Skip to content

Commit b36a4be

Browse files
committed
fix(browser): Refactor span handler to use hint
- Moved fetch-related operations into the grahqlClient integration. - Used Hint types for `beforeOutgoingRequestSpan` hooks. Signed-off-by: Kaung Zin Hein <[email protected]>
1 parent 64c0f13 commit b36a4be

File tree

9 files changed

+293
-49
lines changed

9 files changed

+293
-49
lines changed

packages/browser/src/integrations/graphqlClient.ts

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils';
2-
import { getBodyString } from '@sentry-internal/replay';
2+
import { FetchHint, getBodyString, XhrHint } from '@sentry-internal/replay';
33
import {
44
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
55
SEMANTIC_ATTRIBUTE_SENTRY_OP,
66
SEMANTIC_ATTRIBUTE_URL_FULL,
77
defineIntegration,
88
spanToJSON,
99
} from '@sentry/core';
10-
import type { Client, HandlerDataFetch, HandlerDataXhr, IntegrationFn } from '@sentry/types';
11-
import { getGraphQLRequestPayload, isString, parseGraphQLQuery, stringMatchesSomePattern } from '@sentry/utils';
10+
import type { Client, IntegrationFn } from '@sentry/types';
11+
import { isString, stringMatchesSomePattern } from '@sentry/utils';
1212

1313
interface GraphQLClientOptions {
1414
endpoints: Array<string | RegExp>;
@@ -35,34 +35,37 @@ const _graphqlClientIntegration = ((options: GraphQLClientOptions) => {
3535
}) satisfies IntegrationFn;
3636

3737
function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOptions): void {
38-
client.on('beforeOutgoingRequestSpan', (span, handlerData) => {
38+
client.on('beforeOutgoingRequestSpan', (span, hint) => {
3939
const spanJSON = spanToJSON(span);
4040

4141
const spanAttributes = spanJSON.data || {};
4242
const spanOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP];
4343

4444
const isHttpClientSpan = spanOp === 'http.client';
4545

46-
if (isHttpClientSpan) {
47-
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
48-
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
46+
if (!isHttpClientSpan) {
47+
return;
48+
}
4949

50-
if (!isString(httpUrl) || !isString(httpMethod)) {
51-
return;
52-
}
50+
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
51+
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
5352

54-
const { endpoints } = options;
55-
const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints);
56-
const payload = _getRequestPayloadXhrOrFetch(handlerData);
53+
if (!isString(httpUrl) || !isString(httpMethod)) {
54+
return;
55+
}
5756

58-
if (isTracedGraphqlEndpoint && payload) {
59-
const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload;
60-
const operationInfo = _getGraphQLOperation(graphqlBody);
57+
const { endpoints } = options;
58+
const isTracedGraphqlEndpoint = stringMatchesSomePattern(httpUrl, endpoints);
59+
const payload = _getRequestPayloadXhrOrFetch(hint);
6160

62-
span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`);
63-
span.setAttribute('graphql.document', payload);
64-
}
61+
if (isTracedGraphqlEndpoint && payload) {
62+
const graphqlBody = getGraphQLRequestPayload(payload) as GraphQLRequestPayload;
63+
const operationInfo = _getGraphQLOperation(graphqlBody);
64+
65+
span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`);
66+
span.setAttribute('graphql.document', payload);
6567
}
68+
6669
});
6770
}
6871

@@ -113,26 +116,95 @@ function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string {
113116
}
114117

