Skip to content

Commit 4614a55

Browse files
committed
feat(browser): Add support for fetch graphql request
Added test for fetch graphql. Created new utility functions and added tests. Updated `instrumentFetch` to collect fetch request payload. Signed-off-by: Kaung Zin Hein <[email protected]>
1 parent aa0b588 commit 4614a55

File tree

13 files changed

+219
-58
lines changed

13 files changed

+219
-58
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const query = `query Test{
2+
people {
3+
name
4+
pet
5+
}
6+
}`;
7+
8+
const requestBody = JSON.stringify({ query });
9+
10+
fetch('http://sentry-test.io/foo', {
11+
method: 'POST',
12+
headers: {
13+
Accept: 'application/json',
14+
'Content-Type': 'application/json',
15+
},
16+
body: requestBody,
17+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 Fetch 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+
status: 'ok',
42+
data: expect.objectContaining({
43+
type: 'fetch',
44+
'http.method': 'POST',
45+
'http.url': 'http://sentry-test.io/foo',
46+
url: 'http://sentry-test.io/foo',
47+
'server.address': 'sentry-test.io',
48+
'sentry.op': 'http.client',
49+
'sentry.origin': 'auto.http.browser',
50+
body: {
51+
query: expect.any(String),
52+
},
53+
}),
54+
});
55+
});

dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca
3838
start_timestamp: expect.any(Number),
3939
timestamp: expect.any(Number),
4040
trace_id: eventData.contexts?.trace?.trace_id,
41+
status: 'ok',
4142
data: {
4243
type: 'xhr',
4344
'http.method': 'POST',
@@ -46,6 +47,9 @@ sentryTest.only('should create spans for GraphQL XHR requests', async ({ getLoca
4647
'server.address': 'sentry-test.io',
4748
'sentry.op': 'http.client',
4849
'sentry.origin': 'auto.http.browser',
50+
body: {
51+
query: expect.any(String),
52+
},
4953
},
5054
});
5155
});

packages/browser/src/integrations/graphqlClient.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, defineIntegration, spanToJSON } from '@sentry/core';
1+
import {
2+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
3+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
4+
SEMANTIC_ATTRIBUTE_URL_FULL,
5+
defineIntegration,
6+
spanToJSON,
7+
} from '@sentry/core';
28
import type { IntegrationFn } from '@sentry/types';
39
import { parseGraphQLQuery } from '@sentry/utils';
410

