From f472764460eb53ada25e2999d5e3081645bc0318 Mon Sep 17 00:00:00 2001 From: tbeeren Date: Mon, 15 Dec 2025 13:08:46 +0100 Subject: [PATCH 1/5] feat(browser) Added persisted ops integration in graphqlClient javascript --- .../browser/src/integrations/graphqlClient.ts | 110 +++++++++++--- .../test/integrations/graphqlClient.test.ts | 136 +++++++++++++++++- 2 files changed, 225 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index a467a4a70ff4..ebcc824ab81c 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -16,13 +16,27 @@ interface GraphQLClientOptions { } /** Standard graphql request shape: https://graphql.org/learn/serving-over-http/#post-request-and-body */ -interface GraphQLRequestPayload { +interface GraphQLStandardRequest { query: string; operationName?: string; variables?: Record; extensions?: Record; } +/** Persisted operation request */ +interface GraphQLPersistedRequest { + operationName: string; + variables?: Record; + extensions: { + persistedQuery: { + version: number; + sha256Hash: string; + }; + } & Record; +} + +type GraphQLRequestPayload = GraphQLStandardRequest | GraphQLPersistedRequest; + interface GraphQLOperation { operationType?: string; operationName?: string; @@ -33,7 +47,7 @@ const INTEGRATION_NAME = 'GraphQLClient'; const _graphqlClientIntegration = ((options: GraphQLClientOptions) => { return { name: INTEGRATION_NAME, - setup(client) { + setup(client: Client) { _updateSpanWithGraphQLData(client, options); _updateBreadcrumbWithGraphQLData(client, options); }, @@ -70,7 +84,17 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption if (graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - span.setAttribute('graphql.document', payload); + + // Handle standard requests - always capture the query document + if (isStandardRequest(graphqlBody)) { + span.setAttribute('graphql.document', graphqlBody.query); + } + + // Handle persisted operations - capture hash for debugging + if (isPersistedRequest(graphqlBody)) { + span.setAttribute('graphql.persistedQuery.sha256Hash', graphqlBody.extensions.persistedQuery.sha256Hash); + span.setAttribute('graphql.persistedQuery.version', graphqlBody.extensions.persistedQuery.version); + } } } }); @@ -96,8 +120,17 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient if (!data.graphql && graphqlBody) { const operationInfo = _getGraphQLOperation(graphqlBody); - data['graphql.document'] = graphqlBody.query; + data['graphql.operation'] = operationInfo; + + if (isStandardRequest(graphqlBody)) { + data['graphql.document'] = graphqlBody.query; + } + + if (isPersistedRequest(graphqlBody)) { + data['graphql.persistedQuery.sha256Hash'] = graphqlBody.extensions.persistedQuery.sha256Hash; + data['graphql.persistedQuery.version'] = graphqlBody.extensions.persistedQuery.version; + } } } } @@ -106,15 +139,24 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient /** * @param requestBody - GraphQL request - * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' + * @returns A formatted version of the request: 'TYPE NAME' or 'TYPE' or 'persisted NAME' */ -function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { - const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; +export function _getGraphQLOperation(requestBody: GraphQLRequestPayload): string { + // Handle persisted operations + if (isPersistedRequest(requestBody)) { + return `persisted ${requestBody.operationName}`; + } - const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); - const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; + // Handle standard GraphQL requests + if (isStandardRequest(requestBody)) { + const { query: graphqlQuery, operationName: graphqlOperationName } = requestBody; + const { operationName = graphqlOperationName, operationType } = parseGraphQLQuery(graphqlQuery); + const operationInfo = operationName ? `${operationType} ${operationName}` : `${operationType}`; + return operationInfo; + } - return operationInfo; + // Fallback for unknown request types + return 'unknown'; } /** @@ -168,6 +210,32 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { }; } +/** + * Helper to safely check if a value is a non-null object + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Type guard to check if a request is a standard GraphQL request + */ +function isStandardRequest(payload: unknown): payload is GraphQLStandardRequest { + return isObject(payload) && typeof payload.query === 'string'; +} + +/** + * Type guard to check if a request is a persisted operation request + */ +function isPersistedRequest(payload: unknown): payload is GraphQLPersistedRequest { + return ( + isObject(payload) && + typeof payload.operationName === 'string' && + isObject(payload.extensions) && + isObject(payload.extensions.persistedQuery) + ); +} + /** * Extract the payload of a request if it's GraphQL. * Exported for tests only. @@ -175,20 +243,22 @@ export function parseGraphQLQuery(query: string): GraphQLOperation { * @returns A POJO or undefined */ export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload | undefined { - let graphqlBody = undefined; try { - const requestBody = JSON.parse(payload) satisfies GraphQLRequestPayload; + const requestBody = JSON.parse(payload); + + if (isStandardRequest(requestBody)) { + return requestBody; + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isGraphQLRequest = !!requestBody['query']; - if (isGraphQLRequest) { - graphqlBody = requestBody; + if (isPersistedRequest(requestBody)) { + return requestBody; } - } finally { - // Fallback to undefined if payload is an invalid JSON (SyntaxError) - /* eslint-disable no-unsafe-finally */ - return graphqlBody; + // Not a GraphQL request + return undefined; + } catch { + // Invalid JSON + return undefined; } } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 7a647b776e69..771c47283621 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -6,6 +6,7 @@ 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'; import { + _getGraphQLOperation, getGraphQLRequestPayload, getRequestPayloadXhrOrFetch, parseGraphQLQuery, @@ -57,7 +58,8 @@ describe('GraphqlClient', () => { expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); }); - test('should return the payload object for GraphQL request', () => { + + test('should return the payload object for standard GraphQL request', () => { const requestBody = { query: 'query Test {\r\n items {\r\n id\r\n }\r\n }', operationName: 'Test', @@ -67,6 +69,51 @@ describe('GraphqlClient', () => { expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); }); + + test('should return the payload object for persisted operation request', () => { + const requestBody = { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'abc123def456...', + }, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toEqual(requestBody); + }); + + test('should return undefined for persisted operation without operationName', () => { + const requestBody = { + variables: { id: '123' }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'abc123def456...', + }, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + + test('should return undefined for request with extensions but no persistedQuery', () => { + const requestBody = { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + someOtherExtension: true, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + + test('should return undefined for invalid JSON', () => { + expect(getGraphQLRequestPayload('not valid json {')).toBeUndefined(); + }); }); describe('getRequestPayloadXhrOrFetch', () => { @@ -136,4 +183,91 @@ describe('GraphqlClient', () => { expect(result).toBeUndefined(); }); }); + + describe('_getGraphQLOperation', () => { + test('should format standard GraphQL query with operation name', () => { + const requestBody = { + query: 'query GetUser { user { id } }', + operationName: 'GetUser', + }; + + expect(_getGraphQLOperation(requestBody)).toBe('query GetUser'); + }); + + test('should format standard GraphQL mutation with operation name', () => { + const requestBody = { + query: 'mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }', + operationName: 'CreateUser', + }; + + expect(_getGraphQLOperation(requestBody)).toBe('mutation CreateUser'); + }); + + test('should format standard GraphQL subscription with operation name', () => { + const requestBody = { + query: 'subscription OnUserCreated { userCreated { id } }', + operationName: 'OnUserCreated', + }; + + expect(_getGraphQLOperation(requestBody)).toBe('subscription OnUserCreated'); + }); + + test('should format standard GraphQL query without operation name', () => { + const requestBody = { + query: 'query { users { id } }', + }; + + expect(_getGraphQLOperation(requestBody)).toBe('query'); + }); + + test('should use query operation name when provided in request body', () => { + const requestBody = { + query: 'query { users { id } }', + operationName: 'GetAllUsers', + }; + + expect(_getGraphQLOperation(requestBody)).toBe('query GetAllUsers'); + }); + + test('should format persisted operation request', () => { + const requestBody = { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'abc123def456', + }, + }, + }; + + expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser'); + }); + + test('should handle persisted operation with additional extensions', () => { + const requestBody = { + operationName: 'GetUser', + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'abc123def456', + }, + tracing: true, + customExtension: 'value', + }, + }; + + expect(_getGraphQLOperation(requestBody)).toBe('persisted GetUser'); + }); + + test('should return "unknown" for unrecognized request format', () => { + const requestBody = { + variables: { id: '123' }, + }; + + // This shouldn't happen in practice since getGraphQLRequestPayload filters, + // but test the fallback behavior + expect(_getGraphQLOperation(requestBody as any)).toBe('unknown'); + }); + }); }); From 516610f4b7aa31ca26a23161460d65bd3c53151f Mon Sep 17 00:00:00 2001 From: tbeeren Date: Mon, 15 Dec 2025 13:25:53 +0100 Subject: [PATCH 2/5] fix(browser): Strengthen type guard for persisted GraphQL operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isPersistedRequest type guard now validates that sha256Hash and version properties exist in the persistedQuery object, preventing undefined values from being set as span attributes and breadcrumb data. Added tests for edge cases: - Empty persistedQuery object - Missing sha256Hash property - Missing version property 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../browser/src/integrations/graphqlClient.ts | 4 +- .../test/integrations/graphqlClient.test.ts | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index ebcc824ab81c..5efe172a8edc 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -232,7 +232,9 @@ function isPersistedRequest(payload: unknown): payload is GraphQLPersistedReques isObject(payload) && typeof payload.operationName === 'string' && isObject(payload.extensions) && - isObject(payload.extensions.persistedQuery) + isObject(payload.extensions.persistedQuery) && + typeof payload.extensions.persistedQuery.sha256Hash === 'string' && + typeof payload.extensions.persistedQuery.version === 'number' ); } diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 771c47283621..a90b62ee13d5 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -111,6 +111,44 @@ describe('GraphqlClient', () => { expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); }); + test('should return undefined for persisted operation with incomplete persistedQuery object', () => { + const requestBody = { + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + persistedQuery: {}, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + + test('should return undefined for persisted operation missing sha256Hash', () => { + const requestBody = { + operationName: 'GetUser', + extensions: { + persistedQuery: { + version: 1, + }, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + + test('should return undefined for persisted operation missing version', () => { + const requestBody = { + operationName: 'GetUser', + extensions: { + persistedQuery: { + sha256Hash: 'abc123', + }, + }, + }; + + expect(getGraphQLRequestPayload(JSON.stringify(requestBody))).toBeUndefined(); + }); + test('should return undefined for invalid JSON', () => { expect(getGraphQLRequestPayload('not valid json {')).toBeUndefined(); }); From e5e38faa6c53bcbf376d4b17a2bd661ae90f1fbd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Dec 2025 15:20:38 +0100 Subject: [PATCH 3/5] Update existing browser-integration-tests and add new test for persisted queries --- .../integrations/graphqlClient/fetch/test.ts | 3 +- .../suites/integrations/graphqlClient/init.js | 2 +- .../persistedQuery-fetch/subject.js | 19 ++++ .../persistedQuery-fetch/test.ts | 102 ++++++++++++++++++ .../persistedQuery-xhr/subject.js | 18 ++++ .../graphqlClient/persistedQuery-xhr/test.ts | 98 +++++++++++++++++ .../integrations/graphqlClient/xhr/test.ts | 3 +- .../browser/src/integrations/graphqlClient.ts | 8 +- 8 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/test.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts index f3fd78bc0b94..db3758706737 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/fetch/test.ts @@ -10,7 +10,6 @@ const query = `query Test{ pet } }`; -const queryPayload = JSON.stringify({ query }); sentryTest('should update spans for GraphQL fetch requests', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { @@ -55,7 +54,7 @@ sentryTest('should update spans for GraphQL fetch requests', async ({ getLocalTe 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - 'graphql.document': queryPayload, + 'graphql.document': query, }), }); }); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js index ec5f5b76cd44..ef8d5fa541e4 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/init.js @@ -9,7 +9,7 @@ Sentry.init({ integrations: [ Sentry.browserTracingIntegration(), graphqlClientIntegration({ - endpoints: ['http://sentry-test.io/foo'], + endpoints: ['http://sentry-test.io/foo', 'http://sentry-test.io/graphql'], }), ], tracesSampleRate: 1, diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/subject.js new file mode 100644 index 000000000000..f6da78b0abd6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/subject.js @@ -0,0 +1,19 @@ +const requestBody = JSON.stringify({ + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + }, + }, +}); + +fetch('http://sentry-test.io/graphql', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: requestBody, +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/test.ts new file mode 100644 index 000000000000..14003c708574 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-fetch/test.ts @@ -0,0 +1,102 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should update spans for GraphQL persisted query fetch requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/graphql', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + data: { + user: { + id: '123', + name: 'Test User', + }, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client'); + + expect(requestSpans).toHaveLength(1); + + expect(requestSpans![0]).toMatchObject({ + description: 'POST http://sentry-test.io/graphql (persisted GetUser)', + parent_span_id: eventData.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', + data: expect.objectContaining({ + type: 'fetch', + 'http.method': 'POST', + 'http.url': 'http://sentry-test.io/graphql', + url: 'http://sentry-test.io/graphql', + 'server.address': 'sentry-test.io', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + 'graphql.persisted_query.version': 1, + }), + }); +}); + +sentryTest( + 'should update breadcrumbs for GraphQL persisted query fetch requests', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/graphql', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + data: { + user: { + id: '123', + name: 'Test User', + }, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/graphql', + __span: expect.any(String), + 'graphql.operation': 'persisted GetUser', + 'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + 'graphql.persisted_query.version': 1, + }, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/subject.js b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/subject.js new file mode 100644 index 000000000000..7468bd27a244 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/subject.js @@ -0,0 +1,18 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('POST', 'http://sentry-test.io/graphql'); +xhr.setRequestHeader('Accept', 'application/json'); +xhr.setRequestHeader('Content-Type', 'application/json'); + +const requestBody = JSON.stringify({ + operationName: 'GetUser', + variables: { id: '123' }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + }, + }, +}); + +xhr.send(requestBody); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/test.ts new file mode 100644 index 000000000000..ad62475fa841 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/persistedQuery-xhr/test.ts @@ -0,0 +1,98 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should update spans for GraphQL persisted query XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/graphql', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + data: { + user: { + id: '123', + name: 'Test User', + }, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + const requestSpans = eventData.spans?.filter(({ op }) => op === 'http.client'); + + expect(requestSpans).toHaveLength(1); + + expect(requestSpans![0]).toMatchObject({ + description: 'POST http://sentry-test.io/graphql (persisted GetUser)', + parent_span_id: eventData.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', + data: { + type: 'xhr', + 'http.method': 'POST', + 'http.url': 'http://sentry-test.io/graphql', + url: 'http://sentry-test.io/graphql', + 'server.address': 'sentry-test.io', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + 'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + 'graphql.persisted_query.version': 1, + }, + }); +}); + +sentryTest('should update breadcrumbs for GraphQL persisted query XHR requests', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/graphql', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + data: { + user: { + id: '123', + name: 'Test User', + }, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData?.breadcrumbs?.length).toBe(1); + + expect(eventData.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'POST', + status_code: 200, + url: 'http://sentry-test.io/graphql', + 'graphql.operation': 'persisted GetUser', + 'graphql.persisted_query.hash.sha256': 'ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38', + 'graphql.persisted_query.version': 1, + }, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts index ca9704cc48fe..3f0a37771c66 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/graphqlClient/xhr/test.ts @@ -10,7 +10,6 @@ const query = `query Test{ pet } }`; -const queryPayload = JSON.stringify({ query }); sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { @@ -55,7 +54,7 @@ sentryTest('should update spans for GraphQL XHR requests', async ({ getLocalTest 'server.address': 'sentry-test.io', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.browser', - 'graphql.document': queryPayload, + 'graphql.document': query, }, }); }); diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 5efe172a8edc..505deab7bd44 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -92,8 +92,8 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption // Handle persisted operations - capture hash for debugging if (isPersistedRequest(graphqlBody)) { - span.setAttribute('graphql.persistedQuery.sha256Hash', graphqlBody.extensions.persistedQuery.sha256Hash); - span.setAttribute('graphql.persistedQuery.version', graphqlBody.extensions.persistedQuery.version); + span.setAttribute('graphql.persisted_query.hash.sha256', graphqlBody.extensions.persistedQuery.sha256Hash); + span.setAttribute('graphql.persisted_query.version', graphqlBody.extensions.persistedQuery.version); } } } @@ -128,8 +128,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient } if (isPersistedRequest(graphqlBody)) { - data['graphql.persistedQuery.sha256Hash'] = graphqlBody.extensions.persistedQuery.sha256Hash; - data['graphql.persistedQuery.version'] = graphqlBody.extensions.persistedQuery.version; + data['graphql.persisted_query.hash.sha256'] = graphqlBody.extensions.persistedQuery.sha256Hash; + data['graphql.persisted_query.version'] = graphqlBody.extensions.persistedQuery.version; } } } From a5cce394a9715eb18f2c528c75dfbf895d5f721b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Dec 2025 15:44:48 +0100 Subject: [PATCH 4/5] Combine requestbody check for clarity --- packages/browser/src/integrations/graphqlClient.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 505deab7bd44..d256fa6b72e1 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -248,11 +248,8 @@ export function getGraphQLRequestPayload(payload: string): GraphQLRequestPayload try { const requestBody = JSON.parse(payload); - if (isStandardRequest(requestBody)) { - return requestBody; - } - - if (isPersistedRequest(requestBody)) { + // Return any valid GraphQL request (standard, persisted, or APQ retry with both) + if (isStandardRequest(requestBody) || isPersistedRequest(requestBody)) { return requestBody; } From 5795def0e65c11c8b708f0b433a76a17a60f8413 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 15 Dec 2025 15:47:42 +0100 Subject: [PATCH 5/5] Add changelog entry --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c903d7df829..14a4f2bc7e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Unreleased +### Important Changes + +- **feat(browser): Add support for GraphQL persisted operations ([#18505](https://github.com/getsentry/sentry-javascript/pull/18505))** + +The `graphqlClientIntegration` now supports GraphQL persisted operations (queries). When a persisted query is detected, the integration will capture the operation hash and version as span attributes: + +- `graphql.persisted_query.hash.sha256` - The SHA-256 hash of the persisted query +- `graphql.persisted_query.version` - The version of the persisted query protocol + +Additionally, the `graphql.document` attribute format has changed to align with OpenTelemetry semantic conventions. It now contains only the GraphQL query string instead of the full JSON request payload. + +**Before:** + +```javascript +"graphql.document": "{\"query\":\"query Test { user { id } }\"}" +``` + +**After:** + +```javascript +"graphql.document": "query Test { user { id } }" +``` + +### Other Changes + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott Work in this release was contributed by @sebws. Thank you for your contribution!