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! 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 a467a4a70ff4..d256fa6b72e1 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.persisted_query.hash.sha256', graphqlBody.extensions.persistedQuery.sha256Hash); + span.setAttribute('graphql.persisted_query.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.persisted_query.hash.sha256'] = graphqlBody.extensions.persistedQuery.sha256Hash; + data['graphql.persisted_query.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,34 @@ 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) && + typeof payload.extensions.persistedQuery.sha256Hash === 'string' && + typeof payload.extensions.persistedQuery.version === 'number' + ); +} + /** * Extract the payload of a request if it's GraphQL. * Exported for tests only. @@ -175,20 +245,19 @@ 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); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const isGraphQLRequest = !!requestBody['query']; - if (isGraphQLRequest) { - graphqlBody = requestBody; + // Return any valid GraphQL request (standard, persisted, or APQ retry with both) + if (isStandardRequest(requestBody) || 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..a90b62ee13d5 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,89 @@ 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 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(); + }); }); describe('getRequestPayloadXhrOrFetch', () => { @@ -136,4 +221,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'); + }); + }); });