@@ -13,6 +19,10 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
1319
name: INTEGRATION_NAME,
1420
setup(client) {
1521
client.on('spanStart', span => {
22+
client.emit('outgoingRequestSpanStart', span);
23+
});
24+
25+
client.on('outgoingRequestSpanStart', span => {
1626
const spanJSON = spanToJSON(span);
1727

1828
const spanAttributes = spanJSON.data || {};
@@ -21,17 +31,21 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
2131
const isHttpClientSpan = spanOp === 'http.client';
2232

2333
if (isHttpClientSpan) {
24-
const httpUrl = spanAttributes['http.url'];
34+
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
2535

2636
const { endpoints } = options;
2737

2838
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl);
2939

3040
if (isTracedGraphqlEndpoint) {
31-
const httpMethod = spanAttributes['http.method'];
32-
const graphqlQuery = spanAttributes['body']?.query as string;
41+
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
42+
const graphqlBody = spanAttributes['body'];
43+
44+
// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request
45+
const graphqlQuery = graphqlBody && (graphqlBody['query'] as string);
46+
const graphqlOperationName = graphqlBody && (graphqlBody['operationName'] as string);
3347

34-
const { operationName, operationType } = parseGraphQLQuery(graphqlQuery);
48+
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
3549
const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`;
3650

3751
span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`);

packages/browser/src/tracing/request.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
browserPerformanceTimeOrigin,
2929
dynamicSamplingContextToSentryBaggageHeader,
3030
generateSentryTraceHeader,
31+
getGraphQLRequestPayload,
3132
parseUrl,
3233
stringMatchesSomePattern,
3334
} from '@sentry/utils';
@@ -357,13 +358,13 @@ export function xhrCallback(
357358
return undefined;
358359
}
359360

360-
const requestBody = JSON.parse(sentryXhrData.body as string);
361-
362361
const fullUrl = getFullURL(sentryXhrData.url);
363362
const host = fullUrl ? parseUrl(fullUrl).host : undefined;
364363

365364
const hasParent = !!getActiveSpan();
366365

366+
const graphqlRequest = getGraphQLRequestPayload(sentryXhrData.body as string);
367+
367368
const span =
368369
shouldCreateSpanResult && hasParent
369370
? startInactiveSpan({
@@ -376,7 +377,7 @@ export function xhrCallback(
376377
'server.address': host,
377378
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser',
378379
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
379-
body: requestBody,
380+
body: graphqlRequest,
380381
},
381382
})
382383
: new SentryNonRecordingSpan();

packages/core/src/baseclient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
462462
/** @inheritdoc */
463463
public on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
464464

465+
/** @inheritdoc */
466+
public on(hook: 'outgoingRequestSpanStart', callback: (span: Span) => void): () => void;
467+
465468
public on(hook: 'flush', callback: () => void): () => void;
466469

467470
public on(hook: 'close', callback: () => void): () => void;
@@ -540,6 +543,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
540543
/** @inheritdoc */
541544
public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void;
542545

546+
/** @inheritdoc */
547+
public emit(hook: 'outgoingRequestSpanStart', span: Span): void;
548+
543549
/** @inheritdoc */
544550
public emit(hook: 'flush'): void;
545551

packages/core/src/fetch.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BAGGAGE_HEADER_NAME,
44
dynamicSamplingContextToSentryBaggageHeader,
55
generateSentryTraceHeader,
6+
getGraphQLRequestPayload,
67
isInstanceOf,
78
parseUrl,
89
} from '@sentry/utils';
@@ -65,13 +66,15 @@ export function instrumentFetchRequest(
6566
const scope = getCurrentScope();
6667
const client = getClient();
6768

68-
const { method, url } = handlerData.fetchData;
69+
const { method, url, body } = handlerData.fetchData;
6970

7071
const fullUrl = getFullURL(url);
7172
const host = fullUrl ? parseUrl(fullUrl).host : undefined;
7273

7374
const hasParent = !!getActiveSpan();
7475

76+
const graphqlRequest = getGraphQLRequestPayload(body as string);
77+
7578
const span =
7679
shouldCreateSpanResult && hasParent
7780
? startInactiveSpan({
@@ -84,6 +87,7 @@ export function instrumentFetchRequest(
8487
'server.address': host,
8588
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: spanOrigin,
8689
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client',
90+
body: graphqlRequest,
8791
},
8892
})
8993
: new SentryNonRecordingSpan();

packages/types/src/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,12 @@ export interface Client<O extends ClientOptions = ClientOptions> {
291291
*/
292292
on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
293293

294+
/**
295+
* A hook for GraphQL client integration to enhance a span and breadcrumbs with request data.
296+
* @returns A function that, when executed, removes the registered callback.
297+
*/
298+
on(hook: 'outgoingRequestSpanStart', callback: (span: Span) => void): () => void;
299+
294300
/**
295301
* A hook that is called when the client is flushing
296302
* @returns A function that, when executed, removes the registered callback.
@@ -387,6 +393,11 @@ export interface Client<O extends ClientOptions = ClientOptions> {
387393
*/
388394
emit(hook: 'startNavigationSpan', options: StartSpanOptions): void;
389395

396+
/**
397+
* Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data.
398+
*/
399+
emit(hook: 'outgoingRequestSpanStart', span: Span): void;
400+
390401
/**
391402
* Emit a hook event for client flush
392403
*/

packages/types/src/instrument.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { WebFetchHeaders } from './webfetchapi';
66
// Make sure to cast it where needed!
77
type XHRSendInput = unknown;
88

9+
type FetchInput = unknown;
10+
911
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error' | 'log' | 'assert' | 'trace';
1012

1113
export interface SentryWrappedXMLHttpRequest {
@@ -37,6 +39,7 @@ export interface HandlerDataXhr {
3739
interface SentryFetchData {
3840
method: string;
3941
url: string;
42+
body?: FetchInput;
4043
request_body_size?: number;
4144
response_body_size?: number;
4245
// span_id for the fetch request

packages/utils/src/graphql.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ interface GraphQLOperation {
66
/**
77
* Extract the name and type of the operation from the GraphQL query.
88
* @param query
9-
* @returns
109
*/
1110
export function parseGraphQLQuery(query: string): GraphQLOperation {
12-
const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[\{\(]/;
11+
const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/;
1312

1413
const matched = query.match(queryRe);
1514

@@ -24,3 +23,23 @@ export function parseGraphQLQuery(query: string): GraphQLOperation {
2423
operationName: undefined,
2524
};
2625
}
26+
27+
/**
28+
* Extract the payload of a request ONLY if it's GraphQL.
29+
* @param payload - A valid JSON string
30+
*/
31+
export function getGraphQLRequestPayload(payload: string): any | undefined {
32+
let graphqlBody = undefined;
33+
try {
34+
const requestBody = JSON.parse(payload);
35+
const isGraphQLRequest = !!requestBody['query'];
36+
if (isGraphQLRequest) {
37+
graphqlBody = requestBody;
38+
}
39+
} finally {
40+
// Fallback to undefined if payload is an invalid JSON (SyntaxError)
41+
42+
/* eslint-disable no-unsafe-finally */
43+
return graphqlBody;
44+
}
45+
}

0 commit comments

Comments
 (0)