Skip to content

Commit 9ee1f77

Browse files
logaretmclaude
andauthored
fix(browser): Enrich graphqlClient spans for relative URLs (#20370)
The `graphqlClientIntegration` didn’t enrich spans for GraphQL requests to relative URLs because the handler used `url.full` or `http.url` to identify the request URL. However, in `packages/core/src/fetch.ts:382-386`, the fetch instrumentation only sets `http.url` for non-relative URLs and never sets `url.full`. Therefore, for relative endpoints, `httpUrl` was `undefined`, failed the `isString` guard, and the enrichment bailed out silently. By adding`spanAttributes['url']` as a third option. Scoped to the graphqlClient integration, this ensures enrichment happens and it's only scoped to the gql integration. The alternative was to populate `http.url` (or `url.full`) for relative URLs in `getFetchSpanAttributes` which is dangerous because `http.url` is a span attribute many users filter, group, alert, and build dashboards on. With this change, the `endpoints` matcher now sees the relative path (e.g. `/graphql`) instead of an absolute URL. If a user configured `endpoints` with an absolute-URL regex and uses relative fetches, that pattern won't match the relative form. However this changes nothing today because it never worked before (was `undefined`). closes #20292 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9ab83a commit 9ee1f77

File tree

2 files changed

+116
-1
lines changed

2 files changed

+116
-1
lines changed

packages/browser/src/integrations/graphqlClient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption
6767
return;
6868
}
6969

70-
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'];
70+
// Fall back to `url` because fetch instrumentation only sets `http.url` for absolute URLs;
71+
// relative URLs end up only in `url` (see `getFetchSpanAttributes` in packages/core/src/fetch.ts).
72+
const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'] || spanAttributes['url'];
7173
const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method'];
7274

7375
if (!isString(httpUrl) || !isString(httpMethod)) {

packages/browser/test/integrations/graphqlClient.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
* @vitest-environment jsdom
33
*/
44

5+
import type { Client } from '@sentry/core';
6+
import { SentrySpan, spanToJSON } from '@sentry/core';
57
import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils';
68
import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils';
79
import { describe, expect, test } from 'vitest';
810
import {
911
_getGraphQLOperation,
1012
getGraphQLRequestPayload,
1113
getRequestPayloadXhrOrFetch,
14+
graphqlClientIntegration,
1215
parseGraphQLQuery,
1316
} from '../../src/integrations/graphqlClient';
1417

@@ -308,4 +311,114 @@ describe('GraphqlClient', () => {
308311
expect(_getGraphQLOperation(requestBody as any)).toBe('unknown');
309312
});
310313
});
314+
315+
describe('beforeOutgoingRequestSpan handler', () => {
316+
function setupHandler(endpoints: Array<string | RegExp>): (span: SentrySpan, hint: FetchHint | XhrHint) => void {
317+
let capturedListener: ((span: SentrySpan, hint: FetchHint | XhrHint) => void) | undefined;
318+
const mockClient = {
319+
on: (eventName: string, cb: (span: SentrySpan, hint: FetchHint | XhrHint) => void) => {
320+
if (eventName === 'beforeOutgoingRequestSpan') {
321+
capturedListener = cb;
322+
}
323+
},
324+
} as unknown as Client;
325+
326+
const integration = graphqlClientIntegration({ endpoints });
327+
integration.setup?.(mockClient);
328+
329+
if (!capturedListener) {
330+
throw new Error('beforeOutgoingRequestSpan listener was not registered');
331+
}
332+
return capturedListener;
333+
}
334+
335+
function makeFetchHint(url: string, body: unknown): FetchHint {
336+
return {
337+
input: [url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }],
338+
response: new Response(null, { status: 200 }),
339+
startTimestamp: Date.now(),
340+
endTimestamp: Date.now() + 1,
341+
};
342+
}
343+
344+
const requestBody = {
345+
query: 'query GetHello { hello }',
346+
operationName: 'GetHello',
347+
variables: {},
348+
extensions: {},
349+
};
350+
351+
test('enriches http.client span for absolute URLs (http.url attribute)', () => {
352+
const handler = setupHandler([/\/graphql$/]);
353+
const span = new SentrySpan({
354+
name: 'POST http://localhost:4000/graphql',
355+
op: 'http.client',
356+
attributes: {
357+
'http.method': 'POST',
358+
'http.url': 'http://localhost:4000/graphql',
359+
url: 'http://localhost:4000/graphql',
360+
},
361+
});
362+
363+
handler(span, makeFetchHint('http://localhost:4000/graphql', requestBody));
364+
365+
const json = spanToJSON(span);
366+
expect(json.description).toBe('POST http://localhost:4000/graphql (query GetHello)');
367+
expect(json.data['graphql.document']).toBe(requestBody.query);
368+
});
369+
370+
test('enriches http.client span for relative URLs (only url attribute)', () => {
371+
const handler = setupHandler([/\/graphql$/]);
372+
// Fetch instrumentation does NOT set http.url for relative URLs — only `url`.
373+
const span = new SentrySpan({
374+
name: 'POST /graphql',
375+
op: 'http.client',
376+
attributes: {
377+
'http.method': 'POST',
378+
url: '/graphql',
379+
},
380+
});
381+
382+
handler(span, makeFetchHint('/graphql', requestBody));
383+
384+
const json = spanToJSON(span);
385+
expect(json.description).toBe('POST /graphql (query GetHello)');
386+
expect(json.data['graphql.document']).toBe(requestBody.query);
387+
});
388+
389+
test('does nothing when no URL attribute is present', () => {
390+
const handler = setupHandler([/\/graphql$/]);
391+
const span = new SentrySpan({
392+
name: 'POST',
393+
op: 'http.client',
394+
attributes: {
395+
'http.method': 'POST',
396+
},
397+
});
398+
399+
handler(span, makeFetchHint('/graphql', requestBody));
400+
401+
const json = spanToJSON(span);
402+
expect(json.description).toBe('POST');
403+
expect(json.data['graphql.document']).toBeUndefined();
404+
});
405+
406+
test('does nothing when span op is not http.client', () => {
407+
const handler = setupHandler([/\/graphql$/]);
408+
const span = new SentrySpan({
409+
name: 'custom span',
410+
op: 'custom',
411+
attributes: {
412+
'http.method': 'POST',
413+
url: '/graphql',
414+
},
415+
});
416+
417+
handler(span, makeFetchHint('/graphql', requestBody));
418+
419+
const json = spanToJSON(span);
420+
expect(json.description).toBe('custom span');
421+
expect(json.data['graphql.document']).toBeUndefined();
422+
});
423+
});
311424
});

0 commit comments

Comments
 (0)