Skip to content

Commit aa0b588

Browse files
committed
feat(browser): Add graphqlClientIntegration
Added support for graphql query with `xhr` with tests. Signed-off-by: Kaung Zin Hein <[email protected]>
1 parent 9018132 commit aa0b588

File tree

9 files changed

+204
-12
lines changed

9 files changed

+204
-12
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
integrations: [
8+
Sentry.browserTracingIntegration(),
9+
Sentry.graphqlClientIntegration({
10+
endpoints: ['http://sentry-test.io/foo'],
11+
}),
12+
],
13+
tracesSampleRate: 1,
14+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const xhr = new XMLHttpRequest();
2+
3+
xhr.open('POST', 'http://sentry-test.io/foo');
4+
xhr.setRequestHeader('Accept', 'application/json');
5+
xhr.setRequestHeader('Content-Type', 'application/json');
6+
7+
const query = `query Test{
8+
9+
people {
10+
name
11+
pet
12+
}
13+
}`;
14+
15+
const requestBody = JSON.stringify({ query });
16+
xhr.send(requestBody);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
6+
7+
sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const url = await getLocalTestPath({ testDir: __dirname });
13+
14+
await page.route('**/foo', route => {
15+
return route.fulfill({
16+
status: 200,
17+
body: JSON.stringify({
18+
people: [
19+
{ name: 'Amy', pet: 'dog' },
20+
{ name: 'Jay', pet: 'cat' },
21+
],
22+
}),
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
26+
});
27+
});
28+
29+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
30+
const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client');
31+
32+
expect(requestSpans).toHaveLength(1);
33+
34+
expect(requestSpans![0]).toMatchObject({
35+
description: 'POST http://sentry-test.io/foo (query Test)',
36+
parent_span_id: eventData.contexts?.trace?.span_id,
37+
span_id: expect.any(String),
38+
start_timestamp: expect.any(Number),
39+
timestamp: expect.any(Number),
40+
trace_id: eventData.contexts?.trace?.trace_id,
41+
data: {
42+
type: 'xhr',
43+
'http.method': 'POST',
44+
'http.url': 'http://sentry-test.io/foo',
45+
url: 'http://sentry-test.io/foo',
46+
'server.address': 'sentry-test.io',
47+
'sentry.op': 'http.client',
48+
'sentry.origin': 'auto.http.browser',
49+
},
50+
});
51+
});

packages/browser/src/index.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './exports';
33
export { reportingObserverIntegration } from './integrations/reportingobserver';
44
export { httpClientIntegration } from './integrations/httpclient';
55
export { contextLinesIntegration } from './integrations/contextlines';
6+
export { graphqlClientIntegration } from './integrations/graphqlClient';
67

