Browse Source

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
Malachi Willey 1 year ago
parent
commit
0b85048158

+ 86 - 0
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<HTMLElement | null>(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 (
+    <div>
+      <pre className="language-graphql" data-line={erroredLines.join(',')}>
+        <code className="language-graphql" ref={ref}>
+          {data.query}
+        </code>
+      </pre>
+      <KeyValueList
+        data={Object.entries(omit(data, 'query')).map(([key, value]) => ({
+          key,
+          subject: key,
+          value,
+        }))}
+        isContextData
+      />
+    </div>
+  );
+}

+ 72 - 1
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(<Request event={event} data={event.entries[0].data} />);
+
+        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(
+          <Request event={event} data={event.entries[0].data} />
+        );
+
+        expect(container.querySelector('.line-highlight')).toBeInTheDocument();
+        expect(
+          container.querySelector('.line-highlight')?.getAttribute('data-start')
+        ).toBe('1');
+      });
+    });
   });
 });

+ 27 - 10
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 <GraphQlRequestBody data={data.data} {...{event, meta}} />;
+  }
+
+  return (
+    <RichHttpContentClippedBoxBodySection
+      data={data.data}
+      inferredContentType={data.inferredContentType}
+      meta={meta?.data}
+    />
+  );
+}
+
+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) {
               </ErrorBoundary>
             </ClippedBox>
           )}
-          {defined(data.data) && (
-            <RichHttpContentClippedBoxBodySection
-              data={data.data}
-              inferredContentType={data.inferredContentType}
-              meta={meta?.data}
-            />
-          )}
+          <RequestBodySection {...{data, event, meta}} />
           {defined(data.cookies) && Object.keys(data.cookies).length > 0 && (
             <RichHttpContentClippedBoxKeyValueList
               defaultCollapsed

+ 6 - 3
static/app/styles/prism.tsx

@@ -22,6 +22,11 @@ export const prismStyles = (theme: Theme) => 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;

+ 33 - 14
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<string, any> | [key: string, value: any][];
+  env?: Record<string, string>;
+  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<EntryRequestDataDefault, 'apiTarget' | 'data'> {
+  apiTarget: 'graphql';
   data: {
-    method: string;
-    url: string;
-    cookies?: [key: string, value: string][];
-    data?: string | null | Record<string, any> | [key: string, value: any][];
-    env?: Record<string, string>;
-    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<string, string | number | null>;
+    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;

+ 4 - 4
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',