Skip to content

Commit 55e6f1b

Browse files
committed
feat(browser): Update breadcrumbs with graphql request data
Signed-off-by: Kaung Zin Hein <[email protected]>
1 parent a304218 commit 55e6f1b

File tree

6 files changed

+208
-59
lines changed

6 files changed

+208
-59
lines changed

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const query = `query Test{
1313
}`;
1414
const queryPayload = JSON.stringify({ query });
1515

16-
sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => {
16+
sentryTest('should update spans for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => {
1717
const url = await getLocalTestPath({ testDir: __dirname });
1818

1919
await page.route('**/foo', route => {
@@ -56,3 +56,42 @@ sentryTest('should create spans for GraphQL Fetch requests', async ({ getLocalTe
5656
}),
5757
});
5858
});
59+
60+
sentryTest('should update breadcrumbs for GraphQL Fetch requests', async ({ getLocalTestPath, page }) => {
61+
const url = await getLocalTestPath({ testDir: __dirname });
62+
63+
await page.route('**/foo', route => {
64+
return route.fulfill({
65+
status: 200,
66+
body: JSON.stringify({
67+
people: [
68+
{ name: 'Amy', pet: 'dog' },
69+
{ name: 'Jay', pet: 'cat' },
70+
],
71+
}),
72+
headers: {
73+
'Content-Type': 'application/json',
74+
},
75+
});
76+
});
77+
78+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
79+
80+
expect(eventData?.breadcrumbs?.length).toBe(1);
81+
82+
expect(eventData!.breadcrumbs![0]).toEqual({
83+
timestamp: expect.any(Number),
84+
category: 'fetch',
85+
type: 'http',
86+
data: {
87+
method: 'POST',
88+
status_code: 200,
89+
url: 'http://sentry-test.io/foo',
90+
__span: expect.any(String),
91+
graphql: {
92+
query: query,
93+
operationName: 'query Test',
94+
},
95+
},
96+
});
97+
});

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const query = `query Test{
1313
}`;
1414
const queryPayload = JSON.stringify({ query });
1515

16-
sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => {
16+
sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestPath, page }) => {
1717
const url = await getLocalTestPath({ testDir: __dirname });
1818

1919
await page.route('**/foo', route => {
@@ -56,3 +56,41 @@ sentryTest('should create spans for GraphQL XHR requests', async ({ getLocalTest
5656
},
5757
});
5858
});
59+
60+
sentryTest('should update breadcrumbs for GraphQL XHR requests', async ({ getLocalTestPath, page }) => {
61+
const url = await getLocalTestPath({ testDir: __dirname });
62+
63+
await page.route('**/foo', route => {
64+
return route.fulfill({
65+
status: 200,
66+
body: JSON.stringify({
67+
people: [
68+
{ name: 'Amy', pet: 'dog' },
69+
{ name: 'Jay', pet: 'cat' },
70+
],
71+
}),
72+
headers: {
73+
'Content-Type': 'application/json',
74+
},
75+
});
76+
});
77+
78+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
79+
80+
expect(eventData?.breadcrumbs?.length).toBe(1);
81+
82+
expect(eventData!.breadcrumbs![0]).toEqual({
83+
timestamp: expect.any(Number),
84+
category: 'xhr',
85+
type: 'http',
86+
data: {
87+
method: 'POST',
88+
status_code: 200,
89+
url: 'http://sentry-test.io/foo',
90+
graphql: {
91+
query: query,
92+
operationName: 'query Test',
93+
},
94+
},
95+
});
96+
});

packages/browser/src/integrations/breadcrumbs.ts

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
HandlerDataHistory,
1818
HandlerDataXhr,
1919
IntegrationFn,
20+
SeverityLevel,
2021
XhrBreadcrumbData,
2122
XhrBreadcrumbHint,
2223
} from '@sentry/types';
@@ -26,6 +27,7 @@ import {
2627
getBreadcrumbLogLevelFromHttpStatusCode,
2728
getComponentName,
2829
getEventDescription,
30+
getGraphQLRequestPayload,
2931
htmlTreeAsString,
3032
logger,
3133
parseUrl,
@@ -248,17 +250,16 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr)
248250
endTimestamp,
249251
};
250252

251-
const level = getBreadcrumbLogLevelFromHttpStatusCode(status_code);
253+
const breadcrumb = {
254+
category: 'xhr',
255+
data,
256+
type: 'http',
257+
level: getBreadcrumbLogLevelFromHttpStatusCode(status_code),
258+
};
252259

