diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index d256fa6b72e1..51a3fe939f23 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -67,7 +67,9 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption return; } - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url']; + // Fall back to `url` because fetch instrumentation only sets `http.url` for absolute URLs; + // relative URLs end up only in `url` (see `getFetchSpanAttributes` in packages/core/src/fetch.ts). + const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'] || spanAttributes['url']; const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; if (!isString(httpUrl) || !isString(httpMethod)) { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index a90b62ee13d5..1c4ab60d30f2 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -2,6 +2,8 @@ * @vitest-environment jsdom */ +import type { Client } from '@sentry/core'; +import { SentrySpan, spanToJSON } from '@sentry/core'; import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { describe, expect, test } from 'vitest'; @@ -9,6 +11,7 @@ import { _getGraphQLOperation, getGraphQLRequestPayload, getRequestPayloadXhrOrFetch, + graphqlClientIntegration, parseGraphQLQuery, } from '../../src/integrations/graphqlClient'; @@ -308,4 +311,114 @@ describe('GraphqlClient', () => { expect(_getGraphQLOperation(requestBody as any)).toBe('unknown'); }); }); + + describe('beforeOutgoingRequestSpan handler', () => { + function setupHandler(endpoints: Array): (span: SentrySpan, hint: FetchHint | XhrHint) => void { + let capturedListener: ((span: SentrySpan, hint: FetchHint | XhrHint) => void) | undefined; + const mockClient = { + on: (eventName: string, cb: (span: SentrySpan, hint: FetchHint | XhrHint) => void) => { + if (eventName === 'beforeOutgoingRequestSpan') { + capturedListener = cb; + } + }, + } as unknown as Client; + + const integration = graphqlClientIntegration({ endpoints }); + integration.setup?.(mockClient); + + if (!capturedListener) { + throw new Error('beforeOutgoingRequestSpan listener was not registered'); + } + return capturedListener; + } + + function makeFetchHint(url: string, body: unknown): FetchHint { + return { + input: [url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }], + response: new Response(null, { status: 200 }), + startTimestamp: Date.now(), + endTimestamp: Date.now() + 1, + }; + } + + const requestBody = { + query: 'query GetHello { hello }', + operationName: 'GetHello', + variables: {}, + extensions: {}, + }; + + test('enriches http.client span for absolute URLs (http.url attribute)', () => { + const handler = setupHandler([/\/graphql$/]); + const span = new SentrySpan({ + name: 'POST http://localhost:4000/graphql', + op: 'http.client', + attributes: { + 'http.method': 'POST', + 'http.url': 'http://localhost:4000/graphql', + url: 'http://localhost:4000/graphql', + }, + }); + + handler(span, makeFetchHint('http://localhost:4000/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.description).toBe('POST http://localhost:4000/graphql (query GetHello)'); + expect(json.data['graphql.document']).toBe(requestBody.query); + }); + + test('enriches http.client span for relative URLs (only url attribute)', () => { + const handler = setupHandler([/\/graphql$/]); + // Fetch instrumentation does NOT set http.url for relative URLs — only `url`. + const span = new SentrySpan({ + name: 'POST /graphql', + op: 'http.client', + attributes: { + 'http.method': 'POST', + url: '/graphql', + }, + }); + + handler(span, makeFetchHint('/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.description).toBe('POST /graphql (query GetHello)'); + expect(json.data['graphql.document']).toBe(requestBody.query); + }); + + test('does nothing when no URL attribute is present', () => { + const handler = setupHandler([/\/graphql$/]); + const span = new SentrySpan({ + name: 'POST', + op: 'http.client', + attributes: { + 'http.method': 'POST', + }, + }); + + handler(span, makeFetchHint('/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.description).toBe('POST'); + expect(json.data['graphql.document']).toBeUndefined(); + }); + + test('does nothing when span op is not http.client', () => { + const handler = setupHandler([/\/graphql$/]); + const span = new SentrySpan({ + name: 'custom span', + op: 'custom', + attributes: { + 'http.method': 'POST', + url: '/graphql', + }, + }); + + handler(span, makeFetchHint('/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.description).toBe('custom span'); + expect(json.data['graphql.document']).toBeUndefined(); + }); + }); });