Skip to content

Commit

Permalink
feat(graphql): Add special rendering logic for graphql requests (#50764)
Browse files Browse the repository at this point in the history
Closes #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
  • Loading branch information
malwilley authored Jun 13, 2023
1 parent ce78191 commit 0b85048
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -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>
);
}
73 changes: 72 additions & 1 deletion static/app/components/events/interfaces/request/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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');
});
});
});
});
37 changes: 27 additions & 10 deletions static/app/components/events/interfaces/request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions static/app/styles/prism.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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-'],
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 33 additions & 14 deletions static/app/types/event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -616,6 +629,11 @@ export interface BrowserContext {
version: string;
}

export interface ResponseContext {
data: unknown;
type: 'response';
}

type EventContexts = {
'Memory Info'?: MemoryInfoContext;
'ThreadPool Info'?: ThreadPoolInfoContext;
Expand All @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions static/app/utils/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down

0 comments on commit 0b85048

Please sign in to comment.