253-
addBreadcrumb(
254-
{
255-
category: 'xhr',
256-
data,
257-
type: 'http',
258-
level,
259-
},
260-
hint,
261-
);
260+
client.emit('outgoingRequestBreadcrumbStart', breadcrumb, { body: getGraphQLRequestPayload(body as string) });
261+
262+
addBreadcrumb(breadcrumb, hint);
262263
};
263264
}
264265

@@ -292,15 +293,18 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe
292293
endTimestamp,
293294
};
294295

295-
addBreadcrumb(
296-
{
297-
category: 'fetch',
298-
data,
299-
level: 'error',
300-
type: 'http',
301-
},
302-
hint,
303-
);
296+
const breadcrumb = {
297+
category: 'fetch',
298+
data,
299+
level: 'error' as SeverityLevel,
300+
type: 'http',
301+
};
302+
303+
client.emit('outgoingRequestBreadcrumbStart', breadcrumb, {
304+
body: getGraphQLRequestPayload(handlerData.fetchData.body as string),
305+
});
306+
307+
addBreadcrumb(breadcrumb, hint);
304308
} else {
305309
const response = handlerData.response as Response | undefined;
306310
const data: FetchBreadcrumbData = {
@@ -313,17 +317,19 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe
313317
startTimestamp,
314318
endTimestamp,
315319
};
316-
const level = getBreadcrumbLogLevelFromHttpStatusCode(data.status_code);
317-
318-
addBreadcrumb(
319-
{
320-
category: 'fetch',
321-
data,
322-
type: 'http',
323-
level,
324-
},
325-
hint,
326-
);
320+
321+
const breadcrumb = {
322+
category: 'fetch',
323+
data,
324+
type: 'http',
325+
level: getBreadcrumbLogLevelFromHttpStatusCode(data.status_code),
326+
};
327+
328+
client.emit('outgoingRequestBreadcrumbStart', breadcrumb, {
329+
body: getGraphQLRequestPayload(handlerData.fetchData.body as string),
330+
});
331+
332+
addBreadcrumb(breadcrumb, hint);
327333
}
328334
};
329335
}

packages/browser/src/integrations/graphqlClient.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
defineIntegration,
66
spanToJSON,
77
} from '@sentry/core';
8-
import type { IntegrationFn } from '@sentry/types';
8+
import type { Client, IntegrationFn } from '@sentry/types';
99
import { parseGraphQLQuery } from '@sentry/utils';
1010