115118
/**
116-
* Get the request body/payload based on the shape of the HandlerData
117-
* @param handlerData - Xhr or Fetch HandlerData
119+
* Get the request body/payload based on the shape of the hint
120+
* TODO: export for test?
118121
*/
119-
function _getRequestPayloadXhrOrFetch(handlerData: HandlerDataXhr | HandlerDataFetch): string | undefined {
120-
const isXhr = 'xhr' in handlerData;
121-
const isFetch = 'fetchData' in handlerData;
122+
function _getRequestPayloadXhrOrFetch(hint: XhrHint | FetchHint): string | undefined {
123+
const isXhr = 'xhr' in hint;
124+
const isFetch = !isXhr
122125

123126
let body: string | undefined;
124127

125128
if (isXhr) {
126-
const sentryXhrData = (handlerData as HandlerDataXhr).xhr[SENTRY_XHR_DATA_KEY];
129+
const sentryXhrData = hint.xhr[SENTRY_XHR_DATA_KEY];
127130
body = sentryXhrData && getBodyString(sentryXhrData.body)[0];
128131
} else if (isFetch) {
129-
const sentryFetchData = (handlerData as HandlerDataFetch).fetchData;
130-
body = getBodyString(sentryFetchData.body)[0];
132+
const sentryFetchData = parseFetchPayload(hint.input);
133+
body = getBodyString(sentryFetchData)[0];
131134
}
132135

133136
return body;
134137
}
135138

139+
function hasProp<T extends string>(obj: unknown, prop: T): obj is Record<string, string> {
140+
return !!obj && typeof obj === 'object' && !!(obj as Record<string, string>)[prop];
141+
}
142+
143+
/**
144+
* Parses the fetch arguments to extract the request payload.
145+
* Exported for tests only.
146+
*/
147+
export function parseFetchPayload(fetchArgs: unknown[]): string | undefined {
148+
if (fetchArgs.length === 2) {
149+
const options = fetchArgs[1];
150+
return hasProp(options, 'body') ? String(options.body) : undefined;
151+
}
152+
153+
const arg = fetchArgs[0];
154+
return hasProp(arg, 'body') ? String(arg.body) : undefined;
155+
}
156+
157+
interface GraphQLOperation {
158+
operationType: string | undefined;
159+
operationName: string | undefined;
160+
}
161+
162+
/**
163+
* Extract the name and type of the operation from the GraphQL query.
164+
* @param query
165+
*/
166+
export function parseGraphQLQuery(query: string): GraphQLOperation {
167+
const queryRe = /^(?:\s*)(query|mutation|subscription)(?:\s*)(\w+)(?:\s*)[{(]/;
168+
169+
const matched = query.match(queryRe);
170+
171+
if (matched) {
172+
return {
173+
operationType: matched[1],
174+
operationName: matched[2],
175+
};
176+
}
177+
return {
178+
operationType: undefined,
179+
operationName: undefined,
180+
};
181+
}
182+
183+
/**
184+
* Extract the payload of a request if it's GraphQL.
185+
* Exported for tests only.
186+
* @param payload - A valid JSON string
187+
* @returns A POJO or undefined
188+
*/
189+
export function getGraphQLRequestPayload(payload: string): unknown | undefined {
190+
let graphqlBody = undefined;
191+
try {
192+
const requestBody = JSON.parse(payload);
193+
194+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
195+
const isGraphQLRequest = !!requestBody['query'];
196+
197+
if (isGraphQLRequest) {
198+
graphqlBody = requestBody;
199+
}
200+
} finally {
201+
// Fallback to undefined if payload is an invalid JSON (SyntaxError)
202+
203+
/* eslint-disable no-unsafe-finally */
204+
return graphqlBody;
205+
}
206+
}
207+
136208
/**
137209
* GraphQL Client integration for the browser.
138210
*/

packages/browser/src/tracing/request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
stringMatchesSomePattern,
3434
} from '@sentry/core';
3535
import { WINDOW } from '../helpers';
36+
import { XhrHint } from '@sentry-internal/replay';
3637

3738
/** Options for Request Instrumentation */
3839
export interface RequestInstrumentationOptions {
@@ -415,7 +416,7 @@ export function xhrCallback(
415416
}
416417

417418
if (client) {
418-
client.emit('beforeOutgoingRequestSpan', span, handlerData);
419+
client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint);
419420
}
420421

421422
return span;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { getGraphQLRequestPayload, parseFetchPayload, parseGraphQLQuery } from "../../src/integrations/graphqlClient";
2+
3+
describe('GraphqlClient', () => {
4+
describe('parseFetchPayload', () => {
5+
6+
const data = [1, 2, 3];
7+
const jsonData = '{"data":[1,2,3]}';
8+
9+
it.each([
10+
['string URL only', ['http://example.com'], undefined],
11+
['URL object only', [new URL('http://example.com')], undefined],
12+
['Request URL only', [{ url: 'http://example.com' }], undefined],
13+
[
14+
'Request URL & method only',
15+
[{ url: 'http://example.com', method: 'post', body: JSON.stringify({ data }) }],
16+
jsonData,
17+
],
18+
['string URL & options', ['http://example.com', { method: 'post', body: JSON.stringify({ data }) }], jsonData],
19+
[
20+
'URL object & options',
21+
[new URL('http://example.com'), { method: 'post', body: JSON.stringify({ data }) }],
22+
jsonData,
23+
],
24+
[
25+
'Request URL & options',
26+
[{ url: 'http://example.com' }, { method: 'post', body: JSON.stringify({ data }) }],
27+
jsonData,
28+
],
29+
])('%s', (_name, args, expected) => {
30+
const actual = parseFetchPayload(args as unknown[]);
31+
32+
expect(actual).toEqual(expected);
33+
});
34+
});
35+
36+
describe('parseGraphQLQuery', () => {
37+
const queryOne = `query Test {
38+
items {
39+
id
40+
}
41+
}`;
42+
43+
const queryTwo = `mutation AddTestItem($input: TestItem!) {
44+
addItem(input: $input) {
45+
name
46+
}
47+
}`;
48+
49+
const queryThree = `subscription OnTestItemAdded($itemID: ID!) {
50+
itemAdded(itemID: $itemID) {
51+
id
52+
}
53+
}`;
54+
55+
// TODO: support name-less queries
56+
// const queryFour = ` query {
57+
// items {
58+
// id
59+
// }
60+
// }`;
61+
62+
test.each([
63+
['should handle query type', queryOne, { operationName: 'Test', operationType: 'query' }],
64+
['should handle mutation type', queryTwo, { operationName: 'AddTestItem', operationType: 'mutation' }],
65+
[
66+
'should handle subscription type',
67+
queryThree,
68+
{ operationName: 'OnTestItemAdded', operationType: 'subscription' },
69+
],
70+
// ['should handle query without name', queryFour, { operationName: undefined, operationType: 'query' }],
71+
])('%s', (_, input, output) => {
72+
expect(parseGraphQLQuery(input)).toEqual(output);
73+
});
74+
});
75+
76+
describe('getGraphQLRequestPayload', () => {
77+
test('should return undefined for non-GraphQL request', () => {
78+
const requestBody = { data: [1, 2, 3] };
79+
80+
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined();
81+
});
82+
test('should return the payload object for GraphQL request', () => {
83+
const requestBody = {
84+
query: 'query Test {\r\n items {\r\n id\r\n }\r\n }',
85+
operationName: 'Test',
86+
variables: {},
87+
};
88+
89+
expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody);
90+
});
91+
});
92+
});
93+
94+

packages/core/src/client.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import type {
1414
EventHint,
1515
EventProcessor,
1616
FeedbackEvent,
17+
FetchBreadcrumbHint,
1718
HandlerDataFetch,
1819
HandlerDataXhr,
1920
Integration,
2021
MonitorConfig,
2122
Outcome,
2223
ParameterizedString,
2324
SdkMetadata,
25+
SentryWrappedXMLHttpRequest,
2426
Session,
2527
SessionAggregates,
2628
SeverityLevel,
@@ -32,6 +34,7 @@ import type {
3234
TransactionEvent,
3335
Transport,
3436
TransportMakeRequestResponse,
37+
XhrBreadcrumbHint,
3538
} from './types-hoist';
3639

3740
import { getEnvelopeEndpointWithUrlEncodedAuth } from './api';
@@ -63,6 +66,17 @@ import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson }
6366
const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
6467
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
6568

69+
type RequestBody = null | Blob | BufferSource | FormData | URLSearchParams | string;
70+
71+
export type XhrHint = XhrBreadcrumbHint & {
72+
xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest;
73+
input?: RequestBody;
74+
};
75+
export type FetchHint = FetchBreadcrumbHint & {
76+
input: HandlerDataFetch['args'];
77+
response: Response;
78+
};
79+
6680
/**
6781
* Base implementation for all JavaScript SDK clients.
6882
*
@@ -584,7 +598,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
584598
/** @inheritdoc */
585599
public on(
586600
hook: 'beforeOutgoingRequestSpan',
587-
callback: (span: Span, handlerData: HandlerDataXhr | HandlerDataFetch) => void,
601+
callback: (span: Span, hint: XhrHint | FetchHint ) => void,
588602
): () => void;
589603

590604
/** @inheritdoc */
@@ -720,7 +734,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
720734
public emit(hook: 'startNavigationSpan', options: StartSpanOptions): void;
721735

722736
/** @inheritdoc */
723-
public emit(hook: 'beforeOutgoingRequestSpan', span: Span, handlerData: HandlerDataXhr | HandlerDataFetch): void;
737+
public emit(hook: 'beforeOutgoingRequestSpan', span: Span, hint: XhrHint | FetchHint): void;
724738

725739
/** @inheritdoc */
726740
public emit(

packages/core/src/fetch.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { parseUrl } from './utils-hoist/url';
1717
import { hasTracingEnabled } from './utils/hasTracingEnabled';
1818
import { getActiveSpan } from './utils/spanUtils';
1919
import { getTraceData } from './utils/traceData';
20+
import { FetchHint } from './baseclient';
2021

2122
type PolymorphicRequestHeaders =
2223
| Record<string, string | undefined>
@@ -109,7 +110,8 @@ export function instrumentFetchRequest(
109110
}
110111

111112
if (client) {
112-
client.emit('beforeOutgoingRequestSpan', span, handlerData);
113+
const fetchHint = { input: handlerData.args , response: handlerData.response, startTimestamp: handlerData.startTimestamp, endTimestamp: handlerData.endTimestamp } satisfies FetchHint
114+
client.emit('beforeOutgoingRequestSpan', span, fetchHint);
113115
}
114116

115117
return span;

packages/core/src/utils-hoist/instrument/fetch.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type { HandlerDataFetch } from '../../types-hoist';
33

4-
import { getGraphQLRequestPayload } from '../graphql';
54
import { isError } from '../is';
65
import { addNonEnumerableProperty, fill } from '../object';
76
import { supportsNativeFetch } from '../supports';
@@ -236,16 +235,3 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str
236235
};
237236
}
238237

239-
/**
240-
* Parses the fetch arguments to extract the request payload.
241-
* Exported for tests only.
242-
*/
243-
export function parseFetchPayload(fetchArgs: unknown[]): string | undefined {
244-
if (fetchArgs.length === 2) {
245-
const options = fetchArgs[1];
246-
return hasProp(options, 'body') ? String(options.body) : undefined;
247-
}
248-
249-
const arg = fetchArgs[0];
250-
return hasProp(arg, 'body') ? String(arg.body) : undefined;
251-
}

packages/replay-internal/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type {
1313
ReplaySpanFrameEvent,
1414
CanvasManagerInterface,
1515
CanvasManagerOptions,
16+
FetchHint,
17+
XhrHint,
1618
} from './types';
1719

1820
export { getReplay } from './util/getReplay';

0 commit comments

Comments
 (0)