From d2e1942a1c20216a050dea482472f9b95408b4c4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 1 Jul 2026 14:20:13 +0200 Subject: [PATCH 1/5] wip --- .../apollo-graphql/instrument-orchestrion.mjs | 15 + .../suites/tracing/apollo-graphql/test.ts | 268 ++++++++------- .../tracing/graphql/vendored/graphql-types.ts | 176 ++-------- .../graphql/vendored/instrumentation.ts | 2 +- .../tracing/graphql/vendored/utils.ts | 7 +- .../node/src/integrations/tracing/index.ts | 3 +- ...erimentalUseDiagnosticsChannelInjection.ts | 7 +- .../tracing-channel/graphql/constants.ts | 40 +++ .../tracing-channel/graphql/graphql-types.ts | 156 +++++++++ .../tracing-channel/graphql/index.ts | 134 ++++++++ .../tracing-channel/graphql/resolvers.ts | 317 ++++++++++++++++++ .../tracing-channel/graphql/spans.ts | 270 +++++++++++++++ .../tracing-channel/graphql/types.ts | 33 ++ .../server-utils/src/orchestrion/channels.ts | 3 + .../server-utils/src/orchestrion/config.ts | 19 ++ .../server-utils/src/orchestrion/index.ts | 5 + packages/server-utils/test/graphql.test.ts | 299 +++++++++++++++++ 17 files changed, 1470 insertions(+), 284 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/apollo-graphql/instrument-orchestrion.mjs create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/graphql-types.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/graphql/types.ts create mode 100644 packages/server-utils/test/graphql.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/instrument-orchestrion.mjs new file mode 100644 index 000000000000..68037ad9d310 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/instrument-orchestrion.mjs @@ -0,0 +1,15 @@ +// `Sentry.init()` swaps the OTel `Graphql` instrumentation for the +// diagnostics-channel one and synchronously installs the module hooks that +// inject the `orchestrion:graphql:{parse,validate,execute}` channels into +// `graphql`. +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +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/apollo-graphql/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts index 3e0cf3af4be6..6ddcecfdf33a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts @@ -6,151 +6,167 @@ const EXPECTED_START_SERVER_TRANSACTION = { transaction: 'Test Server Start', }; +// The OTel and diagnostics-channel (orchestrion) instrumentations must produce identical spans — only +// the injection mechanism and the span origin differ. Each scenario runs against both, asserting the +// matching origin, so the orchestrion path is proven a drop-in replacement without duplicating cases. +const VARIANTS = [ + { label: 'otel', instrument: 'instrument.mjs', origin: 'auto.graphql.otel.graphql' }, + { label: 'orchestrion', instrument: 'instrument-orchestrion.mjs', origin: 'auto.graphql.orchestrion.graphql' }, +] as const; + describe('GraphQL/Apollo Tests', () => { afterAll(() => { cleanupChildProcesses(); }); describe('query', () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction (query)', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: { - 'graphql.operation.type': 'query', - 'graphql.source': '{hello}', - 'sentry.origin': 'auto.graphql.otel.graphql', - }, - description: 'query', - status: 'ok', - origin: 'auto.graphql.otel.graphql', - }), - ]), - }; + for (const { label, instrument, origin } of VARIANTS) { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction (query)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.type': 'query', + 'graphql.source': '{hello}', + 'sentry.origin': origin, + }, + description: 'query', + status: 'ok', + origin, + }), + ]), + }; - createEsmAndCjsTests( - __dirname, - 'scenario-query.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should instrument GraphQL queries used from Apollo Server.', async () => { - await createTestRunner() - .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .unordered() - .start() - .completed(); - }); - }, - { copyPaths: ['apollo-server.mjs'] }, - ); + createEsmAndCjsTests( + __dirname, + 'scenario-query.mjs', + instrument, + (createTestRunner, test) => { + test(`should instrument GraphQL queries used from Apollo Server (${label}).`, async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .unordered() + .start() + .completed(); + }); + }, + { copyPaths: ['apollo-server.mjs'] }, + ); + } }); describe('mutation', () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction (mutation Mutation)', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: { - 'graphql.operation.name': 'Mutation', - 'graphql.operation.type': 'mutation', - 'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}', - 'sentry.origin': 'auto.graphql.otel.graphql', - }, - description: 'mutation Mutation', - status: 'ok', - origin: 'auto.graphql.otel.graphql', - }), - ]), - }; + for (const { label, instrument, origin } of VARIANTS) { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction (mutation Mutation)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'Mutation', + 'graphql.operation.type': 'mutation', + 'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}', + 'sentry.origin': origin, + }, + description: 'mutation Mutation', + status: 'ok', + origin, + }), + ]), + }; - createEsmAndCjsTests( - __dirname, - 'scenario-mutation.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should instrument GraphQL mutations used from Apollo Server.', async () => { - await createTestRunner() - .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .unordered() - .start() - .completed(); - }); - }, - { copyPaths: ['apollo-server.mjs'] }, - ); + createEsmAndCjsTests( + __dirname, + 'scenario-mutation.mjs', + instrument, + (createTestRunner, test) => { + test(`should instrument GraphQL mutations used from Apollo Server (${label}).`, async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .unordered() + .start() + .completed(); + }); + }, + { copyPaths: ['apollo-server.mjs'] }, + ); + } }); describe('redaction', () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction (mutation)', - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'mutation', - status: 'ok', - origin: 'auto.graphql.otel.graphql', - data: expect.objectContaining({ - 'graphql.operation.type': 'mutation', - // The inline email literal must be redacted to `"*"`, so the raw value can never reach `graphql.source`. - 'graphql.source': expect.stringContaining('login(email: "*")'), - 'sentry.origin': 'auto.graphql.otel.graphql', + for (const { label, instrument, origin } of VARIANTS) { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction (mutation)', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'mutation', + status: 'ok', + origin, + data: expect.objectContaining({ + 'graphql.operation.type': 'mutation', + // The inline email literal must be redacted to `"*"`, so the raw value can never reach `graphql.source`. + 'graphql.source': expect.stringContaining('login(email: "*")'), + 'sentry.origin': origin, + }), }), - }), - ]), - }; + ]), + }; - createEsmAndCjsTests( - __dirname, - 'scenario-redaction.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('redacts inline literal values from graphql.source.', async () => { - await createTestRunner() - .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .unordered() - .start() - .completed(); - }); - }, - { copyPaths: ['apollo-server.mjs'] }, - ); + createEsmAndCjsTests( + __dirname, + 'scenario-redaction.mjs', + instrument, + (createTestRunner, test) => { + test(`redacts inline literal values from graphql.source (${label}).`, async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .unordered() + .start() + .completed(); + }); + }, + { copyPaths: ['apollo-server.mjs'] }, + ); + } }); describe('error', () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction (mutation Mutation)', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: { - 'graphql.operation.name': 'Mutation', - 'graphql.operation.type': 'mutation', - 'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}', - 'sentry.origin': 'auto.graphql.otel.graphql', - }, - description: 'mutation Mutation', - status: 'internal_error', - origin: 'auto.graphql.otel.graphql', - }), - ]), - }; + for (const { label, instrument, origin } of VARIANTS) { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction (mutation Mutation)', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'graphql.operation.name': 'Mutation', + 'graphql.operation.type': 'mutation', + 'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}', + 'sentry.origin': origin, + }, + description: 'mutation Mutation', + status: 'internal_error', + origin, + }), + ]), + }; - createEsmAndCjsTests( - __dirname, - 'scenario-error.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('should handle GraphQL errors.', async () => { - await createTestRunner() - .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) - .expect({ transaction: EXPECTED_TRANSACTION }) - .unordered() - .start() - .completed(); - }); - }, - { copyPaths: ['apollo-server.mjs'] }, - ); + createEsmAndCjsTests( + __dirname, + 'scenario-error.mjs', + instrument, + (createTestRunner, test) => { + test(`should handle GraphQL errors (${label}).`, async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .unordered() + .start() + .completed(); + }); + }, + { copyPaths: ['apollo-server.mjs'] }, + ); + } }); }); diff --git a/packages/node/src/integrations/tracing/graphql/vendored/graphql-types.ts b/packages/node/src/integrations/tracing/graphql/vendored/graphql-types.ts index 9ea42aff2a8b..922f583ae5bc 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/graphql-types.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/graphql-types.ts @@ -1,152 +1,24 @@ -/* - * Simplified types inlined from the `graphql` package. - * Only includes members accessed by this instrumentation. - */ - -export type PromiseOrValue = T | Promise; - -export type Maybe = null | undefined | T; - -export interface Location { - start: number; - end: number; - startToken: Token; - source: Source; - [key: string]: any; -} - -export interface Token { - kind: string; - start: number; - end: number; - line: number; - column: number; - value: string; - prev: Token | null; - next: Token | null; - [key: string]: any; -} - -export interface Source { - body: string; - name: string; - locationOffset: Location; - [key: string]: any; -} - -export interface DocumentNode { - kind: string; - definitions: ReadonlyArray; - loc?: Location; - [key: string]: any; -} - -export interface DefinitionNode { - kind: string; - loc?: Location; - [key: string]: any; -} - -export interface OperationDefinitionNode extends DefinitionNode { - operation: string; - name?: { kind: string; value: string; loc?: Location }; - [key: string]: any; -} - -export interface ParseOptions { - noLocation?: boolean; - [key: string]: any; -} - -export interface ExecutionArgs { - schema: GraphQLSchema; - document: DocumentNode; - rootValue?: any; - contextValue?: any; - variableValues?: Maybe<{ [key: string]: any }>; - operationName?: Maybe; - fieldResolver?: Maybe>; - typeResolver?: Maybe>; - [key: string]: any; -} - -export interface ExecutionResult { - errors?: ReadonlyArray; - data?: Record | null; - [key: string]: any; -} - -export interface GraphQLError { - message: string; - locations?: ReadonlyArray<{ line: number; column: number }>; - path?: ReadonlyArray; - [key: string]: any; -} - -export interface GraphQLSchema { - getQueryType(): GraphQLObjectType | undefined | null; - getMutationType(): GraphQLObjectType | undefined | null; - [key: string]: any; -} - -export interface GraphQLObjectType { - name: string; - getFields(): { [key: string]: GraphQLField }; - [key: string]: any; -} - -export interface GraphQLField { - name: string; - type: GraphQLOutputType; - resolve?: GraphQLFieldResolver; - [key: string]: any; -} - -export type GraphQLOutputType = GraphQLNamedOutputType | GraphQLWrappingType; - -interface GraphQLNamedOutputType { - name: string; - [key: string]: any; -} - -interface GraphQLWrappingType { - ofType: GraphQLOutputType; - [key: string]: any; -} - -export interface GraphQLUnionType { - name: string; - getTypes(): ReadonlyArray; - [key: string]: any; -} - -export type GraphQLType = GraphQLOutputType | GraphQLUnionType; - -export type GraphQLFieldResolver = ( - source: TSource, - args: TArgs, - context: TContext, - info: GraphQLResolveInfo, -) => any; - -export type GraphQLTypeResolver = ( - value: TSource, - context: TContext, - info: GraphQLResolveInfo, - abstractType: any, -) => any; - -export interface GraphQLResolveInfo { - fieldName: string; - fieldNodes: ReadonlyArray<{ kind: string; loc?: Location; [key: string]: any }>; - returnType: { toString(): string; [key: string]: any }; - parentType: { name: string; [key: string]: any }; - path: any; - [key: string]: any; -} - -export type ValidationRule = any; - -export interface TypeInfo { - [key: string]: any; -} +export type { + DefinitionNode, + DocumentNode, + ExecutionArgs, + ExecutionResult, + GraphQLError, + GraphQLFieldResolver, + GraphQLObjectType, + GraphQLOutputType, + GraphQLResolveInfo, + GraphQLSchema, + GraphQLType, + GraphQLTypeResolver, + GraphQLUnionType, + Location, + Maybe, + OperationDefinitionNode, + ParseOptions, + PromiseOrValue, + Source, + Token, + TypeInfo, + ValidationRule, +} from '@sentry/server-utils/orchestrion'; diff --git a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts index ace0b061296c..b094275d7716 100644 --- a/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/graphql/vendored/instrumentation.ts @@ -367,7 +367,7 @@ export class GraphQLInstrumentation extends InstrumentationBase instrumentMysql2, instrumentPostgres, instrumentHapi, - instrumentGraphql, instrumentRedis, instrumentTedious, instrumentGenericPool, diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 6bba862cdfc0..8a8729f1a8d2 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,6 +1,7 @@ import { mysqlChannelIntegration, lruMemoizerChannelIntegration, + graphqlChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; @@ -41,7 +42,11 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; + const integrations = [ + mysqlChannelIntegration(), + lruMemoizerChannelIntegration(), + graphqlChannelIntegration(), + ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts new file mode 100644 index 000000000000..0b9052a1daa4 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts @@ -0,0 +1,40 @@ +/* + * Constants ported from `@opentelemetry/instrumentation-graphql`, kept OTel-free. + * Span names/attribute names are preserved verbatim so spans match the OTel integration's output + * (existing tests, dashboards, and the SDK's span-description parsing all key off these). + */ + +export const enum SpanNames { + EXECUTE = 'graphql.execute', + PARSE = 'graphql.parse', + RESOLVE = 'graphql.resolve', + VALIDATE = 'graphql.validate', + SCHEMA_VALIDATE = 'graphql.validateSchema', + SCHEMA_PARSE = 'graphql.parseSchema', +} + +// graphql `source`/`field.*`are OTel-specific keys preserved for span parity. +// `graphql.operation.{name,type}` and `sentry.graphql.operation` come from `@sentry/conventions/attributes` instead +export const enum AttributeNames { + SOURCE = 'graphql.source', + FIELD_NAME = 'graphql.field.name', + FIELD_PATH = 'graphql.field.path', + FIELD_TYPE = 'graphql.field.type', + PARENT_NAME = 'graphql.field.parentName', +} + +export const enum TokenKind { + STRING = 'String', + INT = 'Int', + FLOAT = 'Float', + BLOCK_STRING = 'BlockString', + EOF = '', +} + +export const ORIGIN = 'auto.graphql.orchestrion.graphql'; + +// `Symbol.for` keys are shared with any co-resident OTel graphql instrumentation on purpose: the two +// paths are mutually exclusive at runtime, and reusing the key keeps nested-execute detection and +// resolver parenting consistent if both ever load. +export const GRAPHQL_DATA_SYMBOL = Symbol.for('opentelemetry.graphql_data'); +export const GRAPHQL_PATCHED_SYMBOL = Symbol.for('opentelemetry.patched'); diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/graphql-types.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/graphql-types.ts new file mode 100644 index 000000000000..bcb4f300dd8e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/graphql-types.ts @@ -0,0 +1,156 @@ +/* + * Structural (type-only) subset of the `graphql` package, inlined so neither this integration nor the + * node OTel graphql instrumentation takes a runtime or type dependency on `graphql`. This is the single + * source of truth for these types: `@sentry/node`'s vendored `graphql-types.ts` re-exports from here + * (via `@sentry/server-utils/orchestrion`). Only members the instrumentations touch are declared. + */ + +export type PromiseOrValue = T | Promise; +export type Maybe = null | undefined | T; + +export interface Location { + start: number; + end: number; + startToken: Token; + [key: string]: unknown; +} + +export interface Token { + kind: string; + start: number; + end: number; + line: number; + column: number; + value: string; + prev: Token | null; + next: Token | null; + [key: string]: unknown; +} + +export interface Source { + body: string; + name: string; + [key: string]: unknown; +} + +export interface DocumentNode { + kind: string; + definitions: ReadonlyArray; + loc?: Location; + [key: string]: unknown; +} + +export interface DefinitionNode { + kind: string; + operation?: string; + name?: { kind: string; value: string; loc?: Location }; + loc?: Location; + [key: string]: unknown; +} + +export interface OperationDefinitionNode extends DefinitionNode { + operation: string; + name?: { kind: string; value: string; loc?: Location }; +} + +export interface ParseOptions { + noLocation?: boolean; + [key: string]: unknown; +} + +export interface ExecutionArgs { + schema: GraphQLSchema; + document: DocumentNode; + rootValue?: unknown; + contextValue?: unknown; + variableValues?: Maybe>; + operationName?: Maybe; + fieldResolver?: Maybe; + typeResolver?: Maybe; + [key: string]: unknown; +} + +export interface ExecutionResult { + errors?: ReadonlyArray; + data?: Record | null; + [key: string]: unknown; +} + +export interface GraphQLError { + message: string; + [key: string]: unknown; +} + +export interface GraphQLSchema { + getQueryType(): GraphQLObjectType | undefined | null; + getMutationType(): GraphQLObjectType | undefined | null; + [key: string]: unknown; +} + +export interface GraphQLObjectType { + name: string; + getFields(): Record; + [key: string]: unknown; +} + +export interface GraphQLField { + name: string; + type: GraphQLOutputType; + resolve?: GraphQLFieldResolver; + [key: string]: unknown; +} + +export type GraphQLOutputType = GraphQLNamedOutputType | GraphQLWrappingType; + +interface GraphQLNamedOutputType { + name?: string; + [key: string]: unknown; +} + +interface GraphQLWrappingType { + ofType: GraphQLOutputType; + [key: string]: unknown; +} + +export interface GraphQLUnionType { + name: string; + getTypes(): ReadonlyArray; + [key: string]: unknown; +} + +export type GraphQLType = GraphQLOutputType | GraphQLUnionType; + +export type GraphQLFieldResolver = ( + source: TSource, + args: TArgs, + context: TContext, + info: GraphQLResolveInfo, +) => unknown; + +export type GraphQLTypeResolver = ( + value: TSource, + context: TContext, + info: GraphQLResolveInfo, + abstractType: unknown, +) => unknown; + +export interface GraphQLResolveInfo { + fieldName: string; + fieldNodes: ReadonlyArray<{ kind: string; loc?: Location; [key: string]: unknown }>; + returnType: { toString(): string; [key: string]: unknown }; + parentType: { name: string; [key: string]: unknown }; + path: GraphQLPath; + [key: string]: unknown; +} + +export interface GraphQLPath { + prev: GraphQLPath | undefined; + key: string | number; + typename?: string | undefined; +} + +export type ValidationRule = unknown; + +export interface TypeInfo { + [key: string]: unknown; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts new file mode 100644 index 000000000000..21a3eed626ce --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -0,0 +1,134 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { + finalizeExecuteSpan, + finalizeParseSpan, + finalizeValidateSpan, + startExecuteSpan, + startParseSpan, + startValidateSpan, +} from './spans'; +import type { GraphqlResolvedConfig } from './types'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Graphql' integration is omitted from the default set. +const INTEGRATION_NAME = 'Graphql' as const; + +interface GraphqlOptions { + /** + * Do not create spans for resolvers. + * + * Defaults to true. + */ + ignoreResolveSpans?: boolean; + + /** + * Don't create spans for the execution of the default resolver on object properties. + * + * When a resolver function is not defined on the schema for a field, graphql will + * use the default resolver which just looks for a property with that name on the object. + * If the property is not a function, it's not very interesting to trace. + * This option can reduce noise and number of spans created. + * + * Defaults to true. + */ + ignoreTrivialResolveSpans?: boolean; + + /** + * If this is enabled, a http.server root span containing this span will automatically be renamed to include the operation name. + * Set this to `false` if you do not want this behavior, and want to keep the default http.server span name. + * + * Defaults to true. + */ + useOperationNameForRootSpan?: boolean; +} + +// The shapes orchestrion's transform attaches to each tracing-channel `context`. `arguments` is the +// live args array of the wrapped call; `result` is the settled return value. +interface GraphqlChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +function getOptionsWithDefaults(options: GraphqlOptions): GraphqlResolvedConfig { + return { + ignoreResolveSpans: options.ignoreResolveSpans ?? true, + ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans ?? true, + useOperationNameForRootSpan: options.useOperationNameForRootSpan ?? true, + }; +} + +/** + * Runs a span-building callback so a failure inside it can never break the user's graphql call. + * These callbacks run inside the `tracingChannel(...).trace*` machinery that wraps the real graphql + * function — `getSpan` as the `bindStore` producer, `beforeSpanEnd` in the settle handler — so an + * unguarded throw (e.g. an exotic schema shape while wrapping resolvers) would propagate into the + * traced call. On error we drop the span and let graphql run unaffected. Mirrors the OTel + * instrumentation's `safeExecuteInTheMiddle` guarding that this port replaces. + */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); + return undefined; + } +} + +const _graphqlChannelIntegration = ((options: GraphqlOptions = {}) => { + const config = getOptionsWithDefaults(options); + const getConfig = (): GraphqlResolvedConfig => config; + + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && + debug.log( + `[orchestrion:graphql] subscribing to channels "${CHANNELS.GRAPHQL_PARSE}", "${CHANNELS.GRAPHQL_VALIDATE}", "${CHANNELS.GRAPHQL_EXECUTE}"`, + ); + + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), + () => safe(() => startParseSpan()), + { beforeSpanEnd: (span, data) => void safe(() => finalizeParseSpan(span, data.result)) }, + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), + () => safe(() => startValidateSpan()), + // `documentAST` is the 2nd argument to `validate(schema, documentAST, …)`. + { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.arguments[1])) }, + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), + data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), + { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result, config)) }, + ); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven graphql integration. + * + * Subscribes to the `orchestrion:graphql:{parse,validate,execute}` diagnostics channels that the + * orchestrion code transform injects into `graphql`'s `language/parser.js`, `validation/validate.js` + * and `execution/execute.js`. Requires the orchestrion runtime hook or bundler plugin to be active — + * wire that up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts new file mode 100644 index 000000000000..cb4495cb1b1d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts @@ -0,0 +1,317 @@ +/* + * Resolver-span wrapping and GraphQL source extraction. Ported verbatim (minus the OTel tracer) from + * `@opentelemetry/instrumentation-graphql`'s `utils.ts` (upstream 0.66.0). Span shapes are preserved + * so resolver spans match the OTel integration's output. + * + * These run under the execute channel's `start`: the schema's field resolvers are swapped for + * span-creating proxies (the "consumer trick" — the transform can't target user resolvers, but + * `execute` receives the schema, so we mutate it before the wrapped call runs). + */ + +import type { Span, SpanAttributes } from '@sentry/core'; +import { SPAN_STATUS_ERROR, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import { AttributeNames, GRAPHQL_DATA_SYMBOL, GRAPHQL_PATCHED_SYMBOL, SpanNames, TokenKind } from './constants'; +import type { + DefinitionNode, + DocumentNode, + GraphQLFieldResolver, + GraphQLObjectType, + GraphQLOutputType, + GraphQLPath, + GraphQLResolveInfo, + GraphQLType, + GraphQLUnionType, + GraphqlResolvedConfig, + Location, + Maybe, + ObjectWithGraphQLData, + Patched, + Token, +} from './types'; + +const KINDS_TO_REMOVE: string[] = [TokenKind.FLOAT, TokenKind.STRING, TokenKind.INT, TokenKind.BLOCK_STRING]; + +function isPromise(value: unknown): value is Promise { + return typeof (value as { then?: unknown } | undefined)?.then === 'function'; +} + +function isObjectLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function addSpanSource(span: Span, loc?: Location, start?: number, end?: number): void { + span.setAttribute(AttributeNames.SOURCE, getSourceFromLocation(loc, start, end)); +} + +/** + * Reconstructs the (redacted) GraphQL source string for a location by walking the token stream, with + * literal values (strings/numbers) replaced by `*` so query data doesn't leak into span attributes. + */ +export function getSourceFromLocation(loc?: Location, inputStart?: number, inputEnd?: number): string { + let source = ''; + + if (!loc?.startToken) { + return source; + } + + const start = typeof inputStart === 'number' ? inputStart : loc.start; + const end = typeof inputEnd === 'number' ? inputEnd : loc.end; + + let next: Token | null = loc.startToken.next; + let previousLine = 1; + while (next) { + if (next.start < start || next.end > end) { + next = next.next; + previousLine = next?.line ?? previousLine; + continue; + } + + let value = next.value || next.kind; + let space = ''; + if (KINDS_TO_REMOVE.indexOf(next.kind) >= 0) { + value = '*'; + } + if (next.kind === TokenKind.STRING) { + value = `"${value}"`; + } + if (next.kind === TokenKind.EOF) { + value = ''; + } + + if (next.line > previousLine) { + source += '\n'.repeat(next.line - previousLine); + previousLine = next.line; + space = ' '.repeat(next.column - 1); + } else if (next.line === next.prev?.line) { + space = ' '.repeat(next.start - (next.prev?.end || 0)); + } + + source += space + value; + next = next.next; + } + + return source; +} + +/** + * Walks the query/mutation type tree and swaps each field's `resolve` for a span-creating proxy. + * Idempotent per type via {@link GRAPHQL_PATCHED_SYMBOL}. + */ +export function wrapFields(type: Maybe, getConfig: () => GraphqlResolvedConfig): void { + if (!type || type[GRAPHQL_PATCHED_SYMBOL]) { + return; + } + + type[GRAPHQL_PATCHED_SYMBOL] = true; + const fields = type.getFields(); + + Object.keys(fields).forEach(key => { + const field = fields[key]; + if (!field) { + return; + } + + if (field.resolve) { + field.resolve = wrapFieldResolver(getConfig, field.resolve); + } + + if (field.type) { + for (const unwrappedType of unwrapType(field.type)) { + wrapFields(unwrappedType, getConfig); + } + } + }); +} + +export function wrapFieldResolver( + getConfig: () => GraphqlResolvedConfig, + fieldResolver: Maybe, + isDefaultResolver = false, +): GraphQLFieldResolver & Patched { + if ((wrappedFieldResolver as Patched)[GRAPHQL_PATCHED_SYMBOL] || typeof fieldResolver !== 'function') { + return fieldResolver as GraphQLFieldResolver; + } + + function wrappedFieldResolver( + this: unknown, + source: unknown, + args: unknown, + rawContextValue: unknown, + info: GraphQLResolveInfo, + ): unknown { + if (!fieldResolver) { + return undefined; + } + + const contextValue = (rawContextValue ?? {}) as ObjectWithGraphQLData; + const config = getConfig(); + + // Mirror graphql's own "trivial resolver" check: a default resolver that just reads a + // non-function property is not worth a span. + if ( + config.ignoreTrivialResolveSpans && + isDefaultResolver && + (isObjectLike(source) || typeof source === 'function') + ) { + const property = (source as Record)[info.fieldName]; + if (typeof property !== 'function') { + return fieldResolver.call(this, source, args, contextValue, info); + } + } + + if (!contextValue[GRAPHQL_DATA_SYMBOL]) { + return fieldResolver.call(this, source, args, contextValue, info); + } + + const path = pathToArray(info.path); + const { field, spanAdded } = createFieldIfNotExists(contextValue, info, path); + const span = field.span; + + return withActiveSpan(span, () => { + try { + const res = fieldResolver.call(this, source, args, contextValue, info); + if (isPromise(res)) { + return res.then( + r => { + endResolveSpan(span, spanAdded); + return r; + }, + (err: Error) => { + endResolveSpan(span, spanAdded, err); + throw err; + }, + ); + } + endResolveSpan(span, spanAdded); + return res; + } catch (err) { + endResolveSpan(span, spanAdded, err as Error); + throw err; + } + }); + } + + (wrappedFieldResolver as Patched)[GRAPHQL_PATCHED_SYMBOL] = true; + return wrappedFieldResolver; +} + +function endResolveSpan(span: Span, shouldEndSpan: boolean, error?: Error): void { + if (!shouldEndSpan) { + return; + } + if (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message }); + } + span.end(); +} + +function createFieldIfNotExists( + contextValue: ObjectWithGraphQLData, + info: GraphQLResolveInfo, + path: string[], +): { field: { span: Span }; spanAdded: boolean } { + const existing = getField(contextValue, path); + if (existing) { + return { field: existing, spanAdded: false }; + } + + const field = { span: createResolverSpan(contextValue, info, path, getParentFieldSpan(contextValue, path)) }; + addField(contextValue, path, field); + return { field, spanAdded: true }; +} + +function createResolverSpan( + contextValue: ObjectWithGraphQLData, + info: GraphQLResolveInfo, + path: string[], + parentSpan?: Span, +): Span { + const attributes: SpanAttributes = { + [AttributeNames.FIELD_NAME]: info.fieldName, + [AttributeNames.FIELD_PATH]: path.join('.'), + [AttributeNames.FIELD_TYPE]: info.returnType.toString(), + [AttributeNames.PARENT_NAME]: info.parentType.name, + }; + + const span = startInactiveSpan({ name: `${SpanNames.RESOLVE} ${path.join('.')}`, attributes, parentSpan }); + + const document = contextValue[GRAPHQL_DATA_SYMBOL]?.source; + const fieldNode = info.fieldNodes.find(node => node.kind === 'Field'); + if (document && fieldNode) { + addSpanSource(span, document.loc, fieldNode.loc?.start, fieldNode.loc?.end); + } + + return span; +} + +function addField(contextValue: ObjectWithGraphQLData, path: string[], field: { span: Span }): void { + const data = contextValue[GRAPHQL_DATA_SYMBOL]; + if (data) { + data.fields[path.join('.')] = field; + } +} + +function getField(contextValue: ObjectWithGraphQLData, path: string[]): { span: Span } | undefined { + return contextValue[GRAPHQL_DATA_SYMBOL]?.fields[path.join('.')]; +} + +function getParentFieldSpan(contextValue: ObjectWithGraphQLData, path: string[]): Span | undefined { + for (let i = path.length - 1; i > 0; i--) { + const field = getField(contextValue, path.slice(0, i)); + if (field) { + return field.span; + } + } + return contextValue[GRAPHQL_DATA_SYMBOL]?.span; +} + +function pathToArray(path: GraphQLPath): string[] { + const flattened: string[] = []; + let curr: GraphQLPath | undefined = path; + while (curr) { + flattened.push(String(curr.key)); + curr = curr.prev; + } + return flattened.reverse(); +} + +function unwrapType(type: GraphQLOutputType): readonly (GraphQLObjectType & Patched)[] { + // The structural index signature widens `ofType` to `unknown`, so narrow it back explicitly. + if ('ofType' in type && type.ofType) { + return unwrapType(type.ofType as GraphQLOutputType); + } + if (isGraphQLUnionType(type)) { + return type.getTypes(); + } + if (isGraphQLObjectType(type)) { + return [type]; + } + return []; +} + +function isGraphQLUnionType(type: GraphQLType): type is GraphQLUnionType { + return 'getTypes' in type && typeof type.getTypes === 'function'; +} + +function isGraphQLObjectType(type: GraphQLType): type is GraphQLObjectType { + return 'getFields' in type && typeof (type as GraphQLObjectType).getFields === 'function'; +} + +/** + * Returns the operation definition for `operationName` (or the first operation) from a parsed + * document, or `undefined` for schema documents / when no operation is present. + */ +export function getOperation(document: DocumentNode, operationName?: Maybe): DefinitionNode | undefined { + const definitions: readonly DefinitionNode[] | undefined = document?.definitions; + if (!definitions || !Array.isArray(definitions)) { + return undefined; + } + + const isOperation = (def: DefinitionNode): boolean => + !!def?.operation && ['query', 'mutation', 'subscription'].indexOf(def.operation) !== -1; + + if (operationName) { + return definitions.filter(isOperation).find((def: DefinitionNode) => operationName === def?.name?.value); + } + return definitions.find(isOperation); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts new file mode 100644 index 000000000000..e47fa1e42797 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts @@ -0,0 +1,270 @@ +/* + * Span builders for the graphql parse/validate/execute channels. Ported from the private methods of + * `@opentelemetry/instrumentation-graphql`'s `GraphQLInstrumentation` (upstream 0.66.0), with the OTel + * tracer replaced by the `@sentry/core` span API. Span names/attributes/origin are preserved so the + * emitted spans are identical to the OTel integration's. + */ + +import type { Span, SpanAttributeValue } from '@sentry/core'; +import { + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + spanToJSON, + startInactiveSpan, +} from '@sentry/core'; +import { + GRAPHQL_OPERATION_NAME, + GRAPHQL_OPERATION_TYPE, + SENTRY_GRAPHQL_OPERATION, +} from '@sentry/conventions/attributes'; +import { GRAPHQL_DATA_SYMBOL, ORIGIN, SpanNames } from './constants'; +import { addSpanSource, getOperation, wrapFields, wrapFieldResolver } from './resolvers'; +import type { + DefinitionNode, + DocumentNode, + ExecutionResult, + GraphQLFieldResolver, + GraphQLSchema, + GraphqlResolvedConfig, + Maybe, + ObjectWithGraphQLData, +} from './types'; + +const OPERATION_NOT_SUPPORTED = 'Operation$operationName$not supported'; + +/** Positional slots of a `graphql.execute(schema, document, …)` call (v14/v15 legacy signature). */ +const enum ExecuteArg { + SCHEMA = 0, + DOCUMENT = 1, + CONTEXT_VALUE = 3, + OPERATION_NAME = 5, + FIELD_RESOLVER = 6, +} + +// --- parse ----------------------------------------------------------------- + +export function startParseSpan(): Span { + return startInactiveSpan({ name: SpanNames.PARSE }); +} + +/** `result` is the parsed `DocumentNode` (present on a successful parse). */ +export function finalizeParseSpan(span: Span, result: unknown): void { + const document = result as (DocumentNode & ObjectWithGraphQLData) | undefined; + if (!document) { + return; + } + + const operation = getOperation(document); + if (!operation) { + span.updateName(SpanNames.SCHEMA_PARSE); + } else if (document.loc) { + addSpanSource(span, document.loc); + } +} + +// --- validate -------------------------------------------------------------- + +export function startValidateSpan(): Span { + return startInactiveSpan({ name: SpanNames.VALIDATE }); +} + +/** `documentAST` is the second argument to `validate(schema, documentAST, …)`. */ +export function finalizeValidateSpan(span: Span, documentAST: unknown): void { + const document = documentAST as DocumentNode | undefined; + if (!document?.loc) { + span.updateName(SpanNames.SCHEMA_VALIDATE); + } +} + +// --- execute --------------------------------------------------------------- + +interface NormalizedExecuteArgs { + schema?: GraphQLSchema; + document?: DocumentNode; + contextValue: ObjectWithGraphQLData; + operationName?: Maybe; + fieldResolver?: Maybe; + /** Writes `contextValue`/`fieldResolver` mutations back to the live channel `arguments`. */ + writeBack: (contextValue: ObjectWithGraphQLData, fieldResolver: Maybe) => void; +} + +/** + * Reads the execute arguments from the live channel `arguments` array. `execute` accepts either a + * single `ExecutionArgs` object (modern callers, always in v16) or positional args (v14/v15). Both + * are normalized here; `writeBack` puts mutations onto the correct slot so they reach the real call. + */ +function normalizeExecuteArgs(argsArray: unknown[]): NormalizedExecuteArgs { + const isPositional = argsArray.length >= 2; + + if (isPositional) { + return { + schema: argsArray[ExecuteArg.SCHEMA] as GraphQLSchema | undefined, + document: argsArray[ExecuteArg.DOCUMENT] as DocumentNode | undefined, + contextValue: (argsArray[ExecuteArg.CONTEXT_VALUE] ?? {}) as ObjectWithGraphQLData, + operationName: argsArray[ExecuteArg.OPERATION_NAME] as Maybe, + fieldResolver: argsArray[ExecuteArg.FIELD_RESOLVER] as Maybe, + writeBack: (contextValue, fieldResolver) => { + argsArray[ExecuteArg.CONTEXT_VALUE] = contextValue; + argsArray[ExecuteArg.FIELD_RESOLVER] = fieldResolver; + }, + }; + } + + const obj = (argsArray[0] ?? {}) as { + schema?: GraphQLSchema; + document?: DocumentNode; + contextValue?: unknown; + operationName?: Maybe; + fieldResolver?: Maybe; + }; + return { + schema: obj.schema, + document: obj.document, + contextValue: (obj.contextValue ?? {}) as ObjectWithGraphQLData, + operationName: obj.operationName, + fieldResolver: obj.fieldResolver, + writeBack: (contextValue, fieldResolver) => { + obj.contextValue = contextValue; + obj.fieldResolver = fieldResolver; + }, + }; +} + +/** + * Opens the execute span and, unless resolver spans are disabled, swaps the schema's field resolvers + * (and the default field resolver) for span-creating proxies — mutating the live `arguments` in place + * so the wrapped `execute` call runs with them. Always returns a span (matching the OTel integration, + * which creates an execute span even for an unsupported/absent operation); the caller guards against + * throws (see `safe` in `index.ts`). + */ +export function startExecuteSpan( + argsArray: unknown[], + self: unknown, + config: GraphqlResolvedConfig, + getConfig: () => GraphqlResolvedConfig, +): Span { + const args = normalizeExecuteArgs(argsArray); + const { schema, document, operationName } = args; + let { contextValue, fieldResolver } = args; + + // Skip resolver wrapping when disabled or when a parent execute already set up this context + // (nested execute reusing the same contextValue). + const alreadyInstrumented = !!contextValue[GRAPHQL_DATA_SYMBOL]; + if (!config.ignoreResolveSpans && !alreadyInstrumented) { + const isUsingDefaultResolver = fieldResolver == null; + const defaultFieldResolver = (self as { defaultFieldResolver?: GraphQLFieldResolver } | undefined) + ?.defaultFieldResolver; + const fieldResolverForExecute = fieldResolver ?? defaultFieldResolver; + if (fieldResolverForExecute) { + fieldResolver = wrapFieldResolver(getConfig, fieldResolverForExecute, isUsingDefaultResolver); + } + + if (schema) { + wrapFields(schema.getQueryType(), getConfig); + wrapFields(schema.getMutationType(), getConfig); + } + } + + const operation = getOperation(document as DocumentNode, operationName); + const span = createExecuteSpan(operation, document); + + // The resolver proxies read the execute span (and their own bookkeeping) off this symbol. + contextValue[GRAPHQL_DATA_SYMBOL] = { source: document, span, fields: {} }; + args.writeBack(contextValue, fieldResolver); + + return span; +} + +function createExecuteSpan(operation: DefinitionNode | undefined, document: DocumentNode | undefined): Span { + const span = startInactiveSpan({ + name: SpanNames.EXECUTE, + attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN }, + }); + + if (operation) { + const operationType = operation.operation; + const operationName = operation.name?.value; + + if (operationType) { + span.setAttribute(GRAPHQL_OPERATION_TYPE, operationType); + } + + // Span name MUST be ` ` when both are available, else ``. + if (operationName) { + span.setAttribute(GRAPHQL_OPERATION_NAME, operationName); + span.updateName(`${operationType} ${operationName}`); + } else if (operationType) { + span.updateName(operationType); + } + } else { + const operationName = document ? OPERATION_NOT_SUPPORTED.replace('$operationName$', ' ') : OPERATION_NOT_SUPPORTED; + span.setAttribute(GRAPHQL_OPERATION_NAME, operationName); + } + + if (document?.loc) { + addSpanSource(span, document.loc); + } + + return span; +} + +/** + * Applies Sentry-specific mutations from the execution result: marks the execute span errored when + * the result carries errors, and (when enabled) renames the enclosing root span to include the + * GraphQL operation name(s). `result` is the settled `ExecutionResult`. + */ +export function finalizeExecuteSpan(span: Span, result: unknown, config: GraphqlResolvedConfig): void { + const executionResult = result as ExecutionResult | undefined; + if (!executionResult) { + return; + } + + if (executionResult.errors?.length && !spanToJSON(span).status) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + } + + if (!config.useOperationNameForRootSpan) { + return; + } + + const attributes = spanToJSON(span).data; + const operationType = attributes[GRAPHQL_OPERATION_TYPE]; + const operationName = attributes[GRAPHQL_OPERATION_NAME]; + if (!operationType) { + return; + } + + const rootSpan = getRootSpan(span); + const rootSpanAttributes = spanToJSON(rootSpan).data; + const existingOperations = rootSpanAttributes[SENTRY_GRAPHQL_OPERATION] || []; + const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; + + if (Array.isArray(existingOperations)) { + (existingOperations as string[]).push(newOperation); + rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, existingOperations); + } else if (typeof existingOperations === 'string') { + rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]); + } else { + rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, newOperation); + } + + if (!spanToJSON(rootSpan).data['original-description']) { + rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description); + } + // Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again. + rootSpan.updateName( + `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute(existingOperations)})`, + ); +} + +function getGraphqlOperationNamesFromAttribute(attr: SpanAttributeValue): string { + if (Array.isArray(attr)) { + const sorted = attr.slice().sort((a, b) => `${a}`.localeCompare(`${b}`)); + if (sorted.length <= 5) { + return sorted.join(', '); + } + return `${sorted.slice(0, 5).join(', ')}, +${sorted.length - 5}`; + } + return `${attr}`; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/types.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/types.ts new file mode 100644 index 000000000000..992d8ef1d1ed --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/types.ts @@ -0,0 +1,33 @@ +/* + * Implementation-specific graphql types for the orchestrion subscriber. The structural `graphql` + * package types live in `./graphql-types` (the shared single source, re-exported here for convenience); + * this file only adds the bookkeeping/config types that depend on this package's symbols. + */ + +import type { Span } from '@sentry/core'; +import type { GRAPHQL_DATA_SYMBOL, GRAPHQL_PATCHED_SYMBOL } from './constants'; +import type { DocumentNode } from './graphql-types'; + +export type * from './graphql-types'; + +/** Bookkeeping we attach to `contextValue` to parent resolver spans under the execute span. */ +interface GraphQLSpanData { + source?: DocumentNode; + span: Span; + fields: Record; +} + +export interface ObjectWithGraphQLData { + [GRAPHQL_DATA_SYMBOL]?: GraphQLSpanData; +} + +export interface Patched { + [GRAPHQL_PATCHED_SYMBOL]?: boolean; +} + +/** Resolved integration config (defaults applied), shared by the span + resolver builders. */ +export interface GraphqlResolvedConfig { + ignoreResolveSpans: boolean; + ignoreTrivialResolveSpans: boolean; + useOperationNameForRootSpan: boolean; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index ad2d8ccdd4dd..35681379b54e 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,9 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', + GRAPHQL_PARSE: 'orchestrion:graphql:parse', + GRAPHQL_VALIDATE: 'orchestrion:graphql:validate', + GRAPHQL_EXECUTE: 'orchestrion:graphql:execute', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 104df2185386..14646cb9ba70 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -38,6 +38,25 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, + // graphql: three injection points mirroring `@opentelemetry/instrumentation-graphql`. All three are + // top-level named `function` declarations in their compiled files, stable across v14–v16 (the + // supported range), so `functionName` matches every major. `versionRange` matches the OTel + // instrumentation's `supportedVersions` (`>=14.0.0 <17`). + { + channelName: 'parse', + module: { name: 'graphql', versionRange: '>=14.0.0 <17', filePath: 'language/parser.js' }, + functionQuery: { functionName: 'parse', kind: 'Sync' }, + }, + { + channelName: 'validate', + module: { name: 'graphql', versionRange: '>=14.0.0 <17', filePath: 'validation/validate.js' }, + functionQuery: { functionName: 'validate', kind: 'Sync' }, + }, + { + channelName: 'execute', + module: { name: 'graphql', versionRange: '>=14.0.0 <17', filePath: 'execution/execute.js' }, + functionQuery: { functionName: 'execute', kind: 'Auto' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4b182e51ec13..c7580fdd6030 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,8 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +export { graphqlChannelIntegration } from '../integrations/tracing-channel/graphql'; + +// The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s +// vendored OTel graphql instrumentation +export type * from '../integrations/tracing-channel/graphql/graphql-types'; diff --git a/packages/server-utils/test/graphql.test.ts b/packages/server-utils/test/graphql.test.ts new file mode 100644 index 000000000000..8798445c46d6 --- /dev/null +++ b/packages/server-utils/test/graphql.test.ts @@ -0,0 +1,299 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getClient, + getDefaultCurrentScope, + getDefaultIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import * as graphql from 'graphql'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { graphqlChannelIntegration } from '../src/integrations/tracing-channel/graphql'; +import { CHANNELS } from '../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +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 }; + }, + }), + }); +} + +function buildSchema(): graphql.GraphQLSchema { + return new graphql.GraphQLSchema({ + query: new graphql.GraphQLObjectType({ + name: 'Query', + fields: { + hello: { type: graphql.GraphQLString, resolve: () => 'world' }, + boom: { + type: graphql.GraphQLString, + resolve: () => { + throw new Error('resolver failed'); + }, + }, + }, + }), + }); +} + +// Mimic the orchestrion transform: wrap the real graphql function in the matching tracing channel. +// `ctx.arguments[0]` is the SAME object the wrapped call receives, so any mutation the subscriber +// makes in `start` reaches the real call — exactly as the injected `tracingChannel(...).trace*` does. +function tracedParse(source: string): graphql.DocumentNode { + return tracingChannel(CHANNELS.GRAPHQL_PARSE).traceSync(() => graphql.parse(source), { arguments: [source] } as any); +} + +function tracedValidate( + schema: graphql.GraphQLSchema, + document: graphql.DocumentNode, +): readonly graphql.GraphQLError[] { + return tracingChannel(CHANNELS.GRAPHQL_VALIDATE).traceSync(() => graphql.validate(schema, document), { + arguments: [schema, document], + } as any); +} + +function tracedExecute(args: graphql.ExecutionArgs): Promise { + const ctx = { arguments: [args], self: graphql } as any; + return tracingChannel(CHANNELS.GRAPHQL_EXECUTE).tracePromise( + () => Promise.resolve(graphql.execute(ctx.arguments[0])) as Promise, + ctx, + ); +} + +// graphql v16 rejects positional `execute(schema, document, …)` at runtime (the installed version), +// so we can't call the real function — but the subscriber's arg normalization still runs first. Drive +// the channel with the positional (v14/v15) shape and a stub op to exercise that branch in isolation. +function tracedExecutePositional(schema: graphql.GraphQLSchema, document: graphql.DocumentNode): void { + const ctx = { + arguments: [schema, document, undefined, undefined, undefined, undefined, undefined, undefined], + self: graphql, + } as any; + tracingChannel(CHANNELS.GRAPHQL_EXECUTE).traceSync(() => ({ data: {} }), ctx); +} + +function tracedExecuteRejecting(args: graphql.ExecutionArgs, error: Error): Promise { + const ctx = { arguments: [args], self: graphql } as any; + return tracingChannel(CHANNELS.GRAPHQL_EXECUTE).tracePromise(() => Promise.reject(error), ctx); +} + +describe('graphqlChannelIntegration', () => { + let spans: Span[]; + + beforeAll(() => { + installTestAsyncContextStrategy(); + initTestClient(); + // Subscribe exactly once — the tracing channels are global; re-subscribing per test would stack + // duplicate span builders on the same channel. + (graphqlChannelIntegration({ ignoreResolveSpans: false }) as { setupOnce: () => void }).setupOnce(); + getClient()!.on('spanEnd', span => spans.push(span)); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + spans = []; + }); + + function findSpan(name: string): Span | undefined { + return spans.find(s => spanToJSON(s).description === name); + } + + function withRoot(name: string, fn: () => Promise): Promise { + return startSpan({ name, forceTransaction: true }, fn); + } + + it('creates parse, validate and execute spans and preserves the return value', async () => { + const schema = buildSchema(); + const document = tracedParse('query GetHello { hello }'); + const errors = tracedValidate(schema, document); + + let result: graphql.ExecutionResult | undefined; + await withRoot('GET /graphql', async () => { + result = await tracedExecute({ schema, document }); + }); + + expect(errors).toHaveLength(0); + expect(result?.data).toEqual({ hello: 'world' }); + + expect(findSpan('graphql.parse')).toBeDefined(); + expect(findSpan('graphql.validate')).toBeDefined(); + + const executeSpan = findSpan('query GetHello'); + expect(executeSpan).toBeDefined(); + const json = spanToJSON(executeSpan!); + expect(json.origin).toBe('auto.graphql.orchestrion.graphql'); + expect(json.data['graphql.operation.type']).toBe('query'); + expect(json.data['graphql.operation.name']).toBe('GetHello'); + }); + + it('names the execute span by operation type only when the operation is unnamed', async () => { + const schema = buildSchema(); + const document = tracedParse('{ hello }'); + await withRoot('GET /graphql', async () => { + await tracedExecute({ schema, document }); + }); + + expect(findSpan('query')).toBeDefined(); + }); + + it('renames the parse span for a schema document (no operation)', () => { + tracedParse('type Query { hello: String }'); + + expect(findSpan('graphql.parseSchema')).toBeDefined(); + expect(findSpan('graphql.parse')).toBeUndefined(); + }); + + it('creates resolver spans nested under the execute span when enabled', async () => { + const schema = buildSchema(); + const document = tracedParse('{ hello }'); + let executeSpanId: string | undefined; + await withRoot('GET /graphql', async () => { + await tracedExecute({ schema, document }); + }); + + const executeSpan = findSpan('query')!; + executeSpanId = executeSpan.spanContext().spanId; + const resolveSpan = findSpan('graphql.resolve hello'); + expect(resolveSpan).toBeDefined(); + expect(spanToJSON(resolveSpan!).parent_span_id).toBe(executeSpanId); + }); + + it('marks the execute span as errored when the result contains errors', async () => { + const schema = buildSchema(); + const document = tracedParse('{ boom }'); + + let result: graphql.ExecutionResult | undefined; + await withRoot('GET /graphql', async () => { + result = await tracedExecute({ schema, document }); + }); + + expect(result?.errors?.length).toBeGreaterThan(0); + expect(spanToJSON(findSpan('query')!).status).toBe('internal_error'); + }); + + it('renames the enclosing root span to include the operation name', async () => { + const schema = buildSchema(); + const document = tracedParse('query GetHello { hello }'); + + let rootSpan: Span | undefined; + await startSpan({ name: 'GET /graphql', forceTransaction: true }, async span => { + rootSpan = span; + await tracedExecute({ schema, document }); + }); + + expect(spanToJSON(rootSpan!).description).toBe('GET /graphql (query GetHello)'); + }); + + it('reads the operation from positional execute args (v14/v15 signature)', async () => { + const schema = buildSchema(); + const document = tracedParse('query GetHello { hello }'); + + // Under an explicit root so the execute span isn't renamed onto itself (see root-rename test). + await withRoot('GET /graphql', async () => { + tracedExecutePositional(schema, document); + }); + + const executeSpan = findSpan('query GetHello'); + expect(executeSpan).toBeDefined(); + expect(spanToJSON(executeSpan!).data['graphql.operation.type']).toBe('query'); + expect(spanToJSON(executeSpan!).data['graphql.operation.name']).toBe('GetHello'); + }); + + it('marks the execute span as errored when execute rejects', async () => { + const schema = buildSchema(); + const document = tracedParse('{ hello }'); + + await withRoot('GET /graphql', async () => { + await expect(tracedExecuteRejecting({ schema, document }, new Error('execute failed'))).rejects.toThrow( + 'execute failed', + ); + }); + + // A thrown/rejected error annotates the span status with the error message (via the channel's + // `error` handler), unlike a result carrying `errors` which uses a bare error status. + expect(spanToJSON(findSpan('query')!).status).toBe('execute failed'); + }); + + it('does not re-wrap resolvers for a nested execute reusing the same contextValue', async () => { + const schema = buildSchema(); + const document = tracedParse('{ hello }'); + // A single contextValue shared across two executes (e.g. batched/nested execution). + const contextValue = {}; + + await withRoot('GET /graphql', async () => { + await tracedExecute({ schema, document, contextValue }); + await tracedExecute({ schema, document, contextValue }); + }); + + // Each execute still opens its own span, and the shared context is instrumented exactly once. + expect(spans.filter(s => spanToJSON(s).description === 'query')).toHaveLength(2); + expect(spans.some(s => spanToJSON(s).description === 'graphql.resolve hello')).toBe(true); + }); +}); From d5735082b956cebe9a8cd78f1be68862d9260e6e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 1 Jul 2026 16:37:05 +0200 Subject: [PATCH 2/5] bugbot --- .../node/src/integrations/tracing/index.ts | 3 +- ...erimentalUseDiagnosticsChannelInjection.ts | 17 +++++++++-- .../tracing-channel/graphql/index.ts | 6 ++-- .../tracing-channel/graphql/spans.ts | 15 +++++++--- .../server-utils/src/orchestrion/index.ts | 1 + packages/server-utils/test/graphql.test.ts | 30 +++++++++++++++++++ 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index bb0417c91a7e..ec891e51d86d 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -8,7 +8,7 @@ import { fastifyIntegration, instrumentFastifyV3 } from './fastify'; import { firebaseIntegration, instrumentFirebase } from './firebase'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; import { googleGenAIIntegration, instrumentGoogleGenAI } from './google-genai'; -import { graphqlIntegration } from './graphql'; +import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; @@ -87,6 +87,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentMysql2, instrumentPostgres, instrumentHapi, + instrumentGraphql, instrumentRedis, instrumentTedious, instrumentGenericPool, diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 8a8729f1a8d2..10bc5a74ab2f 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -4,10 +4,23 @@ import { graphqlChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; +import type { GraphqlChannelIntegrationOptions } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; +/** Per-integration options for the diagnostics-channel integrations swapped in by injection. */ +export interface DiagnosticsChannelInjectionOptions { + /** + * Options for the diagnostics-channel `graphql` integration. + * + * When you opt into injection, the OTel `graphqlIntegration()` is replaced by the channel-based one. + * Since that swap can't read options off an OTel `graphqlIntegration({ ... })` you may have configured, + * pass graphql options here instead so they apply on the injection path. + */ + graphql?: GraphqlChannelIntegrationOptions; +} + /** * EXPERIMENTAL: opt into diagnostics-channel-based auto-instrumentation. * @@ -40,12 +53,12 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject * * @experimental May change or be removed in any release. */ -export function experimentalUseDiagnosticsChannelInjection(): void { +export function experimentalUseDiagnosticsChannelInjection(options: DiagnosticsChannelInjectionOptions = {}): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { const integrations = [ mysqlChannelIntegration(), lruMemoizerChannelIntegration(), - graphqlChannelIntegration(), + graphqlChannelIntegration(options.graphql), ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 21a3eed626ce..839136ff7755 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -18,7 +18,7 @@ import type { GraphqlResolvedConfig } from './types'; // When enabled, the OTel 'Graphql' integration is omitted from the default set. const INTEGRATION_NAME = 'Graphql' as const; -interface GraphqlOptions { +export interface GraphqlChannelIntegrationOptions { /** * Do not create spans for resolvers. * @@ -56,7 +56,7 @@ interface GraphqlChannelContext { error?: unknown; } -function getOptionsWithDefaults(options: GraphqlOptions): GraphqlResolvedConfig { +function getOptionsWithDefaults(options: GraphqlChannelIntegrationOptions): GraphqlResolvedConfig { return { ignoreResolveSpans: options.ignoreResolveSpans ?? true, ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans ?? true, @@ -81,7 +81,7 @@ function safe(fn: () => T): T | undefined { } } -const _graphqlChannelIntegration = ((options: GraphqlOptions = {}) => { +const _graphqlChannelIntegration = ((options: GraphqlChannelIntegrationOptions = {}) => { const config = getOptionsWithDefaults(options); const getConfig = (): GraphqlResolvedConfig => config; 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 e47fa1e42797..27695ff61a72 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts @@ -167,7 +167,7 @@ export function startExecuteSpan( } const operation = getOperation(document as DocumentNode, operationName); - const span = createExecuteSpan(operation, document); + const span = createExecuteSpan(operation, document, operationName); // The resolver proxies read the execute span (and their own bookkeeping) off this symbol. contextValue[GRAPHQL_DATA_SYMBOL] = { source: document, span, fields: {} }; @@ -176,7 +176,11 @@ export function startExecuteSpan( return span; } -function createExecuteSpan(operation: DefinitionNode | undefined, document: DocumentNode | undefined): Span { +function createExecuteSpan( + operation: DefinitionNode | undefined, + document: DocumentNode | undefined, + executeOperationName: Maybe, +): Span { const span = startInactiveSpan({ name: SpanNames.EXECUTE, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN }, @@ -198,8 +202,11 @@ function createExecuteSpan(operation: DefinitionNode | undefined, document: Docu span.updateName(operationType); } } else { - const operationName = document ? OPERATION_NOT_SUPPORTED.replace('$operationName$', ' ') : OPERATION_NOT_SUPPORTED; - span.setAttribute(GRAPHQL_OPERATION_NAME, operationName); + // No operation definition resolved. Mirror the OTel instrumentation: fold the caller-supplied + // `operationName` into the placeholder (always replacing it, so the raw `$operationName$` template + // never leaks into the attribute). + const placeholder = executeOperationName ? ` "${executeOperationName}" ` : ' '; + span.setAttribute(GRAPHQL_OPERATION_NAME, OPERATION_NOT_SUPPORTED.replace('$operationName$', placeholder)); } if (document?.loc) { diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index c7580fdd6030..65e48b80ffb7 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -2,6 +2,7 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; export { graphqlChannelIntegration } from '../integrations/tracing-channel/graphql'; +export type { GraphqlChannelIntegrationOptions } from '../integrations/tracing-channel/graphql'; // The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s // vendored OTel graphql instrumentation diff --git a/packages/server-utils/test/graphql.test.ts b/packages/server-utils/test/graphql.test.ts index 8798445c46d6..cc74de3d18b3 100644 --- a/packages/server-utils/test/graphql.test.ts +++ b/packages/server-utils/test/graphql.test.ts @@ -141,6 +141,12 @@ function tracedExecuteRejecting(args: graphql.ExecutionArgs, error: Error): Prom return tracingChannel(CHANNELS.GRAPHQL_EXECUTE).tracePromise(() => Promise.reject(error), ctx); } +// Drive the execute channel with a stub op for cases the real `graphql.execute` would reject (e.g. a +// document with no operation definition, or no document at all). +function tracedExecuteStub(args: Partial): void { + tracingChannel(CHANNELS.GRAPHQL_EXECUTE).traceSync(() => ({ data: {} }), { arguments: [args], self: graphql } as any); +} + describe('graphqlChannelIntegration', () => { let spans: Span[]; @@ -281,6 +287,30 @@ describe('graphqlChannelIntegration', () => { expect(spanToJSON(findSpan('query')!).status).toBe('execute failed'); }); + it('folds operationName into the span attribute when no operation definition resolves', () => { + const schema = buildSchema(); + // A fragment-only document has no operation definition, so `getOperation` returns undefined. + const document = tracedParse('fragment Frag on Query { hello }'); + + tracedExecuteStub({ schema, document, operationName: 'Foo' }); + + const span = findSpan('graphql.execute'); + expect(span).toBeDefined(); + expect(spanToJSON(span!).data['graphql.operation.name']).toBe('Operation "Foo" not supported'); + }); + + it('never leaks the $operationName$ placeholder when there is no document', () => { + const schema = buildSchema(); + + tracedExecuteStub({ schema }); + + const span = findSpan('graphql.execute'); + expect(span).toBeDefined(); + const name = spanToJSON(span!).data['graphql.operation.name']; + expect(name).not.toContain('$operationName$'); + expect(name).toBe('Operation not supported'); + }); + it('does not re-wrap resolvers for a nested execute reusing the same contextValue', async () => { const schema = buildSchema(); const document = tracedParse('{ hello }'); From 2f13aa29edca226a5e8ada7b9457f886ef5395a3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 1 Jul 2026 16:50:32 +0200 Subject: [PATCH 3/5] fix(server-utils): Include current graphql operation in root span name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In finalizeExecuteSpan, when the root span's sentry.graphql.operation attribute was already a string, it was merged into an array for the attribute but the renamed span suffix was still formatted from the stale string-only value — dropping the current operation from the displayed name. Derive both the attribute and the rename suffix from a single merged value so they can no longer diverge. Reachable array-branch behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../tracing-channel/graphql/spans.ts | 19 ++++++++++--------- packages/server-utils/test/graphql.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) 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 27695ff61a72..2ce846d4065a 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts @@ -247,21 +247,22 @@ export function finalizeExecuteSpan(span: Span, result: unknown, config: Graphql const existingOperations = rootSpanAttributes[SENTRY_GRAPHQL_OPERATION] || []; const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`; - if (Array.isArray(existingOperations)) { - (existingOperations as string[]).push(newOperation); - rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, existingOperations); - } else if (typeof existingOperations === 'string') { - rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]); - } else { - rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, newOperation); - } + // Accumulate every operation seen on the root span, then derive BOTH the attribute and the renamed + // suffix from the same value — otherwise a pre-existing string attribute would be merged into an + // array but the name would still be formatted from the stale string, omitting the current operation. + const operations: SpanAttributeValue = Array.isArray(existingOperations) + ? [...(existingOperations as string[]), newOperation] + : typeof existingOperations === 'string' + ? [existingOperations, newOperation] + : newOperation; + rootSpan.setAttribute(SENTRY_GRAPHQL_OPERATION, operations); if (!spanToJSON(rootSpan).data['original-description']) { rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description); } // Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again. rootSpan.updateName( - `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute(existingOperations)})`, + `${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute(operations)})`, ); } diff --git a/packages/server-utils/test/graphql.test.ts b/packages/server-utils/test/graphql.test.ts index cc74de3d18b3..71887b12d0f2 100644 --- a/packages/server-utils/test/graphql.test.ts +++ b/packages/server-utils/test/graphql.test.ts @@ -257,6 +257,22 @@ describe('graphqlChannelIntegration', () => { expect(spanToJSON(rootSpan!).description).toBe('GET /graphql (query GetHello)'); }); + it('includes the current operation when the root span already has a string operation attribute', async () => { + const schema = buildSchema(); + const document = tracedParse('query GetHello { hello }'); + + let rootSpan: Span | undefined; + await startSpan({ name: 'GET /graphql', forceTransaction: true }, async span => { + rootSpan = span; + // Seed the attribute as a plain string (not an array) to exercise the string-merge branch. + span.setAttribute('sentry.graphql.operation', 'query Existing'); + await tracedExecute({ schema, document }); + }); + + // Both operations must appear — the bug dropped the current one when merging a string attribute. + expect(spanToJSON(rootSpan!).description).toBe('GET /graphql (query Existing, query GetHello)'); + }); + it('reads the operation from positional execute args (v14/v15 signature)', async () => { const schema = buildSchema(); const document = tracedParse('query GetHello { hello }'); From be1914615e243b13f7fd7cec935afa306422d46d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 2 Jul 2026 10:08:05 +0200 Subject: [PATCH 4/5] fix(server-utils): Use graphql.parent.name for resolver span parent attribute The ported orchestrion resolver spans set the parent-type attribute under graphql.field.parentName, but the OTel graphql instrumentation (and this migration's parity goal) uses graphql.parent.name. Dashboards/tests keyed on the OTel attribute would miss orchestrion resolver spans. Align the key and assert it in the resolver test. Co-Authored-By: Claude Opus 4.8 --- .../src/integrations/tracing-channel/graphql/constants.ts | 4 ++-- packages/server-utils/test/graphql.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts index 0b9052a1daa4..a2525abb8548 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/constants.ts @@ -13,14 +13,14 @@ export const enum SpanNames { SCHEMA_PARSE = 'graphql.parseSchema', } -// graphql `source`/`field.*`are OTel-specific keys preserved for span parity. +// graphql `source`/`field.*`/`parent.*` are OTel-specific keys preserved verbatim for span parity. // `graphql.operation.{name,type}` and `sentry.graphql.operation` come from `@sentry/conventions/attributes` instead export const enum AttributeNames { SOURCE = 'graphql.source', FIELD_NAME = 'graphql.field.name', FIELD_PATH = 'graphql.field.path', FIELD_TYPE = 'graphql.field.type', - PARENT_NAME = 'graphql.field.parentName', + PARENT_NAME = 'graphql.parent.name', } export const enum TokenKind { diff --git a/packages/server-utils/test/graphql.test.ts b/packages/server-utils/test/graphql.test.ts index 71887b12d0f2..51778e43b262 100644 --- a/packages/server-utils/test/graphql.test.ts +++ b/packages/server-utils/test/graphql.test.ts @@ -229,6 +229,11 @@ describe('graphqlChannelIntegration', () => { const resolveSpan = findSpan('graphql.resolve hello'); expect(resolveSpan).toBeDefined(); expect(spanToJSON(resolveSpan!).parent_span_id).toBe(executeSpanId); + // Attribute keys must match the OTel integration verbatim (dashboards/tests key off these). + const resolveData = spanToJSON(resolveSpan!).data; + expect(resolveData['graphql.field.name']).toBe('hello'); + expect(resolveData['graphql.field.path']).toBe('hello'); + expect(resolveData['graphql.parent.name']).toBe('Query'); }); it('marks the execute span as errored when the result contains errors', async () => { From 43d45100fd8f5bcdee8dd6f3aa7b2b4bd60d96dc Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 2 Jul 2026 11:45:59 +0200 Subject: [PATCH 5/5] fix(server-utils): Guard wrapFieldResolver on the incoming resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early-return idempotency check tested GRAPHQL_PATCHED_SYMBOL on the freshly-hoisted inner wrappedFieldResolver (never marked at that point) instead of the incoming fieldResolver, so it was dead code and an already-wrapped resolver could be wrapped again. Test the incoming resolver, which the wrapper marks with the symbol on return. No observable change today (re-wrapping is already prevented by the type-level guard in wrapFields and the contextValue check in startExecuteSpan) — this makes the intended idempotency actually hold. Co-Authored-By: Claude Opus 4.8 --- .../src/integrations/tracing-channel/graphql/resolvers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts index cb4495cb1b1d..99b957ad72b1 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts @@ -128,7 +128,10 @@ export function wrapFieldResolver( fieldResolver: Maybe, isDefaultResolver = false, ): GraphQLFieldResolver & Patched { - if ((wrappedFieldResolver as Patched)[GRAPHQL_PATCHED_SYMBOL] || typeof fieldResolver !== 'function') { + // Return the resolver untouched if it isn't a function or is already a wrapped one — the wrapper we + // return is marked with `GRAPHQL_PATCHED_SYMBOL` below precisely so it can be detected here. (The + // guard must test the incoming `fieldResolver`, not the freshly-hoisted `wrappedFieldResolver`.) + if (typeof fieldResolver !== 'function' || fieldResolver[GRAPHQL_PATCHED_SYMBOL]) { return fieldResolver as GraphQLFieldResolver; }