1111
interface GraphQLClientOptions {
@@ -24,39 +24,82 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
2424
return {
2525
name: INTEGRATION_NAME,
2626
setup(client) {
27-
client.on('outgoingRequestSpanStart', (span, { body }) => {
28-
const spanJSON = spanToJSON(span);
27+
_updateSpanWithGraphQLData(client, options);
28+
_updateBreadcrumbWithGraphQLData(client, options);
29+
},
30+
};
31+
}) satisfies IntegrationFn;
32+
33+
function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void {
34+
client.on('outgoingRequestSpanStart', (span, { body }) => {
35+
const spanJSON = spanToJSON(span);
36+
37+
const spanAttributes = spanJSON.data || {};
38+
const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP];
2939

30-
const spanAttributes = spanJSON.data || {};
40+
const isHttpClientSpan = spanOp === 'http.client';
3141

32-
const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP];
33-
const isHttpClientSpan = spanOp === 'http.client';
42+
if (isHttpClientSpan) {
43+
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
3444

35-
if (isHttpClientSpan) {
36-
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
45+
const { endpoints } = options;
46+
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl);
3747

38-
const { endpoints } = options;
39-
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl);
48+
if (isTracedGraphqlEndpoint) {
49+
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
4050

41-
if (isTracedGraphqlEndpoint) {
42-
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
43-
const graphqlBody = body as GraphQLRequestPayload;
51+
const operationInfo = _getGraphQLOperation(body);
52+
span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`);
53+
span.setAttribute('body', JSON.stringify(body));
54+
}
55+
}
56+
});
57+
}
58+
59+
function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClientOptions): void {
60+
client.on('outgoingRequestBreadcrumbStart', (breadcrumb, { body }) => {
61+
const { category, type, data } = breadcrumb;
62+
63+
const isFetch = category === 'fetch';
64+
const isXhr = category === 'xhr';
65+
const isHttpBreadcrumb = type === 'http';
4466

45-
// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request
46-
const graphqlQuery = graphqlBody.query;
47-
const graphqlOperationName = graphqlBody.operationName;
67+
if (isHttpBreadcrumb && (isFetch || isXhr)) {
68+
const httpUrl = data && data.url;
69+
const { endpoints } = options;
4870

49-
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
50-
const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`;
71+
const isTracedGraphqlEndpoint = endpoints.includes(httpUrl);
5172

52-
span.updateName(`${httpMethod} ${httpUrl} (${newOperation})`);
53-
span.setAttribute('body', JSON.stringify(graphqlBody));
54-
}
73+
if (isTracedGraphqlEndpoint && data) {
74+
if (!data.graphql) {
75+
const operationInfo = _getGraphQLOperation(body);
76+
77+
data.graphql = {
78+
query: (body as GraphQLRequestPayload).query,
79+
operationName: operationInfo,
80+
};
5581
}
56-
});
57-
},
58-
};
59-
}) satisfies IntegrationFn;
82+
83+
// The body prop attached to HandlerDataFetch for the span should be removed.
84+
if (isFetch && data.body) {
85+
delete data.body;
86+
}
87+
}
88+
}
89+
});
90+
}
91+
92+
function _getGraphQLOperation(requestBody: unknown): string {
93+
// Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request
94+
const graphqlBody = requestBody as GraphQLRequestPayload;
95+
const graphqlQuery = graphqlBody.query;
96+
const graphqlOperationName = graphqlBody.operationName;
97+
98+
const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery);
99+
const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`;
100+
101+
return operationInfo;
102+
}
60103

61104
/**
62105
* GraphQL Client integration for the browser.

packages/core/src/baseclient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,12 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
465465
/** @inheritdoc */
466466
public on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void;
467467

468+
/** @inheritdoc */
469+
public on(
470+
hook: 'outgoingRequestBreadcrumbStart',
471+
callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void,
472+
): () => void;
473+
468474
public on(hook: 'flush', callback: () => void): () => void;
469475

470476
public on(hook: 'close', callback: () => void): () => void;
@@ -546,6 +552,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
546552
/** @inheritdoc */
547553
public emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void;
548554

555+
/** @inheritdoc */
556+
public emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void;
557+
549558
/** @inheritdoc */
550559
public emit(hook: 'flush'): void;
551560

packages/types/src/client.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,20 @@ export interface Client<O extends ClientOptions = ClientOptions> {
292292
on(hook: 'startNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;
293293

294294
/**
295-
* A hook for GraphQL client integration to enhance a span and breadcrumbs with request data.
295+
* A hook for GraphQL client integration to enhance a span with request data.
296296
* @returns A function that, when executed, removes the registered callback.
297297
*/
298298
on(hook: 'outgoingRequestSpanStart', callback: (span: Span, { body }: { body: unknown }) => void): () => void;
299299

300+
/**
301+
* A hook for GraphQL client integration to enhance a breadcrumb with request data.
302+
* @returns A function that, when executed, removes the registered callback.
303+
*/
304+
on(
305+
hook: 'outgoingRequestBreadcrumbStart',
306+
callback: (breadcrumb: Breadcrumb, { body }: { body: unknown }) => void,
307+
): () => void;
308+
300309
/**
301310
* A hook that is called when the client is flushing
302311
* @returns A function that, when executed, removes the registered callback.
@@ -394,10 +403,15 @@ export interface Client<O extends ClientOptions = ClientOptions> {
394403
emit(hook: 'startNavigationSpan', options: StartSpanOptions): void;
395404

396405
/**
397-
* Emit a hook event for GraphQL client integration to enhance a span and breadcrumbs with request data.
406+
* Emit a hook event for GraphQL client integration to enhance a span with request data.
398407
*/
399408
emit(hook: 'outgoingRequestSpanStart', span: Span, { body }: { body: unknown }): void;
400409

410+
/**
411+
* Emit a hook event for GraphQL client integration to enhance a breadcrumb with request data.
412+
*/
413+
emit(hook: 'outgoingRequestBreadcrumbStart', breadcrumb: Breadcrumb, { body }: { body: unknown }): void;
414+
401415
/**
402416
* Emit a hook event for client flush
403417
*/

0 commit comments

Comments
 (0)