78
export {
89
captureConsoleIntegration,
@@ -13,10 +14,7 @@ export {
1314
captureFeedback,
1415
} from '@sentry/core';
1516

16-
export {
17-
replayIntegration,
18-
getReplay,
19-
} from '@sentry-internal/replay';
17+
export { replayIntegration, getReplay } from '@sentry-internal/replay';
2018
export type {
2119
ReplayEventType,
2220
ReplayEventWithTime,
@@ -34,17 +32,11 @@ export { replayCanvasIntegration } from '@sentry-internal/replay-canvas';
3432
import { feedbackAsyncIntegration } from './feedbackAsync';
3533
import { feedbackSyncIntegration } from './feedbackSync';
3634
export { feedbackAsyncIntegration, feedbackSyncIntegration, feedbackSyncIntegration as feedbackIntegration };
37-
export {
38-
getFeedback,
39-
sendFeedback,
40-
} from '@sentry-internal/feedback';
35+
export { getFeedback, sendFeedback } from '@sentry-internal/feedback';
4136

4237
export * from './metrics';
4338

44-
export {
45-
defaultRequestInstrumentationOptions,
46-
instrumentOutgoingRequests,
47-
} from './tracing/request';
39+
export { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './tracing/request';
4840
export {
4941
browserTracingIntegration,
5042
startBrowserTracingNavigationSpan,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core';
2+
import type { IntegrationFn } from '@sentry/types';
3+
import { parseGraphQLQuery } from '@sentry/utils';
4+
5+
interface GraphQLClientOptions {
6+
endpoints: Array<string>;
7+
}
8+
9+
const INTEGRATION_NAME = 'GraphQLClient';
10+
11+
const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
12+
return {
13+
name: INTEGRATION_NAME,
14+
setup(client) {
15+
client.on('spanStart', span => {
16+
const spanJSON = spanToJSON(span);
17+
18+
const spanAttributes = spanJSON.data || {};
19+
20+
const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP];
21+
const isHttpClientSpan = spanOp === 'http.client';
22+
23+
if (isHttpClientSpan) {
24+
const httpUrl = spanAttributes['http.url'];
25+
26+
const { endpoints } = options;
27+
28+
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl);
29+
30+
if (isTracedGraphqlEndpoint) {
31+
const httpMethod = spanAttributes['http.method'];
32+
const graphqlQuery = spanAttributes['body']?.query as string;
33+
34+
const { operationName, operationType } = parseGraphQLQuery(graphqlQuery);
35+
const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`;
36+
37+
span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`);
38+
}
39+
}
40+
});
41+
},
42+
};
43+
}) satisfies IntegrationFn;
44+
45+
/**
46+
* GraphQL Client integration for the browser.
47+
*/
48+
export const graphqlClientIntegration = defineIntegration(_graphqlClientIntegration);

packages/browser/src/tracing/request.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ export function xhrCallback(
357357
return undefined;
358358
}
359359

360+
const requestBody = JSON.parse(sentryXhrData.body as string);
361+
360362
const fullUrl = getFullURL(sentryXhrData.url);
361363
const host = fullUrl ? parseUrl(fullUrl).host : undefined;
362364

@@ -374,6 +376,7 @@ export function xhrCallback(
374376
'server.address': host,
375377
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser',
376378
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
379+
body: requestBody,
377380
},
378381
})
379382
: new SentryNonRecordingSpan();

packages/utils/src/graphql.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
interface GraphQLOperation {
2+
operationType: string | undefined;
3+
operationName: string | undefined;
4+
}
5+
6+
/**
7+
* Extract the name and type of the operation from the GraphQL query.
8+
* @param query
9+
* @returns
10+
*/
11+
export function parseGraphQLQuery(query: string): GraphQLOperation {
12+
const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/;
13+
14+
const matched = query.match(queryRe);
15+
16+
if (matched) {
17+
return {
18+
operationType: matched[1],
19+
operationName: matched[2],
20+
};
21+
}
22+
return {
23+
operationType: undefined,
24+
operationName: undefined,
25+
};
26+
}

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ export * from './lru';
3939
export * from './buildPolyfills';
4040
export * from './propagationContext';
4141
export * from './version';
42+
export * from './graphql';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { parseGraphQLQuery } from '../src';
2+
3+
describe('parseGraphQLQuery', () => {
4+
const queryOne = `query Test {
5+
items {
6+
id
7+
}
8+
}`;
9+
10+
const queryTwo = `mutation AddTestItem($input: TestItem!) {
11+
addItem(input: $input) {
12+
name
13+
}
14+
}`;
15+
16+
const queryThree = `subscription OnTestItemAdded($itemID: ID!) {
17+
itemAdded(itemID: $itemID) {
18+
id
19+
}
20+
}`;
21+
22+
// TODO: support name-less queries
23+
// const queryFour = ` query {
24+
// items {
25+
// id
26+
// }
27+
// }`;
28+
29+
test.each([
30+
['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }],
31+
['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }],
32+
[
33+
'should handle subscription type',
34+
queryThree,
35+
{ operationName: 'OnTestItemAdded', operationType: 'subscription' },
36+
],
37+
// ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }],
38+
])('%s', (_, input, output) => {
39+
expect(parseGraphQLQuery(input)).toEqual(output);
40+
});
41+
});

0 commit comments

Comments
 (0)