diff --git a/.size-limit.js b/.size-limit.js index 41a2047a7b8c..6fc6cad995b7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '136 KB', + limit: '140 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument-dc.mjs b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument-dc.mjs new file mode 100644 index 000000000000..6cb872986d90 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/instrument-dc.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Configure graphql on the diagnostics-channel injection path: opt in, then pass the matching +// `diagnosticsChannelInjectionIntegrations()` entry explicitly so its options (here +// `ignoreResolveSpans: false`) apply. This explicit instance wins over the default one the opt-in swaps in. +const { graphqlIntegration } = Sentry.diagnosticsChannelInjectionIntegrations(); +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [graphqlIntegration({ ignoreResolveSpans: false })], + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts index 3a97e4fc33af..ac7b69087356 100644 --- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/resolvers/test.ts @@ -49,4 +49,43 @@ describe('GraphQL/Apollo Tests > resolve spans', () => { .completed(); }); }); + + // Same behavior on the diagnostics-channel path: passing the channel integration explicitly (see + // instrument-dc.mjs) must carry `ignoreResolveSpans: false` through — the explicit instance wins over + // the swapped-in default — and emit resolve spans with the orchestrion origin. + const EXPECTED_ORCHESTRION_TRANSACTION = { + transaction: 'Test Transaction (query)', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'query', + origin: 'auto.graphql.diagnostic_channel', + data: expect.objectContaining({ + 'graphql.operation.type': 'query', + 'graphql.document': '{hello}', + 'sentry.origin': 'auto.graphql.diagnostic_channel', + }), + }), + expect.objectContaining({ description: 'graphql.parse' }), + expect.objectContaining({ description: 'graphql.validate' }), + expect.objectContaining({ + description: 'graphql.resolve hello', + data: expect.objectContaining({ + 'graphql.field.name': 'hello', + 'graphql.field.path': 'hello', + 'graphql.field.type': 'String', + 'graphql.parent.name': 'Query', + }), + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-query.mjs', 'instrument-dc.mjs', (createTestRunner, test) => { + test('emits resolve spans via diagnostics-channel injection when configured explicitly', async () => { + await createTestRunner() + .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) + .expect({ transaction: EXPECTED_ORCHESTRION_TRANSACTION }) + .start() + .completed(); + }); + }); }); 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..f2e0ce279572 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 @@ -1,4 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Server start transaction (Apollo Server v5 no longer runs introspection query on start) @@ -6,6 +7,33 @@ const EXPECTED_START_SERVER_TRANSACTION = { transaction: 'Test Server Start', }; +// apollo uses graphql v16, so the default run instruments it via the vendored OTel patcher and the +// orchestrion run (auto-injected on CI) via the diagnostics-channel path. Both emit the same span +// name/status; the origin and the document attribute (`graphql.source` vs `graphql.document`) differ. +const orchestrion = isOrchestrionEnabled(); +const ORIGIN = orchestrion ? 'auto.graphql.diagnostic_channel' : 'auto.graphql.otel.graphql'; + +function graphqlExecuteSpan(opts: { + description: string; + operationType: string; + operationName?: string; + document: unknown; + status?: string; +}): ReturnType { + const { description, operationType, operationName, document, status = 'ok' } = opts; + return expect.objectContaining({ + description, + status, + origin: ORIGIN, + data: expect.objectContaining({ + 'graphql.operation.type': operationType, + ...(operationName ? { 'graphql.operation.name': operationName } : {}), + [orchestrion ? 'graphql.document' : 'graphql.source']: document, + 'sentry.origin': ORIGIN, + }), + }); +} + describe('GraphQL/Apollo Tests', () => { afterAll(() => { cleanupChildProcesses(); @@ -15,16 +43,7 @@ describe('GraphQL/Apollo Tests', () => { 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', - }), + graphqlExecuteSpan({ description: 'query', operationType: 'query', document: '{hello}' }), ]), }; @@ -50,16 +69,11 @@ describe('GraphQL/Apollo Tests', () => { 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', - }, + graphqlExecuteSpan({ description: 'mutation Mutation', - status: 'ok', - origin: 'auto.graphql.otel.graphql', + operationType: 'mutation', + operationName: 'Mutation', + document: 'mutation Mutation($email: String) {\n login(email: $email)\n}', }), ]), }; @@ -86,16 +100,11 @@ describe('GraphQL/Apollo Tests', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction (mutation)', spans: expect.arrayContaining([ - expect.objectContaining({ + // The inline email literal must be redacted to `"*"`, so the raw value never reaches the span. + graphqlExecuteSpan({ 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', - }), + operationType: 'mutation', + document: expect.stringContaining('login(email: "*")'), }), ]), }; @@ -105,7 +114,7 @@ describe('GraphQL/Apollo Tests', () => { 'scenario-redaction.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('redacts inline literal values from graphql.source.', async () => { + test('redacts inline literal values from the graphql document.', async () => { await createTestRunner() .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION }) .expect({ transaction: EXPECTED_TRANSACTION }) @@ -122,16 +131,12 @@ describe('GraphQL/Apollo Tests', () => { 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', - }, + graphqlExecuteSpan({ description: 'mutation Mutation', + operationType: 'mutation', + operationName: 'Mutation', + document: 'mutation Mutation($email: String) {\n login(email: $email)\n}', status: 'internal_error', - origin: 'auto.graphql.otel.graphql', }), ]), }; 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..e0a2b72e64f0 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,26 @@ -/* - * 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; -} +// Single source of truth for the structural graphql types, shared with the orchestrion graphql +// integration in `@sentry/server-utils` so the two can't drift. +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= 17) emit identical spans — same origin, span names and + * field-attribute keys. `graphql.document`/`graphql.operation.*` and the span `op` come from + * `@sentry/conventions` directly and are imported where used. + */ + +export const ORIGIN = 'auto.graphql.diagnostic_channel'; + +export const SPAN_NAME_PARSE = 'graphql.parse'; +export const SPAN_NAME_VALIDATE = 'graphql.validate'; +export const SPAN_NAME_EXECUTE = 'graphql.execute'; +export const SPAN_NAME_RESOLVE = 'graphql.resolve'; + +// Field-level resolver-span attributes; not in `@sentry/conventions`. +export const GRAPHQL_FIELD_NAME = 'graphql.field.name'; +export const GRAPHQL_FIELD_PATH = 'graphql.field.path'; +export const GRAPHQL_FIELD_TYPE = 'graphql.field.type'; +export const GRAPHQL_PARENT_NAME = 'graphql.parent.name'; + +// `Symbol.for` keys shared with any co-resident OTel graphql instrumentation on purpose: the 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..2a3baa669e16 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -0,0 +1,109 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; +import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { + finalizeExecuteSpan, + finalizeValidateSpan, + startExecuteSpan, + startParseSpan, + startValidateSpan, +} from './spans'; +import type { GraphqlResolvedConfig } from './types'; + +// Same name as the OTel/native integration by design, so enabling injection swaps this in for it. +const INTEGRATION_NAME = 'Graphql' as const; + +// The context orchestrion's transform attaches to each channel: `arguments` is the live args of the +// wrapped call, `result` the settled return value. +interface GraphqlChannelContext { + arguments: unknown[]; + self?: unknown; + result?: unknown; + error?: unknown; +} + +function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): GraphqlResolvedConfig { + return { + ignoreResolveSpans: options.ignoreResolveSpans !== false, + ignoreTrivialResolveSpans: options.ignoreTrivialResolveSpans !== false, + useOperationNameForRootSpan: options.useOperationNameForRootSpan !== false, + }; +} + +/** + * Runs a span-building callback so a throw inside it can never break the user's graphql call: these + * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` + * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. + */ +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: GraphqlDiagnosticChannelsOptions = {}) => { + const config = getOptionsWithDefaults(options); + const getConfig = (): GraphqlResolvedConfig => config; + + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_PARSE), () => + safe(() => startParseSpan()), + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_VALIDATE), + data => safe(() => startValidateSpan(data.arguments[1])), + { beforeSpanEnd: (span, data) => void safe(() => finalizeValidateSpan(span, data.result)) }, + ); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.GRAPHQL_EXECUTE), + data => safe(() => startExecuteSpan(data.arguments, data.self, config, getConfig)), + { beforeSpanEnd: (span, data) => void safe(() => finalizeExecuteSpan(span, data.result)) }, + ); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven graphql integration for graphql v14–16 (v17 publishes native + * `diagnostics_channel` events handled by `@sentry/server-utils`'s graphql integration instead). + * + * Subscribes to the `orchestrion:graphql:{parse,validate,execute}` channels the orchestrion code + * transform injects into `graphql`'s `language/parser.js`, `validation/validate.js` and + * `execution/execute.js`, emitting spans identical to the native path. Requires the orchestrion + * runtime hook or bundler plugin — wire it up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const graphqlChannelIntegration = defineIntegration(_graphqlChannelIntegration); + +/** + * The complete graphql diagnostics-channel integration: the native subscriber (graphql v17) composed + * with the orchestrion subscriber (v14–16), so opting into injection instruments every supported + * version via diagnostics channels without the OTel patcher. Reuses the OTel `Graphql` name so + * enabling injection swaps this in for it. + */ +export const graphqlDiagnosticsChannelIntegration = (options?: GraphqlDiagnosticChannelsOptions) => { + const orchestrion = graphqlChannelIntegration(options); + return extendIntegration(graphqlNativeIntegration(options), { + name: INTEGRATION_NAME, + setupOnce: () => orchestrion.setupOnce?.(), + }); +}; 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..a81abd7cc81e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/resolvers.ts @@ -0,0 +1,265 @@ +/* + * Resolver-span wrapping for the orchestrion graphql path. graphql v14–16 publishes no per-field + * `resolve` channel (unlike v17's native one), so the schema's field resolvers are swapped for + * span-creating proxies under the execute channel's `start` (the "consumer trick" — the transform + * can't target user resolvers, but `execute` receives the schema, so we mutate it before the wrapped + * call runs). Resolver spans use the same origin/op/field attributes as the native subscriber. + */ + +import { WEB_SERVER_GRAPHQL_SPAN_OP } from '@sentry/conventions/op'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import { + GRAPHQL_DATA_SYMBOL, + GRAPHQL_FIELD_NAME, + GRAPHQL_FIELD_PATH, + GRAPHQL_FIELD_TYPE, + GRAPHQL_PARENT_NAME, + GRAPHQL_PATCHED_SYMBOL, + ORIGIN, + SPAN_NAME_RESOLVE, +} from './constants'; +import type { + DefinitionNode, + DocumentNode, + GraphQLFieldResolver, + GraphQLObjectType, + GraphQLOutputType, + GraphQLPath, + GraphQLResolveInfo, + GraphQLType, + GraphQLUnionType, + GraphqlResolvedConfig, + Maybe, + ObjectWithGraphQLData, + Patched, +} from './types'; + +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; +} + +/** + * 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 { + // 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; + } + + 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(info, path, getParentFieldSpan(contextValue, path)) }; + addField(contextValue, path, field); + return { field, spanAdded: true }; +} + +function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan?: Span): Span { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, + [GRAPHQL_FIELD_NAME]: info.fieldName, + [GRAPHQL_FIELD_PATH]: path.join('.'), + [GRAPHQL_FIELD_TYPE]: info.returnType.toString(), + [GRAPHQL_PARENT_NAME]: info.parentType.name, + }; + + return startInactiveSpan({ name: `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan }); +} + +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..a04641c9ade3 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/spans.ts @@ -0,0 +1,184 @@ +/* + * Span builders for the orchestrion graphql channels (v14–16). They emit the same spans as the native + * `diagnostics_channel` subscriber (`../../../graphql/graphql-dc-subscriber.ts`, graphql >= 17) by + * reusing its `utils` and conventions — only the data source differs: here the payloads are the raw + * arguments of the injected `parse`/`validate`/`execute` calls rather than graphql's native events. + */ + +import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from '@sentry/conventions/attributes'; +import { WEB_SERVER_GRAPHQL_SPAN_OP } from '@sentry/conventions/op'; +import type { Span } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, +} from '@sentry/core'; +import type { GraphqlDocumentNode } from '../../../graphql/utils'; +import { + getOperationSpanName, + hasResultErrors, + redactGraphqlDocument, + renameRootSpanWithOperation, +} from '../../../graphql/utils'; +import { GRAPHQL_DATA_SYMBOL, ORIGIN, SPAN_NAME_EXECUTE, SPAN_NAME_PARSE, SPAN_NAME_VALIDATE } from './constants'; +import { getOperation, wrapFields, wrapFieldResolver } from './resolvers'; +import type { + DocumentNode, + GraphQLFieldResolver, + GraphQLSchema, + GraphqlResolvedConfig, + Maybe, + ObjectWithGraphQLData, +} from './types'; + +const BASE_ATTRIBUTES = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: WEB_SERVER_GRAPHQL_SPAN_OP, +} as const; + +export function startParseSpan(): Span { + return startInactiveSpan({ name: SPAN_NAME_PARSE, attributes: { ...BASE_ATTRIBUTES } }); +} + +/** `documentAST` is the 2nd argument to `validate(schema, documentAST, …)`. */ +export function startValidateSpan(documentAST: unknown): Span { + return startInactiveSpan({ + name: SPAN_NAME_VALIDATE, + attributes: { ...BASE_ATTRIBUTES, [GRAPHQL_DOCUMENT]: redactGraphqlDocument(documentAST as GraphqlDocumentNode) }, + }); +} + +/** `result` is validation's return value: a (possibly empty) array of errors. */ +export function finalizeValidateSpan(span: Span, result: unknown): void { + if (Array.isArray(result) && result.length > 0) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); + } +} + +/** 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, +} + +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; +} + +/** + * `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 { + if (argsArray.length >= 2) { + 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; 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 } = 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); + } + } + + // v14–16 channels carry only the raw args, so derive the operation from the document (native v17 + // provides `operationType`/`operationName` on the event directly). + const operation = getOperation(document as DocumentNode, args.operationName); + const operationType = operation?.operation; + const operationName = operation?.name?.value ?? args.operationName ?? undefined; + + const span = startInactiveSpan({ + name: getOperationSpanName(operationType, operationName || undefined, SPAN_NAME_EXECUTE), + attributes: { + ...BASE_ATTRIBUTES, + [GRAPHQL_OPERATION_TYPE]: operationType, + [GRAPHQL_OPERATION_NAME]: operationName || undefined, + [GRAPHQL_DOCUMENT]: redactGraphqlDocument(document as GraphqlDocumentNode | undefined), + }, + }); + + if (config.useOperationNameForRootSpan && operationType) { + renameRootSpanWithOperation(span, operationType, operationName || undefined); + } + + // 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; +} + +/** `result` is the settled `ExecutionResult`; GraphQL errors surface on `result.errors`, not a throw. */ +export function finalizeExecuteSpan(span: Span, result: unknown): void { + if (hasResultErrors(result)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } +} 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 2f681a061238..a714fcbce94b 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -10,6 +10,7 @@ import { vercelAiChannels } from './config/vercel-ai'; import { hapiChannels } from './config/hapi'; import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; +import { graphqlChannels } from './config/graphql'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -37,6 +38,7 @@ export const CHANNELS = { ...hapiChannels, ...redisChannels, ...expressChannels, + ...graphqlChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts new file mode 100644 index 000000000000..be5a7809a0a5 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -0,0 +1,28 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +// `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled +// files, stable across the supported majors, so `functionName` matches. `execute` returns +// `PromiseOrValue`, so `Auto` covers both async (settles on `asyncEnd`) and sync (`end`) schemas. +export const graphqlConfig = [ + { + 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' }, + }, +] satisfies InstrumentationConfig[]; + +export const graphqlChannels = { + GRAPHQL_PARSE: 'orchestrion:graphql:parse', + GRAPHQL_VALIDATE: 'orchestrion:graphql:validate', + GRAPHQL_EXECUTE: 'orchestrion:graphql:execute', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bbbbf37d0b70..bf8fb24b2f21 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,6 +11,7 @@ import { vercelAiConfig } from './vercel-ai'; import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; +import { graphqlConfig } from './graphql'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -25,6 +26,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...hapiConfig, ...redisConfig, ...expressConfig, + ...graphqlConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 6ac9761087f6..09e5ce88f295 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,5 +1,9 @@ import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; +import { + graphqlChannelIntegration, + graphqlDiagnosticsChannelIntegration, +} from '../integrations/tracing-channel/graphql'; import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; @@ -14,6 +18,7 @@ export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; export { anthropicChannelIntegration, googleGenAIChannelIntegration, + graphqlChannelIntegration, hapiChannelIntegration, ioredisChannelIntegration, lruMemoizerChannelIntegration, @@ -29,6 +34,10 @@ export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracin export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; +// The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s +// vendored OTel graphql instrumentation (re-exported from here so the two can't drift). +export type * from '../integrations/tracing-channel/graphql/graphql-types'; + /** * The canonical set of orchestrion diagnostics-channel integrations, keyed by their public * (OTel-parity) factory name. @@ -53,4 +62,5 @@ export const channelIntegrations = { vercelAiIntegration: vercelAiChannelIntegration, hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, + graphqlIntegration: graphqlDiagnosticsChannelIntegration, } as const;