From 3743c513a6cbcab3dac4633d2a1b28616f3ce4fd Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:49:03 +0200 Subject: [PATCH 1/2] feat(core): Add and use `dataCollection.graphQL` --- packages/astro/test/server/middleware.test.ts | 1 + .../browser/src/integrations/graphqlClient.ts | 11 ++-- .../test/integrations/graphqlClient.test.ts | 26 ++++++++- packages/core/src/types/datacollection.ts | 12 +++++ .../defaultPiiToCollectionOptions.ts | 4 ++ .../resolveDataCollectionOptions.ts | 5 ++ .../lib/integrations/mcp-server/testUtils.ts | 1 + .../defaultPiiToCollectionOptions.test.ts | 4 ++ .../resolveDataCollectionOptions.test.ts | 22 +++++++- packages/core/test/lib/utils/request.test.ts | 1 + .../tracing/graphql/vendored/utils.ts | 8 +-- .../tracing/graphql/addSpanSource.test.ts | 53 +++++++++++++++++++ .../hooks/wrapMiddlewareHandler.test.ts | 1 + packages/remix/test/server/errors.test.ts | 1 + .../src/graphql/graphql-dc-subscriber.ts | 10 ++-- packages/server-utils/src/graphql/utils.ts | 14 ++++- .../tracing-channel/graphql/spans.ts | 6 +-- .../server-utils/test/graphql/utils.test.ts | 46 ++++++++++++++++ 18 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 packages/node/test/integrations/tracing/graphql/addSpanSource.test.ts create mode 100644 packages/server-utils/test/graphql/utils.test.ts diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 208e9a522d0a..09e4b7705740 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -64,6 +64,7 @@ describe('sentryMiddleware', () => { httpHeaders: { request: true, response: true }, httpBodies: [], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index b001fa61f04a..f2d298027f3d 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -11,6 +11,7 @@ import { } from '@sentry/core/browser'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { getBodyString, getFetchRequestArgBody, SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; +import { GRAPHQL_DOCUMENT } from '@sentry/conventions/attributes'; interface GraphQLClientOptions { endpoints: Array; @@ -88,9 +89,9 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const operationInfo = _getGraphQLOperation(graphqlBody); span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); - // Handle standard requests - always capture the query document - if (isStandardRequest(graphqlBody)) { - span.setAttribute('graphql.document', graphqlBody.query); + // Handle standard requests - capture the query document when enabled via dataCollection (default true) + if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { + span.setAttribute(GRAPHQL_DOCUMENT, graphqlBody.query); } // Handle persisted operations - capture hash for debugging @@ -126,8 +127,8 @@ function _updateBreadcrumbWithGraphQLData(client: Client, options: GraphQLClient data['graphql.operation'] = operationInfo; - if (isStandardRequest(graphqlBody)) { - data['graphql.document'] = graphqlBody.query; + if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { + data[GRAPHQL_DOCUMENT] = graphqlBody.query; } if (isPersistedRequest(graphqlBody)) { diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index 225274228d40..ee926e812d01 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -313,7 +313,10 @@ describe('GraphqlClient', () => { }); describe('beforeOutgoingRequestSpan handler', () => { - function setupHandler(endpoints: Array): (span: SentrySpan, hint: FetchHint | XhrHint) => void { + function setupHandler( + endpoints: Array, + graphQLDocument = true, + ): (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) => { @@ -321,6 +324,7 @@ describe('GraphqlClient', () => { capturedListener = cb; } }, + getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }), } as unknown as Client; const integration = graphqlClientIntegration({ endpoints }); @@ -420,5 +424,25 @@ describe('GraphqlClient', () => { expect(json.description).toBe('custom span'); expect(json.data['graphql.document']).toBeUndefined(); }); + + test('omits graphql.document when dataCollection.graphQL.document is false', () => { + const handler = setupHandler([/\/graphql$/], false); + 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); + // The span is still renamed with the operation, but the document is not attached. + expect(json.description).toBe('POST http://localhost:4000/graphql (query GetHello)'); + expect(json.data['graphql.document']).toBeUndefined(); + }); }); }); diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index 8d4de6484544..1200dd0bda7a 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -50,6 +50,17 @@ export interface DataCollection { */ queryParams?: CollectBehavior; + /** + * Controls collection of GraphQL operation data. + * - `document`: collect the GraphQL document (query/mutation source). + * - `variables`: collect the variables passed to GraphQL operations. + * @default { document: true, variables: true } + */ + graphQL?: { + document?: boolean; + variables?: boolean; + }; + /** * Controls generative AI input/output recording. * @default { inputs: true, outputs: true } @@ -77,5 +88,6 @@ export interface DataCollection { */ export type ResolvedDataCollection = Required & { httpHeaders: Required>; + graphQL: Required>; genAI: Required>; }; diff --git a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts index 361275f62f02..92f9e957a4c0 100644 --- a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts @@ -16,6 +16,7 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 7, // default should be 5, but ContextLines integration uses 7 @@ -26,6 +27,9 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve httpHeaders: { request: { deny: PII_HEADER_SNIPPETS }, response: { deny: PII_HEADER_SNIPPETS } }, httpBodies: [], queryParams: { deny: PII_HEADER_SNIPPETS }, + // The GraphQL document has literal values redacted at collection time, so it was historically + // always attached regardless of `sendDefaultPii`; keep it on to preserve that behavior. + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 7, // default should be 5, but ContextLines integration uses 7 diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index 558bb05a468c..eb62bbcce126 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -7,6 +7,7 @@ const DEFAULTS: ResolvedDataCollection = { httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 5, @@ -43,6 +44,10 @@ export function resolveDataCollectionOptions(options: { }, httpBodies: dc.httpBodies ?? base.httpBodies, queryParams: dc.queryParams ?? base.queryParams, + graphQL: { + document: dc.graphQL?.document ?? base.graphQL.document, + variables: dc.graphQL?.variables ?? base.graphQL.variables, + }, genAI: { inputs: dc.genAI?.inputs ?? base.genAI.inputs, outputs: dc.genAI?.outputs ?? base.genAI.outputs, diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index fce9bf7d2ae8..15334a47966e 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -17,6 +17,7 @@ export function createMockClient(userInfo = true, genAI?: { inputs: boolean; out httpHeaders: { request: true, response: true }, httpBodies: [], queryParams: true, + graphQL: { document: true, variables: true }, genAI: genAIOptions, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts index fcd9463e7f0f..ce4b8b519097 100644 --- a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts @@ -9,6 +9,7 @@ describe('defaultPiiToCollectionOptions', () => { httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 7, @@ -25,6 +26,7 @@ describe('defaultPiiToCollectionOptions', () => { }, httpBodies: [], queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 7, @@ -41,6 +43,7 @@ describe('defaultPiiToCollectionOptions', () => { }, httpBodies: [], queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 7, @@ -57,6 +60,7 @@ describe('defaultPiiToCollectionOptions', () => { }, httpBodies: [], queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 7, diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 2cd76e599a05..d00935d8b6b0 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -8,6 +8,7 @@ describe('resolveDataCollectionOptions', () => { httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 5, @@ -21,6 +22,8 @@ describe('resolveDataCollectionOptions', () => { expect(result.userInfo).toBe(false); expect(result.httpBodies).toEqual([]); expect(result.genAI).toEqual({ inputs: false, outputs: false }); + // GraphQL documents are redacted at collection time, so they stay on to preserve legacy behavior. + expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.stackFrameVariables).toBe(true); expect(result.frameContextLines).toBe(7); }); @@ -45,6 +48,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.httpHeaders).toEqual({ request: true, response: true }); expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); expect(result.queryParams).toBe(true); + expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); }); @@ -54,6 +58,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.userInfo).toBe(false); expect(result.httpBodies).toEqual([]); expect(result.genAI).toEqual({ inputs: false, outputs: false }); + expect(result.graphQL).toEqual({ document: true, variables: true }); }); }); @@ -87,6 +92,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.cookies).toBe(true); expect(result.httpHeaders).toEqual({ request: true, response: true }); expect(result.queryParams).toBe(true); + expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.stackFrameVariables).toBe(true); expect(result.frameContextLines).toBe(5); @@ -114,6 +120,17 @@ describe('resolveDataCollectionOptions', () => { expect(result.genAI.outputs).toBe(true); }); + it('merges nested graphQL partially', () => { + const result = resolveDataCollectionOptions({ + dataCollection: { + graphQL: { document: false }, + }, + }); + + expect(result.graphQL.document).toBe(false); + expect(result.graphQL.variables).toBe(true); + }); + it('supports allow/deny list for cookies', () => { const result = resolveDataCollectionOptions({ dataCollection: { @@ -139,7 +156,7 @@ describe('resolveDataCollectionOptions', () => { it('always returns all fields', () => { const result = resolveDataCollectionOptions({}); - expect(Object.keys(result)).toHaveLength(8); + expect(Object.keys(result)).toHaveLength(9); expect(result).toHaveProperty('userInfo'); expect(result).toHaveProperty('cookies'); expect(result).toHaveProperty('httpHeaders'); @@ -147,6 +164,9 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('httpHeaders.response'); expect(result).toHaveProperty('httpBodies'); expect(result).toHaveProperty('queryParams'); + expect(result).toHaveProperty('graphQL'); + expect(result).toHaveProperty('graphQL.document'); + expect(result).toHaveProperty('graphQL.variables'); expect(result).toHaveProperty('genAI'); expect(result).toHaveProperty('genAI.inputs'); expect(result).toHaveProperty('genAI.outputs'); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 609dde8a05ab..31acb909000b 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -874,6 +874,7 @@ describe('request utils', () => { httpHeaders: { request: true, response: true }, httpBodies: [], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts index 1a224339718c..ce3562e67d35 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/utils.ts @@ -23,7 +23,7 @@ import type { Token, } from './graphql-types'; import type { Span, SpanAttributes } from '@sentry/core'; -import { isObjectLike, SPAN_STATUS_ERROR, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import { getClient, isObjectLike, SPAN_STATUS_ERROR, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { AllowedOperationTypes, SpanNames, TokenKind } from './enum'; import { AttributeNames } from './enums/AttributeNames'; import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; @@ -38,8 +38,10 @@ export const isPromise = (value: any): value is Promise => { }; export function addSpanSource(span: Span, loc?: Location, start?: number, end?: number): void { - const source = getSourceFromLocation(loc, start, end); - span.setAttribute(AttributeNames.SOURCE, source); + if (getClient()?.getDataCollectionOptions().graphQL.document === true) { + const source = getSourceFromLocation(loc, start, end); + span.setAttribute(AttributeNames.SOURCE, source); + } } function createFieldIfNotExists( diff --git a/packages/node/test/integrations/tracing/graphql/addSpanSource.test.ts b/packages/node/test/integrations/tracing/graphql/addSpanSource.test.ts new file mode 100644 index 000000000000..7df7df97ead1 --- /dev/null +++ b/packages/node/test/integrations/tracing/graphql/addSpanSource.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as core from '@sentry/core'; +import { addSpanSource } from '../../../../src/integrations/tracing/graphql/vendored/utils'; +import type { Location, Token } from '@sentry/server-utils/orchestrion'; + +vi.spyOn(core, 'getClient'); + +function mockClient(graphQLDocument: boolean): void { + vi.mocked(core.getClient).mockReturnValue({ + getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }), + } as any); +} + +function makeLocation(): Location { + const token: Token = { + kind: 'Name', + start: 0, + end: 5, + line: 1, + column: 1, + value: 'hello', + prev: null, + next: null, + }; + return { start: 0, end: 5, startToken: token }; +} + +describe('addSpanSource dataCollection gate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets graphql.source attribute when graphQL.document is true', () => { + mockClient(true); + const span = { setAttribute: vi.fn() } as any; + addSpanSource(span, makeLocation()); + expect(span.setAttribute).toHaveBeenCalledWith('graphql.source', expect.any(String)); + }); + + it('does not set attribute when graphQL.document is false', () => { + mockClient(false); + const span = { setAttribute: vi.fn() } as any; + addSpanSource(span, makeLocation()); + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not set attribute when getClient() is undefined', () => { + vi.mocked(core.getClient).mockReturnValue(undefined); + const span = { setAttribute: vi.fn() } as any; + addSpanSource(span, makeLocation()); + expect(span.setAttribute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index d022eef4492f..e4bf8fea1559 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -47,6 +47,7 @@ describe('wrapMiddlewareHandlerWithSentry', () => { httpHeaders: { request: true, response: true }, httpBodies: [], queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/remix/test/server/errors.test.ts b/packages/remix/test/server/errors.test.ts index a3faf2dfdfdc..b1f6bf887e51 100644 --- a/packages/remix/test/server/errors.test.ts +++ b/packages/remix/test/server/errors.test.ts @@ -17,6 +17,7 @@ function createMockClient(httpBodies: string[] = []): Client { httpHeaders: { request: true, response: true }, httpBodies, queryParams: true, + graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, stackFrameVariables: true, frameContextLines: 5, diff --git a/packages/server-utils/src/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/graphql/graphql-dc-subscriber.ts index 3b6c402578ac..cd4337954dc4 100644 --- a/packages/server-utils/src/graphql/graphql-dc-subscriber.ts +++ b/packages/server-utils/src/graphql/graphql-dc-subscriber.ts @@ -9,7 +9,7 @@ import { } from '@sentry/core'; import { bindTracingChannelToSpan } from '../tracing-channel'; import type { GraphqlDocumentNode } from './utils'; -import { getOperationSpanName, hasResultErrors, redactGraphqlDocument, renameRootSpanWithOperation } from './utils'; +import { collectGraphqlDocument, getOperationSpanName, hasResultErrors, renameRootSpanWithOperation } from './utils'; // Channel names published by graphql >= 17.0.0 (see graphql-js `src/diagnostics.ts`). // Hardcoded so the subscriber does not have to import graphql — the channels just @@ -160,14 +160,12 @@ function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): voi bindTracingChannelToSpan( tracingChannel(GRAPHQL_DC_CHANNEL_VALIDATE), data => { - const document = redactGraphqlDocument(data.document); - return startInactiveSpan({ name: SPAN_NAME_VALIDATE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, - [GRAPHQL_DOCUMENT]: document, + [GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document), }, }); }, @@ -191,8 +189,6 @@ function setupOperationChannel( bindTracingChannelToSpan( tracingChannel(channelName), data => { - const document = redactGraphqlDocument(data.document); - const span = startInactiveSpan({ name: getOperationSpanName(data.operationType, data.operationName, fallbackName), attributes: { @@ -200,7 +196,7 @@ function setupOperationChannel( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, [GRAPHQL_OPERATION_TYPE]: data.operationType, [GRAPHQL_OPERATION_NAME]: data.operationName || undefined, - [GRAPHQL_DOCUMENT]: document, + [GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document), }, }); diff --git a/packages/server-utils/src/graphql/utils.ts b/packages/server-utils/src/graphql/utils.ts index 810ed1fad630..97464c4d7311 100644 --- a/packages/server-utils/src/graphql/utils.ts +++ b/packages/server-utils/src/graphql/utils.ts @@ -1,6 +1,6 @@ import { SENTRY_GRAPHQL_OPERATION } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; -import { isObjectLike, getRootSpan, spanToJSON } from '@sentry/core'; +import { getClient, isObjectLike, getRootSpan, spanToJSON } from '@sentry/core'; // Same key the OTel path uses, so renames stay consistent across both. const ORIGINAL_DESCRIPTION_ATTRIBUTE = 'original-description'; @@ -116,7 +116,7 @@ export function hasResultErrors(result: unknown): boolean { * prevents raw inline values (potential PII) from leaving the process. Variable values are never * included. Returns `undefined` (rather than throwing) on anything it cannot serialize. */ -export function redactGraphqlDocument(document: GraphqlDocumentNode | undefined): string | undefined { +function redactGraphqlDocument(document: GraphqlDocumentNode | undefined): string | undefined { const loc = document?.loc; const body = loc?.source?.body; if (typeof body !== 'string' || !loc?.startToken) { @@ -146,3 +146,13 @@ export function redactGraphqlDocument(document: GraphqlDocumentNode | undefined) return undefined; } } + +/** + * Returns the redacted document if `dataCollection.graphQL.document` is enabled, `undefined` otherwise. + */ +export function collectGraphqlDocument(document: GraphqlDocumentNode | undefined): string | undefined { + if (getClient()?.getDataCollectionOptions().graphQL.document !== true) { + return undefined; + } + return redactGraphqlDocument(document); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts index a04641c9ade3..e308c546cfc1 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts @@ -16,9 +16,9 @@ import { } from '@sentry/core'; import type { GraphqlDocumentNode } from '../../../graphql/utils'; import { + collectGraphqlDocument, getOperationSpanName, hasResultErrors, - redactGraphqlDocument, renameRootSpanWithOperation, } from '../../../graphql/utils'; import { GRAPHQL_DATA_SYMBOL, ORIGIN, SPAN_NAME_EXECUTE, SPAN_NAME_PARSE, SPAN_NAME_VALIDATE } from './constants'; @@ -45,7 +45,7 @@ export function startParseSpan(): Span { export function startValidateSpan(documentAST: unknown): Span { return startInactiveSpan({ name: SPAN_NAME_VALIDATE, - attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: redactGraphqlDocument(documentAST as GraphqlDocumentNode) }, + attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: collectGraphqlDocument(documentAST as GraphqlDocumentNode) }, }); } @@ -161,7 +161,7 @@ export function startExecuteSpan( ...BASE_ATTRIBUTES, [GRAPHQL_OPERATION_TYPE]: operationType, [GRAPHQL_OPERATION_NAME]: operationName || undefined, - [GRAPHQL_DOCUMENT]: redactGraphqlDocument(document as GraphqlDocumentNode | undefined), + [GRAPHQL_DOCUMENT]: collectGraphqlDocument(document as GraphqlDocumentNode | undefined), }, }); diff --git a/packages/server-utils/test/graphql/utils.test.ts b/packages/server-utils/test/graphql/utils.test.ts new file mode 100644 index 000000000000..8b7ebbd95a66 --- /dev/null +++ b/packages/server-utils/test/graphql/utils.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as core from '@sentry/core'; +import { collectGraphqlDocument } from '../../src/graphql/utils'; +import type { GraphqlDocumentNode } from '../../src/graphql/utils'; + +vi.spyOn(core, 'getClient'); + +function mockClient(graphQLDocument: boolean): void { + vi.mocked(core.getClient).mockReturnValue({ + getDataCollectionOptions: () => ({ graphQL: { document: graphQLDocument, variables: true } }), + } as any); +} + +function makeDocument(body: string): GraphqlDocumentNode { + const token = { kind: 'Name', start: 0, end: body.length, next: null }; + return { + loc: { + startToken: token, + source: { body }, + }, + }; +} + +describe('collectGraphqlDocument', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the redacted document when graphQL.document is true', () => { + mockClient(true); + const doc = makeDocument('{ hello }'); + expect(collectGraphqlDocument(doc)).toBe('{ hello }'); + }); + + it('returns undefined when graphQL.document is false', () => { + mockClient(false); + const doc = makeDocument('{ hello }'); + expect(collectGraphqlDocument(doc)).toBeUndefined(); + }); + + it('returns undefined when getClient() is undefined', () => { + vi.mocked(core.getClient).mockReturnValue(undefined); + const doc = makeDocument('{ hello }'); + expect(collectGraphqlDocument(doc)).toBeUndefined(); + }); +}); From a5ef307343dfb7ff5234a93392908fddfd6de2b3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:48:36 +0200 Subject: [PATCH 2/2] improve JSDoc --- packages/core/src/types/datacollection.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index 1200dd0bda7a..995ea1159442 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -51,9 +51,13 @@ export interface DataCollection { queryParams?: CollectBehavior; /** - * Controls collection of GraphQL operation data. - * - `document`: collect the GraphQL document (query/mutation source). - * - `variables`: collect the variables passed to GraphQL operations. + * Allow collection of GraphQL operation data when using the SDK's GraphQL integrations + * + * These options do not add GraphQL instrumentation on their own. The corresponding integration must still be enabled + * for any data to be captured. + * + * - `document`: attach the GraphQL document (query/mutation source text). + * - `variables`: attach the variables passed to GraphQL operations. * @default { document: true, variables: true } */ graphQL?: { @@ -62,7 +66,11 @@ export interface DataCollection { }; /** - * Controls generative AI input/output recording. + * Allow generative AI input/output recording when using the SDK's AI integrations. + * + * These options do not add AI instrumentation on their own. The corresponding integration must still be enabled for + * any data to be captured. Integration-level options, when set, take precedence over these + * values. * @default { inputs: true, outputs: true } */ genAI?: {