From 0b850481581be4894fe6db3f013b5f33f54c1b87 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Tue, 13 Jun 2023 11:37:24 -0700 Subject: [PATCH] feat(graphql): Add special rendering logic for graphql requests (#50764) Closes https://github.com/getsentry/sentry/issues/50230 The goal of this PR is to add: 1. Syntax highlighting for the graphql query 2. Add a line highlight when there is a graphql error that points to the query --- .../interfaces/request/graphQlRequestBody.tsx | 86 +++++++++++++++++++ .../events/interfaces/request/index.spec.tsx | 73 +++++++++++++++- .../events/interfaces/request/index.tsx | 37 +++++--- static/app/styles/prism.tsx | 9 +- static/app/types/event.tsx | 47 +++++++--- static/app/utils/theme.tsx | 8 +- 6 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 static/app/components/events/interfaces/request/graphQlRequestBody.tsx diff --git a/static/app/components/events/interfaces/request/graphQlRequestBody.tsx b/static/app/components/events/interfaces/request/graphQlRequestBody.tsx new file mode 100644 index 00000000000000..262f7661e592b2 --- /dev/null +++ b/static/app/components/events/interfaces/request/graphQlRequestBody.tsx @@ -0,0 +1,86 @@ +import {useEffect, useRef} from 'react'; +import omit from 'lodash/omit'; +import uniq from 'lodash/uniq'; +import Prism from 'prismjs'; + +import KeyValueList from 'sentry/components/events/interfaces/keyValueList'; +import {EntryRequestDataGraphQl, Event} from 'sentry/types'; +import {loadPrismLanguage} from 'sentry/utils/loadPrismLanguage'; + +type GraphQlBodyProps = {data: EntryRequestDataGraphQl['data']; event: Event}; + +type GraphQlErrors = Array<{ + locations?: Array<{column: number; line: number}>; + message?: string; + path?: string[]; +}>; + +function getGraphQlErrorsFromResponseContext(event: Event): GraphQlErrors { + const responseData = event.contexts?.response?.data; + + if ( + responseData && + typeof responseData === 'object' && + 'errors' in responseData && + Array.isArray(responseData.errors) && + responseData.errors.every(error => typeof error === 'object') + ) { + return responseData.errors; + } + + return []; +} + +function getErrorLineNumbers(errors: GraphQlErrors): number[] { + return uniq( + errors.flatMap( + error => + error.locations?.map(loc => loc?.line).filter(line => typeof line === 'number') ?? + [] + ) + ); +} + +export function GraphQlRequestBody({data, event}: GraphQlBodyProps) { + const ref = useRef(null); + + // https://prismjs.com/plugins/line-highlight/ + useEffect(() => { + import('prismjs/plugins/line-highlight/prism-line-highlight'); + }, []); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + if ('graphql' in Prism.languages) { + Prism.highlightElement(element); + return; + } + + loadPrismLanguage('graphql', {onLoad: () => Prism.highlightElement(element)}); + }, []); + + const errors = getGraphQlErrorsFromResponseContext(event); + const erroredLines = getErrorLineNumbers(errors); + + return ( +
+
+        
+          {data.query}
+        
+      
+ ({ + key, + subject: key, + value, + }))} + isContextData + /> +
+ ); +} diff --git a/static/app/components/events/interfaces/request/index.spec.tsx b/static/app/components/events/interfaces/request/index.spec.tsx index 22e863a160448b..72a630e49a97b2 100644 --- a/static/app/components/events/interfaces/request/index.spec.tsx +++ b/static/app/components/events/interfaces/request/index.spec.tsx @@ -182,9 +182,10 @@ describe('Request entry', function () { ).toBeInTheDocument(); // tooltip description }); - describe('getBodySection', function () { + describe('body section', function () { it('should return plain-text when given unrecognized inferred Content-Type', function () { const data: EntryRequest['data'] = { + apiTarget: null, query: [], data: 'helloworld', headers: [], @@ -219,6 +220,7 @@ describe('Request entry', function () { it('should return a KeyValueList element when inferred Content-Type is x-www-form-urlencoded', function () { const data: EntryRequest['data'] = { + apiTarget: null, query: [], data: {foo: ['bar'], bar: ['baz']}, headers: [], @@ -253,6 +255,7 @@ describe('Request entry', function () { it('should return a ContextData element when inferred Content-Type is application/json', function () { const data: EntryRequest['data'] = { + apiTarget: null, query: [], data: {foo: 'bar'}, headers: [], @@ -289,6 +292,7 @@ describe('Request entry', function () { // > decodeURIComponent('a%AFc') // URIError: URI malformed const data: EntryRequest['data'] = { + apiTarget: null, query: 'a%AFc', data: '', headers: [], @@ -320,6 +324,7 @@ describe('Request entry', function () { it("should not cause an invariant violation if data.data isn't a string", function () { const data: EntryRequest['data'] = { + apiTarget: null, query: [], data: [{foo: 'bar', baz: 1}], headers: [], @@ -348,5 +353,71 @@ describe('Request entry', function () { }) ).not.toThrow(); }); + + describe('graphql', function () { + it('should render a graphql query and variables', function () { + const data: EntryRequest['data'] = { + apiTarget: 'graphql', + method: 'POST', + url: '/graphql/', + data: { + query: 'query Test { test }', + variables: {foo: 'bar'}, + operationName: 'Test', + }, + }; + + const event = { + ...TestStubs.Event(), + entries: [ + { + type: EntryType.REQUEST, + data, + }, + ], + }; + + render(); + + expect(screen.getByText('query Test { test }')).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'operationName Test'})).toBeInTheDocument(); + expect( + screen.getByRole('row', {name: 'variables { foo : bar }'}) + ).toBeInTheDocument(); + }); + + it('highlights graphql query lines with errors', function () { + const data: EntryRequest['data'] = { + apiTarget: 'graphql', + method: 'POST', + url: '/graphql/', + data: { + query: 'query Test { test }', + variables: {foo: 'bar'}, + operationName: 'Test', + }, + }; + + const event = { + ...TestStubs.Event(), + entries: [ + { + type: EntryType.REQUEST, + data, + }, + ], + contexts: {response: {data: {errors: [{locations: [{line: 1}]}]}}}, + }; + + const {container} = render( + + ); + + expect(container.querySelector('.line-highlight')).toBeInTheDocument(); + expect( + container.querySelector('.line-highlight')?.getAttribute('data-start') + ).toBe('1'); + }); + }); }); }); diff --git a/static/app/components/events/interfaces/request/index.tsx b/static/app/components/events/interfaces/request/index.tsx index 229e3ff9b3ec2d..9794a0b19a7b3c 100644 --- a/static/app/components/events/interfaces/request/index.tsx +++ b/static/app/components/events/interfaces/request/index.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import ClippedBox from 'sentry/components/clippedBox'; import ErrorBoundary from 'sentry/components/errorBoundary'; import {EventDataSection} from 'sentry/components/events/eventDataSection'; +import {GraphQlRequestBody} from 'sentry/components/events/interfaces/request/graphQlRequestBody'; import {getCurlCommand, getFullUrl} from 'sentry/components/events/interfaces/utils'; import ExternalLink from 'sentry/components/links/externalLink'; import {SegmentedControl} from 'sentry/components/segmentedControl'; @@ -17,14 +18,36 @@ import {defined, isUrl} from 'sentry/utils'; import {RichHttpContentClippedBoxBodySection} from './richHttpContentClippedBoxBodySection'; import {RichHttpContentClippedBoxKeyValueList} from './richHttpContentClippedBoxKeyValueList'; -type Props = { +interface RequestProps { data: EntryRequest['data']; event: Event; -}; +} + +interface RequestBodyProps extends RequestProps { + meta: any; +} type View = 'formatted' | 'curl'; -export function Request({data, event}: Props) { +function RequestBodySection({data, event, meta}: RequestBodyProps) { + if (!defined(data.data)) { + return null; + } + + if (data.apiTarget === 'graphql' && typeof data.data.query === 'string') { + return ; + } + + return ( + + ); +} + +export function Request({data, event}: RequestProps) { const entryIndex = event.entries.findIndex(entry => entry.type === EntryType.REQUEST); const meta = event._meta?.entries?.[entryIndex]?.data; @@ -106,13 +129,7 @@ export function Request({data, event}: Props) { )} - {defined(data.data) && ( - - )} + {defined(data.cookies) && Object.keys(data.cookies).length > 0 && ( css` padding: ${space(1)} ${space(2)}; border-radius: ${theme.borderRadius}; box-shadow: none; + + code { + background: unset; + vertical-align: middle; + } } pre[class*='language-'], @@ -106,10 +111,8 @@ export const prismStyles = (theme: Theme) => css` } .line-highlight { position: absolute; - left: 0; + left: -${space(2)}; right: 0; - padding: inherit 0; - margin-top: 1em; background: var(--prism-highlight-background); box-shadow: inset 5px 0 0 var(--prism-highlight-accent); z-index: 0; diff --git a/static/app/types/event.tsx b/static/app/types/event.tsx index 58f441b0b6809c..e1f4bd8402df68 100644 --- a/static/app/types/event.tsx +++ b/static/app/types/event.tsx @@ -332,22 +332,35 @@ type EntryMessage = { type: EntryType.MESSAGE; }; -export type EntryRequest = { +export interface EntryRequestDataDefault { + apiTarget: null; + method: string; + url: string; + cookies?: [key: string, value: string][]; + data?: string | null | Record | [key: string, value: any][]; + env?: Record; + fragment?: string | null; + headers?: [key: string, value: string][]; + inferredContentType?: + | null + | 'application/json' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data'; + query?: [key: string, value: string][] | string; +} + +export interface EntryRequestDataGraphQl + extends Omit { + apiTarget: 'graphql'; data: { - method: string; - url: string; - cookies?: [key: string, value: string][]; - data?: string | null | Record | [key: string, value: any][]; - env?: Record; - fragment?: string | null; - headers?: [key: string, value: string][]; - inferredContentType?: - | null - | 'application/json' - | 'application/x-www-form-urlencoded' - | 'multipart/form-data'; - query?: [key: string, value: string][] | string; + query: string; + variables: Record; + operationName?: string; }; +} + +export type EntryRequest = { + data: EntryRequestDataDefault | EntryRequestDataGraphQl; type: EntryType.REQUEST; }; @@ -616,6 +629,11 @@ export interface BrowserContext { version: string; } +export interface ResponseContext { + data: unknown; + type: 'response'; +} + type EventContexts = { 'Memory Info'?: MemoryInfoContext; 'ThreadPool Info'?: ThreadPoolInfoContext; @@ -630,6 +648,7 @@ type EventContexts = { // once perf issue data shape is more clear performance_issue?: any; replay?: ReplayContext; + response?: ResponseContext; runtime?: RuntimeContext; threadpool_info?: ThreadPoolInfoContext; trace?: TraceContextType; diff --git a/static/app/utils/theme.tsx b/static/app/utils/theme.tsx index 3d4e6ca3dce72d..9b876905294659 100644 --- a/static/app/utils/theme.tsx +++ b/static/app/utils/theme.tsx @@ -138,8 +138,8 @@ const prismLight = { '--prism-selected': '#E9E0EB', '--prism-inline-code': '#D25F7C', '--prism-inline-code-background': '#F8F9FB', - '--prism-highlight-background': '#E8ECF2', - '--prism-highlight-accent': '#C7CBD1', + '--prism-highlight-background': '#5C78A31C', + '--prism-highlight-accent': '#5C78A344', '--prism-comment': '#72697C', '--prism-punctuation': '#70697C', '--prism-property': '#7A6229', @@ -155,8 +155,8 @@ const prismDark = { '--prism-selected': '#865891', '--prism-inline-code': '#D25F7C', '--prism-inline-code-background': '#F8F9FB', - '--prism-highlight-background': '#382F5C', - '--prism-highlight-accent': '#D25F7C', + '--prism-highlight-background': '#A8A2C31C', + '--prism-highlight-accent': '#A8A2C344', '--prism-comment': '#8B7A9E', '--prism-punctuation': '#B3ACC1', '--prism-property': '#EAB944',