diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/instrument.mjs new file mode 100644 index 000000000000..aed7c6310653 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.graphqlIntegration({ ignoreResolveSpans: false })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/scenario.mjs new file mode 100644 index 000000000000..c119d185a29a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/scenario.mjs @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { graphql, GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; + +// Built programmatically (not via `buildSchema(sdl)`) so no `graphql:parse` fires at module load. +const UserType = new GraphQLObjectType({ + name: 'User', + // `name` has no resolver, so it uses graphql's default property resolver (a "trivial" resolve). + fields: { name: { type: GraphQLString } }, +}); + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hello: { type: GraphQLString, resolve: () => 'world' }, + user: { + type: UserType, + args: { id: { type: new GraphQLNonNull(GraphQLInt) } }, + resolve: (_, { id }) => ({ name: `user-${id}` }), + }, + }, + }), +}); + +async function run() { + await new Promise(resolve => setTimeout(resolve, 100)); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + await graphql({ schema, source: '{ hello }' }); + await graphql({ schema, source: 'query GetUser { user(id: 1) { name } }' }); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/test.ts b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/test.ts new file mode 100644 index 000000000000..24be135829f6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/resolvers/test.ts @@ -0,0 +1,61 @@ +import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +// With `ignoreResolveSpans: false`, the channel path also subscribes `graphql:resolve` and emits a +// span per non-trivial field resolver. `ignoreTrivialResolveSpans` defaults to true, so graphql's +// default property resolver (the `name` field) is skipped. graphql 17 requires Node >= 22. +conditionalTest({ min: 22 })('GraphQL tracing channel Test > resolve spans', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const expectedResolveSpan = (path: string, fieldName: string, parentName: string) => + expect.objectContaining({ + description: `graphql.resolve ${path}`, + op: 'graphql', + origin: 'auto.graphql.diagnostic_channel', + data: expect.objectContaining({ + 'graphql.field.name': fieldName, + 'graphql.field.path': path, + 'graphql.parent.name': parentName, + }), + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ description: 'query', op: 'graphql' }), + expect.objectContaining({ description: 'query GetUser', op: 'graphql' }), + expectedResolveSpan('hello', 'hello', 'Query'), + expectedResolveSpan('user', 'user', 'Query'), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('emits resolver spans when ignoreResolveSpans is false', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + + test('skips the default property resolver (trivial resolve) by default', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + // `user.name` uses graphql's default property resolver, so no span is emitted for it. + expect(spans.find(span => span.description === 'graphql.resolve user.name')).toBeUndefined(); + // ...but the user-defined resolvers do produce spans. + expect(spans.find(span => span.description === 'graphql.resolve user')).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { graphql: '^17' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/scenario.mjs new file mode 100644 index 000000000000..3c6b892ace1e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/scenario.mjs @@ -0,0 +1,71 @@ +import * as Sentry from '@sentry/node'; +import { + graphql, + GraphQLBoolean, + GraphQLInt, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +} from 'graphql'; + +// Build the schema programmatically rather than via `buildSchema(sdl)`: the SDL form parses at module +// load, which would publish a `graphql:parse` event outside any transaction (an orphan span). +const UserType = new GraphQLObjectType({ + name: 'User', + fields: { name: { type: GraphQLString } }, +}); + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hello: { type: GraphQLString, resolve: () => 'world' }, + user: { + type: UserType, + args: { id: { type: new GraphQLNonNull(GraphQLInt) } }, + resolve: (_, { id }) => ({ name: `user-${id}` }), + }, + boom: { + type: GraphQLString, + resolve: () => { + throw new Error('resolver failed'); + }, + }, + }, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + login: { + type: GraphQLBoolean, + args: { email: { type: new GraphQLNonNull(GraphQLString) } }, + resolve: (_, { email }) => Boolean(email), + }, + }, + }), +}); + +async function run() { + // Let the integration's deferred channel subscription and OpenTelemetry's async-context setup + // finish before issuing operations. In a real server graphql runs per-request, long after init; + // here it would otherwise race init within the same tick. + await new Promise(resolve => setTimeout(resolve, 100)); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + await graphql({ schema, source: '{ hello }' }); + await graphql({ schema, source: 'query GetUser { user(id: 42) { name } }' }); + // Inline literal carries a value, to assert it is redacted out of `graphql.document`. + await graphql({ schema, source: 'mutation Login { login(email: "secret@example.com") }' }); + // A resolver throw surfaces as `result.errors`, which must flag the execute span as errored. + await graphql({ schema, source: 'query Boom { boom }' }); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/test.ts new file mode 100644 index 000000000000..40acefedff7b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/graphql-tracing-channel/test.ts @@ -0,0 +1,115 @@ +import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// graphql >= 17 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes to +// those channels (`subscribeGraphqlDiagnosticChannels`) instead of the vendored OTel patcher. This +// suite pins `^17` and asserts the diagnostics-channel path: graphql semconv attributes, redacted +// document text, span relationships, and that the legacy OTel path does NOT also fire (no double +// instrumentation). graphql 17 requires Node >= 22, so this suite is skipped on older Node. +conditionalTest({ min: 22 })('GraphQL tracing channel Test', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const expectedExecuteSpan = (description: string, extraData: Record = {}) => + expect.objectContaining({ + description, + op: 'graphql', + origin: 'auto.graphql.diagnostic_channel', + data: expect.objectContaining(extraData), + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ description: 'graphql.parse', op: 'graphql' }), + expect.objectContaining({ description: 'graphql.validate', op: 'graphql' }), + // anonymous query -> span named after the operation type only + expectedExecuteSpan('query', { 'graphql.operation.type': 'query' }), + expectedExecuteSpan('query GetUser', { + 'graphql.operation.type': 'query', + 'graphql.operation.name': 'GetUser', + // the inline `42` literal is redacted out of the document + 'graphql.document': 'query GetUser { user(id: *) { name } }', + }), + expectedExecuteSpan('mutation Login', { + 'graphql.operation.type': 'mutation', + // the inline email literal must be redacted to `"*"`, so the raw value can never leak + 'graphql.document': 'mutation Login { login(email: "*") }', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('subscribes to graphql >= 17 diagnostics channels with graphql semconv attributes', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + + test('does not double-instrument: the vendored OTel graphql patcher does not fire on 17', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + // The vendored OTel path (origin `auto.graphql.otel.graphql`) must be inactive on 17+. + expect(spans.find(span => span.origin === 'auto.graphql.otel.graphql')).toBeUndefined(); + // ...while the diagnostics-channel path is active. + expect(spans.find(span => span.origin === 'auto.graphql.diagnostic_channel')).toBeDefined(); + }, + }) + .start() + .completed(); + }); + + test('never leaks raw inline literal values into graphql.document', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + for (const span of spans) { + const document = span.data?.['graphql.document']; + if (typeof document === 'string') { + expect(document).not.toContain('secret@example.com'); + } + } + }, + }) + .start() + .completed(); + }); + + test('flags the execute span as errored when a resolver throws', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + const boomSpan = spans.find(span => span.description === 'query Boom'); + expect(boomSpan).toBeDefined(); + expect(boomSpan?.status).toBe('internal_error'); + }, + }) + .start() + .completed(); + }); + + test('parents the execute span to the surrounding transaction', async () => { + await createTestRunner() + .expect({ + transaction: event => { + const spans = event.spans || []; + const executeSpan = spans.find(span => span.description === 'query GetUser'); + expect(executeSpan).toBeDefined(); + expect(executeSpan?.parent_span_id).toBe(event.contexts?.trace?.span_id); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { graphql: '^17' } }, + ); +}); diff --git a/packages/node/src/integrations/tracing/graphql/index.ts b/packages/node/src/integrations/tracing/graphql/index.ts index 780e5bd3a0ba..3979cf6e7ff7 100644 --- a/packages/node/src/integrations/tracing/graphql/index.ts +++ b/packages/node/src/integrations/tracing/graphql/index.ts @@ -1,7 +1,8 @@ import { GraphQLInstrumentation } from './vendored/instrumentation'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import { graphqlIntegration as graphqlChannelIntegration } from '@sentry/server-utils'; interface GraphqlOptions { /** @@ -41,7 +42,10 @@ export const instrumentGraphql = generateInstrumentOnce( ); const _graphqlIntegration = ((options: GraphqlOptions = {}) => { - return { + // The diagnostics_channel subscription (graphql >= 17) lives in server-utils so it is shared + // across server runtimes; we extend it here to also run the vendored OTel patcher for graphql < 17. + // Both paths read the same `ignoreResolveSpans` / `ignoreTrivialResolveSpans` options. + return extendIntegration(graphqlChannelIntegration(getOptionsWithDefaults(options)), { name: INTEGRATION_NAME, setupOnce() { // We set defaults here, too, because otherwise we'd update the instrumentation config @@ -49,7 +53,7 @@ const _graphqlIntegration = ((options: GraphqlOptions = {}) => { // when being called the second time instrumentGraphql(getOptionsWithDefaults(options)); }, - }; + }); }) satisfies IntegrationFn; /** diff --git a/packages/server-utils/src/graphql/graphql-dc-subscriber.ts b/packages/server-utils/src/graphql/graphql-dc-subscriber.ts new file mode 100644 index 000000000000..14fb59ac4ea1 --- /dev/null +++ b/packages/server-utils/src/graphql/graphql-dc-subscriber.ts @@ -0,0 +1,328 @@ +import type { TracingChannel } from 'node:diagnostics_channel'; +import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from '@sentry/conventions/attributes'; +import { WEB_SERVER_GRAPHQL_SPAN_OP } from '@sentry/conventions/op'; +import { + debug, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +// 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 +// have to be subscribed to before the user's graphql code publishes. +export const GRAPHQL_DC_CHANNEL_PARSE = 'graphql:parse'; +export const GRAPHQL_DC_CHANNEL_VALIDATE = 'graphql:validate'; +export const GRAPHQL_DC_CHANNEL_EXECUTE = 'graphql:execute'; +export const GRAPHQL_DC_CHANNEL_SUBSCRIBE = 'graphql:subscribe'; +export const GRAPHQL_DC_CHANNEL_RESOLVE = 'graphql:resolve'; + +const ORIGIN = 'auto.graphql.diagnostic_channel'; + +const SPAN_NAME_PARSE = 'graphql.parse'; +const SPAN_NAME_VALIDATE = 'graphql.validate'; +const SPAN_NAME_EXECUTE = 'graphql.execute'; +const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe'; +const SPAN_NAME_RESOLVE = 'graphql.resolve'; + +// Field-level attributes for resolver spans. Not in `@sentry/conventions`; these match the keys the +// vendored OTel instrumentation emits so there is no drift between the two paths. +const GRAPHQL_FIELD_NAME = 'graphql.field.name'; +const GRAPHQL_FIELD_PATH = 'graphql.field.path'; +const GRAPHQL_FIELD_TYPE = 'graphql.field.type'; +const GRAPHQL_PARENT_NAME = 'graphql.parent.name'; + +// graphql-js token kinds whose values may carry user data (literal arguments). We +// replace them in the serialized document so raw inline values can never reach +// `graphql.document`. Mirrors the legacy OTel instrumentation's redaction set. +const REDACTED_LITERAL_KINDS = new Set(['Int', 'Float', 'String', 'BlockString']); + +/** Minimal shape of a graphql-js lexer token, enough to locate literal spans for redaction. */ +interface GraphqlToken { + kind: string; + start: number; + end: number; + next?: GraphqlToken | null; +} + +/** Minimal shape of a parsed graphql-js `DocumentNode`, enough to read its source and tokens. */ +interface GraphqlDocumentNode { + loc?: { + startToken?: GraphqlToken; + source?: { body?: string }; + }; +} + +/** Context published on the sync-only `graphql:parse` channel. */ +export interface GraphqlParseData { + source: string | { body?: string }; + result?: GraphqlDocumentNode; + error?: unknown; +} + +/** Context published on the sync-only `graphql:validate` channel. */ +export interface GraphqlValidateData { + document: GraphqlDocumentNode; + /** Validation errors returned by validation; an empty array means the document is valid. */ + result?: ReadonlyArray; + error?: unknown; +} + +/** + * Context published on the `graphql:execute` and `graphql:subscribe` channels. + * + * `result` carries an `ExecutionResult` (or, for subscriptions, an async generator); GraphQL errors + * collected during execution surface on `result.errors` rather than as the channel's `error` + * lifecycle event, which only fires on an abrupt throw. + */ +export interface GraphqlOperationData { + document: GraphqlDocumentNode; + operationName?: string; + operationType?: string; + result?: unknown; + error?: unknown; +} + +/** + * Context published on the per-field `graphql:resolve` channel. + * + * A resolver throw or rejection publishes the `error` lifecycle event here; the same failure also + * surfaces in the enclosing execution result. + */ +export interface GraphqlResolveData { + fieldName: string; + parentType: string; + fieldType: string; + fieldPath: string; + /** Whether the field is handled by graphql's default property resolver (vs. a user resolver). */ + isDefaultResolver: boolean; + alias?: string; + args?: unknown; + result?: unknown; + error?: unknown; +} + +/** Options controlling which graphql channels the subscriber emits spans for. */ +export interface GraphqlDiagnosticChannelsOptions { + /** + * Do not create spans for resolvers. Resolver spans are per-field and can be very high volume. + * Defaults to `true`. + */ + ignoreResolveSpans?: boolean; + + /** + * When resolver spans are enabled, do not create them for graphql's default property resolver + * (fields without a user-defined resolver), which are rarely interesting. Defaults to `true`. + */ + ignoreTrivialResolveSpans?: boolean; +} + +/** + * Platform-provided factory that creates a native tracing channel for the given name. The + * subscriber binds the span and its lifecycle onto the channel via `bindTracingChannelToSpan`, + * which propagates the active span through the runtime's async context. + * + * Node passes `node:diagnostics_channel`'s `tracingChannel` directly. + */ +export type GraphqlTracingChannelFactory = (name: string) => TracingChannel; + +let subscribed = false; + +/** + * Subscribe Sentry span handlers to graphql's diagnostics-channel events + * (`graphql:parse`, `:validate`, `:execute`, `:subscribe`), published by graphql >= 17.0.0. + * + * On older graphql versions the channels are never published to, so the subscribers are inert — + * there is no double-instrumentation against the vendored OTel patcher, which is gated to `< 17`. + * + * The per-field `graphql:resolve` channel is only subscribed when `ignoreResolveSpans` is `false`: + * resolver spans are per-field and can be extremely high-volume, so they are off by default (matching + * the legacy OTel path). When enabled, `ignoreTrivialResolveSpans` (default `true`) additionally skips + * graphql's default property resolver. + * + * Idempotent: subsequent calls are a no-op. + */ +export function subscribeGraphqlDiagnosticChannels( + tracingChannel: GraphqlTracingChannelFactory, + options: GraphqlDiagnosticChannelsOptions = {}, +): void { + if (subscribed) { + return; + } + subscribed = true; + + const ignoreResolveSpans = options.ignoreResolveSpans !== false; + const ignoreTrivialResolveSpans = options.ignoreTrivialResolveSpans !== false; + + try { + setupParseChannel(tracingChannel); + setupValidateChannel(tracingChannel); + setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_EXECUTE, SPAN_NAME_EXECUTE); + setupOperationChannel(tracingChannel, GRAPHQL_DC_CHANNEL_SUBSCRIBE, SPAN_NAME_SUBSCRIBE); + + if (!ignoreResolveSpans) { + setupResolveChannel(tracingChannel, ignoreTrivialResolveSpans); + } + } catch { + // The factory relies on `node:diagnostics_channel`, which isn't always + // available. Fail closed; the SDK simply won't emit graphql spans here. + DEBUG_BUILD && debug.log('GraphQL node:diagnostics_channel subscription failed.'); + } +} + +function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void { + bindTracingChannelToSpan(tracingChannel(GRAPHQL_DC_CHANNEL_PARSE), () => + startInactiveSpan({ + name: SPAN_NAME_PARSE, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, + }, + }), + ); +} + +function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): void { + 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, + ...(document != null ? { [GRAPHQL_DOCUMENT]: document } : {}), + }, + }); + }, + { + beforeSpanEnd: (span, data) => { + // Validation completes normally even when it returns errors, so flag the span here. + if (Array.isArray(data.result) && data.result.length > 0) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + } + }, + }, + ); +} + +function setupOperationChannel( + tracingChannel: GraphqlTracingChannelFactory, + channelName: string, + fallbackName: string, +): void { + bindTracingChannelToSpan( + tracingChannel(channelName), + data => { + const document = redactGraphqlDocument(data.document); + + return startInactiveSpan({ + name: getOperationSpanName(data, fallbackName), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, + ...(data.operationType != null ? { [GRAPHQL_OPERATION_TYPE]: data.operationType } : {}), + ...(data.operationName != null ? { [GRAPHQL_OPERATION_NAME]: data.operationName } : {}), + ...(document != null ? { [GRAPHQL_DOCUMENT]: document } : {}), + }, + }); + }, + { + beforeSpanEnd: (span, data) => { + // GraphQL errors are returned on `result.errors`, not as a thrown error, so flag the span here. + if (hasResultErrors(data.result)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } + }, + }, + ); +} + +function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignoreTrivialResolveSpans: boolean): void { + bindTracingChannelToSpan(tracingChannel(GRAPHQL_DC_CHANNEL_RESOLVE), data => { + // Returning `undefined` opts this field out: no span is created and the active context is left + // untouched, so the field still resolves under its parent span. + if (ignoreTrivialResolveSpans && data.isDefaultResolver) { + return undefined; + } + + return startInactiveSpan({ + name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, + [GRAPHQL_FIELD_NAME]: data.fieldName, + [GRAPHQL_FIELD_PATH]: data.fieldPath, + [GRAPHQL_FIELD_TYPE]: data.fieldType, + [GRAPHQL_PARENT_NAME]: data.parentType, + }, + }); + }); +} + +/** + * Span name follows the GraphQL semantic conventions: ` ` when both + * are available, `` when only the type is, otherwise a static fallback. + */ +function getOperationSpanName(data: GraphqlOperationData, fallbackName: string): string { + const { operationType, operationName } = data; + if (operationType && operationName) { + return `${operationType} ${operationName}`; + } + if (operationType) { + return operationType; + } + + return fallbackName; +} + +function hasResultErrors(result: unknown): boolean { + if (result && typeof result === 'object' && 'errors' in result) { + const errors = (result as { errors?: unknown }).errors; + + return Array.isArray(errors) && errors.length > 0; + } + + return false; +} + +/** + * Serialize a parsed document into `graphql.document` while redacting every literal argument value: + * the original source text is preserved verbatim except that string/number literal spans are + * replaced (`"foo"` -> `"*"`, `42` -> `*`). graphql does not sanitize its channel payload, so this + * 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. + */ +function redactGraphqlDocument(document: GraphqlDocumentNode | undefined): string | undefined { + const loc = document?.loc; + const body = loc?.source?.body; + if (typeof body !== 'string' || !loc?.startToken) { + return undefined; + } + + try { + // Collect literal token spans, then splice them out back-to-front so earlier offsets stay valid. + const ranges: Array<{ start: number; end: number; kind: string }> = []; + for (let token: GraphqlToken | null | undefined = loc.startToken; token; token = token.next) { + if (REDACTED_LITERAL_KINDS.has(token.kind)) { + ranges.push({ start: token.start, end: token.end, kind: token.kind }); + } + } + + let out = body; + for (let i = ranges.length - 1; i >= 0; i--) { + const { start, end, kind } = ranges[i]!; + const replacement = kind === 'String' || kind === 'BlockString' ? '"*"' : '*'; + out = out.slice(0, start) + replacement + out.slice(end); + } + + return out; + } catch { + return undefined; + } +} diff --git a/packages/server-utils/src/graphql/index.ts b/packages/server-utils/src/graphql/index.ts new file mode 100644 index 000000000000..7809e8db7cf5 --- /dev/null +++ b/packages/server-utils/src/graphql/index.ts @@ -0,0 +1,31 @@ +import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import * as dc from 'node:diagnostics_channel'; +import { type GraphqlDiagnosticChannelsOptions, subscribeGraphqlDiagnosticChannels } from './graphql-dc-subscriber'; + +const _graphqlIntegration = ((options: GraphqlDiagnosticChannelsOptions = {}) => { + return { + name: 'Graphql', + setupOnce() { + // Bail on Node <= 18.18.0, where `tracingChannel` does not exist. + if (!dc.tracingChannel) { + return; + } + + // Subscribe to graphql's native tracing channels (graphql >= 17). + // This is a no-op on versions that don't publish to the channels, so it is always safe to call. + // `bindTracingChannelToSpan` (inside the subscriber) makes the span the active context via + // `bindStore`, which needs the Sentry OTel context manager — `initOpenTelemetry()` registers + // that after `setupOnce`, so defer a tick. + void Promise.resolve().then(() => subscribeGraphqlDiagnosticChannels(dc.tracingChannel, options)); + }, + }; +}) satisfies IntegrationFn; + +/** + * Auto-instrument the [graphql](https://www.npmjs.com/package/graphql) library via its native + * `node:diagnostics_channel` tracing channels (graphql >= 17). + * + * On older graphql versions the channels are never published to, so this integration is inert and + * the vendored OTel instrumentation (gated to `< 17`) handles instrumentation instead. + */ +export const graphqlIntegration = defineIntegration(_graphqlIntegration); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index b66a789f53d3..f2b6567b7f58 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -4,6 +4,7 @@ * @module */ +export { graphqlIntegration } from './graphql'; export { IOREDIS_DC_CHANNEL_COMMAND, IOREDIS_DC_CHANNEL_CONNECT, diff --git a/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve-trivial.test.ts b/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve-trivial.test.ts new file mode 100644 index 000000000000..67eb78f86832 --- /dev/null +++ b/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve-trivial.test.ts @@ -0,0 +1,44 @@ +import { getCurrentScope, getGlobalScope, setAsyncContextStrategy, spanToJSON } from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + GRAPHQL_DC_CHANNEL_RESOLVE, + subscribeGraphqlDiagnosticChannels, +} from '../../src/graphql/graphql-dc-subscriber'; +import { factory, initTestClient, installTestAsyncContextStrategy, traceOperation } from './helpers'; + +const resolveData = { + fieldName: 'name', + parentType: 'User', + fieldType: 'String', + fieldPath: 'user.name', + isDefaultResolver: true, +}; + +// Own file so it can subscribe with its own options (see sibling resolve test for why). Here: resolve +// spans on AND trivial spans kept, so even graphql's default property resolver gets a span. +describe('subscribeGraphqlDiagnosticChannels (resolve + trivial spans enabled)', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false, ignoreTrivialResolveSpans: false }); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + initTestClient(); + }); + + afterEach(() => { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + it('emits a span for the default resolver', async () => { + const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_RESOLVE, resolveData, { result: 'a' }); + expect(spanToJSON(span!).description).toBe('graphql.resolve user.name'); + }); +}); diff --git a/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve.test.ts b/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve.test.ts new file mode 100644 index 000000000000..de418a644d8b --- /dev/null +++ b/packages/server-utils/test/graphql/graphql-dc-subscriber-resolve.test.ts @@ -0,0 +1,62 @@ +import { getCurrentScope, getGlobalScope, setAsyncContextStrategy, spanToJSON } from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + GRAPHQL_DC_CHANNEL_RESOLVE, + subscribeGraphqlDiagnosticChannels, +} from '../../src/graphql/graphql-dc-subscriber'; +import { factory, initTestClient, installTestAsyncContextStrategy, traceOperation } from './helpers'; + +const resolveData = { + fieldName: 'name', + parentType: 'User', + fieldType: 'String', + fieldPath: 'user.name', + isDefaultResolver: false, +}; + +// `subscribeGraphqlDiagnosticChannels` is process-global and idempotent, so each option configuration +// is exercised in its own file — Vitest isolates files in separate processes. Here: resolve spans on, +// trivial (default-resolver) spans still ignored (the `ignoreTrivialResolveSpans` default). +describe('subscribeGraphqlDiagnosticChannels (resolve spans enabled)', () => { + beforeAll(() => { + installTestAsyncContextStrategy(); + subscribeGraphqlDiagnosticChannels(factory, { ignoreResolveSpans: false }); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + initTestClient(); + }); + + afterEach(() => { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + it('creates a graphql.resolve span with field attributes', async () => { + const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_RESOLVE, resolveData, { result: 'a' }); + + const json = spanToJSON(span!); + expect(json.description).toBe('graphql.resolve user.name'); + expect(json.op).toBe('graphql'); + expect(json.origin).toBe('auto.graphql.diagnostic_channel'); + expect(json.data['graphql.field.name']).toBe('name'); + expect(json.data['graphql.field.path']).toBe('user.name'); + expect(json.data['graphql.field.type']).toBe('String'); + expect(json.data['graphql.parent.name']).toBe('User'); + }); + + it('skips the default property resolver while ignoreTrivialResolveSpans is true (default)', async () => { + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_RESOLVE, + { ...resolveData, isDefaultResolver: true }, + { result: 'a' }, + ); + expect(span).toBeUndefined(); + }); +}); diff --git a/packages/server-utils/test/graphql/graphql-dc-subscriber.test.ts b/packages/server-utils/test/graphql/graphql-dc-subscriber.test.ts new file mode 100644 index 000000000000..ce451047bcf0 --- /dev/null +++ b/packages/server-utils/test/graphql/graphql-dc-subscriber.test.ts @@ -0,0 +1,221 @@ +import * as SentryCore from '@sentry/core'; +import { getCurrentScope, getGlobalScope, setAsyncContextStrategy, spanToJSON, startSpan } from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + GRAPHQL_DC_CHANNEL_EXECUTE, + GRAPHQL_DC_CHANNEL_PARSE, + GRAPHQL_DC_CHANNEL_RESOLVE, + GRAPHQL_DC_CHANNEL_SUBSCRIBE, + GRAPHQL_DC_CHANNEL_VALIDATE, + subscribeGraphqlDiagnosticChannels, +} from '../../src/graphql/graphql-dc-subscriber'; +import { factory, initTestClient, installTestAsyncContextStrategy, makeDocument, traceOperation } from './helpers'; + +describe('subscribeGraphqlDiagnosticChannels', () => { + let captureExceptionSpy: ReturnType; + + // The subscriber captures the async-context strategy's ALS when it binds, so the strategy must be + // installed before we subscribe — and both must stay fixed for the file. We do that once here, + // mirroring production where `setupOnce` subscribes a single time. Tests needing different options + // (resolve channel on/off) live in their own files, which Vitest isolates in separate processes. + beforeAll(() => { + installTestAsyncContextStrategy(); + subscribeGraphqlDiagnosticChannels(factory); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + initTestClient(); + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + }); + + afterEach(() => { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + describe('parse channel', () => { + it('creates a graphql.parse span', async () => { + const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_PARSE, { source: '{ hello }' }, { result: {} }); + + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('graphql.parse'); + expect(json.op).toBe('graphql'); + expect(json.origin).toBe('auto.graphql.diagnostic_channel'); + expect(json.timestamp).toBeDefined(); + }); + }); + + describe('validate channel', () => { + it('creates a graphql.validate span with the redacted document', async () => { + const document = makeDocument('query Q { user(id: 42) { name } }', [{ text: '42', kind: 'Int' }]); + const { span } = await traceOperation(GRAPHQL_DC_CHANNEL_VALIDATE, { document }, { result: [] }); + + const json = spanToJSON(span!); + expect(json.description).toBe('graphql.validate'); + expect(json.op).toBe('graphql'); + expect(json.data['graphql.document']).toBe('query Q { user(id: *) { name } }'); + }); + + it('sets error status when validation returns errors', async () => { + const document = makeDocument('{ unknownField }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_VALIDATE, + { document }, + { result: [new Error('Cannot query field "unknownField"')] }, + ); + + expect(spanToJSON(span!).status).toBe('invalid_argument'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('execute channel', () => { + it('names the span " " and sets graphql semconv attributes', async () => { + const document = makeDocument('query GetUser { user { name } }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'query', operationName: 'GetUser' }, + { result: { data: { user: { name: 'a' } } } }, + ); + + const json = spanToJSON(span!); + expect(json.description).toBe('query GetUser'); + expect(json.op).toBe('graphql'); + expect(json.origin).toBe('auto.graphql.diagnostic_channel'); + expect(json.data['graphql.operation.type']).toBe('query'); + expect(json.data['graphql.operation.name']).toBe('GetUser'); + expect(json.data['graphql.document']).toBe('query GetUser { user { name } }'); + }); + + it('falls back to "" for anonymous operations', async () => { + const document = makeDocument('{ hello }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'query' }, + { result: { data: { hello: 'world' } } }, + ); + + expect(spanToJSON(span!).description).toBe('query'); + expect(spanToJSON(span!).data['graphql.operation.name']).toBeUndefined(); + }); + + it('redacts inline literal values from graphql.document', async () => { + const document = makeDocument('mutation { login(email: "secret@example.com", age: 30) }', [ + { text: '"secret@example.com"', kind: 'String' }, + { text: '30', kind: 'Int' }, + ]); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'mutation' }, + { result: { data: {} } }, + ); + + const graphqlDocument = spanToJSON(span!).data['graphql.document'] as string; + expect(graphqlDocument).toBe('mutation { login(email: "*", age: *) }'); + expect(graphqlDocument).not.toContain('secret@example.com'); + expect(graphqlDocument).not.toContain('30'); + }); + + it('sets error status when the result carries GraphQL errors', async () => { + const document = makeDocument('mutation M { fail }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'mutation', operationName: 'M' }, + { result: { errors: [{ message: 'boom' }] } }, + ); + + expect(spanToJSON(span!).status).toBe('internal_error'); + // GraphQL errors are returned to the caller; we annotate the span but do not capture an event. + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('sets error status and does NOT capture an exception when execution throws', async () => { + const document = makeDocument('query Q { x }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'query', operationName: 'Q' }, + { error: new Error('execution exploded') }, + ); + + // A thrown error sets the span status from the error message (handled by `bindTracingChannelToSpan`). + expect(spanToJSON(span!).status).toBe('execution exploded'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('parents the execute span to the surrounding span and parents children to it', async () => { + const document = makeDocument('{ hello }', []); + let outerSpanId: string | undefined; + let result: Awaited> | undefined; + + await startSpan({ name: 'outer' }, async outer => { + outerSpanId = outer.spanContext().spanId; + result = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'query' }, + { result: { data: {} } }, + ); + }); + + expect(spanToJSON(result!.span!).parent_span_id).toBe(outerSpanId); + expect(result!.childParentSpanId).toBe(result!.span!.spanContext().spanId); + }); + }); + + describe('subscribe channel', () => { + it('names the span "subscription "', async () => { + const document = makeDocument('subscription OnMsg { messageAdded }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_SUBSCRIBE, + { document, operationType: 'subscription', operationName: 'OnMsg' }, + { result: {} }, + ); + + const json = spanToJSON(span!); + expect(json.description).toBe('subscription OnMsg'); + expect(json.op).toBe('graphql'); + expect(json.origin).toBe('auto.graphql.diagnostic_channel'); + }); + }); + + describe('resolve channel', () => { + it('does not subscribe the resolve channel by default (ignoreResolveSpans defaults to true)', async () => { + // We subscribed without options, so the resolve channel has no Sentry handler. + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_RESOLVE, + { + fieldName: 'name', + parentType: 'User', + fieldType: 'String', + fieldPath: 'user.name', + isDefaultResolver: false, + }, + { result: 'a' }, + ); + expect(span).toBeUndefined(); + }); + }); + + describe('idempotency', () => { + it('does not re-subscribe on a second call', async () => { + // A second subscribe must be a no-op: a single execute should still bind exactly one span. + subscribeGraphqlDiagnosticChannels(factory); + + const document = makeDocument('{ hello }', []); + const { span } = await traceOperation( + GRAPHQL_DC_CHANNEL_EXECUTE, + { document, operationType: 'query' }, + { result: { data: {} } }, + ); + + expect(span).toBeDefined(); + expect(spanToJSON(span!).op).toBe('graphql'); + }); + }); +}); diff --git a/packages/server-utils/test/graphql/helpers.ts b/packages/server-utils/test/graphql/helpers.ts new file mode 100644 index 000000000000..e34ac5281b13 --- /dev/null +++ b/packages/server-utils/test/graphql/helpers.ts @@ -0,0 +1,146 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getDefaultCurrentScope, + getDefaultIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import type { GraphqlTracingChannelFactory } from '../../src/graphql/graphql-dc-subscriber'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +export function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +export function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + + return { scope, isolationScope }; + }, + }), + }); +} + +/** + * Build a minimal parsed-document stand-in for the redactor. The redactor only walks the token + * linked list and acts on literal-kind tokens, so the fake only needs the source body plus the + * literal tokens (offsets are derived by searching the body). This mirrors the real graphql-js + * `DocumentNode.loc` shape without depending on graphql at test time. + */ +export function makeDocument(body: string, literals: Array<{ text: string; kind: string }>): unknown { + let cursor = 0; + const startToken: any = { kind: '', start: 0, end: 0, next: null }; + let prev = startToken; + for (const { text, kind } of literals) { + const start = body.indexOf(text, cursor); + if (start < 0) { + throw new Error(`literal not found in body: ${text}`); + } + const end = start + text.length; + cursor = end; + const token = { kind, start, end, next: null }; + prev.next = token; + prev = token; + } + + return { loc: { source: { body }, startToken } }; +} + +/** Drives a channel's `tracePromise` and captures the span bound by the subscriber. */ +export async function traceOperation( + channelName: string, + data: Record, + outcome: { result?: unknown; error?: Error }, +): Promise<{ span: Span | undefined; childParentSpanId: string | undefined }> { + const channel = tracingChannel(channelName); + let span: Span | undefined; + let childParentSpanId: string | undefined; + + const run = channel.tracePromise(async () => { + span = getActiveSpan(); + startSpan({ name: 'child' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + if (outcome.error) { + throw outcome.error; + } + + return outcome.result; + }, data); + + await run.catch(() => undefined); + + return { span, childParentSpanId }; +} + +export const factory = tracingChannel as GraphqlTracingChannelFactory;