From 7b2fcf5b3e899235642a3acc450fbeb61c6f2317 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 12:39:23 -0400 Subject: [PATCH 01/55] feat(diagnostics): add enableDiagnosticsChannel and channel registry Introduces src/diagnostics.ts which exposes a single public function, enableDiagnosticsChannel(dc), that APMs use to register a node:diagnostics_channel-compatible module with graphql-js. Channel names (graphql:parse, graphql:validate, graphql:execute, graphql:resolve, graphql:subscribe) are owned by graphql-js so multiple APM subscribers converge on the same cached TracingChannel instances. Structural MinimalChannel / MinimalTracingChannel / MinimalDiagnosticsChannel types describe the subset of the Node API graphql-js needs; no dependency on @types/node is introduced, and no runtime-specific import is added to the core. Subsequent commits wire emission sites into parse, validate, execute, subscribe, and the resolver path. --- src/__tests__/diagnostics-test.ts | 111 ++++++++++++++++++++++++++ src/diagnostics.ts | 127 ++++++++++++++++++++++++++++++ src/index.ts | 11 +++ 3 files changed, 249 insertions(+) create mode 100644 src/__tests__/diagnostics-test.ts create mode 100644 src/diagnostics.ts diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts new file mode 100644 index 0000000000..e620c86fd7 --- /dev/null +++ b/src/__tests__/diagnostics-test.ts @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { invariant } from '../jsutils/invariant.js'; + +import type { + MinimalDiagnosticsChannel, + MinimalTracingChannel, +} from '../diagnostics.js'; +import { enableDiagnosticsChannel, getChannels } from '../diagnostics.js'; + +function fakeTracingChannel(name: string): MinimalTracingChannel { + const noop: MinimalTracingChannel['start'] = { + hasSubscribers: false, + publish: () => { + /* noop */ + }, + runStores: ( + _ctx: unknown, + fn: (this: unknown, ...args: Array) => T, + ): T => fn(), + }; + const channel: MinimalTracingChannel & { _name: string } = { + _name: name, + hasSubscribers: false, + start: noop, + end: noop, + asyncStart: noop, + asyncEnd: noop, + error: noop, + traceSync: (fn: (...args: Array) => T): T => fn(), + tracePromise: ( + fn: (...args: Array) => Promise, + ): Promise => fn(), + }; + return channel; +} + +function fakeDc(): MinimalDiagnosticsChannel & { + created: Array; +} { + const created: Array = []; + const cache = new Map(); + return { + created, + tracingChannel(name: string) { + let existing = cache.get(name); + if (existing === undefined) { + created.push(name); + existing = fakeTracingChannel(name); + cache.set(name, existing); + } + return existing; + }, + }; +} + +describe('diagnostics', () => { + it('registers the five graphql tracing channels', () => { + const dc = fakeDc(); + enableDiagnosticsChannel(dc); + + expect(dc.created).to.deep.equal([ + 'graphql:execute', + 'graphql:parse', + 'graphql:validate', + 'graphql:resolve', + 'graphql:subscribe', + ]); + + const channels = getChannels(); + invariant(channels !== undefined); + expect(channels.execute).to.not.equal(undefined); + expect(channels.parse).to.not.equal(undefined); + expect(channels.validate).to.not.equal(undefined); + expect(channels.resolve).to.not.equal(undefined); + expect(channels.subscribe).to.not.equal(undefined); + }); + + it('re-registration with the same module preserves channel identity', () => { + const dc = fakeDc(); + enableDiagnosticsChannel(dc); + const first = getChannels(); + invariant(first !== undefined); + + enableDiagnosticsChannel(dc); + const second = getChannels(); + invariant(second !== undefined); + + expect(second.execute).to.equal(first.execute); + expect(second.parse).to.equal(first.parse); + expect(second.validate).to.equal(first.validate); + expect(second.resolve).to.equal(first.resolve); + expect(second.subscribe).to.equal(first.subscribe); + }); + + it('re-registration with a different module replaces stored references', () => { + const dc1 = fakeDc(); + const dc2 = fakeDc(); + + enableDiagnosticsChannel(dc1); + const first = getChannels(); + invariant(first !== undefined); + + enableDiagnosticsChannel(dc2); + const second = getChannels(); + invariant(second !== undefined); + + expect(second.execute).to.not.equal(first.execute); + }); +}); diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000000..2e611312bd --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,127 @@ +/** + * TracingChannel integration. + * + * graphql-js exposes a set of named tracing channels that APM tools can + * subscribe to in order to observe parse, validate, execute, subscribe, and + * resolver lifecycle events. To preserve the isomorphic invariant of the + * core (no runtime-specific imports in `src/`), graphql-js does not import + * `node:diagnostics_channel` itself. Instead, APMs (or runtime-specific + * adapters) hand in a module satisfying `MinimalDiagnosticsChannel` via + * `enableDiagnosticsChannel`. + * + * Channel names are owned by graphql-js so multiple APMs converge on the + * same `TracingChannel` instances and all subscribers coexist. + */ + +/** + * Structural subset of `DiagnosticsChannel` sufficient for publishing and + * subscriber gating. `node:diagnostics_channel`'s `Channel` satisfies this. + */ +export interface MinimalChannel { + readonly hasSubscribers: boolean; + publish: (message: unknown) => void; + runStores: ( + context: ContextType, + fn: (this: ContextType, ...args: Array) => T, + thisArg?: unknown, + ...args: Array + ) => T; +} + +/** + * Structural subset of Node's `TracingChannel`. The `node:diagnostics_channel` + * `TracingChannel` satisfies this by duck typing, so graphql-js does not need + * a dependency on `@types/node` or on the runtime itself. + */ +export interface MinimalTracingChannel { + readonly hasSubscribers: boolean; + readonly start: MinimalChannel; + readonly end: MinimalChannel; + readonly asyncStart: MinimalChannel; + readonly asyncEnd: MinimalChannel; + readonly error: MinimalChannel; + + traceSync: ( + fn: (...args: Array) => T, + ctx: object, + thisArg?: unknown, + ...args: Array + ) => T; + + tracePromise: ( + fn: (...args: Array) => Promise, + ctx: object, + thisArg?: unknown, + ...args: Array + ) => Promise; +} + +/** + * Structural subset of `node:diagnostics_channel` covering just what + * graphql-js needs at registration time. + */ +export interface MinimalDiagnosticsChannel { + tracingChannel: (name: string) => MinimalTracingChannel; +} + +/** + * The collection of tracing channels graphql-js emits on. APMs subscribe to + * these by name on their own `node:diagnostics_channel` import; both paths + * land on the same channel instance because `tracingChannel(name)` is cached + * by name. + */ +export interface GraphQLChannels { + execute: MinimalTracingChannel; + parse: MinimalTracingChannel; + validate: MinimalTracingChannel; + resolve: MinimalTracingChannel; + subscribe: MinimalTracingChannel; +} + +let channels: GraphQLChannels | undefined; + +/** + * Internal accessor used at emission sites. Returns `undefined` when no + * `diagnostics_channel` module has been registered, allowing emission sites + * to short-circuit on a single property access. + * + * @internal + */ +export function getChannels(): GraphQLChannels | undefined { + return channels; +} + +/** + * Register a `node:diagnostics_channel`-compatible module with graphql-js. + * + * After calling this, graphql-js will publish lifecycle events on the + * following tracing channels whenever subscribers are present: + * + * - `graphql:parse` + * - `graphql:validate` + * - `graphql:execute` + * - `graphql:subscribe` + * - `graphql:resolve` + * + * Calling this repeatedly is safe: subsequent calls replace the stored + * channel references, but since `tracingChannel(name)` is cached by name, + * the channel identities remain stable across registrations from the same + * underlying module. + * + * @example + * ```ts + * import dc from 'node:diagnostics_channel'; + * import { enableDiagnosticsChannel } from 'graphql'; + * + * enableDiagnosticsChannel(dc); + * ``` + */ +export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { + channels = { + execute: dc.tracingChannel('graphql:execute'), + parse: dc.tracingChannel('graphql:parse'), + validate: dc.tracingChannel('graphql:validate'), + resolve: dc.tracingChannel('graphql:resolve'), + subscribe: dc.tracingChannel('graphql:subscribe'), + }; +} diff --git a/src/index.ts b/src/index.ts index 7232871e9d..ae44222779 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,17 @@ export { version, versionInfo } from './version.ts'; // Enable development mode for additional checks. export { enableDevMode, isDevModeEnabled } from './devMode.ts'; +// Register a `node:diagnostics_channel`-compatible module to enable +// tracing channel emission from parse, validate, execute, subscribe, +// and resolver lifecycles. +export { enableDiagnosticsChannel } from './diagnostics.js'; +export type { + MinimalChannel, + MinimalTracingChannel, + MinimalDiagnosticsChannel, + GraphQLChannels, +} from './diagnostics.js'; + // The primary entry point into fulfilling a GraphQL request. export type { GraphQLArgs } from './graphql.ts'; export { graphql, graphqlSync } from './graphql.ts'; From c24243fabba62eff695fb64b059ab18978907446 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 12:57:01 -0400 Subject: [PATCH 02/55] feat(language): publish on graphql:parse tracing channel Wraps parse() with the graphql:parse channel when any subscribers are attached. The published context carries the Source (or string) handed to parse() so APM tools can record the GraphQL source on their span. Adds two shared helpers on src/diagnostics.ts used by every subsequent emission site: maybeTraceSync(name, ctxFactory, fn) maybeTracePromise(name, ctxFactory, fn) They share a shouldTrace gate that uses hasSubscribers !== false so runtimes without an aggregated hasSubscribers getter (notably Node 18, see nodejs/node#54470) still publish; Node's traceSync/tracePromise gate each sub-channel internally. Context construction is lazy through the factory so the no-subscriber path allocates nothing beyond the closure. --- src/diagnostics.ts | 58 ++++- .../__tests__/parser-diagnostics-test.ts | 214 ++++++++++++++++++ src/language/parser.ts | 22 +- 3 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/language/__tests__/parser-diagnostics-test.ts diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 2e611312bd..b37c3f4a8e 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -20,7 +20,7 @@ export interface MinimalChannel { readonly hasSubscribers: boolean; publish: (message: unknown) => void; - runStores: ( + runStores: ( context: ContextType, fn: (this: ContextType, ...args: Array) => T, thisArg?: unknown, @@ -125,3 +125,59 @@ export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { subscribe: dc.tracingChannel('graphql:subscribe'), }; } + +/** + * Gate for emission sites. Returns `true` when the named channel exists and + * publishing should proceed. + * + * Uses `!== false` rather than a truthy check so runtimes which do not + * implement the aggregated `hasSubscribers` getter on `TracingChannel` still + * publish. Notably Node 18 (nodejs/node#54470), where the aggregated getter + * returns `undefined` while sub-channels behave correctly. + * + * @internal + */ +function shouldTrace( + channel: MinimalTracingChannel | undefined, +): channel is MinimalTracingChannel { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + return channel !== undefined && channel.hasSubscribers !== false; +} + +/** + * Publish a synchronous operation through the named graphql tracing channel, + * short-circuiting to `fn()` when the channel isn't registered or nothing is + * listening. + * + * @internal + */ +export function maybeTraceSync( + name: keyof GraphQLChannels, + ctxFactory: () => object, + fn: () => T, +): T { + const channel = getChannels()?.[name]; + if (!shouldTrace(channel)) { + return fn(); + } + return channel.traceSync(fn, ctxFactory()); +} + +/** + * Publish a promise-returning operation through the named graphql tracing + * channel, short-circuiting to `fn()` when the channel isn't registered or + * nothing is listening. + * + * @internal + */ +export function maybeTracePromise( + name: keyof GraphQLChannels, + ctxFactory: () => object, + fn: () => Promise, +): Promise { + const channel = getChannels()?.[name]; + if (!shouldTrace(channel)) { + return fn(); + } + return channel.tracePromise(fn, ctxFactory()); +} diff --git a/src/language/__tests__/parser-diagnostics-test.ts b/src/language/__tests__/parser-diagnostics-test.ts new file mode 100644 index 0000000000..ac16f46bce --- /dev/null +++ b/src/language/__tests__/parser-diagnostics-test.ts @@ -0,0 +1,214 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import type { + MinimalChannel, + MinimalDiagnosticsChannel, + MinimalTracingChannel, +} from '../../diagnostics.js'; +import { enableDiagnosticsChannel } from '../../diagnostics.js'; + +import { parse } from '../parser.js'; + +type Listener = (message: unknown) => void; + +class FakeChannel implements MinimalChannel { + listeners: Array = []; + get hasSubscribers(): boolean { + return this.listeners.length > 0; + } + + publish(message: unknown): void { + for (const l of this.listeners) { + l(message); + } + } + + runStores( + _ctx: ContextType, + fn: (this: ContextType, ...args: Array) => T, + thisArg?: unknown, + ...args: Array + ): T { + return fn.apply(thisArg as ContextType, args); + } + + subscribe(listener: Listener): void { + this.listeners.push(listener); + } + + unsubscribe(listener: Listener): void { + const idx = this.listeners.indexOf(listener); + if (idx >= 0) { + this.listeners.splice(idx, 1); + } + } +} + +class FakeTracingChannel implements MinimalTracingChannel { + start = new FakeChannel(); + end = new FakeChannel(); + asyncStart = new FakeChannel(); + asyncEnd = new FakeChannel(); + error = new FakeChannel(); + + get hasSubscribers(): boolean { + return ( + this.start.hasSubscribers || + this.end.hasSubscribers || + this.asyncStart.hasSubscribers || + this.asyncEnd.hasSubscribers || + this.error.hasSubscribers + ); + } + + traceSync( + fn: (...args: Array) => T, + ctx: object, + thisArg?: unknown, + ...args: Array + ): T { + this.start.publish(ctx); + try { + return this.end.runStores(ctx, fn, thisArg, ...args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + throw err; + } finally { + this.end.publish(ctx); + } + } + + tracePromise( + fn: (...args: Array) => Promise, + ctx: object, + thisArg?: unknown, + ...args: Array + ): Promise { + this.start.publish(ctx); + let promise: Promise; + try { + promise = this.end.runStores(ctx, fn, thisArg, ...args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.end.publish(ctx); + throw err; + } + this.end.publish(ctx); + return promise.then( + (result) => { + (ctx as { result: unknown }).result = result; + this.asyncStart.publish(ctx); + this.asyncEnd.publish(ctx); + return result; + }, + (err: unknown) => { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.asyncStart.publish(ctx); + this.asyncEnd.publish(ctx); + throw err; + }, + ); + } +} + +class FakeDc implements MinimalDiagnosticsChannel { + private cache = new Map(); + + tracingChannel(name: string): FakeTracingChannel { + let existing = this.cache.get(name); + if (existing === undefined) { + existing = new FakeTracingChannel(); + this.cache.set(name, existing); + } + return existing; + } +} + +const fakeDc = new FakeDc(); +const parseChannel = fakeDc.tracingChannel('graphql:parse'); + +interface Event { + kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; + source: unknown; + error?: unknown; +} + +function collectEvents(): { events: Array; unsubscribe: () => void } { + const events: Array = []; + const startL: Listener = (m) => + events.push({ kind: 'start', source: (m as { source: unknown }).source }); + const endL: Listener = (m) => + events.push({ kind: 'end', source: (m as { source: unknown }).source }); + const asyncStartL: Listener = (m) => + events.push({ + kind: 'asyncStart', + source: (m as { source: unknown }).source, + }); + const asyncEndL: Listener = (m) => + events.push({ + kind: 'asyncEnd', + source: (m as { source: unknown }).source, + }); + const errorL: Listener = (m) => { + const msg = m as { source: unknown; error: unknown }; + events.push({ kind: 'error', source: msg.source, error: msg.error }); + }; + parseChannel.start.subscribe(startL); + parseChannel.end.subscribe(endL); + parseChannel.asyncStart.subscribe(asyncStartL); + parseChannel.asyncEnd.subscribe(asyncEndL); + parseChannel.error.subscribe(errorL); + return { + events, + unsubscribe() { + parseChannel.start.unsubscribe(startL); + parseChannel.end.unsubscribe(endL); + parseChannel.asyncStart.unsubscribe(asyncStartL); + parseChannel.asyncEnd.unsubscribe(asyncEndL); + parseChannel.error.unsubscribe(errorL); + }, + }; +} + +describe('parse diagnostics channel', () => { + let active: ReturnType | undefined; + + beforeEach(() => { + enableDiagnosticsChannel(fakeDc); + }); + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end around a successful parse', () => { + active = collectEvents(); + + const doc = parse('{ field }'); + + expect(doc.kind).to.equal('Document'); + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].source).to.equal('{ field }'); + expect(active.events[1].source).to.equal('{ field }'); + }); + + it('emits start, error, and end when the parser throws', () => { + active = collectEvents(); + + expect(() => parse('{ ')).to.throw(); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'error', 'end']); + expect(active.events[1].error).to.be.instanceOf(Error); + }); + + it('does nothing when no subscribers are attached', () => { + const doc = parse('{ field }'); + expect(doc.kind).to.equal('Document'); + }); +}); diff --git a/src/language/parser.ts b/src/language/parser.ts index 2948479d6a..45ba91bb32 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,6 +3,8 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; +import { maybeTraceSync } from '../diagnostics.js'; + import type { ArgumentCoordinateNode, ArgumentNode, @@ -145,13 +147,19 @@ export function parse( source: string | Source, options?: ParseOptions, ): DocumentNode { - const parser = new Parser(source, options); - const document = parser.parseDocument(); - Object.defineProperty(document, 'tokenCount', { - enumerable: false, - value: parser.tokenCount, - }); - return document; + return maybeTraceSync( + 'parse', + () => ({ source }), + () => { + const parser = new Parser(source, options); + const document = parser.parseDocument(); + Object.defineProperty(document, 'tokenCount', { + enumerable: false, + value: parser.tokenCount, + }); + return document; + }, + ); } /** From 8cd01190da8bd55b86c987615488543830a7304a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 13:08:48 -0400 Subject: [PATCH 03/55] test(integration): exercise graphql tracing channels on real node:diagnostics_channel Adds an integrationTests/diagnostics/ project that installs the built graphql package, registers node:diagnostics_channel, and asserts that subscribers to graphql:parse receive the expected start/end/error lifecycle events. Complements the unit tests (which use a FakeDc) with coverage against a real TracingChannel. The in-tree eslint rules forbid node: imports and flag TracingChannel as experimental in src/; integrationTests/* overrides already allow the node: import, and a file-scope disable handles the experimental warning. The integration project pins engines to >=22 to match where graphql-js v17 is actively tested. This file grows as later channels (validate, execute, subscribe, resolve) are wired up. --- integrationTests/diagnostics/package.json | 14 +++++ integrationTests/diagnostics/test.js | 73 +++++++++++++++++++++++ resources/integration-test.ts | 3 + 3 files changed, 90 insertions(+) create mode 100644 integrationTests/diagnostics/package.json create mode 100644 integrationTests/diagnostics/test.js diff --git a/integrationTests/diagnostics/package.json b/integrationTests/diagnostics/package.json new file mode 100644 index 0000000000..0face40966 --- /dev/null +++ b/integrationTests/diagnostics/package.json @@ -0,0 +1,14 @@ +{ + "description": "graphql-js tracing channels should publish on node:diagnostics_channel", + "private": true, + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "test": "node test.js" + }, + "dependencies": { + "graphql": "file:../graphql.tgz" + } +} diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js new file mode 100644 index 0000000000..646ceb7046 --- /dev/null +++ b/integrationTests/diagnostics/test.js @@ -0,0 +1,73 @@ +// TracingChannel is marked experimental in Node's docs but is shipped on +// every runtime graphql-js supports. This test exercises it directly. +/* eslint-disable n/no-unsupported-features/node-builtins */ + +import assert from 'node:assert/strict'; +import dc from 'node:diagnostics_channel'; + +import { enableDiagnosticsChannel, parse } from 'graphql'; + +enableDiagnosticsChannel(dc); + +// graphql:parse - synchronous +{ + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + asyncStart: (msg) => + events.push({ kind: 'asyncStart', source: msg.source }), + asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].source, '{ field }'); + assert.equal(events[1].source, '{ field }'); + } finally { + channel.unsubscribe(handler); + } +} + +// graphql:parse - error path fires start, error, end (traceSync finally-emits end) +{ + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + assert.throws(() => parse('{ ')); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'error', 'end'], + ); + assert.ok(events[1].error instanceof Error); + } finally { + channel.unsubscribe(handler); + } +} + +// No-op when nothing is subscribed - parse still succeeds. +{ + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); +} + +console.log('diagnostics integration test passed'); diff --git a/resources/integration-test.ts b/resources/integration-test.ts index b8e45429e3..5523892090 100644 --- a/resources/integration-test.ts +++ b/resources/integration-test.ts @@ -54,6 +54,9 @@ describe('Integration Tests', () => { testOnNodeProject('node'); testOnNodeProject('webpack'); + // Tracing channel tests + testOnNodeProject('diagnostics'); + // Conditional export tests testOnNodeProject('conditions'); From ae06f9169377ef850b9d701fd5e41f88fdce9dcc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 13:15:20 -0400 Subject: [PATCH 04/55] feat(validation): publish on graphql:validate tracing channel Wraps the public validate() entry point with maybeTraceSync. The context carries both the schema being validated against and the parsed document, so APM tools can associate the span with a concrete GraphQL operation. validate() is synchronous so traceSync is appropriate; errors thrown by assertValidSchema or the visitor rethrow propagate through the channel's error sub-channel. Also extracts the in-memory TracingChannel fake used by parser-diagnostics into src/__testUtils__/fakeDiagnosticsChannel.ts so subsequent emission sites (validate, execute, subscribe, resolve) can reuse it without duplicating the lifecycle simulation. --- integrationTests/diagnostics/test.js | 41 +++- src/__testUtils__/fakeDiagnosticsChannel.ts | 186 ++++++++++++++++++ .../__tests__/parser-diagnostics-test.ts | 181 +---------------- .../__tests__/validate-diagnostics-test.ts | 78 ++++++++ src/validation/validate.ts | 82 ++++---- 5 files changed, 359 insertions(+), 209 deletions(-) create mode 100644 src/__testUtils__/fakeDiagnosticsChannel.ts create mode 100644 src/validation/__tests__/validate-diagnostics-test.ts diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index 646ceb7046..86661209ef 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -5,7 +5,12 @@ import assert from 'node:assert/strict'; import dc from 'node:diagnostics_channel'; -import { enableDiagnosticsChannel, parse } from 'graphql'; +import { + buildSchema, + enableDiagnosticsChannel, + parse, + validate, +} from 'graphql'; enableDiagnosticsChannel(dc); @@ -64,6 +69,40 @@ enableDiagnosticsChannel(dc); } } +// graphql:validate - synchronous, with schema/document context +{ + const schema = buildSchema(`type Query { field: String }`); + const doc = parse('{ field }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + schema: msg.schema, + document: msg.document, + }), + end: () => events.push({ kind: 'end' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:validate'); + channel.subscribe(handler); + + try { + const errors = validate(schema, doc); + assert.deepEqual(errors, []); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].schema, schema); + assert.equal(events[0].document, doc); + } finally { + channel.unsubscribe(handler); + } +} + // No-op when nothing is subscribed - parse still succeeds. { const doc = parse('{ field }'); diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts new file mode 100644 index 0000000000..47949f5c17 --- /dev/null +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -0,0 +1,186 @@ +import type { + MinimalChannel, + MinimalDiagnosticsChannel, + MinimalTracingChannel, +} from '../diagnostics.js'; + +export type Listener = (message: unknown) => void; + +/** + * In-memory `MinimalChannel` implementation used by the unit tests. Tracks + * subscribers and replays Node's `runStores` semantics by simply invoking + * `fn`. + */ +export class FakeChannel implements MinimalChannel { + listeners: Array = []; + + get [Symbol.toStringTag]() { + return 'FakeChannel'; + } + + get hasSubscribers(): boolean { + return this.listeners.length > 0; + } + + publish(message: unknown): void { + for (const l of this.listeners) { + l(message); + } + } + + runStores( + _ctx: ContextType, + fn: (this: ContextType, ...args: Array) => T, + thisArg?: unknown, + ...args: Array + ): T { + return fn.apply(thisArg as ContextType, args); + } + + subscribe(listener: Listener): void { + this.listeners.push(listener); + } + + unsubscribe(listener: Listener): void { + const idx = this.listeners.indexOf(listener); + if (idx >= 0) { + this.listeners.splice(idx, 1); + } + } +} + +/** + * Structurally-faithful `MinimalTracingChannel` implementation mirroring + * Node's `TracingChannel.traceSync` / `tracePromise` lifecycle (start, + * runStores, error, asyncStart, asyncEnd, end). + */ +export class FakeTracingChannel implements MinimalTracingChannel { + start: FakeChannel = new FakeChannel(); + end: FakeChannel = new FakeChannel(); + asyncStart: FakeChannel = new FakeChannel(); + asyncEnd: FakeChannel = new FakeChannel(); + error: FakeChannel = new FakeChannel(); + + get [Symbol.toStringTag]() { + return 'FakeTracingChannel'; + } + + get hasSubscribers(): boolean { + return ( + this.start.hasSubscribers || + this.end.hasSubscribers || + this.asyncStart.hasSubscribers || + this.asyncEnd.hasSubscribers || + this.error.hasSubscribers + ); + } + + traceSync( + fn: (...args: Array) => T, + ctx: object, + thisArg?: unknown, + ...args: Array + ): T { + this.start.publish(ctx); + try { + return this.end.runStores(ctx, fn, thisArg, ...args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + throw err; + } finally { + this.end.publish(ctx); + } + } + + tracePromise( + fn: (...args: Array) => Promise, + ctx: object, + thisArg?: unknown, + ...args: Array + ): Promise { + this.start.publish(ctx); + let promise: Promise; + try { + promise = this.end.runStores(ctx, fn, thisArg, ...args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.end.publish(ctx); + throw err; + } + this.end.publish(ctx); + return promise.then( + (result) => { + (ctx as { result: unknown }).result = result; + this.asyncStart.publish(ctx); + this.asyncEnd.publish(ctx); + return result; + }, + (err: unknown) => { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.asyncStart.publish(ctx); + this.asyncEnd.publish(ctx); + throw err; + }, + ); + } +} + +export class FakeDc implements MinimalDiagnosticsChannel { + private cache = new Map(); + + get [Symbol.toStringTag]() { + return 'FakeDc'; + } + + tracingChannel(name: string): FakeTracingChannel { + let existing = this.cache.get(name); + if (existing === undefined) { + existing = new FakeTracingChannel(); + this.cache.set(name, existing); + } + return existing; + } +} + +export interface CollectedEvent { + kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; + ctx: { [key: string]: unknown }; +} + +/** + * Attach listeners to every sub-channel on a FakeTracingChannel and return + * the captured event buffer plus an unsubscribe hook. + */ +export function collectEvents(channel: FakeTracingChannel): { + events: Array; + unsubscribe: () => void; +} { + const events: Array = []; + const make = + (kind: CollectedEvent['kind']): Listener => + (m) => + events.push({ kind, ctx: m as { [key: string]: unknown } }); + const startL = make('start'); + const endL = make('end'); + const asyncStartL = make('asyncStart'); + const asyncEndL = make('asyncEnd'); + const errorL = make('error'); + channel.start.subscribe(startL); + channel.end.subscribe(endL); + channel.asyncStart.subscribe(asyncStartL); + channel.asyncEnd.subscribe(asyncEndL); + channel.error.subscribe(errorL); + return { + events, + unsubscribe() { + channel.start.unsubscribe(startL); + channel.end.unsubscribe(endL); + channel.asyncStart.unsubscribe(asyncStartL); + channel.asyncEnd.unsubscribe(asyncEndL); + channel.error.unsubscribe(errorL); + }, + }; +} diff --git a/src/language/__tests__/parser-diagnostics-test.ts b/src/language/__tests__/parser-diagnostics-test.ts index ac16f46bce..a970a73508 100644 --- a/src/language/__tests__/parser-diagnostics-test.ts +++ b/src/language/__tests__/parser-diagnostics-test.ts @@ -1,179 +1,18 @@ import { expect } from 'chai'; import { afterEach, beforeEach, describe, it } from 'mocha'; -import type { - MinimalChannel, - MinimalDiagnosticsChannel, - MinimalTracingChannel, -} from '../../diagnostics.js'; +import { + collectEvents, + FakeDc, +} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + import { enableDiagnosticsChannel } from '../../diagnostics.js'; import { parse } from '../parser.js'; -type Listener = (message: unknown) => void; - -class FakeChannel implements MinimalChannel { - listeners: Array = []; - get hasSubscribers(): boolean { - return this.listeners.length > 0; - } - - publish(message: unknown): void { - for (const l of this.listeners) { - l(message); - } - } - - runStores( - _ctx: ContextType, - fn: (this: ContextType, ...args: Array) => T, - thisArg?: unknown, - ...args: Array - ): T { - return fn.apply(thisArg as ContextType, args); - } - - subscribe(listener: Listener): void { - this.listeners.push(listener); - } - - unsubscribe(listener: Listener): void { - const idx = this.listeners.indexOf(listener); - if (idx >= 0) { - this.listeners.splice(idx, 1); - } - } -} - -class FakeTracingChannel implements MinimalTracingChannel { - start = new FakeChannel(); - end = new FakeChannel(); - asyncStart = new FakeChannel(); - asyncEnd = new FakeChannel(); - error = new FakeChannel(); - - get hasSubscribers(): boolean { - return ( - this.start.hasSubscribers || - this.end.hasSubscribers || - this.asyncStart.hasSubscribers || - this.asyncEnd.hasSubscribers || - this.error.hasSubscribers - ); - } - - traceSync( - fn: (...args: Array) => T, - ctx: object, - thisArg?: unknown, - ...args: Array - ): T { - this.start.publish(ctx); - try { - return this.end.runStores(ctx, fn, thisArg, ...args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - throw err; - } finally { - this.end.publish(ctx); - } - } - - tracePromise( - fn: (...args: Array) => Promise, - ctx: object, - thisArg?: unknown, - ...args: Array - ): Promise { - this.start.publish(ctx); - let promise: Promise; - try { - promise = this.end.runStores(ctx, fn, thisArg, ...args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.end.publish(ctx); - throw err; - } - this.end.publish(ctx); - return promise.then( - (result) => { - (ctx as { result: unknown }).result = result; - this.asyncStart.publish(ctx); - this.asyncEnd.publish(ctx); - return result; - }, - (err: unknown) => { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.asyncStart.publish(ctx); - this.asyncEnd.publish(ctx); - throw err; - }, - ); - } -} - -class FakeDc implements MinimalDiagnosticsChannel { - private cache = new Map(); - - tracingChannel(name: string): FakeTracingChannel { - let existing = this.cache.get(name); - if (existing === undefined) { - existing = new FakeTracingChannel(); - this.cache.set(name, existing); - } - return existing; - } -} - const fakeDc = new FakeDc(); const parseChannel = fakeDc.tracingChannel('graphql:parse'); -interface Event { - kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; - source: unknown; - error?: unknown; -} - -function collectEvents(): { events: Array; unsubscribe: () => void } { - const events: Array = []; - const startL: Listener = (m) => - events.push({ kind: 'start', source: (m as { source: unknown }).source }); - const endL: Listener = (m) => - events.push({ kind: 'end', source: (m as { source: unknown }).source }); - const asyncStartL: Listener = (m) => - events.push({ - kind: 'asyncStart', - source: (m as { source: unknown }).source, - }); - const asyncEndL: Listener = (m) => - events.push({ - kind: 'asyncEnd', - source: (m as { source: unknown }).source, - }); - const errorL: Listener = (m) => { - const msg = m as { source: unknown; error: unknown }; - events.push({ kind: 'error', source: msg.source, error: msg.error }); - }; - parseChannel.start.subscribe(startL); - parseChannel.end.subscribe(endL); - parseChannel.asyncStart.subscribe(asyncStartL); - parseChannel.asyncEnd.subscribe(asyncEndL); - parseChannel.error.subscribe(errorL); - return { - events, - unsubscribe() { - parseChannel.start.unsubscribe(startL); - parseChannel.end.unsubscribe(endL); - parseChannel.asyncStart.unsubscribe(asyncStartL); - parseChannel.asyncEnd.unsubscribe(asyncEndL); - parseChannel.error.unsubscribe(errorL); - }, - }; -} - describe('parse diagnostics channel', () => { let active: ReturnType | undefined; @@ -187,24 +26,24 @@ describe('parse diagnostics channel', () => { }); it('emits start and end around a successful parse', () => { - active = collectEvents(); + active = collectEvents(parseChannel); const doc = parse('{ field }'); expect(doc.kind).to.equal('Document'); expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].source).to.equal('{ field }'); - expect(active.events[1].source).to.equal('{ field }'); + expect(active.events[0].ctx.source).to.equal('{ field }'); + expect(active.events[1].ctx.source).to.equal('{ field }'); }); it('emits start, error, and end when the parser throws', () => { - active = collectEvents(); + active = collectEvents(parseChannel); expect(() => parse('{ ')).to.throw(); const kinds = active.events.map((e) => e.kind); expect(kinds).to.deep.equal(['start', 'error', 'end']); - expect(active.events[1].error).to.be.instanceOf(Error); + expect(active.events[1].ctx.error).to.be.instanceOf(Error); }); it('does nothing when no subscribers are attached', () => { diff --git a/src/validation/__tests__/validate-diagnostics-test.ts b/src/validation/__tests__/validate-diagnostics-test.ts new file mode 100644 index 0000000000..28d1c40c3b --- /dev/null +++ b/src/validation/__tests__/validate-diagnostics-test.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import { + collectEvents, + FakeDc, +} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + +import { parse } from '../../language/parser.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { enableDiagnosticsChannel } from '../../diagnostics.js'; + +import { validate } from '../validate.js'; + +const schema = buildSchema(` + type Query { + field: String + } +`); + +const fakeDc = new FakeDc(); +const validateChannel = fakeDc.tracingChannel('graphql:validate'); + +describe('validate diagnostics channel', () => { + let active: ReturnType | undefined; + + beforeEach(() => { + enableDiagnosticsChannel(fakeDc); + }); + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end around a successful validate', () => { + active = collectEvents(validateChannel); + + const doc = parse('{ field }'); + const errors = validate(schema, doc); + + expect(errors).to.deep.equal([]); + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].ctx.schema).to.equal(schema); + expect(active.events[0].ctx.document).to.equal(doc); + }); + + it('emits start and end for a document with validation errors', () => { + active = collectEvents(validateChannel); + + const doc = parse('{ missingField }'); + const errors = validate(schema, doc); + + expect(errors).to.have.length.greaterThan(0); + // Validation errors are collected, not thrown, so we still see start/end. + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + }); + + it('emits start, error, and end when validate throws on an invalid schema', () => { + active = collectEvents(validateChannel); + + expect(() => validate({} as typeof schema, parse('{ field }'))).to.throw(); + + expect(active.events.map((e) => e.kind)).to.deep.equal([ + 'start', + 'error', + 'end', + ]); + expect(active.events[1].ctx.error).to.be.instanceOf(Error); + }); + + it('does nothing when no subscribers are attached', () => { + const errors = validate(schema, parse('{ field }')); + expect(errors).to.deep.equal([]); + }); +}); diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 69c3725292..4672b9a8de 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -12,6 +12,8 @@ import { assertValidSchema } from '../type/validate.ts'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo.ts'; +import { maybeTraceSync } from '../diagnostics.ts'; + import { specifiedRules, specifiedSDLRules } from './specifiedRules.ts'; import type { SDLValidationRule, ValidationRule } from './ValidationContext.ts'; import { @@ -61,46 +63,52 @@ export function validate( rules: ReadonlyArray = specifiedRules, options?: ValidationOptions, ): ReadonlyArray { - const maxErrors = options?.maxErrors ?? 100; - const hideSuggestions = options?.hideSuggestions ?? false; - - // If the schema used for validation is invalid, throw an error. - assertValidSchema(schema); - - const errors: Array = []; - const typeInfo = new TypeInfo(schema); - const context = new ValidationContext( - schema, - documentAST, - typeInfo, - (error) => { - if (errors.length >= maxErrors) { - throw tooManyValidationErrorsError; + return maybeTraceSync( + 'validate', + () => ({ schema, document: documentAST }), + () => { + const maxErrors = options?.maxErrors ?? 100; + const hideSuggestions = options?.hideSuggestions ?? false; + + // If the schema used for validation is invalid, throw an error. + assertValidSchema(schema); + + const errors: Array = []; + const typeInfo = new TypeInfo(schema); + const context = new ValidationContext( + schema, + documentAST, + typeInfo, + (error) => { + if (errors.length >= maxErrors) { + throw tooManyValidationErrorsError; + } + errors.push(error); + }, + hideSuggestions, + ); + + // This uses a specialized visitor which runs multiple visitors in + // parallel, while maintaining the visitor skip and break API. + const visitor = visitInParallel(rules.map((rule) => rule(context))); + + // Visit the whole document with each instance of all provided rules. + try { + visit( + documentAST, + visitWithTypeInfo(typeInfo, visitor), + QueryDocumentKeysToValidate, + ); + } catch (e: unknown) { + if (e === tooManyValidationErrorsError) { + errors.push(tooManyValidationErrorsError); + } else { + throw e; + } } - errors.push(error); + return errors; }, - hideSuggestions, ); - - // This uses a specialized visitor which runs multiple visitors in parallel, - // while maintaining the visitor skip and break API. - const visitor = visitInParallel(rules.map((rule) => rule(context))); - - // Visit the whole document with each instance of all provided rules. - try { - visit( - documentAST, - visitWithTypeInfo(typeInfo, visitor), - QueryDocumentKeysToValidate, - ); - } catch (e: unknown) { - if (e === tooManyValidationErrorsError) { - errors.push(tooManyValidationErrorsError); - } else { - throw e; - } - } - return errors; } /** From 56d94c1f6cc8e32fd1146f96ead464b27d12c529 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 15:39:18 -0400 Subject: [PATCH 05/55] feat(execution): publish on graphql:execute tracing channel Wraps the public execute / experimentalExecuteIncrementally / executeIgnoringIncremental / executeSubscriptionEvent entry points with maybeTraceMixed so subscribers see a single graphql:execute span per top-level operation invocation, including every per-event execution of a subscription stream. The context exposes the document, schema, variableValues, operationName, and operationType. operationName and operationType are lazy getters that only resolve the operation AST (getOperationAST) if a subscriber reads them, keeping the gate cheap for APMs that do not need them. The ValidatedExecutionArgs variant used by executeSubscriptionEvent uses the already-resolved operation from validation and therefore needs no lazy lookup; it carries the resolved operation in place of the (unavailable) document. Adds a shared maybeTraceMixed helper to src/diagnostics.ts for PromiseOrValue-returning functions. It delegates start / end / error to Node's traceSync (which also runs fn inside end.runStores for AsyncLocalStorage propagation) and adds the asyncStart / asyncEnd / error-on-rejection emission when the return value is a promise. --- integrationTests/diagnostics/test.js | 46 ++++++ src/diagnostics.ts | 51 +++++++ .../__tests__/execute-diagnostics-test.ts | 139 ++++++++++++++++++ src/execution/execute.ts | 125 ++++++++++++---- 4 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 src/execution/__tests__/execute-diagnostics-test.ts diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index 86661209ef..7013070afc 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -8,6 +8,7 @@ import dc from 'node:diagnostics_channel'; import { buildSchema, enableDiagnosticsChannel, + execute, parse, validate, } from 'graphql'; @@ -103,6 +104,51 @@ enableDiagnosticsChannel(dc); } } +// graphql:execute - sync path, ctx carries operationType, operationName, +// document, schema. +{ + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + document: msg.document, + schema: msg.schema, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].document, document); + assert.equal(events[0].schema, schema); + } finally { + channel.unsubscribe(handler); + } +} + // No-op when nothing is subscribed - parse still succeeds. { const doc = parse('{ field }'); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index b37c3f4a8e..d8551963d8 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -13,6 +13,8 @@ * same `TracingChannel` instances and all subscribers coexist. */ +import { isPromise } from './jsutils/isPromise.js'; + /** * Structural subset of `DiagnosticsChannel` sufficient for publishing and * subscriber gating. `node:diagnostics_channel`'s `Channel` satisfies this. @@ -181,3 +183,52 @@ export function maybeTracePromise( } return channel.tracePromise(fn, ctxFactory()); } + +/** + * Publish a mixed sync-or-promise operation through the named graphql tracing + * channel. Delegates the start/end/error lifecycle to Node's `traceSync` + * (which also runs `fn` inside `end.runStores` for AsyncLocalStorage context + * propagation) and, when `fn` returns a promise, appends `asyncStart` and + * `asyncEnd` on settlement plus `error` on rejection. + * + * Use this when the function may return either a value or a promise, which + * is common on graphql-js's execution path where async-ness is determined + * by resolvers only after the call begins. Short-circuits to `fn()` when + * the channel isn't registered or nothing is listening. + * + * @internal + */ +export function maybeTraceMixed( + name: keyof GraphQLChannels, + ctxFactory: () => object, + fn: () => T | Promise, +): T | Promise { + const channel = getChannels()?.[name]; + if (!shouldTrace(channel)) { + return fn(); + } + const ctx = ctxFactory() as { + error?: unknown; + result?: unknown; + }; + const result = channel.traceSync(fn, ctx); + if (!isPromise(result)) { + ctx.result = result; + return result; + } + return result.then( + (value) => { + ctx.result = value; + channel.asyncStart.publish(ctx); + channel.asyncEnd.publish(ctx); + return value; + }, + (err: unknown) => { + ctx.error = err; + channel.error.publish(ctx); + channel.asyncStart.publish(ctx); + channel.asyncEnd.publish(ctx); + throw err; + }, + ); +} diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/execute-diagnostics-test.ts new file mode 100644 index 0000000000..45fcbdee3c --- /dev/null +++ b/src/execution/__tests__/execute-diagnostics-test.ts @@ -0,0 +1,139 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import { + collectEvents, + FakeDc, +} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + +import { parse } from '../../language/parser.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { enableDiagnosticsChannel } from '../../diagnostics.js'; + +import type { ExecutionArgs } from '../execute.js'; +import { + execute, + executeSubscriptionEvent, + executeSync, + validateExecutionArgs, +} from '../execute.js'; + +const schema = buildSchema(` + type Query { + sync: String + async: String + } +`); + +const rootValue = { + sync: () => 'hello', + async: () => Promise.resolve('hello-async'), +}; + +const fakeDc = new FakeDc(); +const executeChannel = fakeDc.tracingChannel('graphql:execute'); + +describe('execute diagnostics channel', () => { + let active: ReturnType | undefined; + + beforeEach(() => { + enableDiagnosticsChannel(fakeDc); + }); + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end around a synchronous execute', () => { + active = collectEvents(executeChannel); + + const document = parse('query Q { sync }'); + const result = execute({ schema, document, rootValue }); + + expect(result).to.deep.equal({ data: { sync: 'hello' } }); + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].ctx.operationType).to.equal('query'); + expect(active.events[0].ctx.operationName).to.equal('Q'); + expect(active.events[0].ctx.document).to.equal(document); + expect(active.events[0].ctx.schema).to.equal(schema); + }); + + it('emits start, end, and async lifecycle when execute returns a promise', async () => { + active = collectEvents(executeChannel); + + const document = parse('query { async }'); + const result = await execute({ schema, document, rootValue }); + + expect(result).to.deep.equal({ data: { async: 'hello-async' } }); + expect(active.events.map((e) => e.kind)).to.deep.equal([ + 'start', + 'end', + 'asyncStart', + 'asyncEnd', + ]); + }); + + it('emits once for executeSync via experimentalExecuteIncrementally', () => { + active = collectEvents(executeChannel); + + const document = parse('{ sync }'); + executeSync({ schema, document, rootValue }); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + }); + + it('emits start, error, and end when execute throws synchronously', () => { + active = collectEvents(executeChannel); + + const schemaWithDefer = buildSchema(` + directive @defer on FIELD + type Query { sync: String } + `); + const document = parse('{ sync }'); + expect(() => + execute({ schema: schemaWithDefer, document, rootValue }), + ).to.throw(); + + expect(active.events.map((e) => e.kind)).to.deep.equal([ + 'start', + 'error', + 'end', + ]); + }); + + it('emits for each executeSubscriptionEvent call with resolved operation ctx', () => { + const args: ExecutionArgs = { + schema, + document: parse('query Q { sync }'), + rootValue, + }; + const validated = validateExecutionArgs(args); + if (!('schema' in validated)) { + throw new Error('unexpected validation failure'); + } + + active = collectEvents(executeChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + executeSubscriptionEvent(validated); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + executeSubscriptionEvent(validated); + + const starts = active.events.filter((e) => e.kind === 'start'); + expect(starts.length).to.equal(2); + for (const ev of starts) { + expect(ev.ctx.operationType).to.equal('query'); + expect(ev.ctx.operationName).to.equal('Q'); + expect(ev.ctx.schema).to.equal(schema); + } + }); + + it('does nothing when no subscribers are attached', () => { + const document = parse('{ sync }'); + const result = execute({ schema, document, rootValue }); + expect(result).to.deep.equal({ data: { sync: 'hello' } }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 6cc412cd77..a8bda375d1 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -28,6 +28,10 @@ import type { import { assertValidSchema } from '../type/index.ts'; import type { GraphQLSchema } from '../type/schema.ts'; +import { getOperationAST } from '../utilities/getOperationAST.ts'; + +import { maybeTraceMixed } from '../diagnostics.ts'; + import { buildResolveInfo } from './buildResolveInfo.ts'; import { cancellablePromise } from './cancellablePromise.ts'; import type { FieldDetailsList, FragmentDetails } from './collectFields.ts'; @@ -54,6 +58,53 @@ export type RootSelectionSetExecutor = ( validatedExecutionArgs: ValidatedSubscriptionArgs, ) => PromiseOrValue; +/** + * Build a graphql:execute channel context from raw ExecutionArgs. Defers + * resolution of the operation AST to a lazy getter so the cost of walking + * the document is only paid if a subscriber reads it. + */ +function buildExecuteCtxFromArgs(args: ExecutionArgs): () => object { + return () => { + let operation: OperationDefinitionNode | null | undefined; + const resolveOperation = (): OperationDefinitionNode | null | undefined => { + if (operation === undefined) { + operation = getOperationAST(args.document, args.operationName); + } + return operation; + }; + return { + document: args.document, + schema: args.schema, + variableValues: args.variableValues, + get operationName() { + return args.operationName ?? resolveOperation()?.name?.value; + }, + get operationType() { + return resolveOperation()?.operation; + }, + }; + }; +} + +/** + * Build a graphql:execute channel context from ValidatedExecutionArgs. + * Used by executeSubscriptionEvent, where the operation has already been + * resolved during argument validation. The original document is not + * available at this point, only the resolved operation; subscribers that + * need the document should read it from the graphql:subscribe context. + */ +function buildExecuteCtxFromValidatedArgs( + args: ValidatedExecutionArgs, +): () => object { + return () => ({ + operation: args.operation, + schema: args.schema, + variableValues: args.variableValues, + operationName: args.operation.name?.value, + operationType: args.operation.operation, + }); +} + /** * Implements the "Executing requests" section of the GraphQL specification. * @@ -71,18 +122,23 @@ export type RootSelectionSetExecutor = ( * delivery. */ export function execute(args: ExecutionArgs): PromiseOrValue { - if (args.schema.getDirective('defer') || args.schema.getDirective('stream')) { - throw new Error(UNEXPECTED_EXPERIMENTAL_DIRECTIVES); - } + return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { + if ( + args.schema.getDirective('defer') || + args.schema.getDirective('stream') + ) { + throw new Error(UNEXPECTED_EXPERIMENTAL_DIRECTIVES); + } - const validatedExecutionArgs = validateExecutionArgs(args); + const validatedExecutionArgs = validateExecutionArgs(args); - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } - return executeRootSelectionSet(validatedExecutionArgs); + return executeRootSelectionSet(validatedExecutionArgs); + }); } /** @@ -100,31 +156,35 @@ export function execute(args: ExecutionArgs): PromiseOrValue { export function experimentalExecuteIncrementally( args: ExecutionArgs, ): PromiseOrValue { - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const validatedExecutionArgs = validateExecutionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateExecutionArgs(args); + + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } - return experimentalExecuteRootSelectionSet(validatedExecutionArgs); + return experimentalExecuteRootSelectionSet(validatedExecutionArgs); + }); } export function executeIgnoringIncremental( args: ExecutionArgs, ): PromiseOrValue { - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const validatedExecutionArgs = validateExecutionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateExecutionArgs(args); + + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } - return executeRootSelectionSetIgnoringIncremental(validatedExecutionArgs); + return executeRootSelectionSetIgnoringIncremental(validatedExecutionArgs); + }); } /** @@ -183,9 +243,14 @@ export function executeSync(args: ExecutionArgs): ExecutionResult { export function executeSubscriptionEvent( validatedExecutionArgs: ValidatedSubscriptionArgs, ): PromiseOrValue { - return new ExecutorThrowingOnIncremental( - validatedExecutionArgs, - ).executeRootSelectionSet(false); + return maybeTraceMixed( + 'execute', + buildExecuteCtxFromValidatedArgs(validatedExecutionArgs), + () => + new ExecutorThrowingOnIncremental( + validatedExecutionArgs, + ).executeRootSelectionSet(false), + ); } /** From 6a16403a8b8ca88625a79511ef081926dd6bc080 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 16:07:33 -0400 Subject: [PATCH 06/55] refactor(diagnostics): align async lifecycle with Node's tracePromise shape maybeTraceMixed now publishes asyncStart immediately once it knows the operation is in-flight asynchronously and asyncEnd in a .finally once the promise settles, matching the shape subscribers expect from a tracePromise-style channel (asyncStart brackets the async tail rather than being paired with asyncEnd in a single microtask). Also brings the in-memory FakeTracingChannel in line with Node's actual traceSync behavior: Node sets ctx.result before publishing end, letting subscribers check isPromise(ctx.result) inside their end handler to decide whether asyncEnd will follow or the span is complete. The fake now does the same so unit tests aren't looser than real-Node behavior. --- src/__testUtils__/fakeDiagnosticsChannel.ts | 42 ++++++++++-------- src/diagnostics.ts | 47 ++++++++++----------- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 47949f5c17..4a0bcd7f84 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -82,15 +82,22 @@ export class FakeTracingChannel implements MinimalTracingChannel { ...args: Array ): T { this.start.publish(ctx); + let result: T; try { - return this.end.runStores(ctx, fn, thisArg, ...args); + result = this.end.runStores(ctx, fn, thisArg, ...args); } catch (err) { (ctx as { error: unknown }).error = err; this.error.publish(ctx); - throw err; - } finally { this.end.publish(ctx); + throw err; } + // Node's real traceSync sets `ctx.result` before publishing `end`, so + // subscribers can inspect `isPromise(ctx.result)` inside their `end` + // handler to decide whether the operation is complete or async events + // will follow. Match that semantic here. + (ctx as { result: unknown }).result = result; + this.end.publish(ctx); + return result; } tracePromise( @@ -110,21 +117,22 @@ export class FakeTracingChannel implements MinimalTracingChannel { throw err; } this.end.publish(ctx); - return promise.then( - (result) => { - (ctx as { result: unknown }).result = result; - this.asyncStart.publish(ctx); + this.asyncStart.publish(ctx); + return promise + .then( + (result) => { + (ctx as { result: unknown }).result = result; + return result; + }, + (err: unknown) => { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + throw err; + }, + ) + .finally(() => { this.asyncEnd.publish(ctx); - return result; - }, - (err: unknown) => { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.asyncStart.publish(ctx); - this.asyncEnd.publish(ctx); - throw err; - }, - ); + }); } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index d8551963d8..4971fc4fd2 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -185,16 +185,7 @@ export function maybeTracePromise( } /** - * Publish a mixed sync-or-promise operation through the named graphql tracing - * channel. Delegates the start/end/error lifecycle to Node's `traceSync` - * (which also runs `fn` inside `end.runStores` for AsyncLocalStorage context - * propagation) and, when `fn` returns a promise, appends `asyncStart` and - * `asyncEnd` on settlement plus `error` on rejection. - * - * Use this when the function may return either a value or a promise, which - * is common on graphql-js's execution path where async-ness is determined - * by resolvers only after the call begins. Short-circuits to `fn()` when - * the channel isn't registered or nothing is listening. + * Publish a mixed sync-or-promise operation through the named graphql tracing channel. * * @internal */ @@ -211,24 +202,30 @@ export function maybeTraceMixed( error?: unknown; result?: unknown; }; + + // traceSync fires start/end (and error, if fn throws synchronously) const result = channel.traceSync(fn, ctx); if (!isPromise(result)) { - ctx.result = result; return result; } - return result.then( - (value) => { - ctx.result = value; - channel.asyncStart.publish(ctx); - channel.asyncEnd.publish(ctx); - return value; - }, - (err: unknown) => { - ctx.error = err; - channel.error.publish(ctx); - channel.asyncStart.publish(ctx); + + // Fires off `asyncStart` and `asyncEnd` lifecycle events. + channel.asyncStart.publish(ctx); + return result + .then( + (value) => { + ctx.result = value; + + return value; + }, + (err: unknown) => { + ctx.error = err; + channel.error.publish(ctx); + + throw err; + }, + ) + .finally(() => { channel.asyncEnd.publish(ctx); - throw err; - }, - ); + }); } From 37d8b2c7ac1744fe721feaafe6d631d9a313cabb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 16:17:53 -0400 Subject: [PATCH 07/55] feat(execution): publish on graphql:subscribe tracing channel Wraps the public subscribe() entry point with maybeTraceMixed using the same ExecutionArgs-based context factory as graphql:execute. The subscription setup path may return sync (when the subscribe resolver is synchronous) or async (when the resolver returns a promise resolving to an AsyncIterable), so maybeTraceMixed's sync / promise branching fits exactly. Per-subscription-event executions continue to publish on graphql:execute via executeSubscriptionEvent, which was wired in the previous commit. The graphql:subscribe context therefore owns the document reference and covers the setup span; subscribers that need to correlate per-event execute spans to their parent subscription use AsyncLocalStorage context propagation provided by Channel.runStores. --- integrationTests/diagnostics/test.js | 173 ++++++++++++------ .../__tests__/subscribe-diagnostics-test.ts | 130 +++++++++++++ src/execution/execute.ts | 46 ++--- 3 files changed, 269 insertions(+), 80 deletions(-) create mode 100644 src/execution/__tests__/subscribe-diagnostics-test.ts diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index 7013070afc..ae25b6ef64 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -10,68 +10,70 @@ import { enableDiagnosticsChannel, execute, parse, + subscribe, validate, } from 'graphql'; enableDiagnosticsChannel(dc); -// graphql:parse - synchronous -{ - const events = []; - const handler = { - start: (msg) => events.push({ kind: 'start', source: msg.source }), - end: (msg) => events.push({ kind: 'end', source: msg.source }), - asyncStart: (msg) => - events.push({ kind: 'asyncStart', source: msg.source }), - asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), - error: (msg) => - events.push({ kind: 'error', source: msg.source, error: msg.error }), - }; - - const channel = dc.tracingChannel('graphql:parse'); - channel.subscribe(handler); - - try { - const doc = parse('{ field }'); - assert.equal(doc.kind, 'Document'); - assert.deepEqual( - events.map((e) => e.kind), - ['start', 'end'], - ); - assert.equal(events[0].source, '{ field }'); - assert.equal(events[1].source, '{ field }'); - } finally { - channel.unsubscribe(handler); +function runParseCases() { + // graphql:parse - synchronous. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + asyncStart: (msg) => + events.push({ kind: 'asyncStart', source: msg.source }), + asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].source, '{ field }'); + assert.equal(events[1].source, '{ field }'); + } finally { + channel.unsubscribe(handler); + } } -} -// graphql:parse - error path fires start, error, end (traceSync finally-emits end) -{ - const events = []; - const handler = { - start: (msg) => events.push({ kind: 'start', source: msg.source }), - end: (msg) => events.push({ kind: 'end', source: msg.source }), - error: (msg) => - events.push({ kind: 'error', source: msg.source, error: msg.error }), - }; - - const channel = dc.tracingChannel('graphql:parse'); - channel.subscribe(handler); - - try { - assert.throws(() => parse('{ ')); - assert.deepEqual( - events.map((e) => e.kind), - ['start', 'error', 'end'], - ); - assert.ok(events[1].error instanceof Error); - } finally { - channel.unsubscribe(handler); + // graphql:parse - error path fires start, error, end. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + assert.throws(() => parse('{ ')); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'error', 'end'], + ); + assert.ok(events[1].error instanceof Error); + } finally { + channel.unsubscribe(handler); + } } } -// graphql:validate - synchronous, with schema/document context -{ +function runValidateCase() { const schema = buildSchema(`type Query { field: String }`); const doc = parse('{ field }'); @@ -104,9 +106,7 @@ enableDiagnosticsChannel(dc); } } -// graphql:execute - sync path, ctx carries operationType, operationName, -// document, schema. -{ +function runExecuteCase() { const schema = buildSchema(`type Query { hello: String }`); const document = parse('query Greeting { hello }'); @@ -149,10 +149,67 @@ enableDiagnosticsChannel(dc); } } -// No-op when nothing is subscribed - parse still succeeds. -{ +async function runSubscribeCase() { + async function* ticks() { + yield { tick: 'one' }; + } + + const schema = buildSchema(` + type Query { dummy: String } + type Subscription { tick: String } + `); + // buildSchema doesn't attach a subscribe resolver to fields; inject one. + schema.getSubscriptionType().getFields().tick.subscribe = () => ticks(); + + const document = parse('subscription Tick { tick }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:subscribe'); + channel.subscribe(handler); + + try { + const result = subscribe({ schema, document }); + const stream = typeof result.then === 'function' ? await result : result; + if (stream[Symbol.asyncIterator]) { + await stream.return?.(); + } + // Subscription setup is synchronous here; start/end fire, no async tail. + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'subscription'); + assert.equal(events[0].operationName, 'Tick'); + } finally { + channel.unsubscribe(handler); + } +} + +function runNoSubscriberCase() { const doc = parse('{ field }'); assert.equal(doc.kind, 'Document'); } -console.log('diagnostics integration test passed'); +async function main() { + runParseCases(); + runValidateCase(); + runExecuteCase(); + await runSubscribeCase(); + runNoSubscriberCase(); + console.log('diagnostics integration test passed'); +} + +main(); diff --git a/src/execution/__tests__/subscribe-diagnostics-test.ts b/src/execution/__tests__/subscribe-diagnostics-test.ts new file mode 100644 index 0000000000..a1b1987ecb --- /dev/null +++ b/src/execution/__tests__/subscribe-diagnostics-test.ts @@ -0,0 +1,130 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import { + collectEvents, + FakeDc, +} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + +import { isPromise } from '../../jsutils/isPromise.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { enableDiagnosticsChannel } from '../../diagnostics.js'; + +import { subscribe } from '../execute.js'; + +function buildSubscriptionSchema( + subscribeFn: () => AsyncIterable<{ tick: string }>, +): GraphQLSchema { + return new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { dummy: { type: GraphQLString } }, + }), + subscription: new GraphQLObjectType({ + name: 'Subscription', + fields: { + tick: { + type: GraphQLString, + subscribe: subscribeFn, + }, + }, + }), + }); +} + +async function* twoTicks(): AsyncIterable<{ tick: string }> { + await Promise.resolve(); + yield { tick: 'one' }; + yield { tick: 'two' }; +} + +const fakeDc = new FakeDc(); +const subscribeChannel = fakeDc.tracingChannel('graphql:subscribe'); + +describe('subscribe diagnostics channel', () => { + let active: ReturnType | undefined; + + beforeEach(() => { + enableDiagnosticsChannel(fakeDc); + }); + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end for a synchronous subscription setup', async () => { + active = collectEvents(subscribeChannel); + + const schema = buildSubscriptionSchema(twoTicks); + const document = parse('subscription S { tick }'); + + const result = subscribe({ schema, document }); + const resolved = isPromise(result) ? await result : result; + if (!(Symbol.asyncIterator in resolved)) { + throw new Error('Expected an async iterator'); + } + await resolved.return?.(); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].ctx.operationType).to.equal('subscription'); + expect(active.events[0].ctx.operationName).to.equal('S'); + expect(active.events[0].ctx.document).to.equal(document); + expect(active.events[0].ctx.schema).to.equal(schema); + }); + + it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { + active = collectEvents(subscribeChannel); + + const asyncResolver = (): Promise> => + Promise.resolve(twoTicks()); + const schema = buildSubscriptionSchema( + asyncResolver as unknown as () => AsyncIterable<{ tick: string }>, + ); + const document = parse('subscription { tick }'); + + const result = subscribe({ schema, document }); + const resolved = isPromise(result) ? await result : result; + if (!(Symbol.asyncIterator in resolved)) { + throw new Error('Expected an async iterator'); + } + await resolved.return?.(); + + expect(active.events.map((e) => e.kind)).to.deep.equal([ + 'start', + 'end', + 'asyncStart', + 'asyncEnd', + ]); + }); + + it('emits only start and end for a synchronous validation failure', () => { + active = collectEvents(subscribeChannel); + + const schema = buildSubscriptionSchema(twoTicks); + // Invalid: no operation. + const document = parse('fragment F on Subscription { tick }'); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + subscribe({ schema, document }); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + }); + + it('does nothing when no subscribers are attached', async () => { + const schema = buildSubscriptionSchema(twoTicks); + const document = parse('subscription { tick }'); + + const result = subscribe({ schema, document }); + const resolved = isPromise(result) ? await result : result; + if (Symbol.asyncIterator in resolved) { + await resolved.return?.(); + } + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index a8bda375d1..09f9b03214 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -284,31 +284,33 @@ export function subscribe( ): PromiseOrValue< AsyncGenerator | ExecutionResult > { - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const validatedExecutionArgs = validateSubscriptionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + return maybeTraceMixed('subscribe', buildExecuteCtxFromArgs(args), () => { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateSubscriptionArgs(args); - const resultOrStream = createSourceEventStream(validatedExecutionArgs); + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } - if (isPromise(resultOrStream)) { - return resultOrStream.then((resolvedResultOrStream) => - isAsyncIterable(resolvedResultOrStream) - ? mapSourceToResponseEvent( - validatedExecutionArgs, - resolvedResultOrStream, - ) - : resolvedResultOrStream, - ); - } + const resultOrStream = createSourceEventStream(validatedExecutionArgs); + + if (isPromise(resultOrStream)) { + return resultOrStream.then((resolvedResultOrStream) => + isAsyncIterable(resolvedResultOrStream) + ? mapSourceToResponseEvent( + validatedExecutionArgs, + resolvedResultOrStream, + ) + : resolvedResultOrStream, + ); + } - return isAsyncIterable(resultOrStream) - ? mapSourceToResponseEvent(validatedExecutionArgs, resultOrStream) - : resultOrStream; + return isAsyncIterable(resultOrStream) + ? mapSourceToResponseEvent(validatedExecutionArgs, resultOrStream) + : resultOrStream; + }); } /** From 67c9625c53f564dba54a7714abc6323c139f73eb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 16:27:00 -0400 Subject: [PATCH 08/55] feat(execution): publish on graphql:resolve tracing channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the single resolver invocation site in Executor.executeField with maybeTraceMixed so each field resolver call emits start/end (and the async tail when the resolver returns a promise, or error when it throws). The emission is inside the engine — no field.resolve mutation, no schema walking, nothing stacks across multiple APM subscribers. The published context captures the field-level metadata APM tools use to name and attribute per-field spans: - fieldName from info.fieldName - parentType from info.parentType.name - fieldType stringified info.returnType - args resolver arguments, by reference - isTrivialResolver (field.resolve === undefined), a hint for subscribers that want to skip default property-access resolvers - fieldPath lazy getter, serializes info.path only on first read since it is O(depth) work and many subscribers depth-filter without reading it graphql:resolve is the noisiest channel (one event per field per operation); all emission sites are gated on hasSubscribers before any context is constructed, so it remains zero-cost when no APM is loaded. --- integrationTests/diagnostics/test.js | 45 +++++ src/execution/Executor.ts | 34 +++- .../__tests__/resolve-diagnostics-test.ts | 173 ++++++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/execution/__tests__/resolve-diagnostics-test.ts diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index ae25b6ef64..fb67e75d28 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -198,6 +198,50 @@ async function runSubscribeCase() { } } +function runResolveCase() { + const schema = buildSchema( + `type Query { hello: String nested: Nested } type Nested { leaf: String }`, + ); + const document = parse('{ hello nested { leaf } }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + fieldName: msg.fieldName, + parentType: msg.parentType, + fieldType: msg.fieldType, + fieldPath: msg.fieldPath, + isTrivialResolver: msg.isTrivialResolver, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:resolve'); + channel.subscribe(handler); + + try { + const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } }; + execute({ schema, document, rootValue }); + + const starts = events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.fieldPath); + assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']); + + const hello = starts.find((e) => e.fieldName === 'hello'); + assert.equal(hello.parentType, 'Query'); + assert.equal(hello.fieldType, 'String'); + // buildSchema never attaches field.resolve; all fields report as trivial. + assert.equal(hello.isTrivialResolver, true); + } finally { + channel.unsubscribe(handler); + } +} + function runNoSubscriberCase() { const doc = parse('{ field }'); assert.equal(doc.kind, 'Document'); @@ -208,6 +252,7 @@ async function main() { runValidateCase(); runExecuteCase(); await runSubscribeCase(); + runResolveCase(); runNoSubscriberCase(); console.log('diagnostics integration test passed'); } diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index c408370554..9621e1e493 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,6 +45,8 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; +import { maybeTraceMixed } from '../diagnostics.ts'; + import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; import { withCancellation } from './cancellablePromise.ts'; @@ -602,7 +604,11 @@ export class Executor< // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = resolveFn(source, args, contextValue, info); + const result = maybeTraceMixed( + 'resolve', + () => buildResolveCtx(info, args, fieldDef.resolve === undefined), + () => resolveFn(source, args, contextValue, info), + ); if (isPromiseLike(result)) { return this.completePromisedValue( @@ -1383,3 +1389,29 @@ export class Executor< function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); } + +/** + * Build a graphql:resolve channel context for a single field invocation. + * + * `fieldPath` is exposed as a lazy getter because serializing the response + * path is O(depth) and APMs that depth-filter or skip trivial resolvers + * often never read it. `args` is passed through by reference. + */ +function buildResolveCtx( + info: GraphQLResolveInfo, + args: { readonly [argument: string]: unknown }, + isTrivialResolver: boolean, +): object { + let cachedFieldPath: string | undefined; + return { + fieldName: info.fieldName, + parentType: info.parentType.name, + fieldType: String(info.returnType), + args, + isTrivialResolver, + get fieldPath() { + cachedFieldPath ??= pathToArray(info.path).join('.'); + return cachedFieldPath; + }, + }; +} diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts new file mode 100644 index 0000000000..5d5343e4e1 --- /dev/null +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; + +import { + collectEvents, + FakeDc, +} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + +import { isPromise } from '../../jsutils/isPromise.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { enableDiagnosticsChannel } from '../../diagnostics.js'; + +import { execute } from '../execute.js'; + +const schema = buildSchema(` + type Query { + sync: String + async: String + fail: String + plain: String + nested: Nested + } + type Nested { + leaf: String + } +`); + +const rootValue = { + sync: () => 'hello', + async: () => Promise.resolve('hello-async'), + fail: () => { + throw new Error('boom'); + }, + // no `plain` resolver, default property-access is used. + plain: 'plain-value', + nested: { leaf: 'leaf-value' }, +}; + +const fakeDc = new FakeDc(); +const resolveChannel = fakeDc.tracingChannel('graphql:resolve'); + +describe('resolve diagnostics channel', () => { + let active: ReturnType | undefined; + + beforeEach(() => { + enableDiagnosticsChannel(fakeDc); + }); + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end around a synchronous resolver', () => { + active = collectEvents(resolveChannel); + + const result = execute({ schema, document: parse('{ sync }'), rootValue }); + if (isPromise(result)) { + throw new Error('expected sync'); + } + + const starts = active.events.filter((e) => e.kind === 'start'); + expect(starts.length).to.equal(1); + expect(starts[0].ctx.fieldName).to.equal('sync'); + expect(starts[0].ctx.parentType).to.equal('Query'); + expect(starts[0].ctx.fieldType).to.equal('String'); + expect(starts[0].ctx.fieldPath).to.equal('sync'); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'end']); + }); + + it('emits the full async lifecycle when a resolver returns a promise', async () => { + active = collectEvents(resolveChannel); + + const result = execute({ schema, document: parse('{ async }'), rootValue }); + await result; + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'end', 'asyncStart', 'asyncEnd']); + }); + + it('emits start, error, end when a sync resolver throws', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ schema, document: parse('{ fail }'), rootValue }); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'error', 'end']); + }); + + it('reports isTrivialResolver based on field.resolve presence', () => { + const trivialSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + trivial: { type: GraphQLString }, + custom: { + type: GraphQLString, + resolve: () => 'explicit', + }, + }, + }), + }); + + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema: trivialSchema, + document: parse('{ trivial custom }'), + rootValue: { trivial: 'value' }, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const byField = new Map( + starts.map((e) => [e.ctx.fieldName, e.ctx.isTrivialResolver]), + ); + expect(byField.get('trivial')).to.equal(true); + expect(byField.get('custom')).to.equal(false); + }); + + it('serializes fieldPath lazily, joining path keys with dots', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema, + document: parse('{ nested { leaf } }'), + rootValue, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.ctx.fieldPath); + expect(paths).to.deep.equal(['nested', 'nested.leaf']); + }); + + it('fires once per field, not per schema walk', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema, + document: parse('{ sync plain nested { leaf } }'), + rootValue, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const endsSync = active.events.filter((e) => e.kind === 'end'); + expect(starts.length).to.equal(4); // sync, plain, nested, nested.leaf + expect(endsSync.length).to.equal(4); + }); + + it('does nothing when no subscribers are attached', () => { + const result = execute({ + schema, + document: parse('{ sync }'), + rootValue, + }); + if (isPromise(result)) { + throw new Error('expected sync'); + } + }); +}); From 85ea8dfc264172b8857ef28e942de500176a4ec5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 16:40:21 -0400 Subject: [PATCH 09/55] fix(diagnostics): preserve AsyncLocalStorage across async lifecycle maybeTraceMixed previously delegated start / end to traceSync and then attached its own .then / .finally outside that scope. That worked for event ordering but broke AsyncLocalStorage propagation: a subscriber that called channel.start.bindStore(als, ...) could read that store in its start and end handlers but not in asyncStart or asyncEnd, because those fired in a .then attached outside the start.runStores scope. Promise continuations inherit the AsyncLocalStorage context of the frame that attaches them, so the fix is to attach .then inside the start.runStores block. This mirrors Node's own TracingChannel.tracePromise structure exactly, just with an additional sync branch that short- circuits to [start, end] when fn returns synchronously. Also updates the FakeChannel helper to match: its runStores now publishes the context on entry (the behavior Node's Channel.runStores has), so the fake traceSync / tracePromise implementations match real Node's event counts without the old end.runStores workaround. Adds an integration test that binds a store on graphql:execute's start sub-channel and asserts every lifecycle handler sees it when a resolver returns a promise. --- integrationTests/diagnostics/test.js | 37 +++++++++ src/__testUtils__/fakeDiagnosticsChannel.ts | 83 ++++++++++++--------- src/diagnostics.ts | 65 +++++++++++----- 3 files changed, 131 insertions(+), 54 deletions(-) diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index fb67e75d28..93ed499b7a 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -3,6 +3,7 @@ /* eslint-disable n/no-unsupported-features/node-builtins */ import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; import dc from 'node:diagnostics_channel'; import { @@ -247,12 +248,48 @@ function runNoSubscriberCase() { assert.equal(doc.kind, 'Document'); } +async function runAlsPropagationCase() { + // A subscriber that binds a store on the `start` sub-channel should be able + // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). + // This is what APMs use to parent child spans to the current operation + // without threading state through the ctx object. + const als = new AsyncLocalStorage(); + const channel = dc.tracingChannel('graphql:execute'); + channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + + const seen = {}; + const handler = { + start: () => (seen.start = als.getStore()), + end: () => (seen.end = als.getStore()), + asyncStart: () => (seen.asyncStart = als.getStore()), + asyncEnd: () => (seen.asyncEnd = als.getStore()), + }; + channel.subscribe(handler); + + try { + const schema = buildSchema(`type Query { slow: String }`); + const document = parse('query Slow { slow }'); + const rootValue = { slow: () => Promise.resolve('done') }; + + await execute({ schema, document, rootValue }); + + assert.deepEqual(seen.start, { operationName: 'Slow' }); + assert.deepEqual(seen.end, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncStart, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncEnd, { operationName: 'Slow' }); + } finally { + channel.unsubscribe(handler); + channel.start.unbindStore(als); + } +} + async function main() { runParseCases(); runValidateCase(); runExecuteCase(); await runSubscribeCase(); runResolveCase(); + await runAlsPropagationCase(); runNoSubscriberCase(); console.log('diagnostics integration test passed'); } diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 4a0bcd7f84..4ec1499046 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -29,11 +29,15 @@ export class FakeChannel implements MinimalChannel { } runStores( - _ctx: ContextType, + ctx: ContextType, fn: (this: ContextType, ...args: Array) => T, thisArg?: unknown, ...args: Array ): T { + // Node's Channel.runStores publishes the context on the channel before + // invoking fn. Mirror that here so traceSync / tracePromise fake exactly + // matches real Node's start / end event counts. + this.publish(ctx); return fn.apply(thisArg as ContextType, args); } @@ -81,23 +85,24 @@ export class FakeTracingChannel implements MinimalTracingChannel { thisArg?: unknown, ...args: Array ): T { - this.start.publish(ctx); - let result: T; - try { - result = this.end.runStores(ctx, fn, thisArg, ...args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); + return this.start.runStores(ctx, () => { + let result: T; + try { + result = fn.apply(thisArg as object, args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.end.publish(ctx); + throw err; + } + // Node's real traceSync sets `ctx.result` before publishing `end`, so + // subscribers can inspect `isPromise(ctx.result)` inside their `end` + // handler to decide whether the operation is complete or async events + // will follow. Match that semantic here. + (ctx as { result: unknown }).result = result; this.end.publish(ctx); - throw err; - } - // Node's real traceSync sets `ctx.result` before publishing `end`, so - // subscribers can inspect `isPromise(ctx.result)` inside their `end` - // handler to decide whether the operation is complete or async events - // will follow. Match that semantic here. - (ctx as { result: unknown }).result = result; - this.end.publish(ctx); - return result; + return result; + }); } tracePromise( @@ -106,33 +111,39 @@ export class FakeTracingChannel implements MinimalTracingChannel { thisArg?: unknown, ...args: Array ): Promise { - this.start.publish(ctx); - let promise: Promise; - try { - promise = this.end.runStores(ctx, fn, thisArg, ...args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); + return this.start.runStores(ctx, () => { + let promise: Promise; + try { + promise = fn.apply(thisArg as object, args); + } catch (err) { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); + this.end.publish(ctx); + throw err; + } this.end.publish(ctx); - throw err; - } - this.end.publish(ctx); - this.asyncStart.publish(ctx); - return promise - .then( + return promise.then( (result) => { (ctx as { result: unknown }).result = result; - return result; + this.asyncStart.publish(ctx); + try { + return result; + } finally { + this.asyncEnd.publish(ctx); + } }, (err: unknown) => { (ctx as { error: unknown }).error = err; this.error.publish(ctx); - throw err; + this.asyncStart.publish(ctx); + try { + throw err; + } finally { + this.asyncEnd.publish(ctx); + } }, - ) - .finally(() => { - this.asyncEnd.publish(ctx); - }); + ); + }); } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 4971fc4fd2..b175c8f0c9 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -185,7 +185,21 @@ export function maybeTracePromise( } /** - * Publish a mixed sync-or-promise operation through the named graphql tracing channel. + * Publish a mixed sync-or-promise operation through the named graphql tracing + * channel. + * + * Mirrors Node's own `TracingChannel.tracePromise` for the async branch while + * handling sync returns without the cost of a promise wrap. The entire + * lifecycle runs inside `start.runStores`, which is what lets subscribers + * that call `channel.start.bindStore(als, ...)` read that store in every + * sub-channel handler: promise continuations attached inside a `runStores` + * block inherit the AsyncLocalStorage context via async_hooks, so + * `asyncStart` and `asyncEnd` fire with the same store active as `start` + * and `end`. + * + * Subscribers can inspect `isPromise(ctx.result)` inside their `end` handler + * to know whether `asyncEnd` will follow or the operation is complete. This + * matches Node's convention. * * @internal */ @@ -203,29 +217,44 @@ export function maybeTraceMixed( result?: unknown; }; - // traceSync fires start/end (and error, if fn throws synchronously) - const result = channel.traceSync(fn, ctx); - if (!isPromise(result)) { - return result; - } + return channel.start.runStores(ctx, () => { + let result: T | Promise; + try { + result = fn(); + } catch (err) { + ctx.error = err; + channel.error.publish(ctx); + channel.end.publish(ctx); + throw err; + } - // Fires off `asyncStart` and `asyncEnd` lifecycle events. - channel.asyncStart.publish(ctx); - return result - .then( + if (!isPromise(result)) { + ctx.result = result; + channel.end.publish(ctx); + return result; + } + + channel.end.publish(ctx); + return result.then( (value) => { ctx.result = value; - - return value; + channel.asyncStart.publish(ctx); + try { + return value; + } finally { + channel.asyncEnd.publish(ctx); + } }, (err: unknown) => { ctx.error = err; channel.error.publish(ctx); - - throw err; + channel.asyncStart.publish(ctx); + try { + throw err; + } finally { + channel.asyncEnd.publish(ctx); + } }, - ) - .finally(() => { - channel.asyncEnd.publish(ctx); - }); + ); + }); } From 1a24257a42cbe2e7282a9f7e5c32e7c44bf90431 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Apr 2026 16:44:33 -0400 Subject: [PATCH 10/55] fix(diagnostics): fire asyncStart synchronously, asyncEnd in finally maybeTraceMixed was firing asyncStart and asyncEnd back-to-back inside the .then callback. That left no meaningful window between the two events: for APM subscribers, 'async work begins' and 'async work completes' fired in the same microtask, making the pair useless for measuring the async duration. Correct shape: asyncStart publishes synchronously as soon as we know the operation is in-flight asynchronously (right after end), and asyncEnd publishes in a .finally after settlement. Both the .then and .finally are attached inside start.runStores, so AsyncLocalStorage context is preserved end-to-end via async_hooks. Event order now: sync path: [start, end] async path: [start, end, asyncStart, ..., asyncEnd] async error: [start, end, asyncStart, error, asyncEnd] Mirrors the same fix in the FakeTracingChannel helper. --- src/__testUtils__/fakeDiagnosticsChannel.ts | 33 +++++++++----------- src/diagnostics.ts | 34 +++++++++------------ 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 4ec1499046..08c645c2b3 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -122,27 +122,22 @@ export class FakeTracingChannel implements MinimalTracingChannel { throw err; } this.end.publish(ctx); - return promise.then( - (result) => { - (ctx as { result: unknown }).result = result; - this.asyncStart.publish(ctx); - try { + this.asyncStart.publish(ctx); + return promise + .then( + (result) => { + (ctx as { result: unknown }).result = result; return result; - } finally { - this.asyncEnd.publish(ctx); - } - }, - (err: unknown) => { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.asyncStart.publish(ctx); - try { + }, + (err: unknown) => { + (ctx as { error: unknown }).error = err; + this.error.publish(ctx); throw err; - } finally { - this.asyncEnd.publish(ctx); - } - }, - ); + }, + ) + .finally(() => { + this.asyncEnd.publish(ctx); + }); }); } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index b175c8f0c9..b2fcec7a22 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -235,26 +235,22 @@ export function maybeTraceMixed( } channel.end.publish(ctx); - return result.then( - (value) => { - ctx.result = value; - channel.asyncStart.publish(ctx); - try { + channel.asyncStart.publish(ctx); + + return result + .then( + (value) => { + ctx.result = value; return value; - } finally { - channel.asyncEnd.publish(ctx); - } - }, - (err: unknown) => { - ctx.error = err; - channel.error.publish(ctx); - channel.asyncStart.publish(ctx); - try { + }, + (err: unknown) => { + ctx.error = err; + channel.error.publish(ctx); throw err; - } finally { - channel.asyncEnd.publish(ctx); - } - }, - ); + }, + ) + .finally(() => { + channel.asyncEnd.publish(ctx); + }); }); } From bc484198312b1568f82acf205e72304dc30562f3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 18 Apr 2026 00:28:54 -0400 Subject: [PATCH 11/55] chore: remove old comments no longer apply --- src/__testUtils__/fakeDiagnosticsChannel.ts | 3 +-- src/diagnostics.ts | 13 ------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 08c645c2b3..0a33e24d98 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -55,8 +55,7 @@ export class FakeChannel implements MinimalChannel { /** * Structurally-faithful `MinimalTracingChannel` implementation mirroring - * Node's `TracingChannel.traceSync` / `tracePromise` lifecycle (start, - * runStores, error, asyncStart, asyncEnd, end). + * Node's `TracingChannel.traceSync` / `tracePromise` lifecycle */ export class FakeTracingChannel implements MinimalTracingChannel { start: FakeChannel = new FakeChannel(); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index b2fcec7a22..4354b6dfc8 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -188,19 +188,6 @@ export function maybeTracePromise( * Publish a mixed sync-or-promise operation through the named graphql tracing * channel. * - * Mirrors Node's own `TracingChannel.tracePromise` for the async branch while - * handling sync returns without the cost of a promise wrap. The entire - * lifecycle runs inside `start.runStores`, which is what lets subscribers - * that call `channel.start.bindStore(als, ...)` read that store in every - * sub-channel handler: promise continuations attached inside a `runStores` - * block inherit the AsyncLocalStorage context via async_hooks, so - * `asyncStart` and `asyncEnd` fire with the same store active as `start` - * and `end`. - * - * Subscribers can inspect `isPromise(ctx.result)` inside their `end` handler - * to know whether `asyncEnd` will follow or the operation is complete. This - * matches Node's convention. - * * @internal */ export function maybeTraceMixed( From 405051f77923935c986d6a7e9a77bdb559b7a64e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 19 Apr 2026 20:42:00 -0400 Subject: [PATCH 12/55] ref: remove tracePromise as it was not needed with runStores --- src/diagnostics.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 4354b6dfc8..c15f4b4b43 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -165,25 +165,6 @@ export function maybeTraceSync( return channel.traceSync(fn, ctxFactory()); } -/** - * Publish a promise-returning operation through the named graphql tracing - * channel, short-circuiting to `fn()` when the channel isn't registered or - * nothing is listening. - * - * @internal - */ -export function maybeTracePromise( - name: keyof GraphQLChannels, - ctxFactory: () => object, - fn: () => Promise, -): Promise { - const channel = getChannels()?.[name]; - if (!shouldTrace(channel)) { - return fn(); - } - return channel.tracePromise(fn, ctxFactory()); -} - /** * Publish a mixed sync-or-promise operation through the named graphql tracing * channel. From 9289307e3b31d27305a97f2ad179527ebfbb5d15 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 19 Apr 2026 21:01:14 -0400 Subject: [PATCH 13/55] ref(perf): cache publish decision in excutor --- src/diagnostics.ts | 2 +- src/execution/Executor.ts | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index c15f4b4b43..da52dc16fa 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -139,7 +139,7 @@ export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { * * @internal */ -function shouldTrace( +export function shouldTrace( channel: MinimalTracingChannel | undefined, ): channel is MinimalTracingChannel { // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 9621e1e493..7570db2e48 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,7 +45,8 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import { maybeTraceMixed } from '../diagnostics.ts'; +import type { MinimalTracingChannel } from '../diagnostics.ts'; +import { getChannels, maybeTraceMixed, shouldTrace } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; @@ -247,6 +248,12 @@ export class Executor< values: ReadonlyArray>, ) => Promise>; + // Resolved once per Executor so the per-field gate in `executeField` is a + // single member read + null check, not a `getChannels()?.resolve` walk + + // `hasSubscribers` read on every resolution. Undefined when diagnostics + // are off or nobody is listening at construction time. + _resolveChannel: MinimalTracingChannel | undefined; + constructor( validatedExecutionArgs: ValidatedExecutionArgs, sharedExecutionContext?: SharedExecutionContext, @@ -256,6 +263,11 @@ export class Executor< this.abortReason = defaultAbortReason; this.collectedErrors = new CollectedErrors(); + const resolveChannel = getChannels()?.resolve; + this._resolveChannel = shouldTrace(resolveChannel) + ? resolveChannel + : undefined; + if (sharedExecutionContext === undefined) { this.resolverAbortController = new AbortController(); this.sharedExecutionContext = createSharedExecutionContext( @@ -604,11 +616,13 @@ export class Executor< // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = maybeTraceMixed( - 'resolve', - () => buildResolveCtx(info, args, fieldDef.resolve === undefined), - () => resolveFn(source, args, contextValue, info), - ); + const result = this._resolveChannel + ? maybeTraceMixed( + 'resolve', + () => buildResolveCtx(info, args, fieldDef.resolve === undefined), + () => resolveFn(source, args, contextValue, info), + ) + : resolveFn(source, args, contextValue, info); if (isPromiseLike(result)) { return this.completePromisedValue( From 0fccb8949f75f07307dff1ada42c14ab9baabd15 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sun, 19 Apr 2026 21:08:30 -0400 Subject: [PATCH 14/55] test: coverage and cleanup unused type --- src/__testUtils__/fakeDiagnosticsChannel.ts | 45 +++---------------- src/__tests__/diagnostics-test.ts | 3 -- src/diagnostics.ts | 7 --- .../__tests__/resolve-diagnostics-test.ts | 25 +++++++++++ src/index.ts | 2 +- 5 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 0a33e24d98..6d99986214 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -14,6 +14,7 @@ export type Listener = (message: unknown) => void; export class FakeChannel implements MinimalChannel { listeners: Array = []; + /* c8 ignore next 3 */ get [Symbol.toStringTag]() { return 'FakeChannel'; } @@ -55,7 +56,7 @@ export class FakeChannel implements MinimalChannel { /** * Structurally-faithful `MinimalTracingChannel` implementation mirroring - * Node's `TracingChannel.traceSync` / `tracePromise` lifecycle + * Node's `TracingChannel.traceSync` lifecycle. */ export class FakeTracingChannel implements MinimalTracingChannel { start: FakeChannel = new FakeChannel(); @@ -64,6 +65,7 @@ export class FakeTracingChannel implements MinimalTracingChannel { asyncEnd: FakeChannel = new FakeChannel(); error: FakeChannel = new FakeChannel(); + /* c8 ignore next 3 */ get [Symbol.toStringTag]() { return 'FakeTracingChannel'; } @@ -95,55 +97,18 @@ export class FakeTracingChannel implements MinimalTracingChannel { throw err; } // Node's real traceSync sets `ctx.result` before publishing `end`, so - // subscribers can inspect `isPromise(ctx.result)` inside their `end` - // handler to decide whether the operation is complete or async events - // will follow. Match that semantic here. + // subscribers can inspect `ctx.result` inside their `end` handler. (ctx as { result: unknown }).result = result; this.end.publish(ctx); return result; }); } - - tracePromise( - fn: (...args: Array) => Promise, - ctx: object, - thisArg?: unknown, - ...args: Array - ): Promise { - return this.start.runStores(ctx, () => { - let promise: Promise; - try { - promise = fn.apply(thisArg as object, args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.end.publish(ctx); - throw err; - } - this.end.publish(ctx); - this.asyncStart.publish(ctx); - return promise - .then( - (result) => { - (ctx as { result: unknown }).result = result; - return result; - }, - (err: unknown) => { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - throw err; - }, - ) - .finally(() => { - this.asyncEnd.publish(ctx); - }); - }); - } } export class FakeDc implements MinimalDiagnosticsChannel { private cache = new Map(); + /* c8 ignore next 3 */ get [Symbol.toStringTag]() { return 'FakeDc'; } diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index e620c86fd7..06a0c5ff3a 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -29,9 +29,6 @@ function fakeTracingChannel(name: string): MinimalTracingChannel { asyncEnd: noop, error: noop, traceSync: (fn: (...args: Array) => T): T => fn(), - tracePromise: ( - fn: (...args: Array) => Promise, - ): Promise => fn(), }; return channel; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index da52dc16fa..52d65688d3 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -49,13 +49,6 @@ export interface MinimalTracingChannel { thisArg?: unknown, ...args: Array ) => T; - - tracePromise: ( - fn: (...args: Array) => Promise, - ctx: object, - thisArg?: unknown, - ...args: Array - ) => Promise; } /** diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts index 5d5343e4e1..1608bc4471 100644 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -25,6 +25,7 @@ const schema = buildSchema(` sync: String async: String fail: String + asyncFail: String plain: String nested: Nested } @@ -39,6 +40,7 @@ const rootValue = { fail: () => { throw new Error('boom'); }, + asyncFail: () => Promise.reject(new Error('async-boom')), // no `plain` resolver, default property-access is used. plain: 'plain-value', nested: { leaf: 'leaf-value' }, @@ -98,6 +100,29 @@ describe('resolve diagnostics channel', () => { expect(kinds).to.deep.equal(['start', 'error', 'end']); }); + it('emits full async lifecycle with error when a resolver rejects', async () => { + active = collectEvents(resolveChannel); + + await execute({ + schema, + document: parse('{ asyncFail }'), + rootValue, + }); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal([ + 'start', + 'end', + 'asyncStart', + 'error', + 'asyncEnd', + ]); + const errorEvent = active.events.find((e) => e.kind === 'error'); + expect((errorEvent?.ctx as { error?: Error }).error?.message).to.equal( + 'async-boom', + ); + }); + it('reports isTrivialResolver based on field.resolve presence', () => { const trivialSchema = new GraphQLSchema({ query: new GraphQLObjectType({ diff --git a/src/index.ts b/src/index.ts index ae44222779..c82a62ffb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ export { enableDevMode, isDevModeEnabled } from './devMode.ts'; // Register a `node:diagnostics_channel`-compatible module to enable // tracing channel emission from parse, validate, execute, subscribe, -// and resolver lifecycles. +// and resolver lifecycle events. export { enableDiagnosticsChannel } from './diagnostics.js'; export type { MinimalChannel, From 27b8246f9f58a81ab28508f9a34fd1c679df3081 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 21 Apr 2026 11:48:05 -0400 Subject: [PATCH 15/55] feat(diagnostics): throw on re-registration with different dc module Calling enableDiagnosticsChannel again with the same dc is now a no-op; a different dc throws to surface the misconfiguration rather than silently replacing channel references that subscribers already hold. --- src/__testUtils__/fakeDiagnosticsChannel.ts | 8 ++ src/__tests__/diagnostics-test.ts | 117 +++++------------- src/diagnostics.ts | 22 +++- .../__tests__/execute-diagnostics-test.ts | 7 +- .../__tests__/resolve-diagnostics-test.ts | 7 +- .../__tests__/subscribe-diagnostics-test.ts | 7 +- .../__tests__/parser-diagnostics-test.ts | 7 +- .../__tests__/validate-diagnostics-test.ts | 7 +- 8 files changed, 72 insertions(+), 110 deletions(-) diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts index 6d99986214..3c6b3821fc 100644 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ b/src/__testUtils__/fakeDiagnosticsChannel.ts @@ -123,6 +123,14 @@ export class FakeDc implements MinimalDiagnosticsChannel { } } +/** + * Shared fake `diagnostics_channel` instance used across all diagnostics + * test suites. `enableDiagnosticsChannel` now throws when called with a + * different `dc` module than the one previously registered, so all test + * files must register the same instance. + */ +export const sharedFakeDc: FakeDc = new FakeDc(); + export interface CollectedEvent { kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; ctx: { [key: string]: unknown }; diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index 06a0c5ff3a..a87391d020 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -1,108 +1,55 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; +import { sharedFakeDc } from '../__testUtils__/fakeDiagnosticsChannel.js'; + import { invariant } from '../jsutils/invariant.js'; -import type { - MinimalDiagnosticsChannel, - MinimalTracingChannel, -} from '../diagnostics.js'; import { enableDiagnosticsChannel, getChannels } from '../diagnostics.js'; -function fakeTracingChannel(name: string): MinimalTracingChannel { - const noop: MinimalTracingChannel['start'] = { - hasSubscribers: false, - publish: () => { - /* noop */ - }, - runStores: ( - _ctx: unknown, - fn: (this: unknown, ...args: Array) => T, - ): T => fn(), - }; - const channel: MinimalTracingChannel & { _name: string } = { - _name: name, - hasSubscribers: false, - start: noop, - end: noop, - asyncStart: noop, - asyncEnd: noop, - error: noop, - traceSync: (fn: (...args: Array) => T): T => fn(), - }; - return channel; -} - -function fakeDc(): MinimalDiagnosticsChannel & { - created: Array; -} { - const created: Array = []; - const cache = new Map(); - return { - created, - tracingChannel(name: string) { - let existing = cache.get(name); - if (existing === undefined) { - created.push(name); - existing = fakeTracingChannel(name); - cache.set(name, existing); - } - return existing; - }, - }; -} - describe('diagnostics', () => { - it('registers the five graphql tracing channels', () => { - const dc = fakeDc(); - enableDiagnosticsChannel(dc); - - expect(dc.created).to.deep.equal([ - 'graphql:execute', - 'graphql:parse', - 'graphql:validate', - 'graphql:resolve', - 'graphql:subscribe', - ]); + it('exposes the five graphql tracing channels after registration', () => { + enableDiagnosticsChannel(sharedFakeDc); const channels = getChannels(); invariant(channels !== undefined); - expect(channels.execute).to.not.equal(undefined); - expect(channels.parse).to.not.equal(undefined); - expect(channels.validate).to.not.equal(undefined); - expect(channels.resolve).to.not.equal(undefined); - expect(channels.subscribe).to.not.equal(undefined); + expect(channels.execute).to.equal( + sharedFakeDc.tracingChannel('graphql:execute'), + ); + expect(channels.parse).to.equal( + sharedFakeDc.tracingChannel('graphql:parse'), + ); + expect(channels.validate).to.equal( + sharedFakeDc.tracingChannel('graphql:validate'), + ); + expect(channels.resolve).to.equal( + sharedFakeDc.tracingChannel('graphql:resolve'), + ); + expect(channels.subscribe).to.equal( + sharedFakeDc.tracingChannel('graphql:subscribe'), + ); }); - it('re-registration with the same module preserves channel identity', () => { - const dc = fakeDc(); - enableDiagnosticsChannel(dc); + it('re-registration with the same module is a no-op', () => { + enableDiagnosticsChannel(sharedFakeDc); const first = getChannels(); invariant(first !== undefined); - enableDiagnosticsChannel(dc); + enableDiagnosticsChannel(sharedFakeDc); const second = getChannels(); - invariant(second !== undefined); - expect(second.execute).to.equal(first.execute); - expect(second.parse).to.equal(first.parse); - expect(second.validate).to.equal(first.validate); - expect(second.resolve).to.equal(first.resolve); - expect(second.subscribe).to.equal(first.subscribe); + expect(second).to.equal(first); }); - it('re-registration with a different module replaces stored references', () => { - const dc1 = fakeDc(); - const dc2 = fakeDc(); - - enableDiagnosticsChannel(dc1); - const first = getChannels(); - invariant(first !== undefined); - - enableDiagnosticsChannel(dc2); - const second = getChannels(); - invariant(second !== undefined); + it('re-registration with a different module throws', () => { + enableDiagnosticsChannel(sharedFakeDc); - expect(second.execute).to.not.equal(first.execute); + expect(() => + enableDiagnosticsChannel({ + tracingChannel: () => { + throw new Error('should not be called'); + }, + }), + ).to.throw(/different `diagnostics_channel` module/); }); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 52d65688d3..33208d194d 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -74,6 +74,7 @@ export interface GraphQLChannels { } let channels: GraphQLChannels | undefined; +let registeredDc: MinimalDiagnosticsChannel | undefined; /** * Internal accessor used at emission sites. Returns `undefined` when no @@ -98,20 +99,31 @@ export function getChannels(): GraphQLChannels | undefined { * - `graphql:subscribe` * - `graphql:resolve` * - * Calling this repeatedly is safe: subsequent calls replace the stored - * channel references, but since `tracingChannel(name)` is cached by name, - * the channel identities remain stable across registrations from the same - * underlying module. + * @throws {Error} If a different `diagnostics_channel` module is registered. * * @example * ```ts * import dc from 'node:diagnostics_channel'; * import { enableDiagnosticsChannel } from 'graphql'; * - * enableDiagnosticsChannel(dc); + * try { + * enableDiagnosticsChannel(dc); + * } catch { + * // A diagnostic_channel module was already registered, safe to subscribe. + * } * ``` */ export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { + if (registeredDc !== undefined) { + if (registeredDc !== dc) { + throw new Error( + 'enableDiagnosticsChannel was called with a different `diagnostics_channel` module than the one previously registered. graphql-js can only publish to one module at a time; ensure all APMs share the same `node:diagnostics_channel` import.', + ); + } + return; + } + + registeredDc = dc; channels = { execute: dc.tracingChannel('graphql:execute'), parse: dc.tracingChannel('graphql:parse'), diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/execute-diagnostics-test.ts index 45fcbdee3c..e8ecea8266 100644 --- a/src/execution/__tests__/execute-diagnostics-test.ts +++ b/src/execution/__tests__/execute-diagnostics-test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from 'mocha'; import { collectEvents, - FakeDc, + sharedFakeDc, } from '../../__testUtils__/fakeDiagnosticsChannel.js'; import { parse } from '../../language/parser.js'; @@ -32,14 +32,13 @@ const rootValue = { async: () => Promise.resolve('hello-async'), }; -const fakeDc = new FakeDc(); -const executeChannel = fakeDc.tracingChannel('graphql:execute'); +const executeChannel = sharedFakeDc.tracingChannel('graphql:execute'); describe('execute diagnostics channel', () => { let active: ReturnType | undefined; beforeEach(() => { - enableDiagnosticsChannel(fakeDc); + enableDiagnosticsChannel(sharedFakeDc); }); afterEach(() => { diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts index 1608bc4471..c524a2c8f5 100644 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from 'mocha'; import { collectEvents, - FakeDc, + sharedFakeDc, } from '../../__testUtils__/fakeDiagnosticsChannel.js'; import { isPromise } from '../../jsutils/isPromise.js'; @@ -46,14 +46,13 @@ const rootValue = { nested: { leaf: 'leaf-value' }, }; -const fakeDc = new FakeDc(); -const resolveChannel = fakeDc.tracingChannel('graphql:resolve'); +const resolveChannel = sharedFakeDc.tracingChannel('graphql:resolve'); describe('resolve diagnostics channel', () => { let active: ReturnType | undefined; beforeEach(() => { - enableDiagnosticsChannel(fakeDc); + enableDiagnosticsChannel(sharedFakeDc); }); afterEach(() => { diff --git a/src/execution/__tests__/subscribe-diagnostics-test.ts b/src/execution/__tests__/subscribe-diagnostics-test.ts index a1b1987ecb..99c497a613 100644 --- a/src/execution/__tests__/subscribe-diagnostics-test.ts +++ b/src/execution/__tests__/subscribe-diagnostics-test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from 'mocha'; import { collectEvents, - FakeDc, + sharedFakeDc, } from '../../__testUtils__/fakeDiagnosticsChannel.js'; import { isPromise } from '../../jsutils/isPromise.js'; @@ -44,14 +44,13 @@ async function* twoTicks(): AsyncIterable<{ tick: string }> { yield { tick: 'two' }; } -const fakeDc = new FakeDc(); -const subscribeChannel = fakeDc.tracingChannel('graphql:subscribe'); +const subscribeChannel = sharedFakeDc.tracingChannel('graphql:subscribe'); describe('subscribe diagnostics channel', () => { let active: ReturnType | undefined; beforeEach(() => { - enableDiagnosticsChannel(fakeDc); + enableDiagnosticsChannel(sharedFakeDc); }); afterEach(() => { diff --git a/src/language/__tests__/parser-diagnostics-test.ts b/src/language/__tests__/parser-diagnostics-test.ts index a970a73508..9ddfdf30d9 100644 --- a/src/language/__tests__/parser-diagnostics-test.ts +++ b/src/language/__tests__/parser-diagnostics-test.ts @@ -3,21 +3,20 @@ import { afterEach, beforeEach, describe, it } from 'mocha'; import { collectEvents, - FakeDc, + sharedFakeDc, } from '../../__testUtils__/fakeDiagnosticsChannel.js'; import { enableDiagnosticsChannel } from '../../diagnostics.js'; import { parse } from '../parser.js'; -const fakeDc = new FakeDc(); -const parseChannel = fakeDc.tracingChannel('graphql:parse'); +const parseChannel = sharedFakeDc.tracingChannel('graphql:parse'); describe('parse diagnostics channel', () => { let active: ReturnType | undefined; beforeEach(() => { - enableDiagnosticsChannel(fakeDc); + enableDiagnosticsChannel(sharedFakeDc); }); afterEach(() => { diff --git a/src/validation/__tests__/validate-diagnostics-test.ts b/src/validation/__tests__/validate-diagnostics-test.ts index 28d1c40c3b..622d44c5f8 100644 --- a/src/validation/__tests__/validate-diagnostics-test.ts +++ b/src/validation/__tests__/validate-diagnostics-test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it } from 'mocha'; import { collectEvents, - FakeDc, + sharedFakeDc, } from '../../__testUtils__/fakeDiagnosticsChannel.js'; import { parse } from '../../language/parser.js'; @@ -20,14 +20,13 @@ const schema = buildSchema(` } `); -const fakeDc = new FakeDc(); -const validateChannel = fakeDc.tracingChannel('graphql:validate'); +const validateChannel = sharedFakeDc.tracingChannel('graphql:validate'); describe('validate diagnostics channel', () => { let active: ReturnType | undefined; beforeEach(() => { - enableDiagnosticsChannel(fakeDc); + enableDiagnosticsChannel(sharedFakeDc); }); afterEach(() => { From 9ee85ad6fe1beb149ceb523f875066ea5405980f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 21 Apr 2026 13:34:15 -0400 Subject: [PATCH 16/55] feat(diagnostics): allow re-registration with equivalent tracingChannel function --- src/__tests__/diagnostics-test.ts | 16 ++++++++++++++++ src/diagnostics.ts | 8 +++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index a87391d020..fa538207fe 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -52,4 +52,20 @@ describe('diagnostics', () => { }), ).to.throw(/different `diagnostics_channel` module/); }); + + it('re-registration accepts a wrapper sharing the same tracingChannel fn', () => { + enableDiagnosticsChannel(sharedFakeDc); + const first = getChannels(); + invariant(first !== undefined); + + // Models an ESM Module Namespace object: distinct outer reference, but + // the `tracingChannel` property is strictly the same function as the + // default export's, so it routes to the same underlying channel cache. + const namespaceLike = { tracingChannel: sharedFakeDc.tracingChannel }; + expect(namespaceLike).to.not.equal(sharedFakeDc); + expect(namespaceLike.tracingChannel).to.equal(sharedFakeDc.tracingChannel); + + expect(() => enableDiagnosticsChannel(namespaceLike)).to.not.throw(); + expect(getChannels()).to.equal(first); + }); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 33208d194d..fd61212084 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -99,6 +99,8 @@ export function getChannels(): GraphQLChannels | undefined { * - `graphql:subscribe` * - `graphql:resolve` * + * Re-registration is tolerated when the incoming `dc` exposes the same + * `tracingChannel` function as the previously registered one * @throws {Error} If a different `diagnostics_channel` module is registered. * * @example @@ -115,7 +117,11 @@ export function getChannels(): GraphQLChannels | undefined { */ export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { if (registeredDc !== undefined) { - if (registeredDc !== dc) { + // Compare `tracingChannel` function identity rather than module identity + // so consumers that pass an ESM Module Namespace object and consumers + // that pass the default export of the same underlying module are + // treated as equivalent + if (registeredDc.tracingChannel !== dc.tracingChannel) { throw new Error( 'enableDiagnosticsChannel was called with a different `diagnostics_channel` module than the one previously registered. graphql-js can only publish to one module at a time; ensure all APMs share the same `node:diagnostics_channel` import.', ); From 2dc17d37e7d56bf8abf51c387f44031ff8ae9a31 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 02:49:39 -0400 Subject: [PATCH 17/55] ref: autoload tracing channels --- integrationTests/diagnostics/test.js | 11 +- src/__testUtils__/diagnosticsTestUtils.ts | 54 ++++++ src/__testUtils__/fakeDiagnosticsChannel.ts | 172 ------------------ src/__tests__/diagnostics-test.ts | 78 ++------ src/diagnostics.ts | 124 +++++-------- .../__tests__/execute-diagnostics-test.ts | 14 +- .../__tests__/resolve-diagnostics-test.ts | 14 +- .../__tests__/subscribe-diagnostics-test.ts | 14 +- src/index.ts | 14 +- .../__tests__/parser-diagnostics-test.ts | 14 +- .../__tests__/validate-diagnostics-test.ts | 14 +- 11 files changed, 148 insertions(+), 375 deletions(-) create mode 100644 src/__testUtils__/diagnosticsTestUtils.ts delete mode 100644 src/__testUtils__/fakeDiagnosticsChannel.ts diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index 93ed499b7a..8a0e5cebb4 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -6,16 +6,7 @@ import assert from 'node:assert/strict'; import { AsyncLocalStorage } from 'node:async_hooks'; import dc from 'node:diagnostics_channel'; -import { - buildSchema, - enableDiagnosticsChannel, - execute, - parse, - subscribe, - validate, -} from 'graphql'; - -enableDiagnosticsChannel(dc); +import { buildSchema, execute, parse, subscribe, validate } from 'graphql'; function runParseCases() { // graphql:parse - synchronous. diff --git a/src/__testUtils__/diagnosticsTestUtils.ts b/src/__testUtils__/diagnosticsTestUtils.ts new file mode 100644 index 0000000000..e7b92edab9 --- /dev/null +++ b/src/__testUtils__/diagnosticsTestUtils.ts @@ -0,0 +1,54 @@ +/* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ +import dc from 'node:diagnostics_channel'; + +import type { MinimalTracingChannel } from '../diagnostics.js'; + +export interface CollectedEvent { + kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; + ctx: { [key: string]: unknown }; +} + +/** + * Subscribe to every lifecycle sub-channel on a TracingChannel and collect + * events in order. Returns the event buffer plus an unsubscribe hook. + */ +export function collectEvents(channel: MinimalTracingChannel): { + events: Array; + unsubscribe: () => void; +} { + const events: Array = []; + const handler = { + start: (ctx: unknown) => + events.push({ kind: 'start', ctx: ctx as { [key: string]: unknown } }), + end: (ctx: unknown) => + events.push({ kind: 'end', ctx: ctx as { [key: string]: unknown } }), + asyncStart: (ctx: unknown) => + events.push({ + kind: 'asyncStart', + ctx: ctx as { [key: string]: unknown }, + }), + asyncEnd: (ctx: unknown) => + events.push({ + kind: 'asyncEnd', + ctx: ctx as { [key: string]: unknown }, + }), + error: (ctx: unknown) => + events.push({ kind: 'error', ctx: ctx as { [key: string]: unknown } }), + }; + (channel as unknown as dc.TracingChannel).subscribe(handler); + return { + events, + unsubscribe() { + (channel as unknown as dc.TracingChannel).unsubscribe(handler); + }, + }; +} + +/** + * Resolve a graphql tracing channel by name on the real + * `node:diagnostics_channel`. graphql-js publishes on the same channels at + * module load. + */ +export function getTracingChannel(name: string): MinimalTracingChannel { + return dc.tracingChannel(name) as unknown as MinimalTracingChannel; +} diff --git a/src/__testUtils__/fakeDiagnosticsChannel.ts b/src/__testUtils__/fakeDiagnosticsChannel.ts deleted file mode 100644 index 3c6b3821fc..0000000000 --- a/src/__testUtils__/fakeDiagnosticsChannel.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { - MinimalChannel, - MinimalDiagnosticsChannel, - MinimalTracingChannel, -} from '../diagnostics.js'; - -export type Listener = (message: unknown) => void; - -/** - * In-memory `MinimalChannel` implementation used by the unit tests. Tracks - * subscribers and replays Node's `runStores` semantics by simply invoking - * `fn`. - */ -export class FakeChannel implements MinimalChannel { - listeners: Array = []; - - /* c8 ignore next 3 */ - get [Symbol.toStringTag]() { - return 'FakeChannel'; - } - - get hasSubscribers(): boolean { - return this.listeners.length > 0; - } - - publish(message: unknown): void { - for (const l of this.listeners) { - l(message); - } - } - - runStores( - ctx: ContextType, - fn: (this: ContextType, ...args: Array) => T, - thisArg?: unknown, - ...args: Array - ): T { - // Node's Channel.runStores publishes the context on the channel before - // invoking fn. Mirror that here so traceSync / tracePromise fake exactly - // matches real Node's start / end event counts. - this.publish(ctx); - return fn.apply(thisArg as ContextType, args); - } - - subscribe(listener: Listener): void { - this.listeners.push(listener); - } - - unsubscribe(listener: Listener): void { - const idx = this.listeners.indexOf(listener); - if (idx >= 0) { - this.listeners.splice(idx, 1); - } - } -} - -/** - * Structurally-faithful `MinimalTracingChannel` implementation mirroring - * Node's `TracingChannel.traceSync` lifecycle. - */ -export class FakeTracingChannel implements MinimalTracingChannel { - start: FakeChannel = new FakeChannel(); - end: FakeChannel = new FakeChannel(); - asyncStart: FakeChannel = new FakeChannel(); - asyncEnd: FakeChannel = new FakeChannel(); - error: FakeChannel = new FakeChannel(); - - /* c8 ignore next 3 */ - get [Symbol.toStringTag]() { - return 'FakeTracingChannel'; - } - - get hasSubscribers(): boolean { - return ( - this.start.hasSubscribers || - this.end.hasSubscribers || - this.asyncStart.hasSubscribers || - this.asyncEnd.hasSubscribers || - this.error.hasSubscribers - ); - } - - traceSync( - fn: (...args: Array) => T, - ctx: object, - thisArg?: unknown, - ...args: Array - ): T { - return this.start.runStores(ctx, () => { - let result: T; - try { - result = fn.apply(thisArg as object, args); - } catch (err) { - (ctx as { error: unknown }).error = err; - this.error.publish(ctx); - this.end.publish(ctx); - throw err; - } - // Node's real traceSync sets `ctx.result` before publishing `end`, so - // subscribers can inspect `ctx.result` inside their `end` handler. - (ctx as { result: unknown }).result = result; - this.end.publish(ctx); - return result; - }); - } -} - -export class FakeDc implements MinimalDiagnosticsChannel { - private cache = new Map(); - - /* c8 ignore next 3 */ - get [Symbol.toStringTag]() { - return 'FakeDc'; - } - - tracingChannel(name: string): FakeTracingChannel { - let existing = this.cache.get(name); - if (existing === undefined) { - existing = new FakeTracingChannel(); - this.cache.set(name, existing); - } - return existing; - } -} - -/** - * Shared fake `diagnostics_channel` instance used across all diagnostics - * test suites. `enableDiagnosticsChannel` now throws when called with a - * different `dc` module than the one previously registered, so all test - * files must register the same instance. - */ -export const sharedFakeDc: FakeDc = new FakeDc(); - -export interface CollectedEvent { - kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; - ctx: { [key: string]: unknown }; -} - -/** - * Attach listeners to every sub-channel on a FakeTracingChannel and return - * the captured event buffer plus an unsubscribe hook. - */ -export function collectEvents(channel: FakeTracingChannel): { - events: Array; - unsubscribe: () => void; -} { - const events: Array = []; - const make = - (kind: CollectedEvent['kind']): Listener => - (m) => - events.push({ kind, ctx: m as { [key: string]: unknown } }); - const startL = make('start'); - const endL = make('end'); - const asyncStartL = make('asyncStart'); - const asyncEndL = make('asyncEnd'); - const errorL = make('error'); - channel.start.subscribe(startL); - channel.end.subscribe(endL); - channel.asyncStart.subscribe(asyncStartL); - channel.asyncEnd.subscribe(asyncEndL); - channel.error.subscribe(errorL); - return { - events, - unsubscribe() { - channel.start.unsubscribe(startL); - channel.end.unsubscribe(endL); - channel.asyncStart.unsubscribe(asyncStartL); - channel.asyncEnd.unsubscribe(asyncEndL); - channel.error.unsubscribe(errorL); - }, - }; -} diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index fa538207fe..9c62ce9b9c 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -1,71 +1,31 @@ +/* eslint-disable import/no-nodejs-modules */ +import dc from 'node:diagnostics_channel'; + import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { sharedFakeDc } from '../__testUtils__/fakeDiagnosticsChannel.js'; - import { invariant } from '../jsutils/invariant.js'; -import { enableDiagnosticsChannel, getChannels } from '../diagnostics.js'; +import { getChannels } from '../diagnostics.js'; describe('diagnostics', () => { - it('exposes the five graphql tracing channels after registration', () => { - enableDiagnosticsChannel(sharedFakeDc); - + it('auto-registers the five graphql tracing channels', () => { const channels = getChannels(); invariant(channels !== undefined); - expect(channels.execute).to.equal( - sharedFakeDc.tracingChannel('graphql:execute'), - ); - expect(channels.parse).to.equal( - sharedFakeDc.tracingChannel('graphql:parse'), - ); - expect(channels.validate).to.equal( - sharedFakeDc.tracingChannel('graphql:validate'), - ); - expect(channels.resolve).to.equal( - sharedFakeDc.tracingChannel('graphql:resolve'), - ); - expect(channels.subscribe).to.equal( - sharedFakeDc.tracingChannel('graphql:subscribe'), - ); - }); - - it('re-registration with the same module is a no-op', () => { - enableDiagnosticsChannel(sharedFakeDc); - const first = getChannels(); - invariant(first !== undefined); - - enableDiagnosticsChannel(sharedFakeDc); - const second = getChannels(); - - expect(second).to.equal(first); - }); - - it('re-registration with a different module throws', () => { - enableDiagnosticsChannel(sharedFakeDc); - - expect(() => - enableDiagnosticsChannel({ - tracingChannel: () => { - throw new Error('should not be called'); - }, - }), - ).to.throw(/different `diagnostics_channel` module/); - }); - - it('re-registration accepts a wrapper sharing the same tracingChannel fn', () => { - enableDiagnosticsChannel(sharedFakeDc); - const first = getChannels(); - invariant(first !== undefined); - - // Models an ESM Module Namespace object: distinct outer reference, but - // the `tracingChannel` property is strictly the same function as the - // default export's, so it routes to the same underlying channel cache. - const namespaceLike = { tracingChannel: sharedFakeDc.tracingChannel }; - expect(namespaceLike).to.not.equal(sharedFakeDc); - expect(namespaceLike.tracingChannel).to.equal(sharedFakeDc.tracingChannel); - expect(() => enableDiagnosticsChannel(namespaceLike)).to.not.throw(); - expect(getChannels()).to.equal(first); + // Node's `tracingChannel(name)` returns a fresh wrapper per call but + // the underlying sub-channels are cached by name, so compare those. + const byName = { + execute: 'graphql:execute', + parse: 'graphql:parse', + validate: 'graphql:validate', + resolve: 'graphql:resolve', + subscribe: 'graphql:subscribe', + } as const; + for (const [key, name] of Object.entries(byName)) { + expect(channels[key as keyof typeof byName].start).to.equal( + dc.channel(`tracing:${name}:start`), + ); + } }); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index fd61212084..9e731885c8 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,16 +1,14 @@ +/* eslint-disable no-undef, import/no-nodejs-modules, n/global-require, @typescript-eslint/no-require-imports */ /** * TracingChannel integration. * - * graphql-js exposes a set of named tracing channels that APM tools can - * subscribe to in order to observe parse, validate, execute, subscribe, and - * resolver lifecycle events. To preserve the isomorphic invariant of the - * core (no runtime-specific imports in `src/`), graphql-js does not import - * `node:diagnostics_channel` itself. Instead, APMs (or runtime-specific - * adapters) hand in a module satisfying `MinimalDiagnosticsChannel` via - * `enableDiagnosticsChannel`. - * - * Channel names are owned by graphql-js so multiple APMs converge on the - * same `TracingChannel` instances and all subscribers coexist. + * graphql-js publishes lifecycle events on a set of named tracing channels + * that APM tools can subscribe to in order to observe parse, validate, + * execute, subscribe, and resolver behavior. At module load time graphql-js + * resolves `node:diagnostics_channel` itself so APMs do not need to interact + * with the graphql API to enable tracing. On runtimes that do not expose + * `node:diagnostics_channel` (e.g., browsers) the load silently no-ops and + * emission sites short-circuit. */ import { isPromise } from './jsutils/isPromise.js'; @@ -18,6 +16,8 @@ import { isPromise } from './jsutils/isPromise.js'; /** * Structural subset of `DiagnosticsChannel` sufficient for publishing and * subscriber gating. `node:diagnostics_channel`'s `Channel` satisfies this. + * + * @internal */ export interface MinimalChannel { readonly hasSubscribers: boolean; @@ -34,6 +34,8 @@ export interface MinimalChannel { * Structural subset of Node's `TracingChannel`. The `node:diagnostics_channel` * `TracingChannel` satisfies this by duck typing, so graphql-js does not need * a dependency on `@types/node` or on the runtime itself. + * + * @internal */ export interface MinimalTracingChannel { readonly hasSubscribers: boolean; @@ -51,11 +53,7 @@ export interface MinimalTracingChannel { ) => T; } -/** - * Structural subset of `node:diagnostics_channel` covering just what - * graphql-js needs at registration time. - */ -export interface MinimalDiagnosticsChannel { +interface DiagnosticsChannelModule { tracingChannel: (name: string) => MinimalTracingChannel; } @@ -73,13 +71,44 @@ export interface GraphQLChannels { subscribe: MinimalTracingChannel; } -let channels: GraphQLChannels | undefined; -let registeredDc: MinimalDiagnosticsChannel | undefined; +function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { + let dc: DiagnosticsChannelModule | undefined; + try { + if ( + typeof process !== 'undefined' && + typeof (process as { getBuiltinModule?: (id: string) => unknown }) + .getBuiltinModule === 'function' + ) { + dc = ( + process as { getBuiltinModule: (id: string) => DiagnosticsChannelModule } + ).getBuiltinModule('node:diagnostics_channel'); + } + if (!dc && typeof require === 'function') { + // CJS fallback for runtimes that lack `process.getBuiltinModule` + // (e.g. Node 20.0 - 20.15). ESM builds skip this branch because + // `require` is undeclared there. + dc = require('node:diagnostics_channel') as DiagnosticsChannelModule; + } + } catch { + // diagnostics_channel not available on this runtime; tracing is a no-op. + } + return dc; +} + +const dc = resolveDiagnosticsChannel(); + +const channels: GraphQLChannels | undefined = dc && { + execute: dc.tracingChannel('graphql:execute'), + parse: dc.tracingChannel('graphql:parse'), + validate: dc.tracingChannel('graphql:validate'), + resolve: dc.tracingChannel('graphql:resolve'), + subscribe: dc.tracingChannel('graphql:subscribe'), +}; /** - * Internal accessor used at emission sites. Returns `undefined` when no - * `diagnostics_channel` module has been registered, allowing emission sites - * to short-circuit on a single property access. + * Internal accessor used at emission sites. Returns `undefined` when + * `node:diagnostics_channel` isn't available on this runtime, allowing + * emission sites to short-circuit on a single property access. * * @internal */ @@ -87,66 +116,13 @@ export function getChannels(): GraphQLChannels | undefined { return channels; } -/** - * Register a `node:diagnostics_channel`-compatible module with graphql-js. - * - * After calling this, graphql-js will publish lifecycle events on the - * following tracing channels whenever subscribers are present: - * - * - `graphql:parse` - * - `graphql:validate` - * - `graphql:execute` - * - `graphql:subscribe` - * - `graphql:resolve` - * - * Re-registration is tolerated when the incoming `dc` exposes the same - * `tracingChannel` function as the previously registered one - * @throws {Error} If a different `diagnostics_channel` module is registered. - * - * @example - * ```ts - * import dc from 'node:diagnostics_channel'; - * import { enableDiagnosticsChannel } from 'graphql'; - * - * try { - * enableDiagnosticsChannel(dc); - * } catch { - * // A diagnostic_channel module was already registered, safe to subscribe. - * } - * ``` - */ -export function enableDiagnosticsChannel(dc: MinimalDiagnosticsChannel): void { - if (registeredDc !== undefined) { - // Compare `tracingChannel` function identity rather than module identity - // so consumers that pass an ESM Module Namespace object and consumers - // that pass the default export of the same underlying module are - // treated as equivalent - if (registeredDc.tracingChannel !== dc.tracingChannel) { - throw new Error( - 'enableDiagnosticsChannel was called with a different `diagnostics_channel` module than the one previously registered. graphql-js can only publish to one module at a time; ensure all APMs share the same `node:diagnostics_channel` import.', - ); - } - return; - } - - registeredDc = dc; - channels = { - execute: dc.tracingChannel('graphql:execute'), - parse: dc.tracingChannel('graphql:parse'), - validate: dc.tracingChannel('graphql:validate'), - resolve: dc.tracingChannel('graphql:resolve'), - subscribe: dc.tracingChannel('graphql:subscribe'), - }; -} - /** * Gate for emission sites. Returns `true` when the named channel exists and * publishing should proceed. * * Uses `!== false` rather than a truthy check so runtimes which do not * implement the aggregated `hasSubscribers` getter on `TracingChannel` still - * publish. Notably Node 18 (nodejs/node#54470), where the aggregated getter - * returns `undefined` while sub-channels behave correctly. + * publish. * * @internal */ diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/execute-diagnostics-test.ts index e8ecea8266..fe4666c489 100644 --- a/src/execution/__tests__/execute-diagnostics-test.ts +++ b/src/execution/__tests__/execute-diagnostics-test.ts @@ -1,17 +1,15 @@ import { expect } from 'chai'; -import { afterEach, beforeEach, describe, it } from 'mocha'; +import { afterEach, describe, it } from 'mocha'; import { collectEvents, - sharedFakeDc, -} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + getTracingChannel, +} from '../../__testUtils__/diagnosticsTestUtils.js'; import { parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import { enableDiagnosticsChannel } from '../../diagnostics.js'; - import type { ExecutionArgs } from '../execute.js'; import { execute, @@ -32,15 +30,11 @@ const rootValue = { async: () => Promise.resolve('hello-async'), }; -const executeChannel = sharedFakeDc.tracingChannel('graphql:execute'); +const executeChannel = getTracingChannel('graphql:execute'); describe('execute diagnostics channel', () => { let active: ReturnType | undefined; - beforeEach(() => { - enableDiagnosticsChannel(sharedFakeDc); - }); - afterEach(() => { active?.unsubscribe(); active = undefined; diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts index c524a2c8f5..fc6a53cc17 100644 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; -import { afterEach, beforeEach, describe, it } from 'mocha'; +import { afterEach, describe, it } from 'mocha'; import { collectEvents, - sharedFakeDc, -} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + getTracingChannel, +} from '../../__testUtils__/diagnosticsTestUtils.js'; import { isPromise } from '../../jsutils/isPromise.js'; @@ -16,8 +16,6 @@ import { GraphQLSchema } from '../../type/schema.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import { enableDiagnosticsChannel } from '../../diagnostics.js'; - import { execute } from '../execute.js'; const schema = buildSchema(` @@ -46,15 +44,11 @@ const rootValue = { nested: { leaf: 'leaf-value' }, }; -const resolveChannel = sharedFakeDc.tracingChannel('graphql:resolve'); +const resolveChannel = getTracingChannel('graphql:resolve'); describe('resolve diagnostics channel', () => { let active: ReturnType | undefined; - beforeEach(() => { - enableDiagnosticsChannel(sharedFakeDc); - }); - afterEach(() => { active?.unsubscribe(); active = undefined; diff --git a/src/execution/__tests__/subscribe-diagnostics-test.ts b/src/execution/__tests__/subscribe-diagnostics-test.ts index 99c497a613..7ea78d6836 100644 --- a/src/execution/__tests__/subscribe-diagnostics-test.ts +++ b/src/execution/__tests__/subscribe-diagnostics-test.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; -import { afterEach, beforeEach, describe, it } from 'mocha'; +import { afterEach, describe, it } from 'mocha'; import { collectEvents, - sharedFakeDc, -} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + getTracingChannel, +} from '../../__testUtils__/diagnosticsTestUtils.js'; import { isPromise } from '../../jsutils/isPromise.js'; @@ -14,8 +14,6 @@ import { GraphQLObjectType } from '../../type/definition.js'; import { GraphQLString } from '../../type/scalars.js'; import { GraphQLSchema } from '../../type/schema.js'; -import { enableDiagnosticsChannel } from '../../diagnostics.js'; - import { subscribe } from '../execute.js'; function buildSubscriptionSchema( @@ -44,15 +42,11 @@ async function* twoTicks(): AsyncIterable<{ tick: string }> { yield { tick: 'two' }; } -const subscribeChannel = sharedFakeDc.tracingChannel('graphql:subscribe'); +const subscribeChannel = getTracingChannel('graphql:subscribe'); describe('subscribe diagnostics channel', () => { let active: ReturnType | undefined; - beforeEach(() => { - enableDiagnosticsChannel(sharedFakeDc); - }); - afterEach(() => { active?.unsubscribe(); active = undefined; diff --git a/src/index.ts b/src/index.ts index c82a62ffb6..fe25855b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,16 +32,10 @@ export { version, versionInfo } from './version.ts'; // Enable development mode for additional checks. export { enableDevMode, isDevModeEnabled } from './devMode.ts'; -// Register a `node:diagnostics_channel`-compatible module to enable -// tracing channel emission from parse, validate, execute, subscribe, -// and resolver lifecycle events. -export { enableDiagnosticsChannel } from './diagnostics.js'; -export type { - MinimalChannel, - MinimalTracingChannel, - MinimalDiagnosticsChannel, - GraphQLChannels, -} from './diagnostics.js'; +// Tracing channel types for subscribers that want to strongly type the +// `graphql:*` channel context payloads. Channels are auto-registered on +// `node:diagnostics_channel` at module load. +export type { GraphQLChannels } from './diagnostics.js'; // The primary entry point into fulfilling a GraphQL request. export type { GraphQLArgs } from './graphql.ts'; diff --git a/src/language/__tests__/parser-diagnostics-test.ts b/src/language/__tests__/parser-diagnostics-test.ts index 9ddfdf30d9..49535dacc7 100644 --- a/src/language/__tests__/parser-diagnostics-test.ts +++ b/src/language/__tests__/parser-diagnostics-test.ts @@ -1,24 +1,18 @@ import { expect } from 'chai'; -import { afterEach, beforeEach, describe, it } from 'mocha'; +import { afterEach, describe, it } from 'mocha'; import { collectEvents, - sharedFakeDc, -} from '../../__testUtils__/fakeDiagnosticsChannel.js'; - -import { enableDiagnosticsChannel } from '../../diagnostics.js'; + getTracingChannel, +} from '../../__testUtils__/diagnosticsTestUtils.js'; import { parse } from '../parser.js'; -const parseChannel = sharedFakeDc.tracingChannel('graphql:parse'); +const parseChannel = getTracingChannel('graphql:parse'); describe('parse diagnostics channel', () => { let active: ReturnType | undefined; - beforeEach(() => { - enableDiagnosticsChannel(sharedFakeDc); - }); - afterEach(() => { active?.unsubscribe(); active = undefined; diff --git a/src/validation/__tests__/validate-diagnostics-test.ts b/src/validation/__tests__/validate-diagnostics-test.ts index 622d44c5f8..85c308ab6a 100644 --- a/src/validation/__tests__/validate-diagnostics-test.ts +++ b/src/validation/__tests__/validate-diagnostics-test.ts @@ -1,17 +1,15 @@ import { expect } from 'chai'; -import { afterEach, beforeEach, describe, it } from 'mocha'; +import { afterEach, describe, it } from 'mocha'; import { collectEvents, - sharedFakeDc, -} from '../../__testUtils__/fakeDiagnosticsChannel.js'; + getTracingChannel, +} from '../../__testUtils__/diagnosticsTestUtils.js'; import { parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import { enableDiagnosticsChannel } from '../../diagnostics.js'; - import { validate } from '../validate.js'; const schema = buildSchema(` @@ -20,15 +18,11 @@ const schema = buildSchema(` } `); -const validateChannel = sharedFakeDc.tracingChannel('graphql:validate'); +const validateChannel = getTracingChannel('graphql:validate'); describe('validate diagnostics channel', () => { let active: ReturnType | undefined; - beforeEach(() => { - enableDiagnosticsChannel(sharedFakeDc); - }); - afterEach(() => { active?.unsubscribe(); active = undefined; From fe46d17df9f99f5de19ab3cbb96a3bee60fc59cd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 09:39:49 -0400 Subject: [PATCH 18/55] ref: create direct pointers to tracing channels --- src/__tests__/diagnostics-test.ts | 42 ++++++++----- src/diagnostics.ts | 90 +++++++++++----------------- src/language/parser.ts | 32 +++++----- src/validation/validate.ts | 97 +++++++++++++++++-------------- 4 files changed, 132 insertions(+), 129 deletions(-) diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index 9c62ce9b9c..c7c97b0c60 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -6,26 +6,38 @@ import { describe, it } from 'mocha'; import { invariant } from '../jsutils/invariant.js'; -import { getChannels } from '../diagnostics.js'; +import { + executeChannel, + parseChannel, + resolveChannel, + subscribeChannel, + validateChannel, +} from '../diagnostics.js'; describe('diagnostics', () => { it('auto-registers the five graphql tracing channels', () => { - const channels = getChannels(); - invariant(channels !== undefined); + invariant(parseChannel !== undefined); + invariant(validateChannel !== undefined); + invariant(executeChannel !== undefined); + invariant(subscribeChannel !== undefined); + invariant(resolveChannel !== undefined); // Node's `tracingChannel(name)` returns a fresh wrapper per call but // the underlying sub-channels are cached by name, so compare those. - const byName = { - execute: 'graphql:execute', - parse: 'graphql:parse', - validate: 'graphql:validate', - resolve: 'graphql:resolve', - subscribe: 'graphql:subscribe', - } as const; - for (const [key, name] of Object.entries(byName)) { - expect(channels[key as keyof typeof byName].start).to.equal( - dc.channel(`tracing:${name}:start`), - ); - } + expect(parseChannel.start).to.equal( + dc.channel('tracing:graphql:parse:start'), + ); + expect(validateChannel.start).to.equal( + dc.channel('tracing:graphql:validate:start'), + ); + expect(executeChannel.start).to.equal( + dc.channel('tracing:graphql:execute:start'), + ); + expect(subscribeChannel.start).to.equal( + dc.channel('tracing:graphql:subscribe:start'), + ); + expect(resolveChannel.start).to.equal( + dc.channel('tracing:graphql:resolve:start'), + ); }); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 9e731885c8..d194d0debc 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -80,7 +80,9 @@ function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { .getBuiltinModule === 'function' ) { dc = ( - process as { getBuiltinModule: (id: string) => DiagnosticsChannelModule } + process as { + getBuiltinModule: (id: string) => DiagnosticsChannelModule; + } ).getBuiltinModule('node:diagnostics_channel'); } if (!dc && typeof require === 'function') { @@ -97,80 +99,56 @@ function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { const dc = resolveDiagnosticsChannel(); -const channels: GraphQLChannels | undefined = dc && { - execute: dc.tracingChannel('graphql:execute'), - parse: dc.tracingChannel('graphql:parse'), - validate: dc.tracingChannel('graphql:validate'), - resolve: dc.tracingChannel('graphql:resolve'), - subscribe: dc.tracingChannel('graphql:subscribe'), -}; - /** - * Internal accessor used at emission sites. Returns `undefined` when - * `node:diagnostics_channel` isn't available on this runtime, allowing - * emission sites to short-circuit on a single property access. + * Per-channel handles, resolved once at module load. `undefined` when + * `node:diagnostics_channel` isn't available. Emission sites read these + * directly to keep the no-subscriber fast path to a single property access + * plus a `hasSubscribers` check (no function calls, no closures). * * @internal */ -export function getChannels(): GraphQLChannels | undefined { - return channels; -} +export const parseChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:parse'); +/** @internal */ +export const validateChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:validate'); +/** @internal */ +export const executeChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:execute'); +/** @internal */ +export const subscribeChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:subscribe'); +/** @internal */ +export const resolveChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:resolve'); /** - * Gate for emission sites. Returns `true` when the named channel exists and - * publishing should proceed. - * - * Uses `!== false` rather than a truthy check so runtimes which do not - * implement the aggregated `hasSubscribers` getter on `TracingChannel` still - * publish. + * Publish a synchronous operation through `channel`. Caller has already + * verified that a subscriber is attached; this helper exists only so the + * traced path doesn't need to be duplicated at every emission site. * * @internal */ -export function shouldTrace( - channel: MinimalTracingChannel | undefined, -): channel is MinimalTracingChannel { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare - return channel !== undefined && channel.hasSubscribers !== false; -} - -/** - * Publish a synchronous operation through the named graphql tracing channel, - * short-circuiting to `fn()` when the channel isn't registered or nothing is - * listening. - * - * @internal - */ -export function maybeTraceSync( - name: keyof GraphQLChannels, - ctxFactory: () => object, +export function traceSync( + channel: MinimalTracingChannel, + ctx: object, fn: () => T, ): T { - const channel = getChannels()?.[name]; - if (!shouldTrace(channel)) { - return fn(); - } - return channel.traceSync(fn, ctxFactory()); + return channel.traceSync(fn, ctx); } /** - * Publish a mixed sync-or-promise operation through the named graphql tracing - * channel. + * Publish a mixed sync-or-promise operation through `channel`. Caller has + * already verified that a subscriber is attached. * * @internal */ -export function maybeTraceMixed( - name: keyof GraphQLChannels, - ctxFactory: () => object, +export function traceMixed( + channel: MinimalTracingChannel, + ctxInput: object, fn: () => T | Promise, ): T | Promise { - const channel = getChannels()?.[name]; - if (!shouldTrace(channel)) { - return fn(); - } - const ctx = ctxFactory() as { - error?: unknown; - result?: unknown; - }; + const ctx = ctxInput as { error?: unknown; result?: unknown }; return channel.start.runStores(ctx, () => { let result: T | Promise; diff --git a/src/language/parser.ts b/src/language/parser.ts index 45ba91bb32..7561390184 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,7 +3,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; -import { maybeTraceSync } from '../diagnostics.js'; +import { parseChannel } from '../diagnostics.js'; import type { ArgumentCoordinateNode, @@ -147,19 +147,23 @@ export function parse( source: string | Source, options?: ParseOptions, ): DocumentNode { - return maybeTraceSync( - 'parse', - () => ({ source }), - () => { - const parser = new Parser(source, options); - const document = parser.parseDocument(); - Object.defineProperty(document, 'tokenCount', { - enumerable: false, - value: parser.tokenCount, - }); - return document; - }, - ); + if (!parseChannel?.hasSubscribers) { + return parseImpl(source, options); + } + return parseChannel.traceSync(() => parseImpl(source, options), { source }); +} + +function parseImpl( + source: string | Source, + options: ParseOptions | undefined, +): DocumentNode { + const parser = new Parser(source, options); + const document = parser.parseDocument(); + Object.defineProperty(document, 'tokenCount', { + enumerable: false, + value: parser.tokenCount, + }); + return document; } /** diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 4672b9a8de..f1d5ffe909 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -12,7 +12,7 @@ import { assertValidSchema } from '../type/validate.ts'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo.ts'; -import { maybeTraceSync } from '../diagnostics.ts'; +import { validateChannel } from '../diagnostics.ts'; import { specifiedRules, specifiedSDLRules } from './specifiedRules.ts'; import type { SDLValidationRule, ValidationRule } from './ValidationContext.ts'; @@ -63,52 +63,61 @@ export function validate( rules: ReadonlyArray = specifiedRules, options?: ValidationOptions, ): ReadonlyArray { - return maybeTraceSync( - 'validate', - () => ({ schema, document: documentAST }), - () => { - const maxErrors = options?.maxErrors ?? 100; - const hideSuggestions = options?.hideSuggestions ?? false; - - // If the schema used for validation is invalid, throw an error. - assertValidSchema(schema); - - const errors: Array = []; - const typeInfo = new TypeInfo(schema); - const context = new ValidationContext( - schema, - documentAST, - typeInfo, - (error) => { - if (errors.length >= maxErrors) { - throw tooManyValidationErrorsError; - } - errors.push(error); - }, - hideSuggestions, - ); - - // This uses a specialized visitor which runs multiple visitors in - // parallel, while maintaining the visitor skip and break API. - const visitor = visitInParallel(rules.map((rule) => rule(context))); - - // Visit the whole document with each instance of all provided rules. - try { - visit( - documentAST, - visitWithTypeInfo(typeInfo, visitor), - QueryDocumentKeysToValidate, - ); - } catch (e: unknown) { - if (e === tooManyValidationErrorsError) { - errors.push(tooManyValidationErrorsError); - } else { - throw e; - } + if (!validateChannel?.hasSubscribers) { + return validateImpl(schema, documentAST, rules, options); + } + return validateChannel.traceSync( + () => validateImpl(schema, documentAST, rules, options), + { schema, document: documentAST }, + ); +} + +function validateImpl( + schema: GraphQLSchema, + documentAST: DocumentNode, + rules: ReadonlyArray, + options: ValidationOptions | undefined, +): ReadonlyArray { + const maxErrors = options?.maxErrors ?? 100; + const hideSuggestions = options?.hideSuggestions ?? false; + + // If the schema used for validation is invalid, throw an error. + assertValidSchema(schema); + + const errors: Array = []; + const typeInfo = new TypeInfo(schema); + const context = new ValidationContext( + schema, + documentAST, + typeInfo, + (error) => { + if (errors.length >= maxErrors) { + throw tooManyValidationErrorsError; } - return errors; + errors.push(error); }, + hideSuggestions, ); + + // This uses a specialized visitor which runs multiple visitors in + // parallel, while maintaining the visitor skip and break API. + const visitor = visitInParallel(rules.map((rule) => rule(context))); + + // Visit the whole document with each instance of all provided rules. + try { + visit( + documentAST, + visitWithTypeInfo(typeInfo, visitor), + QueryDocumentKeysToValidate, + ); + } catch (e: unknown) { + if (e === tooManyValidationErrorsError) { + errors.push(tooManyValidationErrorsError); + } else { + throw e; + } + } + return errors; } /** From f57fb9fda735c6da7c3d96d59323abef02ed6b13 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 10:13:36 -0400 Subject: [PATCH 19/55] ref(perf): inline no-subscriber fast path at tracing emission sites On parse/validate/execute/subscribe entry points, the previous `maybeTrace*` wrapper allocated a ctx-factory closure and a body closure on every call before short-circuiting on `hasSubscribers`. Inline the gate as `if (!channel?.hasSubscribers) return impl(args)` so closures and ctx objects are only allocated when a subscriber is attached. Recovers roughly half of the tracing-channel overhead seen in benchmarks. List-sync is back within noise of the pre-tracing baseline. Introspection remains ~3% slower due to the per-field resolve gate; the shape-change fix (dropping `_resolveChannel` from Executor) in the previous commit already captured the other half of that regression. --- src/diagnostics.ts | 10 ++ src/execution/Executor.ts | 22 +---- src/execution/execute.ts | 192 +++++++++++++++++++++++-------------- src/language/parser.ts | 9 +- src/validation/validate.ts | 15 ++- 5 files changed, 146 insertions(+), 102 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index d194d0debc..c0fb84e34e 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -187,3 +187,13 @@ export function traceMixed( }); }); } + +/** + * Check if a channel is defined and has subscribers. + */ +export function shouldTrace( + channel: MinimalTracingChannel | undefined, +): channel is MinimalTracingChannel { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + return channel !== undefined && channel.hasSubscribers !== false; +} diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 7570db2e48..d8c6ad2aea 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,8 +45,7 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import type { MinimalTracingChannel } from '../diagnostics.ts'; -import { getChannels, maybeTraceMixed, shouldTrace } from '../diagnostics.ts'; +import { resolveChannel, shouldTrace, traceMixed } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; @@ -248,12 +247,6 @@ export class Executor< values: ReadonlyArray>, ) => Promise>; - // Resolved once per Executor so the per-field gate in `executeField` is a - // single member read + null check, not a `getChannels()?.resolve` walk + - // `hasSubscribers` read on every resolution. Undefined when diagnostics - // are off or nobody is listening at construction time. - _resolveChannel: MinimalTracingChannel | undefined; - constructor( validatedExecutionArgs: ValidatedExecutionArgs, sharedExecutionContext?: SharedExecutionContext, @@ -263,11 +256,6 @@ export class Executor< this.abortReason = defaultAbortReason; this.collectedErrors = new CollectedErrors(); - const resolveChannel = getChannels()?.resolve; - this._resolveChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; - if (sharedExecutionContext === undefined) { this.resolverAbortController = new AbortController(); this.sharedExecutionContext = createSharedExecutionContext( @@ -616,10 +604,10 @@ export class Executor< // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = this._resolveChannel - ? maybeTraceMixed( - 'resolve', - () => buildResolveCtx(info, args, fieldDef.resolve === undefined), + const result = shouldTrace(resolveChannel) + ? traceMixed( + resolveChannel, + buildResolveCtx(info, args, fieldDef.resolve === undefined), () => resolveFn(source, args, contextValue, info), ) : resolveFn(source, args, contextValue, info); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 09f9b03214..881abd2c47 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -30,7 +30,11 @@ import type { GraphQLSchema } from '../type/schema.ts'; import { getOperationAST } from '../utilities/getOperationAST.ts'; -import { maybeTraceMixed } from '../diagnostics.ts'; +import { + executeChannel, + subscribeChannel, + traceMixed, +} from '../diagnostics.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; import { cancellablePromise } from './cancellablePromise.ts'; @@ -63,26 +67,24 @@ export type RootSelectionSetExecutor = ( * resolution of the operation AST to a lazy getter so the cost of walking * the document is only paid if a subscriber reads it. */ -function buildExecuteCtxFromArgs(args: ExecutionArgs): () => object { - return () => { - let operation: OperationDefinitionNode | null | undefined; - const resolveOperation = (): OperationDefinitionNode | null | undefined => { - if (operation === undefined) { - operation = getOperationAST(args.document, args.operationName); - } - return operation; - }; - return { - document: args.document, - schema: args.schema, - variableValues: args.variableValues, - get operationName() { - return args.operationName ?? resolveOperation()?.name?.value; - }, - get operationType() { - return resolveOperation()?.operation; - }, - }; +function buildExecuteCtxFromArgs(args: ExecutionArgs): object { + let operation: OperationDefinitionNode | null | undefined; + const resolveOperation = (): OperationDefinitionNode | null | undefined => { + if (operation === undefined) { + operation = getOperationAST(args.document, args.operationName); + } + return operation; + }; + return { + document: args.document, + schema: args.schema, + variableValues: args.variableValues, + get operationName() { + return args.operationName ?? resolveOperation()?.name?.value; + }, + get operationType() { + return resolveOperation()?.operation; + }, }; } @@ -95,14 +97,14 @@ function buildExecuteCtxFromArgs(args: ExecutionArgs): () => object { */ function buildExecuteCtxFromValidatedArgs( args: ValidatedExecutionArgs, -): () => object { - return () => ({ +): object { + return { operation: args.operation, schema: args.schema, variableValues: args.variableValues, operationName: args.operation.name?.value, operationType: args.operation.operation, - }); + }; } /** @@ -122,23 +124,26 @@ function buildExecuteCtxFromValidatedArgs( * delivery. */ export function execute(args: ExecutionArgs): PromiseOrValue { - return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { - if ( - args.schema.getDirective('defer') || - args.schema.getDirective('stream') - ) { - throw new Error(UNEXPECTED_EXPERIMENTAL_DIRECTIVES); - } + if (!executeChannel?.hasSubscribers) { + return executeImpl(args); + } + return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + executeImpl(args), + ); +} - const validatedExecutionArgs = validateExecutionArgs(args); +function executeImpl(args: ExecutionArgs): PromiseOrValue { + if (args.schema.getDirective('defer') || args.schema.getDirective('stream')) { + throw new Error(UNEXPECTED_EXPERIMENTAL_DIRECTIVES); + } - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + const validatedExecutionArgs = validateExecutionArgs(args); + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } - return executeRootSelectionSet(validatedExecutionArgs); - }); + return executeRootSelectionSet(validatedExecutionArgs); } /** @@ -156,35 +161,51 @@ export function execute(args: ExecutionArgs): PromiseOrValue { export function experimentalExecuteIncrementally( args: ExecutionArgs, ): PromiseOrValue { - return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { - // If a valid execution context cannot be created due to incorrect - // arguments, a "Response" with only errors is returned. - const validatedExecutionArgs = validateExecutionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + if (!executeChannel?.hasSubscribers) { + return experimentalExecuteIncrementallyImpl(args); + } + return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + experimentalExecuteIncrementallyImpl(args), + ); +} - return experimentalExecuteRootSelectionSet(validatedExecutionArgs); - }); +function experimentalExecuteIncrementallyImpl( + args: ExecutionArgs, +): PromiseOrValue { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateExecutionArgs(args); + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + + return experimentalExecuteRootSelectionSet(validatedExecutionArgs); } export function executeIgnoringIncremental( args: ExecutionArgs, ): PromiseOrValue { - return maybeTraceMixed('execute', buildExecuteCtxFromArgs(args), () => { - // If a valid execution context cannot be created due to incorrect - // arguments, a "Response" with only errors is returned. - const validatedExecutionArgs = validateExecutionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + if (!executeChannel?.hasSubscribers) { + return executeIgnoringIncrementalImpl(args); + } + return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + executeIgnoringIncrementalImpl(args), + ); +} - return executeRootSelectionSetIgnoringIncremental(validatedExecutionArgs); - }); +function executeIgnoringIncrementalImpl( + args: ExecutionArgs, +): PromiseOrValue { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateExecutionArgs(args); + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + + return executeRootSelectionSetIgnoringIncremental(validatedExecutionArgs); } /** @@ -243,8 +264,13 @@ export function executeSync(args: ExecutionArgs): ExecutionResult { export function executeSubscriptionEvent( validatedExecutionArgs: ValidatedSubscriptionArgs, ): PromiseOrValue { - return maybeTraceMixed( - 'execute', + if (!executeChannel?.hasSubscribers) { + return new ExecutorThrowingOnIncremental( + validatedExecutionArgs, + ).executeRootSelectionSet(false); + } + return traceMixed( + executeChannel, buildExecuteCtxFromValidatedArgs(validatedExecutionArgs), () => new ExecutorThrowingOnIncremental( @@ -284,18 +310,29 @@ export function subscribe( ): PromiseOrValue< AsyncGenerator | ExecutionResult > { - return maybeTraceMixed('subscribe', buildExecuteCtxFromArgs(args), () => { - // If a valid execution context cannot be created due to incorrect - // arguments, a "Response" with only errors is returned. - const validatedExecutionArgs = validateSubscriptionArgs(args); - - // Return early errors if execution context failed. - if (!('schema' in validatedExecutionArgs)) { - return { errors: validatedExecutionArgs }; - } + if (!subscribeChannel?.hasSubscribers) { + return subscribeImpl(args); + } + return traceMixed(subscribeChannel, buildExecuteCtxFromArgs(args), () => + subscribeImpl(args), + ); +} - const resultOrStream = createSourceEventStream(validatedExecutionArgs); +function subscribeImpl( + args: ExecutionArgs, +): PromiseOrValue< + AsyncGenerator | ExecutionResult +> { + // If a valid execution context cannot be created due to incorrect + // arguments, a "Response" with only errors is returned. + const validatedExecutionArgs = validateSubscriptionArgs(args); + // Return early errors if execution context failed. + if (!('schema' in validatedExecutionArgs)) { + return { errors: validatedExecutionArgs }; + } + +<<<<<<< HEAD if (isPromise(resultOrStream)) { return resultOrStream.then((resolvedResultOrStream) => isAsyncIterable(resolvedResultOrStream) @@ -311,6 +348,17 @@ export function subscribe( ? mapSourceToResponseEvent(validatedExecutionArgs, resultOrStream) : resultOrStream; }); +======= + const resultOrStream = createSourceEventStream(validatedExecutionArgs); + + if (isPromise(resultOrStream)) { + return resultOrStream.then((resolvedResultOrStream) => + mapSourceToResponse(validatedExecutionArgs, resolvedResultOrStream), + ); + } + + return mapSourceToResponse(validatedExecutionArgs, resultOrStream); +>>>>>>> 19112ed5 (ref(perf): inline no-subscriber fast path at tracing emission sites) } /** diff --git a/src/language/parser.ts b/src/language/parser.ts index 7561390184..47e40eb1e7 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,7 +3,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; -import { parseChannel } from '../diagnostics.js'; +import { parseChannel, shouldTrace } from '../diagnostics.js'; import type { ArgumentCoordinateNode, @@ -147,10 +147,9 @@ export function parse( source: string | Source, options?: ParseOptions, ): DocumentNode { - if (!parseChannel?.hasSubscribers) { - return parseImpl(source, options); - } - return parseChannel.traceSync(() => parseImpl(source, options), { source }); + return shouldTrace(parseChannel) + ? parseChannel.traceSync(() => parseImpl(source, options), { source }) + : parseImpl(source, options); } function parseImpl( diff --git a/src/validation/validate.ts b/src/validation/validate.ts index f1d5ffe909..30a7ff12fa 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -12,7 +12,7 @@ import { assertValidSchema } from '../type/validate.ts'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo.ts'; -import { validateChannel } from '../diagnostics.ts'; +import { shouldTrace, validateChannel } from '../diagnostics.ts'; import { specifiedRules, specifiedSDLRules } from './specifiedRules.ts'; import type { SDLValidationRule, ValidationRule } from './ValidationContext.ts'; @@ -63,13 +63,12 @@ export function validate( rules: ReadonlyArray = specifiedRules, options?: ValidationOptions, ): ReadonlyArray { - if (!validateChannel?.hasSubscribers) { - return validateImpl(schema, documentAST, rules, options); - } - return validateChannel.traceSync( - () => validateImpl(schema, documentAST, rules, options), - { schema, document: documentAST }, - ); + return shouldTrace(validateChannel) + ? validateChannel.traceSync( + () => validateImpl(schema, documentAST, rules, options), + { schema, document: documentAST }, + ) + : validateImpl(schema, documentAST, rules, options); } function validateImpl( From 3414e386fb77b6a684101576fd7a0df42aa71716 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 11:33:33 -0400 Subject: [PATCH 20/55] test: cover executeIgnoringIncremental traced path and drop unused traceSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The traced branch of `executeIgnoringIncremental` was not exercised by any subscriber-attached test, tripping the 100% coverage threshold on CI. Add a case in execute-diagnostics-test that subscribes to `graphql:execute` and asserts start/end around a call. Also drop the `traceSync` helper from diagnostics.ts; every call site invokes `channel.traceSync(...)` directly, so the export was dead code. Mark the `require('node:diagnostics_channel')` CJS fallback and the enclosing `catch` block with c8 ignore comments — they are only reachable on Node 20.0-20.15 (pre-`getBuiltinModule`) and on runtimes without `diagnostics_channel` at all, neither of which the unit tests run on. --- src/diagnostics.ts | 17 ++--------------- .../__tests__/execute-diagnostics-test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index c0fb84e34e..de11c758cf 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -85,12 +85,14 @@ function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { } ).getBuiltinModule('node:diagnostics_channel'); } + /* c8 ignore next 6 */ if (!dc && typeof require === 'function') { // CJS fallback for runtimes that lack `process.getBuiltinModule` // (e.g. Node 20.0 - 20.15). ESM builds skip this branch because // `require` is undeclared there. dc = require('node:diagnostics_channel') as DiagnosticsChannelModule; } + /* c8 ignore next 3 */ } catch { // diagnostics_channel not available on this runtime; tracing is a no-op. } @@ -122,21 +124,6 @@ export const subscribeChannel: MinimalTracingChannel | undefined = export const resolveChannel: MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:resolve'); -/** - * Publish a synchronous operation through `channel`. Caller has already - * verified that a subscriber is attached; this helper exists only so the - * traced path doesn't need to be duplicated at every emission site. - * - * @internal - */ -export function traceSync( - channel: MinimalTracingChannel, - ctx: object, - fn: () => T, -): T { - return channel.traceSync(fn, ctx); -} - /** * Publish a mixed sync-or-promise operation through `channel`. Caller has * already verified that a subscriber is attached. diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/execute-diagnostics-test.ts index fe4666c489..896189c7a3 100644 --- a/src/execution/__tests__/execute-diagnostics-test.ts +++ b/src/execution/__tests__/execute-diagnostics-test.ts @@ -13,6 +13,7 @@ import { buildSchema } from '../../utilities/buildASTSchema.js'; import type { ExecutionArgs } from '../execute.js'; import { execute, + executeIgnoringIncremental, executeSubscriptionEvent, executeSync, validateExecutionArgs, @@ -78,6 +79,17 @@ describe('execute diagnostics channel', () => { expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); }); + it('emits start and end around executeIgnoringIncremental', () => { + active = collectEvents(executeChannel); + + const document = parse('query Q { sync }'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + executeIgnoringIncremental({ schema, document, rootValue }); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].ctx.operationName).to.equal('Q'); + }); + it('emits start, error, and end when execute throws synchronously', () => { active = collectEvents(executeChannel); From 566e0ad30b0bad34fd20bfc324dc98a267e45910 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Apr 2026 18:48:45 -0400 Subject: [PATCH 21/55] ref(perf): keep executeField inlinable by extracting traced path Hoist the resolve tracing decision once per `executeFields` / `executeFieldsSerially` call instead of re-checking `shouldTrace(resolveChannel)` per field, and move the traced branch's `traceMixed` call + ctx closure into a module-scope helper `invokeResolverWithTracing`. The latter is the load-bearing change: once `executeField`'s body referenced `traceMixed` and the `() => resolveFn(...)` closure, V8 stopped inlining `executeField` into `executeFields`, paying a real call frame per field. Extracting the two-function tail keeps `executeField`'s bytecode small enough to inline again. Effect on the introspection benchmark (the worst-case fan-out shape, ~1000 resolver calls per iteration, no subscribers attached): - with the inline ternary: -5.94% vs upstream - with this refactor: -2.05% / -2.30% across two paired runs Other benchmarks (list-sync, list-async, list-asyncIterable, object-async) sit at the noise floor with 95% CIs that cross zero. Adds a serial-mutation case to resolve-diagnostics-test to cover the `executeFieldsSerially` branch the hoisted snapshot now lives in. --- src/execution/Executor.ts | 45 ++++++++++++++++--- .../__tests__/resolve-diagnostics-test.ts | 29 ++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index d8c6ad2aea..1413dfe310 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,6 +45,7 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; +import type { MinimalTracingChannel } from '../diagnostics.ts'; import { resolveChannel, shouldTrace, traceMixed } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; @@ -469,6 +470,9 @@ export class Executor< groupedFieldSet: GroupedFieldSet, positionContext: TPositionContext | undefined, ): PromiseOrValue> { + const tracingChannel = shouldTrace(resolveChannel) + ? resolveChannel + : undefined; return promiseReduce( groupedFieldSet, (results, [responseName, fieldDetailsList]) => { @@ -482,6 +486,7 @@ export class Executor< fieldDetailsList, fieldPath, positionContext, + tracingChannel, ); if (result === undefined) { return results; @@ -512,6 +517,9 @@ export class Executor< ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; + const tracingChannel = shouldTrace(resolveChannel) + ? resolveChannel + : undefined; try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { @@ -522,6 +530,7 @@ export class Executor< fieldDetailsList, fieldPath, positionContext, + tracingChannel, ); if (result !== undefined) { @@ -563,6 +572,7 @@ export class Executor< fieldDetailsList: FieldDetailsList, path: Path, positionContext: TPositionContext | undefined, + tracingChannel: MinimalTracingChannel | undefined, ): PromiseOrValue { const validatedExecutionArgs = this.validatedExecutionArgs; const { schema, contextValue, variableValues, hideSuggestions } = @@ -604,11 +614,15 @@ export class Executor< // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = shouldTrace(resolveChannel) - ? traceMixed( - resolveChannel, - buildResolveCtx(info, args, fieldDef.resolve === undefined), - () => resolveFn(source, args, contextValue, info), + const result = tracingChannel + ? invokeResolverWithTracing( + tracingChannel, + resolveFn, + source, + args, + contextValue, + info, + fieldDef.resolve === undefined, ) : resolveFn(source, args, contextValue, info); @@ -1417,3 +1431,24 @@ function buildResolveCtx( }, }; } + +/** + * Traced path for a single resolver call. Extracted as a module-scope function to increase likelihood of inlining. + * + * @internal + */ +function invokeResolverWithTracing( + tracingChannel: MinimalTracingChannel, + resolveFn: GraphQLFieldResolver, + source: unknown, + args: { readonly [argument: string]: unknown }, + contextValue: unknown, + info: GraphQLResolveInfo, + isTrivialResolver: boolean, +): PromiseOrValue { + return traceMixed( + tracingChannel, + buildResolveCtx(info, args, isTrivialResolver), + () => resolveFn(source, args, contextValue, info), + ); +} diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts index fc6a53cc17..75fe68fe35 100644 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -178,6 +178,35 @@ describe('resolve diagnostics channel', () => { expect(endsSync.length).to.equal(4); }); + it('emits per-field for serial mutation execution', async () => { + const mutationSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { dummy: { type: GraphQLString } }, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + first: { type: GraphQLString, resolve: () => 'one' }, + second: { type: GraphQLString, resolve: () => 'two' }, + }, + }), + }); + + active = collectEvents(resolveChannel); + + await execute({ + schema: mutationSchema, + document: parse('mutation M { first second }'), + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + expect(starts.map((e) => e.ctx.fieldName)).to.deep.equal([ + 'first', + 'second', + ]); + }); + it('does nothing when no subscribers are attached', () => { const result = execute({ schema, From 7b6fa1521c40e04fb6b8618839c76ff4cbdf6293 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 22:35:17 +0300 Subject: [PATCH 22/55] rename from isTrivialResolver => isDefaultResolver --- integrationTests/diagnostics/test.js | 4 ++-- src/execution/Executor.ts | 8 ++++---- src/execution/__tests__/resolve-diagnostics-test.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics/test.js index 8a0e5cebb4..dd1d8e71ba 100644 --- a/integrationTests/diagnostics/test.js +++ b/integrationTests/diagnostics/test.js @@ -205,7 +205,7 @@ function runResolveCase() { parentType: msg.parentType, fieldType: msg.fieldType, fieldPath: msg.fieldPath, - isTrivialResolver: msg.isTrivialResolver, + isDefaultResolver: msg.isDefaultResolver, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -228,7 +228,7 @@ function runResolveCase() { assert.equal(hello.parentType, 'Query'); assert.equal(hello.fieldType, 'String'); // buildSchema never attaches field.resolve; all fields report as trivial. - assert.equal(hello.isTrivialResolver, true); + assert.equal(hello.isDefaultResolver, true); } finally { channel.unsubscribe(handler); } diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 1413dfe310..e0afcea51f 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -1416,7 +1416,7 @@ function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { function buildResolveCtx( info: GraphQLResolveInfo, args: { readonly [argument: string]: unknown }, - isTrivialResolver: boolean, + isDefaultResolver: boolean, ): object { let cachedFieldPath: string | undefined; return { @@ -1424,7 +1424,7 @@ function buildResolveCtx( parentType: info.parentType.name, fieldType: String(info.returnType), args, - isTrivialResolver, + isDefaultResolver, get fieldPath() { cachedFieldPath ??= pathToArray(info.path).join('.'); return cachedFieldPath; @@ -1444,11 +1444,11 @@ function invokeResolverWithTracing( args: { readonly [argument: string]: unknown }, contextValue: unknown, info: GraphQLResolveInfo, - isTrivialResolver: boolean, + isDefaultResolver: boolean, ): PromiseOrValue { return traceMixed( tracingChannel, - buildResolveCtx(info, args, isTrivialResolver), + buildResolveCtx(info, args, isDefaultResolver), () => resolveFn(source, args, contextValue, info), ); } diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts index 75fe68fe35..d3c0dc4f3b 100644 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ b/src/execution/__tests__/resolve-diagnostics-test.ts @@ -116,7 +116,7 @@ describe('resolve diagnostics channel', () => { ); }); - it('reports isTrivialResolver based on field.resolve presence', () => { + it('reports isDefaultResolver based on field.resolve presence', () => { const trivialSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -141,7 +141,7 @@ describe('resolve diagnostics channel', () => { const starts = active.events.filter((e) => e.kind === 'start'); const byField = new Map( - starts.map((e) => [e.ctx.fieldName, e.ctx.isTrivialResolver]), + starts.map((e) => [e.ctx.fieldName, e.ctx.isDefaultResolver]), ); expect(byField.get('trivial')).to.equal(true); expect(byField.get('custom')).to.equal(false); From 27abe1d65b435f660cff4622c95e449cf8a8d437 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 22:35:43 +0300 Subject: [PATCH 23/55] move helpers into class --- src/execution/Executor.ts | 91 ++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 48 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index e0afcea51f..0614c10203 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -615,7 +615,7 @@ export class Executor< // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. const result = tracingChannel - ? invokeResolverWithTracing( + ? this.invokeResolverWithTracing( tracingChannel, resolveFn, source, @@ -661,6 +661,48 @@ export class Executor< } } + invokeResolverWithTracing( + tracingChannel: MinimalTracingChannel, + resolveFn: GraphQLFieldResolver, + source: unknown, + args: { readonly [argument: string]: unknown }, + contextValue: unknown, + info: GraphQLResolveInfo, + isTrivialResolver: boolean, + ): PromiseOrValue { + return traceMixed( + tracingChannel, + this.buildResolveCtx(args, info, isTrivialResolver), + () => resolveFn(source, args, contextValue, info), + ); + } + + /** + * Build a graphql:resolve channel context for a single field invocation. + * + * `fieldPath` is exposed as a lazy getter because serializing the response + * path is O(depth) and APMs that depth-filter or skip default resolvers + * often never read it. `args` is passed through by reference. + */ + buildResolveCtx( + args: ObjMap, + info: GraphQLResolveInfo, + isDefaultResolver: boolean, + ): object { + let cachedFieldPath: string | undefined; + return { + fieldName: info.fieldName, + parentType: info.parentType.name, + fieldType: String(info.returnType), + args, + isDefaultResolver, + get fieldPath() { + cachedFieldPath ??= pathToArray(info.path).join('.'); + return cachedFieldPath; + }, + }; + } + handleFieldError( rawError: unknown, returnType: GraphQLOutputType, @@ -1405,50 +1447,3 @@ export class Executor< function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); } - -/** - * Build a graphql:resolve channel context for a single field invocation. - * - * `fieldPath` is exposed as a lazy getter because serializing the response - * path is O(depth) and APMs that depth-filter or skip trivial resolvers - * often never read it. `args` is passed through by reference. - */ -function buildResolveCtx( - info: GraphQLResolveInfo, - args: { readonly [argument: string]: unknown }, - isDefaultResolver: boolean, -): object { - let cachedFieldPath: string | undefined; - return { - fieldName: info.fieldName, - parentType: info.parentType.name, - fieldType: String(info.returnType), - args, - isDefaultResolver, - get fieldPath() { - cachedFieldPath ??= pathToArray(info.path).join('.'); - return cachedFieldPath; - }, - }; -} - -/** - * Traced path for a single resolver call. Extracted as a module-scope function to increase likelihood of inlining. - * - * @internal - */ -function invokeResolverWithTracing( - tracingChannel: MinimalTracingChannel, - resolveFn: GraphQLFieldResolver, - source: unknown, - args: { readonly [argument: string]: unknown }, - contextValue: unknown, - info: GraphQLResolveInfo, - isDefaultResolver: boolean, -): PromiseOrValue { - return traceMixed( - tracingChannel, - buildResolveCtx(info, args, isDefaultResolver), - () => resolveFn(source, args, contextValue, info), - ); -} From 8ae296ee083eaf2e59550fec2b3a7af7a2941614 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 22:37:47 +0300 Subject: [PATCH 24/55] condense --- src/diagnostics.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index de11c758cf..b9516eedba 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -181,6 +181,5 @@ export function traceMixed( export function shouldTrace( channel: MinimalTracingChannel | undefined, ): channel is MinimalTracingChannel { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare - return channel !== undefined && channel.hasSubscribers !== false; + return channel?.hasSubscribers === true; } From ff66d39e30c6e3c261ae7af7450a7ee4df29f095 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 22:51:10 +0300 Subject: [PATCH 25/55] move subscription event tracing to mapSourceToResponse to cover custom perEventExecutors --- src/execution/execute.ts | 54 +++++++++++++++------------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 881abd2c47..dd44251d11 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -264,19 +264,9 @@ export function executeSync(args: ExecutionArgs): ExecutionResult { export function executeSubscriptionEvent( validatedExecutionArgs: ValidatedSubscriptionArgs, ): PromiseOrValue { - if (!executeChannel?.hasSubscribers) { - return new ExecutorThrowingOnIncremental( - validatedExecutionArgs, - ).executeRootSelectionSet(false); - } - return traceMixed( - executeChannel, - buildExecuteCtxFromValidatedArgs(validatedExecutionArgs), - () => - new ExecutorThrowingOnIncremental( - validatedExecutionArgs, - ).executeRootSelectionSet(false), - ); + return new ExecutorThrowingOnIncremental( + validatedExecutionArgs, + ).executeRootSelectionSet(false); } /** @@ -332,33 +322,22 @@ function subscribeImpl( return { errors: validatedExecutionArgs }; } -<<<<<<< HEAD - if (isPromise(resultOrStream)) { - return resultOrStream.then((resolvedResultOrStream) => - isAsyncIterable(resolvedResultOrStream) - ? mapSourceToResponseEvent( - validatedExecutionArgs, - resolvedResultOrStream, - ) - : resolvedResultOrStream, - ); - } - - return isAsyncIterable(resultOrStream) - ? mapSourceToResponseEvent(validatedExecutionArgs, resultOrStream) - : resultOrStream; - }); -======= const resultOrStream = createSourceEventStream(validatedExecutionArgs); if (isPromise(resultOrStream)) { return resultOrStream.then((resolvedResultOrStream) => - mapSourceToResponse(validatedExecutionArgs, resolvedResultOrStream), + isAsyncIterable(resolvedResultOrStream) + ? mapSourceToResponseEvent( + validatedExecutionArgs, + resolvedResultOrStream, + ) + : resolvedResultOrStream, ); } - return mapSourceToResponse(validatedExecutionArgs, resultOrStream); ->>>>>>> 19112ed5 (ref(perf): inline no-subscriber fast path at tracing emission sites) + return isAsyncIterable(resultOrStream) + ? mapSourceToResponseEvent(validatedExecutionArgs, resultOrStream) + : resultOrStream; } /** @@ -666,7 +645,14 @@ export function mapSourceToResponseEvent( ...validatedExecutionArgs, rootValue: payload, }; - return rootSelectionSetExecutor(perEventExecutionArgs); + if (!executeChannel?.hasSubscribers) { + return rootSelectionSetExecutor(perEventExecutionArgs); + } + return traceMixed( + executeChannel, + buildExecuteCtxFromValidatedArgs(validatedExecutionArgs), + () => rootSelectionSetExecutor(perEventExecutionArgs), + ); } const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; From 3e07b44bfef69803d372a2ca24cb998ee76c7e47 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 23:05:14 +0300 Subject: [PATCH 26/55] fixes failing test --- .../__tests__/execute-diagnostics-test.ts | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/execute-diagnostics-test.ts index 896189c7a3..c1dfdcfc2e 100644 --- a/src/execution/__tests__/execute-diagnostics-test.ts +++ b/src/execution/__tests__/execute-diagnostics-test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { afterEach, describe, it } from 'mocha'; import { @@ -6,17 +6,17 @@ import { getTracingChannel, } from '../../__testUtils__/diagnosticsTestUtils.js'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; + import { parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; -import type { ExecutionArgs } from '../execute.js'; import { execute, executeIgnoringIncremental, - executeSubscriptionEvent, executeSync, - validateExecutionArgs, + subscribe, } from '../execute.js'; const schema = buildSchema(` @@ -109,30 +109,50 @@ describe('execute diagnostics channel', () => { ]); }); - it('emits for each executeSubscriptionEvent call with resolved operation ctx', () => { - const args: ExecutionArgs = { - schema, - document: parse('query Q { sync }'), - rootValue, - }; - const validated = validateExecutionArgs(args); - if (!('schema' in validated)) { - throw new Error('unexpected validation failure'); + it('emits for each subscription event with resolved operation ctx', async () => { + const subscriptionSchema = buildSchema(` + type Query { + dummy: String + } + + type Subscription { + tick: String + } + `); + + async function* tickGenerator() { + await Promise.resolve(); + yield { tick: 'one' }; + yield { tick: 'two' }; } + const document = parse('subscription S { tick }'); + active = collectEvents(executeChannel); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeSubscriptionEvent(validated); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeSubscriptionEvent(validated); + const subscription = await subscribe({ + schema: subscriptionSchema, + document, + rootValue: { tick: tickGenerator }, + }); + assert(isAsyncIterable(subscription)); + + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { data: { tick: 'one' } }, + }); + expect(await subscription.next()).to.deep.equal({ + done: false, + value: { data: { tick: 'two' } }, + }); const starts = active.events.filter((e) => e.kind === 'start'); expect(starts.length).to.equal(2); for (const ev of starts) { - expect(ev.ctx.operationType).to.equal('query'); - expect(ev.ctx.operationName).to.equal('Q'); - expect(ev.ctx.schema).to.equal(schema); + expect(ev.ctx.operationType).to.equal('subscription'); + expect(ev.ctx.operationName).to.equal('S'); + expect(ev.ctx.operation).to.equal(document.definitions[0]); + expect(ev.ctx.schema).to.equal(subscriptionSchema); } }); From 12afc6daf60e0d4e5a8fbdd2da2dcd3b5946b550 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 23:18:01 +0300 Subject: [PATCH 27/55] rename test files also combine execute/subscribe --- .../__tests__/subscribe-diagnostics-test.ts | 123 ------------------ ...te-diagnostics-test.ts => tracing-test.ts} | 121 ++++++++++++++--- ...er-diagnostics-test.ts => tracing-test.ts} | 0 ...te-diagnostics-test.ts => tracing-test.ts} | 0 4 files changed, 102 insertions(+), 142 deletions(-) delete mode 100644 src/execution/__tests__/subscribe-diagnostics-test.ts rename src/execution/__tests__/{execute-diagnostics-test.ts => tracing-test.ts} (59%) rename src/language/__tests__/{parser-diagnostics-test.ts => tracing-test.ts} (100%) rename src/validation/__tests__/{validate-diagnostics-test.ts => tracing-test.ts} (100%) diff --git a/src/execution/__tests__/subscribe-diagnostics-test.ts b/src/execution/__tests__/subscribe-diagnostics-test.ts deleted file mode 100644 index 7ea78d6836..0000000000 --- a/src/execution/__tests__/subscribe-diagnostics-test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect } from 'chai'; -import { afterEach, describe, it } from 'mocha'; - -import { - collectEvents, - getTracingChannel, -} from '../../__testUtils__/diagnosticsTestUtils.js'; - -import { isPromise } from '../../jsutils/isPromise.js'; - -import { parse } from '../../language/parser.js'; - -import { GraphQLObjectType } from '../../type/definition.js'; -import { GraphQLString } from '../../type/scalars.js'; -import { GraphQLSchema } from '../../type/schema.js'; - -import { subscribe } from '../execute.js'; - -function buildSubscriptionSchema( - subscribeFn: () => AsyncIterable<{ tick: string }>, -): GraphQLSchema { - return new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { dummy: { type: GraphQLString } }, - }), - subscription: new GraphQLObjectType({ - name: 'Subscription', - fields: { - tick: { - type: GraphQLString, - subscribe: subscribeFn, - }, - }, - }), - }); -} - -async function* twoTicks(): AsyncIterable<{ tick: string }> { - await Promise.resolve(); - yield { tick: 'one' }; - yield { tick: 'two' }; -} - -const subscribeChannel = getTracingChannel('graphql:subscribe'); - -describe('subscribe diagnostics channel', () => { - let active: ReturnType | undefined; - - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end for a synchronous subscription setup', async () => { - active = collectEvents(subscribeChannel); - - const schema = buildSubscriptionSchema(twoTicks); - const document = parse('subscription S { tick }'); - - const result = subscribe({ schema, document }); - const resolved = isPromise(result) ? await result : result; - if (!(Symbol.asyncIterator in resolved)) { - throw new Error('Expected an async iterator'); - } - await resolved.return?.(); - - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.operationType).to.equal('subscription'); - expect(active.events[0].ctx.operationName).to.equal('S'); - expect(active.events[0].ctx.document).to.equal(document); - expect(active.events[0].ctx.schema).to.equal(schema); - }); - - it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { - active = collectEvents(subscribeChannel); - - const asyncResolver = (): Promise> => - Promise.resolve(twoTicks()); - const schema = buildSubscriptionSchema( - asyncResolver as unknown as () => AsyncIterable<{ tick: string }>, - ); - const document = parse('subscription { tick }'); - - const result = subscribe({ schema, document }); - const resolved = isPromise(result) ? await result : result; - if (!(Symbol.asyncIterator in resolved)) { - throw new Error('Expected an async iterator'); - } - await resolved.return?.(); - - expect(active.events.map((e) => e.kind)).to.deep.equal([ - 'start', - 'end', - 'asyncStart', - 'asyncEnd', - ]); - }); - - it('emits only start and end for a synchronous validation failure', () => { - active = collectEvents(subscribeChannel); - - const schema = buildSubscriptionSchema(twoTicks); - // Invalid: no operation. - const document = parse('fragment F on Subscription { tick }'); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscribe({ schema, document }); - - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - }); - - it('does nothing when no subscribers are attached', async () => { - const schema = buildSubscriptionSchema(twoTicks); - const document = parse('subscription { tick }'); - - const result = subscribe({ schema, document }); - const resolved = isPromise(result) ? await result : result; - if (Symbol.asyncIterator in resolved) { - await resolved.return?.(); - } - }); -}); diff --git a/src/execution/__tests__/execute-diagnostics-test.ts b/src/execution/__tests__/tracing-test.ts similarity index 59% rename from src/execution/__tests__/execute-diagnostics-test.ts rename to src/execution/__tests__/tracing-test.ts index c1dfdcfc2e..b91d252034 100644 --- a/src/execution/__tests__/execute-diagnostics-test.ts +++ b/src/execution/__tests__/tracing-test.ts @@ -7,6 +7,7 @@ import { } from '../../__testUtils__/diagnosticsTestUtils.js'; import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; +import { isPromise } from '../../jsutils/isPromise.js'; import { parse } from '../../language/parser.js'; @@ -23,18 +24,22 @@ const schema = buildSchema(` type Query { sync: String async: String + dummy: String } -`); - -const rootValue = { - sync: () => 'hello', - async: () => Promise.resolve('hello-async'), -}; -const executeChannel = getTracingChannel('graphql:execute'); + type Subscription { + tick: String + } +`); describe('execute diagnostics channel', () => { let active: ReturnType | undefined; + const executeChannel = getTracingChannel('graphql:execute'); + + const rootValue = { + sync: () => 'hello', + async: () => Promise.resolve('hello-async'), + }; afterEach(() => { active?.unsubscribe(); @@ -110,16 +115,6 @@ describe('execute diagnostics channel', () => { }); it('emits for each subscription event with resolved operation ctx', async () => { - const subscriptionSchema = buildSchema(` - type Query { - dummy: String - } - - type Subscription { - tick: String - } - `); - async function* tickGenerator() { await Promise.resolve(); yield { tick: 'one' }; @@ -131,7 +126,7 @@ describe('execute diagnostics channel', () => { active = collectEvents(executeChannel); const subscription = await subscribe({ - schema: subscriptionSchema, + schema, document, rootValue: { tick: tickGenerator }, }); @@ -152,7 +147,7 @@ describe('execute diagnostics channel', () => { expect(ev.ctx.operationType).to.equal('subscription'); expect(ev.ctx.operationName).to.equal('S'); expect(ev.ctx.operation).to.equal(document.definitions[0]); - expect(ev.ctx.schema).to.equal(subscriptionSchema); + expect(ev.ctx.schema).to.equal(schema); } }); @@ -162,3 +157,91 @@ describe('execute diagnostics channel', () => { expect(result).to.deep.equal({ data: { sync: 'hello' } }); }); }); + +describe('subscribe diagnostics channel', () => { + let active: ReturnType | undefined; + const subscribeChannel = getTracingChannel('graphql:subscribe'); + + async function* twoTicks(): AsyncIterable<{ tick: string }> { + await Promise.resolve(); + yield { tick: 'one' }; + yield { tick: 'two' }; + } + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end for a synchronous subscription setup', async () => { + active = collectEvents(subscribeChannel); + + const document = parse('subscription S { tick }'); + + const result = subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + }); + const resolved = isPromise(result) ? await result : result; + assert(isAsyncIterable(resolved)); + await resolved.return?.(); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + expect(active.events[0].ctx.operationType).to.equal('subscription'); + expect(active.events[0].ctx.operationName).to.equal('S'); + expect(active.events[0].ctx.document).to.equal(document); + expect(active.events[0].ctx.schema).to.equal(schema); + }); + + it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { + active = collectEvents(subscribeChannel); + + const document = parse('subscription { tick }'); + + const result = subscribe({ + schema, + document, + rootValue: { + tick: (): Promise> => + Promise.resolve(twoTicks()), + }, + }); + const resolved = isPromise(result) ? await result : result; + assert(isAsyncIterable(resolved)); + await resolved.return?.(); + + expect(active.events.map((e) => e.kind)).to.deep.equal([ + 'start', + 'end', + 'asyncStart', + 'asyncEnd', + ]); + }); + + it('emits only start and end for a synchronous validation failure', () => { + active = collectEvents(subscribeChannel); + + // Invalid: no operation. + const document = parse('fragment F on Subscription { tick }'); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + subscribe({ schema, document, rootValue: { tick: twoTicks } }); + + expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + }); + + it('does nothing when no subscribers are attached', async () => { + const document = parse('subscription { tick }'); + + const result = subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + }); + const resolved = isPromise(result) ? await result : result; + if (isAsyncIterable(resolved)) { + await resolved.return?.(); + } + }); +}); diff --git a/src/language/__tests__/parser-diagnostics-test.ts b/src/language/__tests__/tracing-test.ts similarity index 100% rename from src/language/__tests__/parser-diagnostics-test.ts rename to src/language/__tests__/tracing-test.ts diff --git a/src/validation/__tests__/validate-diagnostics-test.ts b/src/validation/__tests__/tracing-test.ts similarity index 100% rename from src/validation/__tests__/validate-diagnostics-test.ts rename to src/validation/__tests__/tracing-test.ts From 140d0ff489a2ba6cb43417a4bea8132c636b8ec8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 23:27:38 +0300 Subject: [PATCH 28/55] merge resolve tracing tests into execution tracing file --- .../__tests__/resolve-diagnostics-test.ts | 220 ------------------ src/execution/__tests__/tracing-test.ts | 206 ++++++++++++++++ 2 files changed, 206 insertions(+), 220 deletions(-) delete mode 100644 src/execution/__tests__/resolve-diagnostics-test.ts diff --git a/src/execution/__tests__/resolve-diagnostics-test.ts b/src/execution/__tests__/resolve-diagnostics-test.ts deleted file mode 100644 index d3c0dc4f3b..0000000000 --- a/src/execution/__tests__/resolve-diagnostics-test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { expect } from 'chai'; -import { afterEach, describe, it } from 'mocha'; - -import { - collectEvents, - getTracingChannel, -} from '../../__testUtils__/diagnosticsTestUtils.js'; - -import { isPromise } from '../../jsutils/isPromise.js'; - -import { parse } from '../../language/parser.js'; - -import { GraphQLObjectType } from '../../type/definition.js'; -import { GraphQLString } from '../../type/scalars.js'; -import { GraphQLSchema } from '../../type/schema.js'; - -import { buildSchema } from '../../utilities/buildASTSchema.js'; - -import { execute } from '../execute.js'; - -const schema = buildSchema(` - type Query { - sync: String - async: String - fail: String - asyncFail: String - plain: String - nested: Nested - } - type Nested { - leaf: String - } -`); - -const rootValue = { - sync: () => 'hello', - async: () => Promise.resolve('hello-async'), - fail: () => { - throw new Error('boom'); - }, - asyncFail: () => Promise.reject(new Error('async-boom')), - // no `plain` resolver, default property-access is used. - plain: 'plain-value', - nested: { leaf: 'leaf-value' }, -}; - -const resolveChannel = getTracingChannel('graphql:resolve'); - -describe('resolve diagnostics channel', () => { - let active: ReturnType | undefined; - - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end around a synchronous resolver', () => { - active = collectEvents(resolveChannel); - - const result = execute({ schema, document: parse('{ sync }'), rootValue }); - if (isPromise(result)) { - throw new Error('expected sync'); - } - - const starts = active.events.filter((e) => e.kind === 'start'); - expect(starts.length).to.equal(1); - expect(starts[0].ctx.fieldName).to.equal('sync'); - expect(starts[0].ctx.parentType).to.equal('Query'); - expect(starts[0].ctx.fieldType).to.equal('String'); - expect(starts[0].ctx.fieldPath).to.equal('sync'); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'end']); - }); - - it('emits the full async lifecycle when a resolver returns a promise', async () => { - active = collectEvents(resolveChannel); - - const result = execute({ schema, document: parse('{ async }'), rootValue }); - await result; - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'end', 'asyncStart', 'asyncEnd']); - }); - - it('emits start, error, end when a sync resolver throws', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ schema, document: parse('{ fail }'), rootValue }); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'error', 'end']); - }); - - it('emits full async lifecycle with error when a resolver rejects', async () => { - active = collectEvents(resolveChannel); - - await execute({ - schema, - document: parse('{ asyncFail }'), - rootValue, - }); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal([ - 'start', - 'end', - 'asyncStart', - 'error', - 'asyncEnd', - ]); - const errorEvent = active.events.find((e) => e.kind === 'error'); - expect((errorEvent?.ctx as { error?: Error }).error?.message).to.equal( - 'async-boom', - ); - }); - - it('reports isDefaultResolver based on field.resolve presence', () => { - const trivialSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - trivial: { type: GraphQLString }, - custom: { - type: GraphQLString, - resolve: () => 'explicit', - }, - }, - }), - }); - - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema: trivialSchema, - document: parse('{ trivial custom }'), - rootValue: { trivial: 'value' }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const byField = new Map( - starts.map((e) => [e.ctx.fieldName, e.ctx.isDefaultResolver]), - ); - expect(byField.get('trivial')).to.equal(true); - expect(byField.get('custom')).to.equal(false); - }); - - it('serializes fieldPath lazily, joining path keys with dots', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema, - document: parse('{ nested { leaf } }'), - rootValue, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const paths = starts.map((e) => e.ctx.fieldPath); - expect(paths).to.deep.equal(['nested', 'nested.leaf']); - }); - - it('fires once per field, not per schema walk', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema, - document: parse('{ sync plain nested { leaf } }'), - rootValue, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const endsSync = active.events.filter((e) => e.kind === 'end'); - expect(starts.length).to.equal(4); // sync, plain, nested, nested.leaf - expect(endsSync.length).to.equal(4); - }); - - it('emits per-field for serial mutation execution', async () => { - const mutationSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { dummy: { type: GraphQLString } }, - }), - mutation: new GraphQLObjectType({ - name: 'Mutation', - fields: { - first: { type: GraphQLString, resolve: () => 'one' }, - second: { type: GraphQLString, resolve: () => 'two' }, - }, - }), - }); - - active = collectEvents(resolveChannel); - - await execute({ - schema: mutationSchema, - document: parse('mutation M { first second }'), - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - expect(starts.map((e) => e.ctx.fieldName)).to.deep.equal([ - 'first', - 'second', - ]); - }); - - it('does nothing when no subscribers are attached', () => { - const result = execute({ - schema, - document: parse('{ sync }'), - rootValue, - }); - if (isPromise(result)) { - throw new Error('expected sync'); - } - }); -}); diff --git a/src/execution/__tests__/tracing-test.ts b/src/execution/__tests__/tracing-test.ts index b91d252034..06a8cb8d47 100644 --- a/src/execution/__tests__/tracing-test.ts +++ b/src/execution/__tests__/tracing-test.ts @@ -11,6 +11,10 @@ import { isPromise } from '../../jsutils/isPromise.js'; import { parse } from '../../language/parser.js'; +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + import { buildSchema } from '../../utilities/buildASTSchema.js'; import { @@ -24,9 +28,17 @@ const schema = buildSchema(` type Query { sync: String async: String + fail: String + asyncFail: String + plain: String + nested: Nested dummy: String } + type Nested { + leaf: String + } + type Subscription { tick: String } @@ -245,3 +257,197 @@ describe('subscribe diagnostics channel', () => { } }); }); + +describe('resolve diagnostics channel', () => { + let active: ReturnType | undefined; + const resolveChannel = getTracingChannel('graphql:resolve'); + + const rootValue = { + sync: () => 'hello', + async: () => Promise.resolve('hello-async'), + fail: () => { + throw new Error('boom'); + }, + asyncFail: () => Promise.reject(new Error('async-boom')), + // no `plain` resolver, default property-access is used. + plain: 'plain-value', + nested: { leaf: 'leaf-value' }, + }; + + afterEach(() => { + active?.unsubscribe(); + active = undefined; + }); + + it('emits start and end around a synchronous resolver', () => { + active = collectEvents(resolveChannel); + + const result = execute({ + schema, + document: parse('{ sync }'), + rootValue, + }); + if (isPromise(result)) { + throw new Error('expected sync'); + } + + const starts = active.events.filter((e) => e.kind === 'start'); + expect(starts.length).to.equal(1); + expect(starts[0].ctx.fieldName).to.equal('sync'); + expect(starts[0].ctx.parentType).to.equal('Query'); + expect(starts[0].ctx.fieldType).to.equal('String'); + expect(starts[0].ctx.fieldPath).to.equal('sync'); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'end']); + }); + + it('emits the full async lifecycle when a resolver returns a promise', async () => { + active = collectEvents(resolveChannel); + + const result = execute({ + schema, + document: parse('{ async }'), + rootValue, + }); + await result; + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'end', 'asyncStart', 'asyncEnd']); + }); + + it('emits start, error, end when a sync resolver throws', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ schema, document: parse('{ fail }'), rootValue }); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal(['start', 'error', 'end']); + }); + + it('emits full async lifecycle with error when a resolver rejects', async () => { + active = collectEvents(resolveChannel); + + await execute({ + schema, + document: parse('{ asyncFail }'), + rootValue, + }); + + const kinds = active.events.map((e) => e.kind); + expect(kinds).to.deep.equal([ + 'start', + 'end', + 'asyncStart', + 'error', + 'asyncEnd', + ]); + const errorEvent = active.events.find((e) => e.kind === 'error'); + expect((errorEvent?.ctx as { error?: Error }).error?.message).to.equal( + 'async-boom', + ); + }); + + it('reports isDefaultResolver based on field.resolve presence', () => { + const trivialSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + trivial: { type: GraphQLString }, + custom: { + type: GraphQLString, + resolve: () => 'explicit', + }, + }, + }), + }); + + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema: trivialSchema, + document: parse('{ trivial custom }'), + rootValue: { trivial: 'value' }, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const byField = new Map( + starts.map((e) => [e.ctx.fieldName, e.ctx.isDefaultResolver]), + ); + expect(byField.get('trivial')).to.equal(true); + expect(byField.get('custom')).to.equal(false); + }); + + it('serializes fieldPath lazily, joining path keys with dots', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema, + document: parse('{ nested { leaf } }'), + rootValue, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.ctx.fieldPath); + expect(paths).to.deep.equal(['nested', 'nested.leaf']); + }); + + it('fires once per field, not per schema walk', () => { + active = collectEvents(resolveChannel); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + execute({ + schema, + document: parse('{ sync plain nested { leaf } }'), + rootValue, + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + const endsSync = active.events.filter((e) => e.kind === 'end'); + expect(starts.length).to.equal(4); // sync, plain, nested, nested.leaf + expect(endsSync.length).to.equal(4); + }); + + it('emits per-field for serial mutation execution', async () => { + const mutationSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { dummy: { type: GraphQLString } }, + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + first: { type: GraphQLString, resolve: () => 'one' }, + second: { type: GraphQLString, resolve: () => 'two' }, + }, + }), + }); + + active = collectEvents(resolveChannel); + + await execute({ + schema: mutationSchema, + document: parse('mutation M { first second }'), + }); + + const starts = active.events.filter((e) => e.kind === 'start'); + expect(starts.map((e) => e.ctx.fieldName)).to.deep.equal([ + 'first', + 'second', + ]); + }); + + it('does nothing when no subscribers are attached', () => { + const result = execute({ + schema, + document: parse('{ sync }'), + rootValue, + }); + if (isPromise(result)) { + throw new Error('expected sync'); + } + }); +}); From fad92a67bd0f3858b7f0fed4c831c98caa4a6d98 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sat, 25 Apr 2026 23:30:26 +0300 Subject: [PATCH 29/55] use per-test rootValue in execution tracing tests --- src/execution/__tests__/tracing-test.ts | 105 +++++++++++++----------- 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/src/execution/__tests__/tracing-test.ts b/src/execution/__tests__/tracing-test.ts index 06a8cb8d47..574c855864 100644 --- a/src/execution/__tests__/tracing-test.ts +++ b/src/execution/__tests__/tracing-test.ts @@ -39,6 +39,11 @@ const schema = buildSchema(` leaf: String } + type Mutation { + first: String + second: String + } + type Subscription { tick: String } @@ -48,11 +53,6 @@ describe('execute diagnostics channel', () => { let active: ReturnType | undefined; const executeChannel = getTracingChannel('graphql:execute'); - const rootValue = { - sync: () => 'hello', - async: () => Promise.resolve('hello-async'), - }; - afterEach(() => { active?.unsubscribe(); active = undefined; @@ -62,7 +62,11 @@ describe('execute diagnostics channel', () => { active = collectEvents(executeChannel); const document = parse('query Q { sync }'); - const result = execute({ schema, document, rootValue }); + const result = execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }); expect(result).to.deep.equal({ data: { sync: 'hello' } }); expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); @@ -76,7 +80,11 @@ describe('execute diagnostics channel', () => { active = collectEvents(executeChannel); const document = parse('query { async }'); - const result = await execute({ schema, document, rootValue }); + const result = await execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }); expect(result).to.deep.equal({ data: { async: 'hello-async' } }); expect(active.events.map((e) => e.kind)).to.deep.equal([ @@ -91,7 +99,7 @@ describe('execute diagnostics channel', () => { active = collectEvents(executeChannel); const document = parse('{ sync }'); - executeSync({ schema, document, rootValue }); + executeSync({ schema, document, rootValue: { sync: () => 'hello' } }); expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); }); @@ -101,7 +109,11 @@ describe('execute diagnostics channel', () => { const document = parse('query Q { sync }'); // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeIgnoringIncremental({ schema, document, rootValue }); + executeIgnoringIncremental({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }); expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); expect(active.events[0].ctx.operationName).to.equal('Q'); @@ -115,9 +127,7 @@ describe('execute diagnostics channel', () => { type Query { sync: String } `); const document = parse('{ sync }'); - expect(() => - execute({ schema: schemaWithDefer, document, rootValue }), - ).to.throw(); + expect(() => execute({ schema: schemaWithDefer, document })).to.throw(); expect(active.events.map((e) => e.kind)).to.deep.equal([ 'start', @@ -165,7 +175,11 @@ describe('execute diagnostics channel', () => { it('does nothing when no subscribers are attached', () => { const document = parse('{ sync }'); - const result = execute({ schema, document, rootValue }); + const result = execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }); expect(result).to.deep.equal({ data: { sync: 'hello' } }); }); }); @@ -238,7 +252,7 @@ describe('subscribe diagnostics channel', () => { const document = parse('fragment F on Subscription { tick }'); // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscribe({ schema, document, rootValue: { tick: twoTicks } }); + subscribe({ schema, document }); expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); }); @@ -262,18 +276,6 @@ describe('resolve diagnostics channel', () => { let active: ReturnType | undefined; const resolveChannel = getTracingChannel('graphql:resolve'); - const rootValue = { - sync: () => 'hello', - async: () => Promise.resolve('hello-async'), - fail: () => { - throw new Error('boom'); - }, - asyncFail: () => Promise.reject(new Error('async-boom')), - // no `plain` resolver, default property-access is used. - plain: 'plain-value', - nested: { leaf: 'leaf-value' }, - }; - afterEach(() => { active?.unsubscribe(); active = undefined; @@ -285,7 +287,7 @@ describe('resolve diagnostics channel', () => { const result = execute({ schema, document: parse('{ sync }'), - rootValue, + rootValue: { sync: () => 'hello' }, }); if (isPromise(result)) { throw new Error('expected sync'); @@ -308,7 +310,7 @@ describe('resolve diagnostics channel', () => { const result = execute({ schema, document: parse('{ async }'), - rootValue, + rootValue: { async: () => Promise.resolve('hello-async') }, }); await result; @@ -320,7 +322,15 @@ describe('resolve diagnostics channel', () => { active = collectEvents(resolveChannel); // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ schema, document: parse('{ fail }'), rootValue }); + execute({ + schema, + document: parse('{ fail }'), + rootValue: { + fail: () => { + throw new Error('boom'); + }, + }, + }); const kinds = active.events.map((e) => e.kind); expect(kinds).to.deep.equal(['start', 'error', 'end']); @@ -332,7 +342,9 @@ describe('resolve diagnostics channel', () => { await execute({ schema, document: parse('{ asyncFail }'), - rootValue, + rootValue: { + asyncFail: () => Promise.reject(new Error('async-boom')), + }, }); const kinds = active.events.map((e) => e.kind); @@ -387,7 +399,9 @@ describe('resolve diagnostics channel', () => { execute({ schema, document: parse('{ nested { leaf } }'), - rootValue, + rootValue: { + nested: { leaf: 'leaf-value' }, + }, }); const starts = active.events.filter((e) => e.kind === 'start'); @@ -402,7 +416,12 @@ describe('resolve diagnostics channel', () => { execute({ schema, document: parse('{ sync plain nested { leaf } }'), - rootValue, + rootValue: { + sync: () => 'hello', + // no `plain` resolver, default property-access is used. + plain: 'plain-value', + nested: { leaf: 'leaf-value' }, + }, }); const starts = active.events.filter((e) => e.kind === 'start'); @@ -412,25 +431,15 @@ describe('resolve diagnostics channel', () => { }); it('emits per-field for serial mutation execution', async () => { - const mutationSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { dummy: { type: GraphQLString } }, - }), - mutation: new GraphQLObjectType({ - name: 'Mutation', - fields: { - first: { type: GraphQLString, resolve: () => 'one' }, - second: { type: GraphQLString, resolve: () => 'two' }, - }, - }), - }); - active = collectEvents(resolveChannel); await execute({ - schema: mutationSchema, + schema, document: parse('mutation M { first second }'), + rootValue: { + first: () => 'one', + second: () => 'two', + }, }); const starts = active.events.filter((e) => e.kind === 'start'); @@ -444,7 +453,7 @@ describe('resolve diagnostics channel', () => { const result = execute({ schema, document: parse('{ sync }'), - rootValue, + rootValue: { sync: () => 'hello' }, }); if (isPromise(result)) { throw new Error('expected sync'); From ce0d1f038cee9cfdf200f81669e9ed56ec63195a Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 06:08:57 +0300 Subject: [PATCH 30/55] test no tracing activity --- src/__testUtils__/diagnosticsTestUtils.ts | 67 +++++++++++++++++++++++ src/execution/__tests__/tracing-test.ts | 52 ++++++++++-------- src/language/__tests__/tracing-test.ts | 7 ++- src/validation/__tests__/tracing-test.ts | 7 ++- 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/__testUtils__/diagnosticsTestUtils.ts b/src/__testUtils__/diagnosticsTestUtils.ts index e7b92edab9..2a15800929 100644 --- a/src/__testUtils__/diagnosticsTestUtils.ts +++ b/src/__testUtils__/diagnosticsTestUtils.ts @@ -1,6 +1,8 @@ /* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ import dc from 'node:diagnostics_channel'; +import { expect } from 'chai'; + import type { MinimalTracingChannel } from '../diagnostics.js'; export interface CollectedEvent { @@ -52,3 +54,68 @@ export function collectEvents(channel: MinimalTracingChannel): { export function getTracingChannel(name: string): MinimalTracingChannel { return dc.tracingChannel(name) as unknown as MinimalTracingChannel; } + +/** + * Assert that a graphql tracing channel stays on its zero-subscriber fast path. + * The test installs wrappers around the real tracing methods and verifies none + * of them were touched while `fn` ran. + */ +export async function expectNoTracingActivity( + channel: MinimalTracingChannel, + fn: () => T | Promise, +): Promise> { + expect(channel.hasSubscribers).to.equal(false); + expect(channel.start.hasSubscribers).to.equal(false); + expect(channel.end.hasSubscribers).to.equal(false); + expect(channel.asyncStart.hasSubscribers).to.equal(false); + expect(channel.asyncEnd.hasSubscribers).to.equal(false); + expect(channel.error.hasSubscribers).to.equal(false); + + const calls: Array = []; + const restore: Array<() => void> = []; + + function interceptMethod( + target: { [key: string]: unknown }, + key: string, + name: string, + ): void { + const original = target[key] as (...args: Array) => unknown; + target[key] = function interceptedMethod( + this: unknown, + ...args: Array + ) { + calls.push(name); + return original.apply(this, args); + }; + restore.push(() => { + target[key] = original; + }); + } + + interceptMethod( + channel as unknown as { [key: string]: unknown }, + 'traceSync', + 'traceSync', + ); + + for (const phase of ['start', 'end', 'asyncStart', 'asyncEnd', 'error']) { + const subChannel = channel[ + phase as keyof Pick< + MinimalTracingChannel, + 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error' + > + ] as unknown as { [key: string]: unknown }; + interceptMethod(subChannel, 'publish', `${phase}.publish`); + interceptMethod(subChannel, 'runStores', `${phase}.runStores`); + } + + try { + const result = await fn(); + expect(calls).to.deep.equal([]); + return result; + } finally { + while (restore.length > 0) { + restore.pop()?.(); + } + } +} diff --git a/src/execution/__tests__/tracing-test.ts b/src/execution/__tests__/tracing-test.ts index 574c855864..926bace605 100644 --- a/src/execution/__tests__/tracing-test.ts +++ b/src/execution/__tests__/tracing-test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, it } from 'mocha'; import { collectEvents, + expectNoTracingActivity, getTracingChannel, } from '../../__testUtils__/diagnosticsTestUtils.js'; @@ -173,13 +174,15 @@ describe('execute diagnostics channel', () => { } }); - it('does nothing when no subscribers are attached', () => { + it('does not call tracing methods when no subscribers are attached', async () => { const document = parse('{ sync }'); - const result = execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }); + const result = await expectNoTracingActivity(executeChannel, () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + ); expect(result).to.deep.equal({ data: { sync: 'hello' } }); }); }); @@ -257,18 +260,19 @@ describe('subscribe diagnostics channel', () => { expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); }); - it('does nothing when no subscribers are attached', async () => { + it('does not call tracing methods when no subscribers are attached', async () => { const document = parse('subscription { tick }'); - const result = subscribe({ - schema, - document, - rootValue: { tick: twoTicks }, - }); - const resolved = isPromise(result) ? await result : result; - if (isAsyncIterable(resolved)) { + await expectNoTracingActivity(subscribeChannel, async () => { + const result = subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + }); + const resolved = isPromise(result) ? await result : result; + assert(isAsyncIterable(resolved)); await resolved.return?.(); - } + }); }); }); @@ -449,14 +453,14 @@ describe('resolve diagnostics channel', () => { ]); }); - it('does nothing when no subscribers are attached', () => { - const result = execute({ - schema, - document: parse('{ sync }'), - rootValue: { sync: () => 'hello' }, - }); - if (isPromise(result)) { - throw new Error('expected sync'); - } + it('does not call tracing methods when no subscribers are attached', async () => { + const result = await expectNoTracingActivity(resolveChannel, () => + execute({ + schema, + document: parse('{ sync }'), + rootValue: { sync: () => 'hello' }, + }), + ); + expect(result).to.deep.equal({ data: { sync: 'hello' } }); }); }); diff --git a/src/language/__tests__/tracing-test.ts b/src/language/__tests__/tracing-test.ts index 49535dacc7..4882c6b6d2 100644 --- a/src/language/__tests__/tracing-test.ts +++ b/src/language/__tests__/tracing-test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, it } from 'mocha'; import { collectEvents, + expectNoTracingActivity, getTracingChannel, } from '../../__testUtils__/diagnosticsTestUtils.js'; @@ -39,8 +40,10 @@ describe('parse diagnostics channel', () => { expect(active.events[1].ctx.error).to.be.instanceOf(Error); }); - it('does nothing when no subscribers are attached', () => { - const doc = parse('{ field }'); + it('does not call tracing methods when no subscribers are attached', async () => { + const doc = await expectNoTracingActivity(parseChannel, () => + parse('{ field }'), + ); expect(doc.kind).to.equal('Document'); }); }); diff --git a/src/validation/__tests__/tracing-test.ts b/src/validation/__tests__/tracing-test.ts index 85c308ab6a..913b74eb03 100644 --- a/src/validation/__tests__/tracing-test.ts +++ b/src/validation/__tests__/tracing-test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, it } from 'mocha'; import { collectEvents, + expectNoTracingActivity, getTracingChannel, } from '../../__testUtils__/diagnosticsTestUtils.js'; @@ -64,8 +65,10 @@ describe('validate diagnostics channel', () => { expect(active.events[1].ctx.error).to.be.instanceOf(Error); }); - it('does nothing when no subscribers are attached', () => { - const errors = validate(schema, parse('{ field }')); + it('does not call tracing methods when no subscribers are attached', async () => { + const errors = await expectNoTracingActivity(validateChannel, () => + validate(schema, parse('{ field }')), + ); expect(errors).to.deep.equal([]); }); }); From 238608d5841cee57128a750650278840cdf8639c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 14:15:46 +0300 Subject: [PATCH 31/55] add c8 ignore directive --- src/__testUtils__/diagnosticsTestUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__testUtils__/diagnosticsTestUtils.ts b/src/__testUtils__/diagnosticsTestUtils.ts index 2a15800929..df140cac45 100644 --- a/src/__testUtils__/diagnosticsTestUtils.ts +++ b/src/__testUtils__/diagnosticsTestUtils.ts @@ -80,6 +80,7 @@ export async function expectNoTracingActivity( name: string, ): void { const original = target[key] as (...args: Array) => unknown; + /* c8 ignore next 7 */ target[key] = function interceptedMethod( this: unknown, ...args: Array From 3389f51cd58e1519ffac28a0ed3c3bf36efc88ce Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 14:13:24 +0300 Subject: [PATCH 32/55] move tracing back to executeField --- src/diagnostics.ts | 9 ------- src/execution/Executor.ts | 53 ++++++++++---------------------------- src/language/parser.ts | 4 +-- src/validation/validate.ts | 4 +-- 4 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index b9516eedba..1e8fee36d9 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -174,12 +174,3 @@ export function traceMixed( }); }); } - -/** - * Check if a channel is defined and has subscribers. - */ -export function shouldTrace( - channel: MinimalTracingChannel | undefined, -): channel is MinimalTracingChannel { - return channel?.hasSubscribers === true; -} diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 0614c10203..acd340199b 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,8 +45,7 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import type { MinimalTracingChannel } from '../diagnostics.ts'; -import { resolveChannel, shouldTrace, traceMixed } from '../diagnostics.ts'; +import { resolveChannel, traceMixed } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; @@ -470,9 +469,6 @@ export class Executor< groupedFieldSet: GroupedFieldSet, positionContext: TPositionContext | undefined, ): PromiseOrValue> { - const tracingChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; return promiseReduce( groupedFieldSet, (results, [responseName, fieldDetailsList]) => { @@ -486,7 +482,6 @@ export class Executor< fieldDetailsList, fieldPath, positionContext, - tracingChannel, ); if (result === undefined) { return results; @@ -517,9 +512,6 @@ export class Executor< ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; - const tracingChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { @@ -530,7 +522,6 @@ export class Executor< fieldDetailsList, fieldPath, positionContext, - tracingChannel, ); if (result !== undefined) { @@ -572,7 +563,6 @@ export class Executor< fieldDetailsList: FieldDetailsList, path: Path, positionContext: TPositionContext | undefined, - tracingChannel: MinimalTracingChannel | undefined, ): PromiseOrValue { const validatedExecutionArgs = this.validatedExecutionArgs; const { schema, contextValue, variableValues, hideSuggestions } = @@ -586,7 +576,18 @@ export class Executor< } const returnType = fieldDef.type; - const resolveFn = fieldDef.resolve ?? validatedExecutionArgs.fieldResolver; + let resolveFn = fieldDef.resolve ?? validatedExecutionArgs.fieldResolver; + + if (resolveChannel?.hasSubscribers) { + const channel = resolveChannel; + const originalResolveFn = resolveFn; + resolveFn = (s, args, c, info) => + traceMixed( + channel, + this.buildResolveCtx(args, info, fieldDef.resolve === undefined), + () => originalResolveFn(s, args, c, info), + ); + } const info = buildResolveInfo( validatedExecutionArgs, @@ -614,17 +615,7 @@ export class Executor< // The resolve function's optional third argument is a context value that // is provided to every resolve function within an execution. It is commonly // used to represent an authenticated user, or request-specific caches. - const result = tracingChannel - ? this.invokeResolverWithTracing( - tracingChannel, - resolveFn, - source, - args, - contextValue, - info, - fieldDef.resolve === undefined, - ) - : resolveFn(source, args, contextValue, info); + const result = resolveFn(source, args, contextValue, info); if (isPromiseLike(result)) { return this.completePromisedValue( @@ -661,22 +652,6 @@ export class Executor< } } - invokeResolverWithTracing( - tracingChannel: MinimalTracingChannel, - resolveFn: GraphQLFieldResolver, - source: unknown, - args: { readonly [argument: string]: unknown }, - contextValue: unknown, - info: GraphQLResolveInfo, - isTrivialResolver: boolean, - ): PromiseOrValue { - return traceMixed( - tracingChannel, - this.buildResolveCtx(args, info, isTrivialResolver), - () => resolveFn(source, args, contextValue, info), - ); - } - /** * Build a graphql:resolve channel context for a single field invocation. * diff --git a/src/language/parser.ts b/src/language/parser.ts index 47e40eb1e7..f220c95d85 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,7 +3,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; -import { parseChannel, shouldTrace } from '../diagnostics.js'; +import { parseChannel } from '../diagnostics.js'; import type { ArgumentCoordinateNode, @@ -147,7 +147,7 @@ export function parse( source: string | Source, options?: ParseOptions, ): DocumentNode { - return shouldTrace(parseChannel) + return parseChannel?.hasSubscribers ? parseChannel.traceSync(() => parseImpl(source, options), { source }) : parseImpl(source, options); } diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 30a7ff12fa..87024d977a 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -12,7 +12,7 @@ import { assertValidSchema } from '../type/validate.ts'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo.ts'; -import { shouldTrace, validateChannel } from '../diagnostics.ts'; +import { validateChannel } from '../diagnostics.ts'; import { specifiedRules, specifiedSDLRules } from './specifiedRules.ts'; import type { SDLValidationRule, ValidationRule } from './ValidationContext.ts'; @@ -63,7 +63,7 @@ export function validate( rules: ReadonlyArray = specifiedRules, options?: ValidationOptions, ): ReadonlyArray { - return shouldTrace(validateChannel) + return validateChannel?.hasSubscribers ? validateChannel.traceSync( () => validateImpl(schema, documentAST, rules, options), { schema, document: documentAST }, From a2cb53e9e60f86cb246433363be6dfc9572b4b99 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 16:29:04 +0300 Subject: [PATCH 33/55] add globalThis to protect against potentially missing process symbol --- src/diagnostics.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 1e8fee36d9..fa12897cfa 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -75,16 +75,19 @@ function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { let dc: DiagnosticsChannelModule | undefined; try { if ( - typeof process !== 'undefined' && - typeof (process as { getBuiltinModule?: (id: string) => unknown }) - .getBuiltinModule === 'function' - ) { - dc = ( - process as { - getBuiltinModule: (id: string) => DiagnosticsChannelModule; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + typeof ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => unknown }; } - ).getBuiltinModule('node:diagnostics_channel'); + )?.process?.getBuiltinModule === 'function' + ) { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + dc = globalThis.process.getBuiltinModule( + 'node:diagnostics_channel', + ) as DiagnosticsChannelModule; } + // TODO: remove this code when we drop support for Node < 20.16>. /* c8 ignore next 6 */ if (!dc && typeof require === 'function') { // CJS fallback for runtimes that lack `process.getBuiltinModule` From 2fd340e1c153ee7aa2e590cacbe01c3f818ee328 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 17:15:21 +0300 Subject: [PATCH 34/55] rename test files (yet again!) --- src/execution/__tests__/{tracing-test.ts => diagnostics-test.ts} | 0 src/language/__tests__/{tracing-test.ts => diagnostics-test.ts} | 0 src/validation/__tests__/{tracing-test.ts => diagnostics-test.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/execution/__tests__/{tracing-test.ts => diagnostics-test.ts} (100%) rename src/language/__tests__/{tracing-test.ts => diagnostics-test.ts} (100%) rename src/validation/__tests__/{tracing-test.ts => diagnostics-test.ts} (100%) diff --git a/src/execution/__tests__/tracing-test.ts b/src/execution/__tests__/diagnostics-test.ts similarity index 100% rename from src/execution/__tests__/tracing-test.ts rename to src/execution/__tests__/diagnostics-test.ts diff --git a/src/language/__tests__/tracing-test.ts b/src/language/__tests__/diagnostics-test.ts similarity index 100% rename from src/language/__tests__/tracing-test.ts rename to src/language/__tests__/diagnostics-test.ts diff --git a/src/validation/__tests__/tracing-test.ts b/src/validation/__tests__/diagnostics-test.ts similarity index 100% rename from src/validation/__tests__/tracing-test.ts rename to src/validation/__tests__/diagnostics-test.ts From f45427b3b792493ef3ad746a93714a6333983531 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 17:16:28 +0300 Subject: [PATCH 35/55] prune required MinimalChannelInterface --- src/__testUtils__/diagnosticsTestUtils.ts | 5 ----- src/diagnostics.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/src/__testUtils__/diagnosticsTestUtils.ts b/src/__testUtils__/diagnosticsTestUtils.ts index df140cac45..b794c16379 100644 --- a/src/__testUtils__/diagnosticsTestUtils.ts +++ b/src/__testUtils__/diagnosticsTestUtils.ts @@ -65,11 +65,6 @@ export async function expectNoTracingActivity( fn: () => T | Promise, ): Promise> { expect(channel.hasSubscribers).to.equal(false); - expect(channel.start.hasSubscribers).to.equal(false); - expect(channel.end.hasSubscribers).to.equal(false); - expect(channel.asyncStart.hasSubscribers).to.equal(false); - expect(channel.asyncEnd.hasSubscribers).to.equal(false); - expect(channel.error.hasSubscribers).to.equal(false); const calls: Array = []; const restore: Array<() => void> = []; diff --git a/src/diagnostics.ts b/src/diagnostics.ts index fa12897cfa..3605b27637 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -20,7 +20,6 @@ import { isPromise } from './jsutils/isPromise.js'; * @internal */ export interface MinimalChannel { - readonly hasSubscribers: boolean; publish: (message: unknown) => void; runStores: ( context: ContextType, From dd4b56aa48b7d20d5b8bbac08874dfe3d1c0d16e Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 18:03:43 +0300 Subject: [PATCH 36/55] move function helpers to follow their use --- src/execution/execute.ts | 69 +++++++++++++++------------------------- 1 file changed, 25 insertions(+), 44 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index dd44251d11..c1c7b438d6 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -62,6 +62,31 @@ export type RootSelectionSetExecutor = ( validatedExecutionArgs: ValidatedSubscriptionArgs, ) => PromiseOrValue; +/** + * Implements the "Executing requests" section of the GraphQL specification. + * + * Returns either a synchronous ExecutionResult (if all encountered resolvers + * are synchronous), or a Promise of an ExecutionResult that will eventually be + * resolved and never rejected. + * + * If the arguments to this function do not result in a legal execution context, + * a GraphQLError will be thrown immediately explaining the invalid input. + * + * This function does not support incremental delivery (`@defer` and `@stream`). + * If an operation which would defer or stream data is executed with this + * function, it will throw or return a rejected promise. + * Use `experimentalExecuteIncrementally` if you want to support incremental + * delivery. + */ +export function execute(args: ExecutionArgs): PromiseOrValue { + if (!executeChannel?.hasSubscribers) { + return executeImpl(args); + } + return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + executeImpl(args), + ); +} + /** * Build a graphql:execute channel context from raw ExecutionArgs. Defers * resolution of the operation AST to a lazy getter so the cost of walking @@ -88,50 +113,6 @@ function buildExecuteCtxFromArgs(args: ExecutionArgs): object { }; } -/** - * Build a graphql:execute channel context from ValidatedExecutionArgs. - * Used by executeSubscriptionEvent, where the operation has already been - * resolved during argument validation. The original document is not - * available at this point, only the resolved operation; subscribers that - * need the document should read it from the graphql:subscribe context. - */ -function buildExecuteCtxFromValidatedArgs( - args: ValidatedExecutionArgs, -): object { - return { - operation: args.operation, - schema: args.schema, - variableValues: args.variableValues, - operationName: args.operation.name?.value, - operationType: args.operation.operation, - }; -} - -/** - * Implements the "Executing requests" section of the GraphQL specification. - * - * Returns either a synchronous ExecutionResult (if all encountered resolvers - * are synchronous), or a Promise of an ExecutionResult that will eventually be - * resolved and never rejected. - * - * If the arguments to this function do not result in a legal execution context, - * a GraphQLError will be thrown immediately explaining the invalid input. - * - * This function does not support incremental delivery (`@defer` and `@stream`). - * If an operation which would defer or stream data is executed with this - * function, it will throw or return a rejected promise. - * Use `experimentalExecuteIncrementally` if you want to support incremental - * delivery. - */ -export function execute(args: ExecutionArgs): PromiseOrValue { - if (!executeChannel?.hasSubscribers) { - return executeImpl(args); - } - return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => - executeImpl(args), - ); -} - function executeImpl(args: ExecutionArgs): PromiseOrValue { if (args.schema.getDirective('defer') || args.schema.getDirective('stream')) { throw new Error(UNEXPECTED_EXPERIMENTAL_DIRECTIVES); From 36eb11f961baadb22d1e6d2e62943db6372f58b0 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 22:36:24 +0300 Subject: [PATCH 37/55] revamp diagnostics tests with full subscription data --- .../__tests__/catchThrownError-test.ts | 22 + .../__tests__/expectEvents-test.ts | 128 ++ .../__tests__/expectNoTracingActivity-test.ts | 78 + .../__tests__/interceptMethod-test.ts | 52 + src/__testUtils__/catchThrownError.ts | 9 + src/__testUtils__/diagnosticsTestUtils.ts | 117 -- src/__testUtils__/diagnosticsTracing.ts | 28 + src/__testUtils__/expectEvents.ts | 53 + src/__testUtils__/expectNoTracingActivity.ts | 78 + src/__testUtils__/getTracingChannel.ts | 13 + src/__testUtils__/interceptMethod.ts | 19 + src/execution/__tests__/diagnostics-test.ts | 1415 +++++++++++++---- src/language/__tests__/diagnostics-test.ts | 68 +- src/validation/__tests__/diagnostics-test.ts | 97 +- 14 files changed, 1700 insertions(+), 477 deletions(-) create mode 100644 src/__testUtils__/__tests__/catchThrownError-test.ts create mode 100644 src/__testUtils__/__tests__/expectEvents-test.ts create mode 100644 src/__testUtils__/__tests__/expectNoTracingActivity-test.ts create mode 100644 src/__testUtils__/__tests__/interceptMethod-test.ts create mode 100644 src/__testUtils__/catchThrownError.ts delete mode 100644 src/__testUtils__/diagnosticsTestUtils.ts create mode 100644 src/__testUtils__/diagnosticsTracing.ts create mode 100644 src/__testUtils__/expectEvents.ts create mode 100644 src/__testUtils__/expectNoTracingActivity.ts create mode 100644 src/__testUtils__/getTracingChannel.ts create mode 100644 src/__testUtils__/interceptMethod.ts diff --git a/src/__testUtils__/__tests__/catchThrownError-test.ts b/src/__testUtils__/__tests__/catchThrownError-test.ts new file mode 100644 index 0000000000..1ca238ba32 --- /dev/null +++ b/src/__testUtils__/__tests__/catchThrownError-test.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { catchThrownError } from '../catchThrownError.js'; + +describe('catchThrownError', () => { + it('returns the thrown value', () => { + const error = new Error('boom'); + + expect( + catchThrownError(() => { + throw error; + }), + ).to.equal(error); + }); + + it('throws when the function does not throw', () => { + expect(() => catchThrownError(() => undefined)).to.throw( + 'Expected function to throw.', + ); + }); +}); diff --git a/src/__testUtils__/__tests__/expectEvents-test.ts b/src/__testUtils__/__tests__/expectEvents-test.ts new file mode 100644 index 0000000000..0ec586ccd4 --- /dev/null +++ b/src/__testUtils__/__tests__/expectEvents-test.ts @@ -0,0 +1,128 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectEvents } from '../expectEvents.js'; +import { expectPromise } from '../expectPromise.js'; + +type TestTracingChannel = Parameters[0]; + +function createFakeTracingChannel(): TestTracingChannel { + let handler: + | { + start: (context: unknown) => void; + end: (context: unknown) => void; + asyncStart: (context: unknown) => void; + asyncEnd: (context: unknown) => void; + error: (context: unknown) => void; + } + | undefined; + + function runStores( + context: object, + fn: (this: object, ...args: Array) => T, + thisArg?: unknown, + ...args: Array + ): T { + return fn.apply((thisArg as object | undefined) ?? context, args); + } + + return { + hasSubscribers: false, + subscribe(nextHandler) { + handler = nextHandler; + }, + unsubscribe(nextHandler) { + expect(handler).to.equal(nextHandler); + handler = undefined; + }, + traceSync(fn, _context, thisArg, ...args) { + return fn.apply(thisArg, args); + }, + start: { + publish(context) { + handler?.start(context); + }, + runStores, + }, + end: { + publish(context) { + handler?.end(context); + }, + runStores, + }, + asyncStart: { + publish(context) { + handler?.asyncStart(context); + }, + runStores, + }, + asyncEnd: { + publish(context) { + handler?.asyncEnd(context); + }, + runStores, + }, + error: { + publish(context) { + handler?.error(context); + }, + runStores, + }, + }; +} + +describe('expectEvents', () => { + it('collects events and snapshots each published context', async () => { + const channel = createFakeTracingChannel(); + const context = { value: 1 }; + + await expectEvents( + channel, + () => { + channel.start.publish(context); + context.value = 2; + channel.end.publish(context); + return 'done'; + }, + (_result) => [ + { + channel: 'start', + context: { value: 1 }, + }, + { + channel: 'end', + context: { value: 2 }, + }, + ], + ); + }); + + it('unsubscribes when the callback rejects', async () => { + let activeHandler: object | undefined; + const error = new Error('boom'); + const channel = createFakeTracingChannel(); + const originalSubscribe = channel.subscribe; + const originalUnsubscribe = channel.unsubscribe; + + channel.subscribe = (handler) => { + activeHandler = handler; + originalSubscribe.call(channel, handler); + }; + channel.unsubscribe = (handler) => { + expect(handler).to.equal(activeHandler); + activeHandler = undefined; + originalUnsubscribe.call(channel, handler); + }; + + expect( + await expectPromise( + expectEvents( + channel, + () => Promise.reject(error), + () => [], + ), + ).toReject(), + ).to.equal(error); + expect(activeHandler).to.equal(undefined); + }); +}); diff --git a/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts new file mode 100644 index 0000000000..883b4d2fc9 --- /dev/null +++ b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectNoTracingActivity } from '../expectNoTracingActivity.js'; +import { expectPromise } from '../expectPromise.js'; + +type TestTracingChannel = Parameters[0]; + +function createFakeTracingChannel(): TestTracingChannel { + function runStores( + context: object, + fn: (this: object, ...args: Array) => T, + thisArg?: unknown, + ...args: Array + ): T { + return fn.apply((thisArg as object | undefined) ?? context, args); + } + + return { + hasSubscribers: false, + traceSync(fn, context, thisArg, ...args) { + return runStores(context, fn, thisArg, ...args); + }, + start: { + publish(_context) { + return undefined; + }, + runStores, + }, + end: { + publish(_context) { + return undefined; + }, + runStores, + }, + asyncStart: { + publish(_context) { + return undefined; + }, + runStores, + }, + asyncEnd: { + publish(_context) { + return undefined; + }, + runStores, + }, + error: { + publish(_context) { + return undefined; + }, + runStores, + }, + }; +} + +describe('expectNoTracingActivity', () => { + it('returns the callback result when no tracing methods are touched', async () => { + const channel = createFakeTracingChannel(); + + expect( + await expectNoTracingActivity(channel, () => ({ value: 'ok' })), + ).to.deep.equal({ value: 'ok' }); + }); + + it('fails and restores methods when tracing activity occurs', async () => { + const channel = createFakeTracingChannel(); + const originalPublish = channel.start.publish; + + await expectPromise( + expectNoTracingActivity(channel, () => { + channel.start.publish({ value: 1 }); + }), + ).toRejectWith("expected [ 'start.publish' ] to deeply equal []"); + + expect(channel.start.publish).to.equal(originalPublish); + }); +}); diff --git a/src/__testUtils__/__tests__/interceptMethod-test.ts b/src/__testUtils__/__tests__/interceptMethod-test.ts new file mode 100644 index 0000000000..d9dc9f070f --- /dev/null +++ b/src/__testUtils__/__tests__/interceptMethod-test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { interceptMethod } from '../interceptMethod.js'; + +describe('interceptMethod', () => { + it('wraps a method and preserves this binding', () => { + const calls: Array = []; + const target = { + value: 3, + add(delta: number): number { + return this.value + delta; + }, + }; + + const restore = interceptMethod( + target, + 'add', + (original) => + function interceptedAdd(this: unknown, ...args: Array) { + const [delta] = args as [number]; + calls.push(delta); + return original.call(this, delta * 2); + }, + ); + + expect(target.add(4)).to.equal(11); + expect(calls).to.deep.equal([4]); + + restore(); + + expect(target.add(4)).to.equal(7); + }); + + it('restores the original method', () => { + const target = { + value(): string { + return 'original'; + }, + }; + const original = target.value; + + const restore = interceptMethod(target, 'value', () => () => 'wrapped'); + + expect(target.value()).to.equal('wrapped'); + + restore(); + + expect(target.value).to.equal(original); + expect(target.value()).to.equal('original'); + }); +}); diff --git a/src/__testUtils__/catchThrownError.ts b/src/__testUtils__/catchThrownError.ts new file mode 100644 index 0000000000..e1c2ea9830 --- /dev/null +++ b/src/__testUtils__/catchThrownError.ts @@ -0,0 +1,9 @@ +export function catchThrownError(fn: () => unknown): unknown { + try { + fn(); + } catch (error) { + return error; + } + + throw new Error('Expected function to throw.'); +} diff --git a/src/__testUtils__/diagnosticsTestUtils.ts b/src/__testUtils__/diagnosticsTestUtils.ts deleted file mode 100644 index b794c16379..0000000000 --- a/src/__testUtils__/diagnosticsTestUtils.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ -import dc from 'node:diagnostics_channel'; - -import { expect } from 'chai'; - -import type { MinimalTracingChannel } from '../diagnostics.js'; - -export interface CollectedEvent { - kind: 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error'; - ctx: { [key: string]: unknown }; -} - -/** - * Subscribe to every lifecycle sub-channel on a TracingChannel and collect - * events in order. Returns the event buffer plus an unsubscribe hook. - */ -export function collectEvents(channel: MinimalTracingChannel): { - events: Array; - unsubscribe: () => void; -} { - const events: Array = []; - const handler = { - start: (ctx: unknown) => - events.push({ kind: 'start', ctx: ctx as { [key: string]: unknown } }), - end: (ctx: unknown) => - events.push({ kind: 'end', ctx: ctx as { [key: string]: unknown } }), - asyncStart: (ctx: unknown) => - events.push({ - kind: 'asyncStart', - ctx: ctx as { [key: string]: unknown }, - }), - asyncEnd: (ctx: unknown) => - events.push({ - kind: 'asyncEnd', - ctx: ctx as { [key: string]: unknown }, - }), - error: (ctx: unknown) => - events.push({ kind: 'error', ctx: ctx as { [key: string]: unknown } }), - }; - (channel as unknown as dc.TracingChannel).subscribe(handler); - return { - events, - unsubscribe() { - (channel as unknown as dc.TracingChannel).unsubscribe(handler); - }, - }; -} - -/** - * Resolve a graphql tracing channel by name on the real - * `node:diagnostics_channel`. graphql-js publishes on the same channels at - * module load. - */ -export function getTracingChannel(name: string): MinimalTracingChannel { - return dc.tracingChannel(name) as unknown as MinimalTracingChannel; -} - -/** - * Assert that a graphql tracing channel stays on its zero-subscriber fast path. - * The test installs wrappers around the real tracing methods and verifies none - * of them were touched while `fn` ran. - */ -export async function expectNoTracingActivity( - channel: MinimalTracingChannel, - fn: () => T | Promise, -): Promise> { - expect(channel.hasSubscribers).to.equal(false); - - const calls: Array = []; - const restore: Array<() => void> = []; - - function interceptMethod( - target: { [key: string]: unknown }, - key: string, - name: string, - ): void { - const original = target[key] as (...args: Array) => unknown; - /* c8 ignore next 7 */ - target[key] = function interceptedMethod( - this: unknown, - ...args: Array - ) { - calls.push(name); - return original.apply(this, args); - }; - restore.push(() => { - target[key] = original; - }); - } - - interceptMethod( - channel as unknown as { [key: string]: unknown }, - 'traceSync', - 'traceSync', - ); - - for (const phase of ['start', 'end', 'asyncStart', 'asyncEnd', 'error']) { - const subChannel = channel[ - phase as keyof Pick< - MinimalTracingChannel, - 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error' - > - ] as unknown as { [key: string]: unknown }; - interceptMethod(subChannel, 'publish', `${phase}.publish`); - interceptMethod(subChannel, 'runStores', `${phase}.runStores`); - } - - try { - const result = await fn(); - expect(calls).to.deep.equal([]); - return result; - } finally { - while (restore.length > 0) { - restore.pop()?.(); - } - } -} diff --git a/src/__testUtils__/diagnosticsTracing.ts b/src/__testUtils__/diagnosticsTracing.ts new file mode 100644 index 0000000000..ade53406cf --- /dev/null +++ b/src/__testUtils__/diagnosticsTracing.ts @@ -0,0 +1,28 @@ +import type { MinimalChannel, MinimalTracingChannel } from '../diagnostics.js'; + +export type TracingSubChannel = { + [Key in keyof MinimalTracingChannel]: MinimalTracingChannel[Key] extends MinimalChannel + ? Key + : never; +}[keyof MinimalTracingChannel]; + +export type TracingSubChannelRecord = { + [Channel in TracingSubChannel]: TValue; +}; + +export type TracingSubscriptionHandler = TracingSubChannelRecord< + (context: unknown) => void +>; + +export type TestTracingChannel = MinimalTracingChannel & { + subscribe: (handler: TracingSubscriptionHandler) => void; + unsubscribe: (handler: TracingSubscriptionHandler) => void; +}; + +export const tracingSubChannels: ReadonlyArray = [ + 'start', + 'end', + 'asyncStart', + 'asyncEnd', + 'error', +]; diff --git a/src/__testUtils__/expectEvents.ts b/src/__testUtils__/expectEvents.ts new file mode 100644 index 0000000000..552a98547d --- /dev/null +++ b/src/__testUtils__/expectEvents.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; + +import type { MinimalTracingChannel } from '../diagnostics.js'; + +import type { + TestTracingChannel, + TracingSubChannel, + TracingSubChannelRecord, +} from './diagnosticsTracing.js'; +import { tracingSubChannels } from './diagnosticsTracing.js'; + +export type CollectedEvent = { + [Channel in TracingSubChannel]: { + channel: Channel; + context: Parameters[0]; + }; +}[TracingSubChannel]; + +type ExpectedEventsFactory = ( + result: Awaited, +) => ReadonlyArray; + +/** + * Collect graphql tracing events while `fn` runs, build the expected event + * list from the callback result, and always unsubscribe before returning. + */ +export async function expectEvents( + channel: TestTracingChannel, + fn: () => TResult, + getExpectedEvents: ExpectedEventsFactory, +): Promise { + const events: Array = []; + const handler = {} as TracingSubChannelRecord<(context: unknown) => void>; + + for (const tracingSubChannel of tracingSubChannels) { + handler[tracingSubChannel] = (context: unknown) => { + const snapshot = + typeof context === 'object' && context !== null + ? { ...context } + : context; + events.push({ channel: tracingSubChannel, context: snapshot }); + }; + } + + channel.subscribe(handler); + + try { + const resolvedResult = await fn(); + expect(events).to.deep.equal(getExpectedEvents(resolvedResult)); + } finally { + channel.unsubscribe(handler); + } +} diff --git a/src/__testUtils__/expectNoTracingActivity.ts b/src/__testUtils__/expectNoTracingActivity.ts new file mode 100644 index 0000000000..0e0b6e982a --- /dev/null +++ b/src/__testUtils__/expectNoTracingActivity.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; + +import type { MinimalTracingChannel } from '../diagnostics.js'; + +import { tracingSubChannels } from './diagnosticsTracing.js'; +import { interceptMethod } from './interceptMethod.js'; + +/** + * Assert that a graphql tracing channel stays on its zero-subscriber fast path. + * The test installs wrappers around the real tracing methods and verifies none + * of them were touched while `fn` ran. + */ +export async function expectNoTracingActivity( + channel: MinimalTracingChannel, + fn: () => T | Promise, +): Promise> { + expect(channel.hasSubscribers).to.equal(false); + + const calls: Array = []; + const restore: Array<() => void> = []; + + restore.push( + interceptMethod( + channel, + 'traceSync', + (original) => + function interceptedTraceSync( + this: unknown, + ...args: Array + ): unknown { + calls.push('traceSync'); + return original.apply(this, args); + }, + ), + ); + + for (const phase of tracingSubChannels) { + const subChannel = channel[phase]; + restore.push( + interceptMethod( + subChannel, + 'publish', + (original) => + function interceptedPublish( + this: unknown, + ...args: Array + ): unknown { + calls.push(`${phase}.publish`); + return original.apply(this, args); + }, + ), + ); + restore.push( + interceptMethod( + subChannel, + 'runStores', + (original) => + function interceptedRunStores( + this: unknown, + ...args: Array + ): unknown { + calls.push(`${phase}.runStores`); + return original.apply(this, args); + }, + ), + ); + } + + try { + const result = await fn(); + expect(calls).to.deep.equal([]); + return result; + } finally { + while (restore.length > 0) { + restore.pop()?.(); + } + } +} diff --git a/src/__testUtils__/getTracingChannel.ts b/src/__testUtils__/getTracingChannel.ts new file mode 100644 index 0000000000..411b8a5113 --- /dev/null +++ b/src/__testUtils__/getTracingChannel.ts @@ -0,0 +1,13 @@ +/* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ +import dc from 'node:diagnostics_channel'; + +import type { TestTracingChannel } from './diagnosticsTracing.js'; + +/** + * Resolve a graphql tracing channel by name on the real + * `node:diagnostics_channel`. graphql-js publishes on the same channels at + * module load. + */ +export function getTracingChannel(name: string): TestTracingChannel { + return dc.tracingChannel(name) as TestTracingChannel; +} diff --git a/src/__testUtils__/interceptMethod.ts b/src/__testUtils__/interceptMethod.ts new file mode 100644 index 0000000000..2487086141 --- /dev/null +++ b/src/__testUtils__/interceptMethod.ts @@ -0,0 +1,19 @@ +type InterceptedMethod = (this: unknown, ...args: Array) => unknown; + +/** + * Replace one method on an object for the duration of a test and return a + * restore callback for putting the original method back. + */ +export function interceptMethod( + target: object, + key: string, + createReplacement: (original: InterceptedMethod) => InterceptedMethod, +): () => void { + const objectTarget = target as { [key: string]: unknown }; + const original = objectTarget[key] as InterceptedMethod; + objectTarget[key] = createReplacement(original); + + return () => { + objectTarget[key] = original; + }; +} diff --git a/src/execution/__tests__/diagnostics-test.ts b/src/execution/__tests__/diagnostics-test.ts index 926bace605..4a4c8d758b 100644 --- a/src/execution/__tests__/diagnostics-test.ts +++ b/src/execution/__tests__/diagnostics-test.ts @@ -1,14 +1,14 @@ import { assert, expect } from 'chai'; -import { afterEach, describe, it } from 'mocha'; +import { describe, it } from 'mocha'; -import { - collectEvents, - expectNoTracingActivity, - getTracingChannel, -} from '../../__testUtils__/diagnosticsTestUtils.js'; +import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; -import { isPromise } from '../../jsutils/isPromise.js'; import { parse } from '../../language/parser.js'; @@ -51,90 +51,316 @@ const schema = buildSchema(` `); describe('execute diagnostics channel', () => { - let active: ReturnType | undefined; const executeChannel = getTracingChannel('graphql:execute'); - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end around a synchronous execute', () => { - active = collectEvents(executeChannel); - + it('emits start and end around a synchronous execute', async () => { const document = parse('query Q { sync }'); - const result = execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }); - expect(result).to.deep.equal({ data: { sync: 'hello' } }); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.operationType).to.equal('query'); - expect(active.events[0].ctx.operationName).to.equal('Q'); - expect(active.events[0].ctx.document).to.equal(document); - expect(active.events[0].ctx.schema).to.equal(schema); + await expectEvents( + executeChannel, + () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + result, + }, + }, + ], + ); }); it('emits start, end, and async lifecycle when execute returns a promise', async () => { - active = collectEvents(executeChannel); - const document = parse('query { async }'); - const result = await execute({ - schema, - document, - rootValue: { async: () => Promise.resolve('hello-async') }, - }); - expect(result).to.deep.equal({ data: { async: 'hello-async' } }); - expect(active.events.map((e) => e.kind)).to.deep.equal([ - 'start', - 'end', - 'asyncStart', - 'asyncEnd', - ]); + await expectEvents( + executeChannel, + () => + execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + result, + }, + }, + ], + ); }); - it('emits once for executeSync via experimentalExecuteIncrementally', () => { - active = collectEvents(executeChannel); + it('emits full async lifecycle with error when execute returns a rejected promise', async () => { + const asyncDeferSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hero: { + type: new GraphQLObjectType({ + name: 'Hero', + fields: { + id: { type: GraphQLString }, + name: { type: GraphQLString }, + }, + }), + }, + }, + }), + }); + const document = parse(` + query Deferred { + hero { name ... @defer { id } } + } + `); + + await expectEvents( + executeChannel, + () => + expectPromise( + execute({ + schema: asyncDeferSchema, + document, + rootValue: { + hero: Promise.resolve({ + id: '1', + name: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }), + }, + }), + ).toReject(), + (error) => [ + { + channel: 'start', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'error', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + error, + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + error, + }, + }, + ], + ); + }); + it('emits once for executeSync via experimentalExecuteIncrementally', async () => { const document = parse('{ sync }'); - executeSync({ schema, document, rootValue: { sync: () => 'hello' } }); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + await expectEvents( + executeChannel, + () => + executeSync({ schema, document, rootValue: { sync: () => 'hello' } }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + result, + }, + }, + ], + ); }); - it('emits start and end around executeIgnoringIncremental', () => { - active = collectEvents(executeChannel); - + it('emits start and end around executeIgnoringIncremental', async () => { const document = parse('query Q { sync }'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeIgnoringIncremental({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.operationName).to.equal('Q'); + await expectEvents( + executeChannel, + () => + executeIgnoringIncremental({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + result, + }, + }, + ], + ); }); - it('emits start, error, and end when execute throws synchronously', () => { - active = collectEvents(executeChannel); - - const schemaWithDefer = buildSchema(` - directive @defer on FIELD - type Query { sync: String } - `); + it('emits start, error, and end when execute throws synchronously', async () => { const document = parse('{ sync }'); - expect(() => execute({ schema: schemaWithDefer, document })).to.throw(); - - expect(active.events.map((e) => e.kind)).to.deep.equal([ - 'start', - 'error', - 'end', - ]); + const invalidSchema = buildSchema(` + directive @defer on FIELD + type Query { sync: String } + `); + + await expectEvents( + executeChannel, + () => + catchThrownError(() => execute({ schema: invalidSchema, document })), + (error) => [ + { + channel: 'start', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'error', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + error, + }, + }, + { + channel: 'end', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + error, + }, + }, + ], + ); }); it('emits for each subscription event with resolved operation ctx', async () => { @@ -145,33 +371,80 @@ describe('execute diagnostics channel', () => { } const document = parse('subscription S { tick }'); - - active = collectEvents(executeChannel); - - const subscription = await subscribe({ - schema, - document, - rootValue: { tick: tickGenerator }, - }); - assert(isAsyncIterable(subscription)); - - expect(await subscription.next()).to.deep.equal({ - done: false, - value: { data: { tick: 'one' } }, - }); - expect(await subscription.next()).to.deep.equal({ - done: false, - value: { data: { tick: 'two' } }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - expect(starts.length).to.equal(2); - for (const ev of starts) { - expect(ev.ctx.operationType).to.equal('subscription'); - expect(ev.ctx.operationName).to.equal('S'); - expect(ev.ctx.operation).to.equal(document.definitions[0]); - expect(ev.ctx.schema).to.equal(schema); - } + const operation = document.definitions[0]; + const variableValues = { coerced: {}, sources: {} }; + + await expectEvents( + executeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { tick: tickGenerator }, + }); + assert(isAsyncIterable(subscription)); + + const firstResult = await subscription.next(); + expect(firstResult).to.deep.equal({ + done: false, + value: { data: { tick: 'one' } }, + }); + const secondResult = await subscription.next(); + expect(secondResult).to.deep.equal({ + done: false, + value: { data: { tick: 'two' } }, + }); + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return [firstResult, secondResult] as const; + }, + ([firstResult, secondResult]) => [ + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: firstResult.value, + }, + }, + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: secondResult.value, + }, + }, + ], + ); }); it('does not call tracing methods when no subscribers are attached', async () => { @@ -188,7 +461,6 @@ describe('execute diagnostics channel', () => { }); describe('subscribe diagnostics channel', () => { - let active: ReturnType | undefined; const subscribeChannel = getTracingChannel('graphql:subscribe'); async function* twoTicks(): AsyncIterable<{ tick: string }> { @@ -197,175 +469,518 @@ describe('subscribe diagnostics channel', () => { yield { tick: 'two' }; } - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - it('emits start and end for a synchronous subscription setup', async () => { - active = collectEvents(subscribeChannel); - const document = parse('subscription S { tick }'); - const result = subscribe({ - schema, - document, - rootValue: { tick: twoTicks }, - }); - const resolved = isPromise(result) ? await result : result; - assert(isAsyncIterable(resolved)); - await resolved.return?.(); - - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.operationType).to.equal('subscription'); - expect(active.events[0].ctx.operationName).to.equal('S'); - expect(active.events[0].ctx.document).to.equal(document); - expect(active.events[0].ctx.schema).to.equal(schema); + await expectEvents( + subscribeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + }); + assert(isAsyncIterable(subscription)); + + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return subscription; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + result, + }, + }, + ], + ); }); it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { - active = collectEvents(subscribeChannel); - const document = parse('subscription { tick }'); - const result = subscribe({ - schema, - document, - rootValue: { - tick: (): Promise> => - Promise.resolve(twoTicks()), + await expectEvents( + subscribeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { + tick: (): Promise> => + Promise.resolve(twoTicks()), + }, + }); + assert(isAsyncIterable(subscription)); + + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return subscription; }, - }); - const resolved = isPromise(result) ? await result : result; - assert(isAsyncIterable(resolved)); - await resolved.return?.(); - - expect(active.events.map((e) => e.kind)).to.deep.equal([ - 'start', - 'end', - 'asyncStart', - 'asyncEnd', - ]); + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + result, + }, + }, + ], + ); }); - it('emits only start and end for a synchronous validation failure', () => { - active = collectEvents(subscribeChannel); - - // Invalid: no operation. + it('emits only start and end for a synchronous validation failure', async () => { const document = parse('fragment F on Subscription { tick }'); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscribe({ schema, document }); + await expectEvents( + subscribeChannel, + async () => { + const result = await subscribe({ schema, document }); + expect(result).to.have.property('errors'); + return result; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: undefined, + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: undefined, + result, + }, + }, + ], + ); + }); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + it('emits start, error, and end when subscribe throws synchronously', async () => { + const document = parse('subscription S { tick }'); + const invalidSchema = {} as GraphQLSchema; + + await expectEvents( + subscribeChannel, + () => + catchThrownError(() => subscribe({ schema: invalidSchema, document })), + (error) => [ + { + channel: 'start', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'error', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + error, + }, + }, + { + channel: 'end', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + error, + }, + }, + ], + ); + }); + + it('emits full async lifecycle when subscribe resolver rejects and subscribe resolves to an error result', async () => { + const document = parse('subscription S { tick }'); + const error = new Error('subscribe-boom'); + + await expectEvents( + subscribeChannel, + async () => { + const result = await subscribe({ + schema, + document, + rootValue: { + tick: () => Promise.reject(error), + }, + }); + expect(result).to.have.property('errors'); + return result; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + result, + }, + }, + ], + ); }); it('does not call tracing methods when no subscribers are attached', async () => { const document = parse('subscription { tick }'); await expectNoTracingActivity(subscribeChannel, async () => { - const result = subscribe({ + const resolved = await subscribe({ schema, document, rootValue: { tick: twoTicks }, }); - const resolved = isPromise(result) ? await result : result; assert(isAsyncIterable(resolved)); - await resolved.return?.(); + + const returned = resolved.return?.(); + if (returned !== undefined) { + await returned; + } }); }); }); describe('resolve diagnostics channel', () => { - let active: ReturnType | undefined; const resolveChannel = getTracingChannel('graphql:resolve'); - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end around a synchronous resolver', () => { - active = collectEvents(resolveChannel); - - const result = execute({ - schema, - document: parse('{ sync }'), - rootValue: { sync: () => 'hello' }, - }); - if (isPromise(result)) { - throw new Error('expected sync'); - } - - const starts = active.events.filter((e) => e.kind === 'start'); - expect(starts.length).to.equal(1); - expect(starts[0].ctx.fieldName).to.equal('sync'); - expect(starts[0].ctx.parentType).to.equal('Query'); - expect(starts[0].ctx.fieldType).to.equal('String'); - expect(starts[0].ctx.fieldPath).to.equal('sync'); + it('emits start and end around a synchronous resolver', async () => { + const document = parse('{ sync }'); - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'end']); + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + }, + }, + { + channel: 'end', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + result: 'hello', + }, + }, + ], + ); }); it('emits the full async lifecycle when a resolver returns a promise', async () => { - active = collectEvents(resolveChannel); - - const result = execute({ - schema, - document: parse('{ async }'), - rootValue: { async: () => Promise.resolve('hello-async') }, - }); - await result; - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'end', 'asyncStart', 'asyncEnd']); + const document = parse('{ async }'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'end', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'asyncStart', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'asyncEnd', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + result: 'hello-async', + }, + }, + ], + ); }); - it('emits start, error, end when a sync resolver throws', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema, - document: parse('{ fail }'), - rootValue: { - fail: () => { - throw new Error('boom'); + it('emits start, error, end when a sync resolver throws', async () => { + const document = parse('{ fail }'); + const error = new Error('boom'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + fail: () => { + throw error; + }, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + }, }, - }, - }); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'error', 'end']); + { + channel: 'error', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + error, + }, + }, + { + channel: 'end', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + error, + }, + }, + ], + ); }); it('emits full async lifecycle with error when a resolver rejects', async () => { - active = collectEvents(resolveChannel); - - await execute({ - schema, - document: parse('{ asyncFail }'), - rootValue: { - asyncFail: () => Promise.reject(new Error('async-boom')), - }, - }); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal([ - 'start', - 'end', - 'asyncStart', - 'error', - 'asyncEnd', - ]); - const errorEvent = active.events.find((e) => e.kind === 'error'); - expect((errorEvent?.ctx as { error?: Error }).error?.message).to.equal( - 'async-boom', + const document = parse('{ asyncFail }'); + const error = new Error('async-boom'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + asyncFail: () => Promise.reject(error), + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'end', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'asyncStart', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'error', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + error, + }, + }, + { + channel: 'asyncEnd', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + error, + }, + }, + ], ); }); - it('reports isDefaultResolver based on field.resolve presence', () => { + it('reports isDefaultResolver based on field.resolve presence', async () => { const trivialSchema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', @@ -379,78 +994,306 @@ describe('resolve diagnostics channel', () => { }), }); - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema: trivialSchema, - document: parse('{ trivial custom }'), - rootValue: { trivial: 'value' }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const byField = new Map( - starts.map((e) => [e.ctx.fieldName, e.ctx.isDefaultResolver]), + await expectEvents( + resolveChannel, + () => + execute({ + schema: trivialSchema, + document: parse('{ trivial custom }'), + rootValue: { trivial: 'value' }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'trivial', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'trivial', + }, + }, + { + channel: 'end', + context: { + fieldName: 'trivial', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'trivial', + result: 'value', + }, + }, + { + channel: 'start', + context: { + fieldName: 'custom', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: false, + fieldPath: 'custom', + }, + }, + { + channel: 'end', + context: { + fieldName: 'custom', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: false, + fieldPath: 'custom', + result: 'explicit', + }, + }, + ], ); - expect(byField.get('trivial')).to.equal(true); - expect(byField.get('custom')).to.equal(false); }); - it('serializes fieldPath lazily, joining path keys with dots', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema, - document: parse('{ nested { leaf } }'), - rootValue: { - nested: { leaf: 'leaf-value' }, - }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const paths = starts.map((e) => e.ctx.fieldPath); - expect(paths).to.deep.equal(['nested', 'nested.leaf']); + it('serializes fieldPath lazily, joining path keys with dots', async () => { + const document = parse('{ nested { leaf } }'); + const nested = { leaf: 'leaf-value' }; + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + nested, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + }, + }, + { + channel: 'end', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + result: nested, + }, + }, + { + channel: 'start', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + }, + }, + { + channel: 'end', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + result: 'leaf-value', + }, + }, + ], + ); }); - it('fires once per field, not per schema walk', () => { - active = collectEvents(resolveChannel); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - execute({ - schema, - document: parse('{ sync plain nested { leaf } }'), - rootValue: { - sync: () => 'hello', - // no `plain` resolver, default property-access is used. - plain: 'plain-value', - nested: { leaf: 'leaf-value' }, - }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - const endsSync = active.events.filter((e) => e.kind === 'end'); - expect(starts.length).to.equal(4); // sync, plain, nested, nested.leaf - expect(endsSync.length).to.equal(4); + it('fires once per field, not per schema walk', async () => { + const document = parse('{ sync plain nested { leaf } }'); + const nested = { leaf: 'leaf-value' }; + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + sync: () => 'hello', + plain: 'plain-value', + nested, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + }, + }, + { + channel: 'end', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + result: 'hello', + }, + }, + { + channel: 'start', + context: { + fieldName: 'plain', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'plain', + }, + }, + { + channel: 'end', + context: { + fieldName: 'plain', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'plain', + result: 'plain-value', + }, + }, + { + channel: 'start', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + }, + }, + { + channel: 'end', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + result: nested, + }, + }, + { + channel: 'start', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + }, + }, + { + channel: 'end', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + result: 'leaf-value', + }, + }, + ], + ); }); it('emits per-field for serial mutation execution', async () => { - active = collectEvents(resolveChannel); - - await execute({ - schema, - document: parse('mutation M { first second }'), - rootValue: { - first: () => 'one', - second: () => 'two', - }, - }); - - const starts = active.events.filter((e) => e.kind === 'start'); - expect(starts.map((e) => e.ctx.fieldName)).to.deep.equal([ - 'first', - 'second', - ]); + const document = parse('mutation M { first second }'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + first: () => 'one', + second: () => 'two', + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'first', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'first', + }, + }, + { + channel: 'end', + context: { + fieldName: 'first', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'first', + result: 'one', + }, + }, + { + channel: 'start', + context: { + fieldName: 'second', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'second', + }, + }, + { + channel: 'end', + context: { + fieldName: 'second', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'second', + result: 'two', + }, + }, + ], + ); }); it('does not call tracing methods when no subscribers are attached', async () => { diff --git a/src/language/__tests__/diagnostics-test.ts b/src/language/__tests__/diagnostics-test.ts index 4882c6b6d2..3abdb68ced 100644 --- a/src/language/__tests__/diagnostics-test.ts +++ b/src/language/__tests__/diagnostics-test.ts @@ -1,49 +1,53 @@ import { expect } from 'chai'; -import { afterEach, describe, it } from 'mocha'; +import { describe, it } from 'mocha'; -import { - collectEvents, - expectNoTracingActivity, - getTracingChannel, -} from '../../__testUtils__/diagnosticsTestUtils.js'; +import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { parse } from '../parser.js'; const parseChannel = getTracingChannel('graphql:parse'); describe('parse diagnostics channel', () => { - let active: ReturnType | undefined; - - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end around a successful parse', () => { - active = collectEvents(parseChannel); - - const doc = parse('{ field }'); - - expect(doc.kind).to.equal('Document'); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.source).to.equal('{ field }'); - expect(active.events[1].ctx.source).to.equal('{ field }'); + it('emits start and end around a successful parse', async () => { + const source = '{ field }'; + + await expectEvents( + parseChannel, + () => parse(source), + (result) => [ + { channel: 'start', context: { source } }, + { channel: 'end', context: { source, result } }, + ], + ); }); - it('emits start, error, and end when the parser throws', () => { - active = collectEvents(parseChannel); - - expect(() => parse('{ ')).to.throw(); - - const kinds = active.events.map((e) => e.kind); - expect(kinds).to.deep.equal(['start', 'error', 'end']); - expect(active.events[1].ctx.error).to.be.instanceOf(Error); + it('emits start, error, and end when the parser throws', async () => { + const source = '{ '; + + await expectEvents( + parseChannel, + () => catchThrownError(() => parse(source)), + (error) => [ + { channel: 'start', context: { source } }, + { + channel: 'error', + context: { + source, + error, + }, + }, + { channel: 'end', context: { source, error } }, + ], + ); }); it('does not call tracing methods when no subscribers are attached', async () => { - const doc = await expectNoTracingActivity(parseChannel, () => + const document = await expectNoTracingActivity(parseChannel, () => parse('{ field }'), ); - expect(doc.kind).to.equal('Document'); + expect(document.kind).to.equal('Document'); }); }); diff --git a/src/validation/__tests__/diagnostics-test.ts b/src/validation/__tests__/diagnostics-test.ts index 913b74eb03..512ab7e65e 100644 --- a/src/validation/__tests__/diagnostics-test.ts +++ b/src/validation/__tests__/diagnostics-test.ts @@ -1,14 +1,15 @@ import { expect } from 'chai'; -import { afterEach, describe, it } from 'mocha'; +import { describe, it } from 'mocha'; -import { - collectEvents, - expectNoTracingActivity, - getTracingChannel, -} from '../../__testUtils__/diagnosticsTestUtils.js'; +import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { parse } from '../../language/parser.js'; +import type { GraphQLSchema } from '../../type/schema.js'; + import { buildSchema } from '../../utilities/buildASTSchema.js'; import { validate } from '../validate.js'; @@ -22,47 +23,59 @@ const schema = buildSchema(` const validateChannel = getTracingChannel('graphql:validate'); describe('validate diagnostics channel', () => { - let active: ReturnType | undefined; - - afterEach(() => { - active?.unsubscribe(); - active = undefined; - }); - - it('emits start and end around a successful validate', () => { - active = collectEvents(validateChannel); - - const doc = parse('{ field }'); - const errors = validate(schema, doc); - - expect(errors).to.deep.equal([]); - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); - expect(active.events[0].ctx.schema).to.equal(schema); - expect(active.events[0].ctx.document).to.equal(doc); + it('emits start and end around a successful validate', async () => { + const document = parse('{ field }'); + + await expectEvents( + validateChannel, + () => validate(schema, document), + (result) => [ + { channel: 'start', context: { schema, document } }, + { channel: 'end', context: { schema, document, result } }, + ], + ); }); - it('emits start and end for a document with validation errors', () => { - active = collectEvents(validateChannel); - - const doc = parse('{ missingField }'); - const errors = validate(schema, doc); + it('emits start and end for a document with validation errors', async () => { + const document = parse('{ missingField }'); - expect(errors).to.have.length.greaterThan(0); - // Validation errors are collected, not thrown, so we still see start/end. - expect(active.events.map((e) => e.kind)).to.deep.equal(['start', 'end']); + await expectEvents( + validateChannel, + () => validate(schema, document), + (result) => [ + { channel: 'start', context: { schema, document } }, + { channel: 'end', context: { schema, document, result } }, + ], + ); }); - it('emits start, error, and end when validate throws on an invalid schema', () => { - active = collectEvents(validateChannel); - - expect(() => validate({} as typeof schema, parse('{ field }'))).to.throw(); - - expect(active.events.map((e) => e.kind)).to.deep.equal([ - 'start', - 'error', - 'end', - ]); - expect(active.events[1].ctx.error).to.be.instanceOf(Error); + it('emits start, error, and end when validate throws on an invalid schema', async () => { + const context = { + document: parse('{ field }'), + schema: {} as GraphQLSchema, + }; + + await expectEvents( + validateChannel, + () => catchThrownError(() => validate(context.schema, context.document)), + (error) => [ + { + channel: 'start', + context, + }, + { + channel: 'error', + context: { + ...context, + error, + }, + }, + { + channel: 'end', + context: { ...context, error }, + }, + ], + ); }); it('does not call tracing methods when no subscribers are attached', async () => { From 74b136c081a0264303a1d554d9ca5b6ee3940248 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 22:41:51 +0300 Subject: [PATCH 38/55] make sure variableValues in context for execution and subscription are the original passed values, not the internal type --- src/execution/__tests__/diagnostics-test.ts | 21 ++++++++----- src/execution/execute.ts | 34 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/execution/__tests__/diagnostics-test.ts b/src/execution/__tests__/diagnostics-test.ts index 4a4c8d758b..c3e2914175 100644 --- a/src/execution/__tests__/diagnostics-test.ts +++ b/src/execution/__tests__/diagnostics-test.ts @@ -54,7 +54,8 @@ describe('execute diagnostics channel', () => { const executeChannel = getTracingChannel('graphql:execute'); it('emits start and end around a synchronous execute', async () => { - const document = parse('query Q { sync }'); + const document = parse('query Q($sync: String) { sync }'); + const variableValues = { sync: 'ignored by the field' }; await expectEvents( executeChannel, @@ -63,6 +64,7 @@ describe('execute diagnostics channel', () => { schema, document, rootValue: { sync: () => 'hello' }, + variableValues, }), (result) => [ { @@ -70,7 +72,7 @@ describe('execute diagnostics channel', () => { context: { document, schema, - variableValues: undefined, + variableValues, operationName: 'Q', operationType: 'query', }, @@ -80,7 +82,7 @@ describe('execute diagnostics channel', () => { context: { document, schema, - variableValues: undefined, + variableValues, operationName: 'Q', operationType: 'query', result, @@ -370,9 +372,9 @@ describe('execute diagnostics channel', () => { yield { tick: 'two' }; } - const document = parse('subscription S { tick }'); + const document = parse('subscription S($tick: String) { tick }'); const operation = document.definitions[0]; - const variableValues = { coerced: {}, sources: {} }; + const variableValues = { tick: 'ignored by the field' }; await expectEvents( executeChannel, @@ -381,6 +383,7 @@ describe('execute diagnostics channel', () => { schema, document, rootValue: { tick: tickGenerator }, + variableValues, }); assert(isAsyncIterable(subscription)); @@ -470,7 +473,8 @@ describe('subscribe diagnostics channel', () => { } it('emits start and end for a synchronous subscription setup', async () => { - const document = parse('subscription S { tick }'); + const document = parse('subscription S($tick: String) { tick }'); + const variableValues = { tick: 'ignored by the field' }; await expectEvents( subscribeChannel, @@ -479,6 +483,7 @@ describe('subscribe diagnostics channel', () => { schema, document, rootValue: { tick: twoTicks }, + variableValues, }); assert(isAsyncIterable(subscription)); @@ -494,7 +499,7 @@ describe('subscribe diagnostics channel', () => { context: { document, schema, - variableValues: undefined, + variableValues, operationName: 'S', operationType: 'subscription', }, @@ -504,7 +509,7 @@ describe('subscribe diagnostics channel', () => { context: { document, schema, - variableValues: undefined, + variableValues, operationName: 'S', operationType: 'subscription', result, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index c1c7b438d6..24d6d2b0ae 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -770,6 +770,40 @@ function assertEventStream(result: unknown): AsyncIterable { return result; } +/** + * Build a graphql:execute channel context from ValidatedExecutionArgs. + * Used by executeSubscriptionEvent, where the operation has already been + * resolved during argument validation. The original document is not + * available at this point, only the resolved operation; subscribers that + * need the document should read it from the graphql:subscribe context. + */ +function buildExecuteCtxFromValidatedArgs( + args: ValidatedExecutionArgs, +): object { + return { + operation: args.operation, + schema: args.schema, + variableValues: getOriginalVariableValues(args), + operationName: args.operation.name?.value, + operationType: args.operation.operation, + }; +} + +function getOriginalVariableValues( + args: ValidatedExecutionArgs, +): Maybe<{ readonly [variable: string]: unknown }> { + const originalVariableValues: { [variable: string]: unknown } = {}; + for (const [variableName, source] of Object.entries( + args.variableValues.sources, + )) { + if (Object.hasOwn(source, 'value')) { + originalVariableValues[variableName] = source.value; + } + } + + return originalVariableValues; +} + function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); } From 47e494fa1043ffe3af014a371a5a230480e83bef Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 26 Apr 2026 22:50:00 +0300 Subject: [PATCH 39/55] separate out diagnostics for execute/subscribe/resolve now that the files are longer... :) --- .../__tests__/diagnostics-execute-test.ts | 453 ++++++ .../__tests__/diagnostics-resolve-test.ts | 599 ++++++++ .../__tests__/diagnostics-subscribe-test.ts | 314 ++++ src/execution/__tests__/diagnostics-test.ts | 1314 ----------------- 4 files changed, 1366 insertions(+), 1314 deletions(-) create mode 100644 src/execution/__tests__/diagnostics-execute-test.ts create mode 100644 src/execution/__tests__/diagnostics-resolve-test.ts create mode 100644 src/execution/__tests__/diagnostics-subscribe-test.ts delete mode 100644 src/execution/__tests__/diagnostics-test.ts diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts new file mode 100644 index 0000000000..3e6631a79a --- /dev/null +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -0,0 +1,453 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { + execute, + executeIgnoringIncremental, + executeSync, + subscribe, +} from '../execute.js'; + +const schema = buildSchema(` + type Query { + sync: String + async: String + } + + type Subscription { + tick: String + } +`); + +const executeChannel = getTracingChannel('graphql:execute'); + +describe('execute diagnostics channel', () => { + it('emits start and end around a synchronous execute', async () => { + const document = parse('query Q($sync: String) { sync }'); + const variableValues = { sync: 'ignored by the field' }; + + await expectEvents( + executeChannel, + () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + variableValues, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues, + operationName: 'Q', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues, + operationName: 'Q', + operationType: 'query', + result, + }, + }, + ], + ); + }); + + it('emits start, end, and async lifecycle when execute returns a promise', async () => { + const document = parse('query { async }'); + + await expectEvents( + executeChannel, + () => + execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + result, + }, + }, + ], + ); + }); + + it('emits full async lifecycle with error when execute returns a rejected promise', async () => { + const asyncDeferSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + hero: { + type: new GraphQLObjectType({ + name: 'Hero', + fields: { + id: { type: GraphQLString }, + name: { type: GraphQLString }, + }, + }), + }, + }, + }), + }); + const document = parse(` + query Deferred { + hero { name ... @defer { id } } + } + `); + + await expectEvents( + executeChannel, + () => + expectPromise( + execute({ + schema: asyncDeferSchema, + document, + rootValue: { + hero: Promise.resolve({ + id: '1', + name: async () => { + await resolveOnNextTick(); + return 'slow'; + }, + }), + }, + }), + ).toReject(), + (error) => [ + { + channel: 'start', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + }, + }, + { + channel: 'error', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + error, + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema: asyncDeferSchema, + variableValues: undefined, + operationName: 'Deferred', + operationType: 'query', + error, + }, + }, + ], + ); + }); + + it('emits once for executeSync via experimentalExecuteIncrementally', async () => { + const document = parse('{ sync }'); + + await expectEvents( + executeChannel, + () => + executeSync({ schema, document, rootValue: { sync: () => 'hello' } }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + result, + }, + }, + ], + ); + }); + + it('emits start and end around executeIgnoringIncremental', async () => { + const document = parse('query Q { sync }'); + + await expectEvents( + executeChannel, + () => + executeIgnoringIncremental({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'Q', + operationType: 'query', + result, + }, + }, + ], + ); + }); + + it('emits start, error, and end when execute throws synchronously', async () => { + const document = parse('{ sync }'); + const invalidSchema = buildSchema(` + directive @defer on FIELD + + type Query { + sync: String + } + `); + + await expectEvents( + executeChannel, + () => + catchThrownError(() => execute({ schema: invalidSchema, document })), + (error) => [ + { + channel: 'start', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'error', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + error, + }, + }, + { + channel: 'end', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: undefined, + operationType: 'query', + error, + }, + }, + ], + ); + }); + + it('emits for each subscription event with resolved operation ctx', async () => { + async function* tickGenerator() { + await Promise.resolve(); + yield { tick: 'one' }; + yield { tick: 'two' }; + } + + const document = parse('subscription S($tick: String) { tick }'); + const operation = document.definitions[0]; + const variableValues = { tick: 'ignored by the field' }; + + await expectEvents( + executeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { tick: tickGenerator }, + variableValues, + }); + assert(isAsyncIterable(subscription)); + + const firstResult = await subscription.next(); + expect(firstResult).to.deep.equal({ + done: false, + value: { data: { tick: 'one' } }, + }); + const secondResult = await subscription.next(); + expect(secondResult).to.deep.equal({ + done: false, + value: { data: { tick: 'two' } }, + }); + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return [firstResult, secondResult] as const; + }, + ([firstResult, secondResult]) => [ + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: firstResult.value, + }, + }, + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: secondResult.value, + }, + }, + ], + ); + }); + + it('does not call tracing methods when no subscribers are attached', async () => { + const document = parse('{ sync }'); + const result = await expectNoTracingActivity(executeChannel, () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + ); + expect(result).to.deep.equal({ data: { sync: 'hello' } }); + }); +}); diff --git a/src/execution/__tests__/diagnostics-resolve-test.ts b/src/execution/__tests__/diagnostics-resolve-test.ts new file mode 100644 index 0000000000..281efd6d72 --- /dev/null +++ b/src/execution/__tests__/diagnostics-resolve-test.ts @@ -0,0 +1,599 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; + +import { parse } from '../../language/parser.js'; + +import { GraphQLObjectType } from '../../type/definition.js'; +import { GraphQLString } from '../../type/scalars.js'; +import { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { execute } from '../execute.js'; + +const schema = buildSchema(` + type Query { + sync: String + async: String + fail: String + asyncFail: String + plain: String + nested: Nested + } + + type Nested { + leaf: String + } + + type Mutation { + first: String + second: String + } +`); + +const resolveChannel = getTracingChannel('graphql:resolve'); + +describe('resolve diagnostics channel', () => { + it('emits start and end around a synchronous resolver', async () => { + const document = parse('{ sync }'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + }, + }, + { + channel: 'end', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + result: 'hello', + }, + }, + ], + ); + }); + + it('emits the full async lifecycle when a resolver returns a promise', async () => { + const document = parse('{ async }'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'end', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'asyncStart', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + }, + }, + { + channel: 'asyncEnd', + context: { + fieldName: 'async', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'async', + result: 'hello-async', + }, + }, + ], + ); + }); + + it('emits start, error, end when a sync resolver throws', async () => { + const document = parse('{ fail }'); + const error = new Error('boom'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + fail: () => { + throw error; + }, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + }, + }, + { + channel: 'error', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + error, + }, + }, + { + channel: 'end', + context: { + fieldName: 'fail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'fail', + error, + }, + }, + ], + ); + }); + + it('emits full async lifecycle with error when a resolver rejects', async () => { + const document = parse('{ asyncFail }'); + const error = new Error('async-boom'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + asyncFail: () => Promise.reject(error), + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'end', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'asyncStart', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + }, + }, + { + channel: 'error', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + error, + }, + }, + { + channel: 'asyncEnd', + context: { + fieldName: 'asyncFail', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'asyncFail', + error, + }, + }, + ], + ); + }); + + it('reports isDefaultResolver based on field.resolve presence', async () => { + const trivialSchema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + trivial: { type: GraphQLString }, + custom: { + type: GraphQLString, + resolve: () => 'explicit', + }, + }, + }), + }); + + await expectEvents( + resolveChannel, + () => + execute({ + schema: trivialSchema, + document: parse('{ trivial custom }'), + rootValue: { trivial: 'value' }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'trivial', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'trivial', + }, + }, + { + channel: 'end', + context: { + fieldName: 'trivial', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'trivial', + result: 'value', + }, + }, + { + channel: 'start', + context: { + fieldName: 'custom', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: false, + fieldPath: 'custom', + }, + }, + { + channel: 'end', + context: { + fieldName: 'custom', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: false, + fieldPath: 'custom', + result: 'explicit', + }, + }, + ], + ); + }); + + it('serializes fieldPath lazily, joining path keys with dots', async () => { + const document = parse('{ nested { leaf } }'); + const nested = { leaf: 'leaf-value' }; + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + nested, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + }, + }, + { + channel: 'end', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + result: nested, + }, + }, + { + channel: 'start', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + }, + }, + { + channel: 'end', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + result: 'leaf-value', + }, + }, + ], + ); + }); + + it('fires once per field, not per schema walk', async () => { + const document = parse('{ sync plain nested { leaf } }'); + const nested = { leaf: 'leaf-value' }; + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + sync: () => 'hello', + plain: 'plain-value', + nested, + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + }, + }, + { + channel: 'end', + context: { + fieldName: 'sync', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'sync', + result: 'hello', + }, + }, + { + channel: 'start', + context: { + fieldName: 'plain', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'plain', + }, + }, + { + channel: 'end', + context: { + fieldName: 'plain', + parentType: 'Query', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'plain', + result: 'plain-value', + }, + }, + { + channel: 'start', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + }, + }, + { + channel: 'end', + context: { + fieldName: 'nested', + parentType: 'Query', + fieldType: 'Nested', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested', + result: nested, + }, + }, + { + channel: 'start', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + }, + }, + { + channel: 'end', + context: { + fieldName: 'leaf', + parentType: 'Nested', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'nested.leaf', + result: 'leaf-value', + }, + }, + ], + ); + }); + + it('emits per-field for serial mutation execution', async () => { + const document = parse('mutation M { first second }'); + + await expectEvents( + resolveChannel, + () => + execute({ + schema, + document, + rootValue: { + first: () => 'one', + second: () => 'two', + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'first', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'first', + }, + }, + { + channel: 'end', + context: { + fieldName: 'first', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'first', + result: 'one', + }, + }, + { + channel: 'start', + context: { + fieldName: 'second', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'second', + }, + }, + { + channel: 'end', + context: { + fieldName: 'second', + parentType: 'Mutation', + fieldType: 'String', + args: {}, + isDefaultResolver: true, + fieldPath: 'second', + result: 'two', + }, + }, + ], + ); + }); + + it('does not call tracing methods when no subscribers are attached', async () => { + const result = await expectNoTracingActivity(resolveChannel, () => + execute({ + schema, + document: parse('{ sync }'), + rootValue: { sync: () => 'hello' }, + }), + ); + expect(result).to.deep.equal({ data: { sync: 'hello' } }); + }); +}); diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts new file mode 100644 index 0000000000..48bb24c96d --- /dev/null +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -0,0 +1,314 @@ +import { assert, expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.js'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; + +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; + +import { parse } from '../../language/parser.js'; + +import type { GraphQLSchema } from '../../type/schema.js'; + +import { buildSchema } from '../../utilities/buildASTSchema.js'; + +import { subscribe } from '../execute.js'; + +const schema = buildSchema(` + type Query { + dummy: String + } + + type Subscription { + tick: String + } +`); + +const subscribeChannel = getTracingChannel('graphql:subscribe'); + +async function* twoTicks(): AsyncIterable<{ tick: string }> { + await Promise.resolve(); + yield { tick: 'one' }; + yield { tick: 'two' }; +} + +describe('subscribe diagnostics channel', () => { + it('emits start and end for a synchronous subscription setup', async () => { + const document = parse('subscription S($tick: String) { tick }'); + const variableValues = { tick: 'ignored by the field' }; + + await expectEvents( + subscribeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + variableValues, + }); + assert(isAsyncIterable(subscription)); + + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return subscription; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result, + }, + }, + ], + ); + }); + + it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { + const document = parse('subscription { tick }'); + + await expectEvents( + subscribeChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { + tick: (): Promise> => + Promise.resolve(twoTicks()), + }, + }); + assert(isAsyncIterable(subscription)); + + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return subscription; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: 'subscription', + result, + }, + }, + ], + ); + }); + + it('emits only start and end for a synchronous validation failure', async () => { + const document = parse('fragment F on Subscription { tick }'); + + await expectEvents( + subscribeChannel, + async () => { + const result = await subscribe({ schema, document }); + expect(result).to.have.property('errors'); + return result; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: undefined, + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: undefined, + operationType: undefined, + result, + }, + }, + ], + ); + }); + + it('emits start, error, and end when subscribe throws synchronously', async () => { + const document = parse('subscription S { tick }'); + const invalidSchema = {} as GraphQLSchema; + + await expectEvents( + subscribeChannel, + () => + catchThrownError(() => subscribe({ schema: invalidSchema, document })), + (error) => [ + { + channel: 'start', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'error', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + error, + }, + }, + { + channel: 'end', + context: { + document, + schema: invalidSchema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + error, + }, + }, + ], + ); + }); + + it('emits full async lifecycle when subscribe resolver rejects and subscribe resolves to an error result', async () => { + const document = parse('subscription S { tick }'); + const error = new Error('subscribe-boom'); + + await expectEvents( + subscribeChannel, + async () => { + const result = await subscribe({ + schema, + document, + rootValue: { + tick: () => Promise.reject(error), + }, + }); + expect(result).to.have.property('errors'); + return result; + }, + (result) => [ + { + channel: 'start', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'asyncStart', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'asyncEnd', + context: { + document, + schema, + variableValues: undefined, + operationName: 'S', + operationType: 'subscription', + result, + }, + }, + ], + ); + }); + + it('does not call tracing methods when no subscribers are attached', async () => { + const document = parse('subscription { tick }'); + + await expectNoTracingActivity(subscribeChannel, async () => { + const resolved = await subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + }); + assert(isAsyncIterable(resolved)); + + const returned = resolved.return?.(); + if (returned !== undefined) { + await returned; + } + }); + }); +}); diff --git a/src/execution/__tests__/diagnostics-test.ts b/src/execution/__tests__/diagnostics-test.ts deleted file mode 100644 index c3e2914175..0000000000 --- a/src/execution/__tests__/diagnostics-test.ts +++ /dev/null @@ -1,1314 +0,0 @@ -import { assert, expect } from 'chai'; -import { describe, it } from 'mocha'; - -import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { expectPromise } from '../../__testUtils__/expectPromise.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; -import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; - -import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; - -import { parse } from '../../language/parser.js'; - -import { GraphQLObjectType } from '../../type/definition.js'; -import { GraphQLString } from '../../type/scalars.js'; -import { GraphQLSchema } from '../../type/schema.js'; - -import { buildSchema } from '../../utilities/buildASTSchema.js'; - -import { - execute, - executeIgnoringIncremental, - executeSync, - subscribe, -} from '../execute.js'; - -const schema = buildSchema(` - type Query { - sync: String - async: String - fail: String - asyncFail: String - plain: String - nested: Nested - dummy: String - } - - type Nested { - leaf: String - } - - type Mutation { - first: String - second: String - } - - type Subscription { - tick: String - } -`); - -describe('execute diagnostics channel', () => { - const executeChannel = getTracingChannel('graphql:execute'); - - it('emits start and end around a synchronous execute', async () => { - const document = parse('query Q($sync: String) { sync }'); - const variableValues = { sync: 'ignored by the field' }; - - await expectEvents( - executeChannel, - () => - execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - variableValues, - }), - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues, - operationName: 'Q', - operationType: 'query', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues, - operationName: 'Q', - operationType: 'query', - result, - }, - }, - ], - ); - }); - - it('emits start, end, and async lifecycle when execute returns a promise', async () => { - const document = parse('query { async }'); - - await expectEvents( - executeChannel, - () => - execute({ - schema, - document, - rootValue: { async: () => Promise.resolve('hello-async') }, - }), - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - }, - }, - { - channel: 'asyncStart', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - }, - }, - { - channel: 'asyncEnd', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - result, - }, - }, - ], - ); - }); - - it('emits full async lifecycle with error when execute returns a rejected promise', async () => { - const asyncDeferSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - hero: { - type: new GraphQLObjectType({ - name: 'Hero', - fields: { - id: { type: GraphQLString }, - name: { type: GraphQLString }, - }, - }), - }, - }, - }), - }); - const document = parse(` - query Deferred { - hero { name ... @defer { id } } - } - `); - - await expectEvents( - executeChannel, - () => - expectPromise( - execute({ - schema: asyncDeferSchema, - document, - rootValue: { - hero: Promise.resolve({ - id: '1', - name: async () => { - await resolveOnNextTick(); - return 'slow'; - }, - }), - }, - }), - ).toReject(), - (error) => [ - { - channel: 'start', - context: { - document, - schema: asyncDeferSchema, - variableValues: undefined, - operationName: 'Deferred', - operationType: 'query', - }, - }, - { - channel: 'end', - context: { - document, - schema: asyncDeferSchema, - variableValues: undefined, - operationName: 'Deferred', - operationType: 'query', - }, - }, - { - channel: 'asyncStart', - context: { - document, - schema: asyncDeferSchema, - variableValues: undefined, - operationName: 'Deferred', - operationType: 'query', - }, - }, - { - channel: 'error', - context: { - document, - schema: asyncDeferSchema, - variableValues: undefined, - operationName: 'Deferred', - operationType: 'query', - error, - }, - }, - { - channel: 'asyncEnd', - context: { - document, - schema: asyncDeferSchema, - variableValues: undefined, - operationName: 'Deferred', - operationType: 'query', - error, - }, - }, - ], - ); - }); - - it('emits once for executeSync via experimentalExecuteIncrementally', async () => { - const document = parse('{ sync }'); - - await expectEvents( - executeChannel, - () => - executeSync({ schema, document, rootValue: { sync: () => 'hello' } }), - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - result, - }, - }, - ], - ); - }); - - it('emits start and end around executeIgnoringIncremental', async () => { - const document = parse('query Q { sync }'); - - await expectEvents( - executeChannel, - () => - executeIgnoringIncremental({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }), - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: 'Q', - operationType: 'query', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: 'Q', - operationType: 'query', - result, - }, - }, - ], - ); - }); - - it('emits start, error, and end when execute throws synchronously', async () => { - const document = parse('{ sync }'); - const invalidSchema = buildSchema(` - directive @defer on FIELD - type Query { sync: String } - `); - - await expectEvents( - executeChannel, - () => - catchThrownError(() => execute({ schema: invalidSchema, document })), - (error) => [ - { - channel: 'start', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - }, - }, - { - channel: 'error', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - error, - }, - }, - { - channel: 'end', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: undefined, - operationType: 'query', - error, - }, - }, - ], - ); - }); - - it('emits for each subscription event with resolved operation ctx', async () => { - async function* tickGenerator() { - await Promise.resolve(); - yield { tick: 'one' }; - yield { tick: 'two' }; - } - - const document = parse('subscription S($tick: String) { tick }'); - const operation = document.definitions[0]; - const variableValues = { tick: 'ignored by the field' }; - - await expectEvents( - executeChannel, - async () => { - const subscription = await subscribe({ - schema, - document, - rootValue: { tick: tickGenerator }, - variableValues, - }); - assert(isAsyncIterable(subscription)); - - const firstResult = await subscription.next(); - expect(firstResult).to.deep.equal({ - done: false, - value: { data: { tick: 'one' } }, - }); - const secondResult = await subscription.next(); - expect(secondResult).to.deep.equal({ - done: false, - value: { data: { tick: 'two' } }, - }); - const returned = subscription.return?.(); - if (returned !== undefined) { - await returned; - } - return [firstResult, secondResult] as const; - }, - ([firstResult, secondResult]) => [ - { - channel: 'start', - context: { - operation, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'end', - context: { - operation, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - result: firstResult.value, - }, - }, - { - channel: 'start', - context: { - operation, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'end', - context: { - operation, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - result: secondResult.value, - }, - }, - ], - ); - }); - - it('does not call tracing methods when no subscribers are attached', async () => { - const document = parse('{ sync }'); - const result = await expectNoTracingActivity(executeChannel, () => - execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }), - ); - expect(result).to.deep.equal({ data: { sync: 'hello' } }); - }); -}); - -describe('subscribe diagnostics channel', () => { - const subscribeChannel = getTracingChannel('graphql:subscribe'); - - async function* twoTicks(): AsyncIterable<{ tick: string }> { - await Promise.resolve(); - yield { tick: 'one' }; - yield { tick: 'two' }; - } - - it('emits start and end for a synchronous subscription setup', async () => { - const document = parse('subscription S($tick: String) { tick }'); - const variableValues = { tick: 'ignored by the field' }; - - await expectEvents( - subscribeChannel, - async () => { - const subscription = await subscribe({ - schema, - document, - rootValue: { tick: twoTicks }, - variableValues, - }); - assert(isAsyncIterable(subscription)); - - const returned = subscription.return?.(); - if (returned !== undefined) { - await returned; - } - return subscription; - }, - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues, - operationName: 'S', - operationType: 'subscription', - result, - }, - }, - ], - ); - }); - - it('emits the full async lifecycle when subscribe resolver returns a promise', async () => { - const document = parse('subscription { tick }'); - - await expectEvents( - subscribeChannel, - async () => { - const subscription = await subscribe({ - schema, - document, - rootValue: { - tick: (): Promise> => - Promise.resolve(twoTicks()), - }, - }); - assert(isAsyncIterable(subscription)); - - const returned = subscription.return?.(); - if (returned !== undefined) { - await returned; - } - return subscription; - }, - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'subscription', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'subscription', - }, - }, - { - channel: 'asyncStart', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'subscription', - }, - }, - { - channel: 'asyncEnd', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: 'subscription', - result, - }, - }, - ], - ); - }); - - it('emits only start and end for a synchronous validation failure', async () => { - const document = parse('fragment F on Subscription { tick }'); - - await expectEvents( - subscribeChannel, - async () => { - const result = await subscribe({ schema, document }); - expect(result).to.have.property('errors'); - return result; - }, - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: undefined, - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: undefined, - operationType: undefined, - result, - }, - }, - ], - ); - }); - - it('emits start, error, and end when subscribe throws synchronously', async () => { - const document = parse('subscription S { tick }'); - const invalidSchema = {} as GraphQLSchema; - - await expectEvents( - subscribeChannel, - () => - catchThrownError(() => subscribe({ schema: invalidSchema, document })), - (error) => [ - { - channel: 'start', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'error', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - error, - }, - }, - { - channel: 'end', - context: { - document, - schema: invalidSchema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - error, - }, - }, - ], - ); - }); - - it('emits full async lifecycle when subscribe resolver rejects and subscribe resolves to an error result', async () => { - const document = parse('subscription S { tick }'); - const error = new Error('subscribe-boom'); - - await expectEvents( - subscribeChannel, - async () => { - const result = await subscribe({ - schema, - document, - rootValue: { - tick: () => Promise.reject(error), - }, - }); - expect(result).to.have.property('errors'); - return result; - }, - (result) => [ - { - channel: 'start', - context: { - document, - schema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'end', - context: { - document, - schema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'asyncStart', - context: { - document, - schema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - }, - }, - { - channel: 'asyncEnd', - context: { - document, - schema, - variableValues: undefined, - operationName: 'S', - operationType: 'subscription', - result, - }, - }, - ], - ); - }); - - it('does not call tracing methods when no subscribers are attached', async () => { - const document = parse('subscription { tick }'); - - await expectNoTracingActivity(subscribeChannel, async () => { - const resolved = await subscribe({ - schema, - document, - rootValue: { tick: twoTicks }, - }); - assert(isAsyncIterable(resolved)); - - const returned = resolved.return?.(); - if (returned !== undefined) { - await returned; - } - }); - }); -}); - -describe('resolve diagnostics channel', () => { - const resolveChannel = getTracingChannel('graphql:resolve'); - - it('emits start and end around a synchronous resolver', async () => { - const document = parse('{ sync }'); - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'sync', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'sync', - }, - }, - { - channel: 'end', - context: { - fieldName: 'sync', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'sync', - result: 'hello', - }, - }, - ], - ); - }); - - it('emits the full async lifecycle when a resolver returns a promise', async () => { - const document = parse('{ async }'); - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { async: () => Promise.resolve('hello-async') }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'async', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'async', - }, - }, - { - channel: 'end', - context: { - fieldName: 'async', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'async', - }, - }, - { - channel: 'asyncStart', - context: { - fieldName: 'async', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'async', - }, - }, - { - channel: 'asyncEnd', - context: { - fieldName: 'async', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'async', - result: 'hello-async', - }, - }, - ], - ); - }); - - it('emits start, error, end when a sync resolver throws', async () => { - const document = parse('{ fail }'); - const error = new Error('boom'); - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { - fail: () => { - throw error; - }, - }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'fail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'fail', - }, - }, - { - channel: 'error', - context: { - fieldName: 'fail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'fail', - error, - }, - }, - { - channel: 'end', - context: { - fieldName: 'fail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'fail', - error, - }, - }, - ], - ); - }); - - it('emits full async lifecycle with error when a resolver rejects', async () => { - const document = parse('{ asyncFail }'); - const error = new Error('async-boom'); - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { - asyncFail: () => Promise.reject(error), - }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'asyncFail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'asyncFail', - }, - }, - { - channel: 'end', - context: { - fieldName: 'asyncFail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'asyncFail', - }, - }, - { - channel: 'asyncStart', - context: { - fieldName: 'asyncFail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'asyncFail', - }, - }, - { - channel: 'error', - context: { - fieldName: 'asyncFail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'asyncFail', - error, - }, - }, - { - channel: 'asyncEnd', - context: { - fieldName: 'asyncFail', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'asyncFail', - error, - }, - }, - ], - ); - }); - - it('reports isDefaultResolver based on field.resolve presence', async () => { - const trivialSchema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: 'Query', - fields: { - trivial: { type: GraphQLString }, - custom: { - type: GraphQLString, - resolve: () => 'explicit', - }, - }, - }), - }); - - await expectEvents( - resolveChannel, - () => - execute({ - schema: trivialSchema, - document: parse('{ trivial custom }'), - rootValue: { trivial: 'value' }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'trivial', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'trivial', - }, - }, - { - channel: 'end', - context: { - fieldName: 'trivial', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'trivial', - result: 'value', - }, - }, - { - channel: 'start', - context: { - fieldName: 'custom', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: false, - fieldPath: 'custom', - }, - }, - { - channel: 'end', - context: { - fieldName: 'custom', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: false, - fieldPath: 'custom', - result: 'explicit', - }, - }, - ], - ); - }); - - it('serializes fieldPath lazily, joining path keys with dots', async () => { - const document = parse('{ nested { leaf } }'); - const nested = { leaf: 'leaf-value' }; - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { - nested, - }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'nested', - parentType: 'Query', - fieldType: 'Nested', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested', - }, - }, - { - channel: 'end', - context: { - fieldName: 'nested', - parentType: 'Query', - fieldType: 'Nested', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested', - result: nested, - }, - }, - { - channel: 'start', - context: { - fieldName: 'leaf', - parentType: 'Nested', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested.leaf', - }, - }, - { - channel: 'end', - context: { - fieldName: 'leaf', - parentType: 'Nested', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested.leaf', - result: 'leaf-value', - }, - }, - ], - ); - }); - - it('fires once per field, not per schema walk', async () => { - const document = parse('{ sync plain nested { leaf } }'); - const nested = { leaf: 'leaf-value' }; - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { - sync: () => 'hello', - plain: 'plain-value', - nested, - }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'sync', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'sync', - }, - }, - { - channel: 'end', - context: { - fieldName: 'sync', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'sync', - result: 'hello', - }, - }, - { - channel: 'start', - context: { - fieldName: 'plain', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'plain', - }, - }, - { - channel: 'end', - context: { - fieldName: 'plain', - parentType: 'Query', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'plain', - result: 'plain-value', - }, - }, - { - channel: 'start', - context: { - fieldName: 'nested', - parentType: 'Query', - fieldType: 'Nested', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested', - }, - }, - { - channel: 'end', - context: { - fieldName: 'nested', - parentType: 'Query', - fieldType: 'Nested', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested', - result: nested, - }, - }, - { - channel: 'start', - context: { - fieldName: 'leaf', - parentType: 'Nested', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested.leaf', - }, - }, - { - channel: 'end', - context: { - fieldName: 'leaf', - parentType: 'Nested', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'nested.leaf', - result: 'leaf-value', - }, - }, - ], - ); - }); - - it('emits per-field for serial mutation execution', async () => { - const document = parse('mutation M { first second }'); - - await expectEvents( - resolveChannel, - () => - execute({ - schema, - document, - rootValue: { - first: () => 'one', - second: () => 'two', - }, - }), - () => [ - { - channel: 'start', - context: { - fieldName: 'first', - parentType: 'Mutation', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'first', - }, - }, - { - channel: 'end', - context: { - fieldName: 'first', - parentType: 'Mutation', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'first', - result: 'one', - }, - }, - { - channel: 'start', - context: { - fieldName: 'second', - parentType: 'Mutation', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'second', - }, - }, - { - channel: 'end', - context: { - fieldName: 'second', - parentType: 'Mutation', - fieldType: 'String', - args: {}, - isDefaultResolver: true, - fieldPath: 'second', - result: 'two', - }, - }, - ], - ); - }); - - it('does not call tracing methods when no subscribers are attached', async () => { - const result = await expectNoTracingActivity(resolveChannel, () => - execute({ - schema, - document: parse('{ sync }'), - rootValue: { sync: () => 'hello' }, - }), - ); - expect(result).to.deep.equal({ data: { sync: 'hello' } }); - }); -}); From fe67b0e488e1416b457826a0925437ffcb1bd27c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 27 Apr 2026 06:01:09 +0300 Subject: [PATCH 40/55] fix coverage --- .../__tests__/expectEvents-test.ts | 28 +++++++++++++++++++ .../__tests__/expectNoTracingActivity-test.ts | 18 ++++++++++++ src/__testUtils__/expectNoTracingActivity.ts | 2 ++ 3 files changed, 48 insertions(+) diff --git a/src/__testUtils__/__tests__/expectEvents-test.ts b/src/__testUtils__/__tests__/expectEvents-test.ts index 0ec586ccd4..678da828c1 100644 --- a/src/__testUtils__/__tests__/expectEvents-test.ts +++ b/src/__testUtils__/__tests__/expectEvents-test.ts @@ -97,6 +97,34 @@ describe('expectEvents', () => { ); }); + it('collects events with non-object contexts', async () => { + const channel = createFakeTracingChannel(); + + await expectEvents( + channel, + () => { + channel.start.publish(null); + channel.end.publish(undefined); + channel.error.publish('error'); + return 'done'; + }, + (_result) => [ + { + channel: 'start', + context: null, + }, + { + channel: 'end', + context: undefined, + }, + { + channel: 'error', + context: 'error', + }, + ], + ); + }); + it('unsubscribes when the callback rejects', async () => { let activeHandler: object | undefined; const error = new Error('boom'); diff --git a/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts index 883b4d2fc9..b47452dddd 100644 --- a/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts +++ b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts @@ -75,4 +75,22 @@ describe('expectNoTracingActivity', () => { expect(channel.start.publish).to.equal(originalPublish); }); + + it('fails when traceSync is called', async () => { + const channel = createFakeTracingChannel(); + await expectPromise( + expectNoTracingActivity(channel, () => { + channel.traceSync(() => 'ok', {}, undefined); + }), + ).toRejectWith("expected [ 'traceSync' ] to deeply equal []"); + }); + + it('fails when runStores is called', async () => { + const channel = createFakeTracingChannel(); + await expectPromise( + expectNoTracingActivity(channel, () => { + channel.start.runStores({}, () => 'ok'); + }), + ).toRejectWith("expected [ 'start.runStores' ] to deeply equal []"); + }); }); diff --git a/src/__testUtils__/expectNoTracingActivity.ts b/src/__testUtils__/expectNoTracingActivity.ts index 0e0b6e982a..da0f410939 100644 --- a/src/__testUtils__/expectNoTracingActivity.ts +++ b/src/__testUtils__/expectNoTracingActivity.ts @@ -24,6 +24,7 @@ export async function expectNoTracingActivity( channel, 'traceSync', (original) => + // c8 ignore next 5 function interceptedTraceSync( this: unknown, ...args: Array @@ -55,6 +56,7 @@ export async function expectNoTracingActivity( subChannel, 'runStores', (original) => + // c8 ignore next 6 function interceptedRunStores( this: unknown, ...args: Array From f27be455ae14cc254319dfc5e635a97e7316f33c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 27 Apr 2026 06:12:56 +0300 Subject: [PATCH 41/55] expand integration tests for non latest node --- integrationTests/diagnostics-bun/package.json | 10 + .../{diagnostics => diagnostics-bun}/test.js | 0 .../deno.json | 6 + .../package.json | 7 + .../diagnostics-deno-with-deno-build/test.js | 288 ++++++++++++++++++ .../package.json | 10 + .../diagnostics-deno-with-node-build/test.js | 288 ++++++++++++++++++ .../package.json | 7 +- integrationTests/diagnostics-node20/test.js | 288 ++++++++++++++++++ resources/integration-test.ts | 5 +- 10 files changed, 903 insertions(+), 6 deletions(-) create mode 100644 integrationTests/diagnostics-bun/package.json rename integrationTests/{diagnostics => diagnostics-bun}/test.js (100%) create mode 100644 integrationTests/diagnostics-deno-with-deno-build/deno.json create mode 100644 integrationTests/diagnostics-deno-with-deno-build/package.json create mode 100644 integrationTests/diagnostics-deno-with-deno-build/test.js create mode 100644 integrationTests/diagnostics-deno-with-node-build/package.json create mode 100644 integrationTests/diagnostics-deno-with-node-build/test.js rename integrationTests/{diagnostics => diagnostics-node20}/package.json (53%) create mode 100644 integrationTests/diagnostics-node20/test.js diff --git a/integrationTests/diagnostics-bun/package.json b/integrationTests/diagnostics-bun/package.json new file mode 100644 index 0000000000..4f837f7ab9 --- /dev/null +++ b/integrationTests/diagnostics-bun/package.json @@ -0,0 +1,10 @@ +{ + "description": "graphql-js tracing channels should publish on node:diagnostics_channel (Bun)", + "private": true, + "scripts": { + "test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app oven/bun:\"$BUN_VERSION\"-slim bun test.js" + }, + "dependencies": { + "graphql": "file:../graphql.tgz" + } +} diff --git a/integrationTests/diagnostics/test.js b/integrationTests/diagnostics-bun/test.js similarity index 100% rename from integrationTests/diagnostics/test.js rename to integrationTests/diagnostics-bun/test.js diff --git a/integrationTests/diagnostics-deno-with-deno-build/deno.json b/integrationTests/diagnostics-deno-with-deno-build/deno.json new file mode 100644 index 0000000000..bf1ba58641 --- /dev/null +++ b/integrationTests/diagnostics-deno-with-deno-build/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "graphql": "../graphql-deno-dist/index.ts", + "graphql/": "../graphql-deno-dist/" + } +} diff --git a/integrationTests/diagnostics-deno-with-deno-build/package.json b/integrationTests/diagnostics-deno-with-deno-build/package.json new file mode 100644 index 0000000000..4c8d2c1d37 --- /dev/null +++ b/integrationTests/diagnostics-deno-with-deno-build/package.json @@ -0,0 +1,7 @@ +{ + "description": "graphql-js tracing channels should publish on node:diagnostics_channel (Deno with deno build)", + "private": true, + "scripts": { + "test": "docker run --rm --volume \"$PWD/..\":/usr/src/app -w /usr/src/app/diagnostics-deno-with-deno-build denoland/deno:alpine-\"$DENO_VERSION\" deno run test.js" + } +} diff --git a/integrationTests/diagnostics-deno-with-deno-build/test.js b/integrationTests/diagnostics-deno-with-deno-build/test.js new file mode 100644 index 0000000000..dd1d8e71ba --- /dev/null +++ b/integrationTests/diagnostics-deno-with-deno-build/test.js @@ -0,0 +1,288 @@ +// TracingChannel is marked experimental in Node's docs but is shipped on +// every runtime graphql-js supports. This test exercises it directly. +/* eslint-disable n/no-unsupported-features/node-builtins */ + +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import dc from 'node:diagnostics_channel'; + +import { buildSchema, execute, parse, subscribe, validate } from 'graphql'; + +function runParseCases() { + // graphql:parse - synchronous. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + asyncStart: (msg) => + events.push({ kind: 'asyncStart', source: msg.source }), + asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].source, '{ field }'); + assert.equal(events[1].source, '{ field }'); + } finally { + channel.unsubscribe(handler); + } + } + + // graphql:parse - error path fires start, error, end. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + assert.throws(() => parse('{ ')); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'error', 'end'], + ); + assert.ok(events[1].error instanceof Error); + } finally { + channel.unsubscribe(handler); + } + } +} + +function runValidateCase() { + const schema = buildSchema(`type Query { field: String }`); + const doc = parse('{ field }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + schema: msg.schema, + document: msg.document, + }), + end: () => events.push({ kind: 'end' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:validate'); + channel.subscribe(handler); + + try { + const errors = validate(schema, doc); + assert.deepEqual(errors, []); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].schema, schema); + assert.equal(events[0].document, doc); + } finally { + channel.unsubscribe(handler); + } +} + +function runExecuteCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + document: msg.document, + schema: msg.schema, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].document, document); + assert.equal(events[0].schema, schema); + } finally { + channel.unsubscribe(handler); + } +} + +async function runSubscribeCase() { + async function* ticks() { + yield { tick: 'one' }; + } + + const schema = buildSchema(` + type Query { dummy: String } + type Subscription { tick: String } + `); + // buildSchema doesn't attach a subscribe resolver to fields; inject one. + schema.getSubscriptionType().getFields().tick.subscribe = () => ticks(); + + const document = parse('subscription Tick { tick }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:subscribe'); + channel.subscribe(handler); + + try { + const result = subscribe({ schema, document }); + const stream = typeof result.then === 'function' ? await result : result; + if (stream[Symbol.asyncIterator]) { + await stream.return?.(); + } + // Subscription setup is synchronous here; start/end fire, no async tail. + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'subscription'); + assert.equal(events[0].operationName, 'Tick'); + } finally { + channel.unsubscribe(handler); + } +} + +function runResolveCase() { + const schema = buildSchema( + `type Query { hello: String nested: Nested } type Nested { leaf: String }`, + ); + const document = parse('{ hello nested { leaf } }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + fieldName: msg.fieldName, + parentType: msg.parentType, + fieldType: msg.fieldType, + fieldPath: msg.fieldPath, + isDefaultResolver: msg.isDefaultResolver, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:resolve'); + channel.subscribe(handler); + + try { + const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } }; + execute({ schema, document, rootValue }); + + const starts = events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.fieldPath); + assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']); + + const hello = starts.find((e) => e.fieldName === 'hello'); + assert.equal(hello.parentType, 'Query'); + assert.equal(hello.fieldType, 'String'); + // buildSchema never attaches field.resolve; all fields report as trivial. + assert.equal(hello.isDefaultResolver, true); + } finally { + channel.unsubscribe(handler); + } +} + +function runNoSubscriberCase() { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); +} + +async function runAlsPropagationCase() { + // A subscriber that binds a store on the `start` sub-channel should be able + // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). + // This is what APMs use to parent child spans to the current operation + // without threading state through the ctx object. + const als = new AsyncLocalStorage(); + const channel = dc.tracingChannel('graphql:execute'); + channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + + const seen = {}; + const handler = { + start: () => (seen.start = als.getStore()), + end: () => (seen.end = als.getStore()), + asyncStart: () => (seen.asyncStart = als.getStore()), + asyncEnd: () => (seen.asyncEnd = als.getStore()), + }; + channel.subscribe(handler); + + try { + const schema = buildSchema(`type Query { slow: String }`); + const document = parse('query Slow { slow }'); + const rootValue = { slow: () => Promise.resolve('done') }; + + await execute({ schema, document, rootValue }); + + assert.deepEqual(seen.start, { operationName: 'Slow' }); + assert.deepEqual(seen.end, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncStart, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncEnd, { operationName: 'Slow' }); + } finally { + channel.unsubscribe(handler); + channel.start.unbindStore(als); + } +} + +async function main() { + runParseCases(); + runValidateCase(); + runExecuteCase(); + await runSubscribeCase(); + runResolveCase(); + await runAlsPropagationCase(); + runNoSubscriberCase(); + console.log('diagnostics integration test passed'); +} + +main(); diff --git a/integrationTests/diagnostics-deno-with-node-build/package.json b/integrationTests/diagnostics-deno-with-node-build/package.json new file mode 100644 index 0000000000..21228b0925 --- /dev/null +++ b/integrationTests/diagnostics-deno-with-node-build/package.json @@ -0,0 +1,10 @@ +{ + "description": "graphql-js tracing channels should publish on node:diagnostics_channel (Deno with node build)", + "private": true, + "scripts": { + "test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app denoland/deno:alpine-\"$DENO_VERSION\" deno run --conditions=development test.js" + }, + "dependencies": { + "graphql": "file:../graphql.tgz" + } +} diff --git a/integrationTests/diagnostics-deno-with-node-build/test.js b/integrationTests/diagnostics-deno-with-node-build/test.js new file mode 100644 index 0000000000..dd1d8e71ba --- /dev/null +++ b/integrationTests/diagnostics-deno-with-node-build/test.js @@ -0,0 +1,288 @@ +// TracingChannel is marked experimental in Node's docs but is shipped on +// every runtime graphql-js supports. This test exercises it directly. +/* eslint-disable n/no-unsupported-features/node-builtins */ + +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import dc from 'node:diagnostics_channel'; + +import { buildSchema, execute, parse, subscribe, validate } from 'graphql'; + +function runParseCases() { + // graphql:parse - synchronous. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + asyncStart: (msg) => + events.push({ kind: 'asyncStart', source: msg.source }), + asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].source, '{ field }'); + assert.equal(events[1].source, '{ field }'); + } finally { + channel.unsubscribe(handler); + } + } + + // graphql:parse - error path fires start, error, end. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + assert.throws(() => parse('{ ')); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'error', 'end'], + ); + assert.ok(events[1].error instanceof Error); + } finally { + channel.unsubscribe(handler); + } + } +} + +function runValidateCase() { + const schema = buildSchema(`type Query { field: String }`); + const doc = parse('{ field }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + schema: msg.schema, + document: msg.document, + }), + end: () => events.push({ kind: 'end' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:validate'); + channel.subscribe(handler); + + try { + const errors = validate(schema, doc); + assert.deepEqual(errors, []); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].schema, schema); + assert.equal(events[0].document, doc); + } finally { + channel.unsubscribe(handler); + } +} + +function runExecuteCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + document: msg.document, + schema: msg.schema, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].document, document); + assert.equal(events[0].schema, schema); + } finally { + channel.unsubscribe(handler); + } +} + +async function runSubscribeCase() { + async function* ticks() { + yield { tick: 'one' }; + } + + const schema = buildSchema(` + type Query { dummy: String } + type Subscription { tick: String } + `); + // buildSchema doesn't attach a subscribe resolver to fields; inject one. + schema.getSubscriptionType().getFields().tick.subscribe = () => ticks(); + + const document = parse('subscription Tick { tick }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:subscribe'); + channel.subscribe(handler); + + try { + const result = subscribe({ schema, document }); + const stream = typeof result.then === 'function' ? await result : result; + if (stream[Symbol.asyncIterator]) { + await stream.return?.(); + } + // Subscription setup is synchronous here; start/end fire, no async tail. + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'subscription'); + assert.equal(events[0].operationName, 'Tick'); + } finally { + channel.unsubscribe(handler); + } +} + +function runResolveCase() { + const schema = buildSchema( + `type Query { hello: String nested: Nested } type Nested { leaf: String }`, + ); + const document = parse('{ hello nested { leaf } }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + fieldName: msg.fieldName, + parentType: msg.parentType, + fieldType: msg.fieldType, + fieldPath: msg.fieldPath, + isDefaultResolver: msg.isDefaultResolver, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:resolve'); + channel.subscribe(handler); + + try { + const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } }; + execute({ schema, document, rootValue }); + + const starts = events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.fieldPath); + assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']); + + const hello = starts.find((e) => e.fieldName === 'hello'); + assert.equal(hello.parentType, 'Query'); + assert.equal(hello.fieldType, 'String'); + // buildSchema never attaches field.resolve; all fields report as trivial. + assert.equal(hello.isDefaultResolver, true); + } finally { + channel.unsubscribe(handler); + } +} + +function runNoSubscriberCase() { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); +} + +async function runAlsPropagationCase() { + // A subscriber that binds a store on the `start` sub-channel should be able + // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). + // This is what APMs use to parent child spans to the current operation + // without threading state through the ctx object. + const als = new AsyncLocalStorage(); + const channel = dc.tracingChannel('graphql:execute'); + channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + + const seen = {}; + const handler = { + start: () => (seen.start = als.getStore()), + end: () => (seen.end = als.getStore()), + asyncStart: () => (seen.asyncStart = als.getStore()), + asyncEnd: () => (seen.asyncEnd = als.getStore()), + }; + channel.subscribe(handler); + + try { + const schema = buildSchema(`type Query { slow: String }`); + const document = parse('query Slow { slow }'); + const rootValue = { slow: () => Promise.resolve('done') }; + + await execute({ schema, document, rootValue }); + + assert.deepEqual(seen.start, { operationName: 'Slow' }); + assert.deepEqual(seen.end, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncStart, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncEnd, { operationName: 'Slow' }); + } finally { + channel.unsubscribe(handler); + channel.start.unbindStore(als); + } +} + +async function main() { + runParseCases(); + runValidateCase(); + runExecuteCase(); + await runSubscribeCase(); + runResolveCase(); + await runAlsPropagationCase(); + runNoSubscriberCase(); + console.log('diagnostics integration test passed'); +} + +main(); diff --git a/integrationTests/diagnostics/package.json b/integrationTests/diagnostics-node20/package.json similarity index 53% rename from integrationTests/diagnostics/package.json rename to integrationTests/diagnostics-node20/package.json index 0face40966..335e6d1eee 100644 --- a/integrationTests/diagnostics/package.json +++ b/integrationTests/diagnostics-node20/package.json @@ -1,12 +1,9 @@ { - "description": "graphql-js tracing channels should publish on node:diagnostics_channel", + "description": "graphql-js tracing channels should publish on node:diagnostics_channel (Node 20 with CJS fallback)", "private": true, "type": "module", - "engines": { - "node": ">=22.0.0" - }, "scripts": { - "test": "node test.js" + "test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app node:20.15-alpine node test.js" }, "dependencies": { "graphql": "file:../graphql.tgz" diff --git a/integrationTests/diagnostics-node20/test.js b/integrationTests/diagnostics-node20/test.js new file mode 100644 index 0000000000..dd1d8e71ba --- /dev/null +++ b/integrationTests/diagnostics-node20/test.js @@ -0,0 +1,288 @@ +// TracingChannel is marked experimental in Node's docs but is shipped on +// every runtime graphql-js supports. This test exercises it directly. +/* eslint-disable n/no-unsupported-features/node-builtins */ + +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import dc from 'node:diagnostics_channel'; + +import { buildSchema, execute, parse, subscribe, validate } from 'graphql'; + +function runParseCases() { + // graphql:parse - synchronous. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + asyncStart: (msg) => + events.push({ kind: 'asyncStart', source: msg.source }), + asyncEnd: (msg) => events.push({ kind: 'asyncEnd', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].source, '{ field }'); + assert.equal(events[1].source, '{ field }'); + } finally { + channel.unsubscribe(handler); + } + } + + // graphql:parse - error path fires start, error, end. + { + const events = []; + const handler = { + start: (msg) => events.push({ kind: 'start', source: msg.source }), + end: (msg) => events.push({ kind: 'end', source: msg.source }), + error: (msg) => + events.push({ kind: 'error', source: msg.source, error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:parse'); + channel.subscribe(handler); + + try { + assert.throws(() => parse('{ ')); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'error', 'end'], + ); + assert.ok(events[1].error instanceof Error); + } finally { + channel.unsubscribe(handler); + } + } +} + +function runValidateCase() { + const schema = buildSchema(`type Query { field: String }`); + const doc = parse('{ field }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + schema: msg.schema, + document: msg.document, + }), + end: () => events.push({ kind: 'end' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:validate'); + channel.subscribe(handler); + + try { + const errors = validate(schema, doc); + assert.deepEqual(errors, []); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].schema, schema); + assert.equal(events[0].document, doc); + } finally { + channel.unsubscribe(handler); + } +} + +function runExecuteCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + document: msg.document, + schema: msg.schema, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].document, document); + assert.equal(events[0].schema, schema); + } finally { + channel.unsubscribe(handler); + } +} + +async function runSubscribeCase() { + async function* ticks() { + yield { tick: 'one' }; + } + + const schema = buildSchema(` + type Query { dummy: String } + type Subscription { tick: String } + `); + // buildSchema doesn't attach a subscribe resolver to fields; inject one. + schema.getSubscriptionType().getFields().tick.subscribe = () => ticks(); + + const document = parse('subscription Tick { tick }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:subscribe'); + channel.subscribe(handler); + + try { + const result = subscribe({ schema, document }); + const stream = typeof result.then === 'function' ? await result : result; + if (stream[Symbol.asyncIterator]) { + await stream.return?.(); + } + // Subscription setup is synchronous here; start/end fire, no async tail. + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'subscription'); + assert.equal(events[0].operationName, 'Tick'); + } finally { + channel.unsubscribe(handler); + } +} + +function runResolveCase() { + const schema = buildSchema( + `type Query { hello: String nested: Nested } type Nested { leaf: String }`, + ); + const document = parse('{ hello nested { leaf } }'); + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + fieldName: msg.fieldName, + parentType: msg.parentType, + fieldType: msg.fieldType, + fieldPath: msg.fieldPath, + isDefaultResolver: msg.isDefaultResolver, + }), + end: () => events.push({ kind: 'end' }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:resolve'); + channel.subscribe(handler); + + try { + const rootValue = { hello: () => 'world', nested: { leaf: 'leaf-value' } }; + execute({ schema, document, rootValue }); + + const starts = events.filter((e) => e.kind === 'start'); + const paths = starts.map((e) => e.fieldPath); + assert.deepEqual(paths, ['hello', 'nested', 'nested.leaf']); + + const hello = starts.find((e) => e.fieldName === 'hello'); + assert.equal(hello.parentType, 'Query'); + assert.equal(hello.fieldType, 'String'); + // buildSchema never attaches field.resolve; all fields report as trivial. + assert.equal(hello.isDefaultResolver, true); + } finally { + channel.unsubscribe(handler); + } +} + +function runNoSubscriberCase() { + const doc = parse('{ field }'); + assert.equal(doc.kind, 'Document'); +} + +async function runAlsPropagationCase() { + // A subscriber that binds a store on the `start` sub-channel should be able + // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). + // This is what APMs use to parent child spans to the current operation + // without threading state through the ctx object. + const als = new AsyncLocalStorage(); + const channel = dc.tracingChannel('graphql:execute'); + channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + + const seen = {}; + const handler = { + start: () => (seen.start = als.getStore()), + end: () => (seen.end = als.getStore()), + asyncStart: () => (seen.asyncStart = als.getStore()), + asyncEnd: () => (seen.asyncEnd = als.getStore()), + }; + channel.subscribe(handler); + + try { + const schema = buildSchema(`type Query { slow: String }`); + const document = parse('query Slow { slow }'); + const rootValue = { slow: () => Promise.resolve('done') }; + + await execute({ schema, document, rootValue }); + + assert.deepEqual(seen.start, { operationName: 'Slow' }); + assert.deepEqual(seen.end, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncStart, { operationName: 'Slow' }); + assert.deepEqual(seen.asyncEnd, { operationName: 'Slow' }); + } finally { + channel.unsubscribe(handler); + channel.start.unbindStore(als); + } +} + +async function main() { + runParseCases(); + runValidateCase(); + runExecuteCase(); + await runSubscribeCase(); + runResolveCase(); + await runAlsPropagationCase(); + runNoSubscriberCase(); + console.log('diagnostics integration test passed'); +} + +main(); diff --git a/resources/integration-test.ts b/resources/integration-test.ts index 5523892090..b3616a9a42 100644 --- a/resources/integration-test.ts +++ b/resources/integration-test.ts @@ -55,7 +55,10 @@ describe('Integration Tests', () => { testOnNodeProject('webpack'); // Tracing channel tests - testOnNodeProject('diagnostics'); + testOnNodeProject('diagnostics-node20'); + testOnNodeProject('diagnostics-bun'); + testOnNodeProject('diagnostics-deno-with-deno-build'); + testOnNodeProject('diagnostics-deno-with-node-build'); // Conditional export tests testOnNodeProject('conditions'); From d7b5e63b23505336ced862a1a99576264d222805 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 27 Apr 2026 10:20:16 -0400 Subject: [PATCH 42/55] support runtimes without TracingChannel.hasSubscribers aggregate Bun's node:diagnostics_channel ships TracingChannel but does not expose the aggregate hasSubscribers getter, so a subscriber added via tracingChannel.subscribe(handlers) is invisible to the previous ?.hasSubscribers gate and graphql-js silently drops every event. Introduce a shouldTrace helper that trusts the aggregate when present and falls back to checking each of the five underlying lifecycle channels when it is undefined. Route every emission gate (parse/validate/execute/ subscribe/resolve) through it. --- src/diagnostics.ts | 32 +++++++++++++++++++++++++++++++- src/execution/Executor.ts | 4 ++-- src/execution/execute.ts | 11 ++++++----- src/language/parser.ts | 4 ++-- src/validation/validate.ts | 4 ++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 3605b27637..36a3cc0f51 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -20,6 +20,7 @@ import { isPromise } from './jsutils/isPromise.js'; * @internal */ export interface MinimalChannel { + readonly hasSubscribers?: boolean; publish: (message: unknown) => void; runStores: ( context: ContextType, @@ -37,7 +38,9 @@ export interface MinimalChannel { * @internal */ export interface MinimalTracingChannel { - readonly hasSubscribers: boolean; + // `undefined` accommodates runtimes (e.g. Bun) that ship `tracingChannel` + // without exposing the aggregate `hasSubscribers` getter. + readonly hasSubscribers: boolean | undefined; readonly start: MinimalChannel; readonly end: MinimalChannel; readonly asyncStart: MinimalChannel; @@ -126,6 +129,33 @@ export const subscribeChannel: MinimalTracingChannel | undefined = export const resolveChannel: MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:resolve'); +const SUB_CHANNEL_KEYS: ReadonlyArray< + 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error' +> = ['start', 'end', 'asyncStart', 'asyncEnd', 'error']; + +/** + * Whether emission sites should publish to `channel`. Trusts the + * `TracingChannel.hasSubscribers` aggregate when the runtime exposes it; if + * the getter is missing (e.g. Bun's `node:diagnostics_channel`, where + * `tracingChannel.hasSubscribers` is `undefined`), falls back to checking + * each of the five underlying lifecycle channels so a subscriber attached + * via `tracingChannel.subscribe(handlers)` is still observed. + * + * @internal + */ +export function shouldTrace( + channel: MinimalTracingChannel | undefined, +): channel is MinimalTracingChannel { + if (channel == null) { + return false; + } + const aggregate = channel.hasSubscribers; + if (aggregate !== undefined) { + return aggregate; + } + return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers === true); +} + /** * Publish a mixed sync-or-promise operation through `channel`. Caller has * already verified that a subscriber is attached. diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index acd340199b..5c00d6a2e2 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -45,7 +45,7 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import { resolveChannel, traceMixed } from '../diagnostics.ts'; +import { resolveChannel, shouldTrace, traceMixed } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; @@ -578,7 +578,7 @@ export class Executor< const returnType = fieldDef.type; let resolveFn = fieldDef.resolve ?? validatedExecutionArgs.fieldResolver; - if (resolveChannel?.hasSubscribers) { + if (shouldTrace(resolveChannel)) { const channel = resolveChannel; const originalResolveFn = resolveFn; resolveFn = (s, args, c, info) => diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 24d6d2b0ae..759f7ef60f 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -32,6 +32,7 @@ import { getOperationAST } from '../utilities/getOperationAST.ts'; import { executeChannel, + shouldTrace, subscribeChannel, traceMixed, } from '../diagnostics.ts'; @@ -79,7 +80,7 @@ export type RootSelectionSetExecutor = ( * delivery. */ export function execute(args: ExecutionArgs): PromiseOrValue { - if (!executeChannel?.hasSubscribers) { + if (!shouldTrace(executeChannel)) { return executeImpl(args); } return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => @@ -142,7 +143,7 @@ function executeImpl(args: ExecutionArgs): PromiseOrValue { export function experimentalExecuteIncrementally( args: ExecutionArgs, ): PromiseOrValue { - if (!executeChannel?.hasSubscribers) { + if (!shouldTrace(executeChannel)) { return experimentalExecuteIncrementallyImpl(args); } return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => @@ -167,7 +168,7 @@ function experimentalExecuteIncrementallyImpl( export function executeIgnoringIncremental( args: ExecutionArgs, ): PromiseOrValue { - if (!executeChannel?.hasSubscribers) { + if (!shouldTrace(executeChannel)) { return executeIgnoringIncrementalImpl(args); } return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => @@ -281,7 +282,7 @@ export function subscribe( ): PromiseOrValue< AsyncGenerator | ExecutionResult > { - if (!subscribeChannel?.hasSubscribers) { + if (!shouldTrace(subscribeChannel)) { return subscribeImpl(args); } return traceMixed(subscribeChannel, buildExecuteCtxFromArgs(args), () => @@ -626,7 +627,7 @@ export function mapSourceToResponseEvent( ...validatedExecutionArgs, rootValue: payload, }; - if (!executeChannel?.hasSubscribers) { + if (!shouldTrace(executeChannel)) { return rootSelectionSetExecutor(perEventExecutionArgs); } return traceMixed( diff --git a/src/language/parser.ts b/src/language/parser.ts index f220c95d85..47e40eb1e7 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,7 +3,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; -import { parseChannel } from '../diagnostics.js'; +import { parseChannel, shouldTrace } from '../diagnostics.js'; import type { ArgumentCoordinateNode, @@ -147,7 +147,7 @@ export function parse( source: string | Source, options?: ParseOptions, ): DocumentNode { - return parseChannel?.hasSubscribers + return shouldTrace(parseChannel) ? parseChannel.traceSync(() => parseImpl(source, options), { source }) : parseImpl(source, options); } diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 87024d977a..30a7ff12fa 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -12,7 +12,7 @@ import { assertValidSchema } from '../type/validate.ts'; import { TypeInfo, visitWithTypeInfo } from '../utilities/TypeInfo.ts'; -import { validateChannel } from '../diagnostics.ts'; +import { shouldTrace, validateChannel } from '../diagnostics.ts'; import { specifiedRules, specifiedSDLRules } from './specifiedRules.ts'; import type { SDLValidationRule, ValidationRule } from './ValidationContext.ts'; @@ -63,7 +63,7 @@ export function validate( rules: ReadonlyArray = specifiedRules, options?: ValidationOptions, ): ReadonlyArray { - return validateChannel?.hasSubscribers + return shouldTrace(validateChannel) ? validateChannel.traceSync( () => validateImpl(schema, documentAST, rules, options), { schema, document: documentAST }, From 878feb9b30055c151188127e190a7cc2dc0efe39 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 27 Apr 2026 12:08:38 -0400 Subject: [PATCH 43/55] cover shouldTrace: real-channel test plus c8 ignore for Bun fallback --- src/__tests__/diagnostics-test.ts | 32 ++++++++++++++++++++++++++++++- src/diagnostics.ts | 7 ++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index c7c97b0c60..e076406cc4 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import/no-nodejs-modules, n/no-unsupported-features/node-builtins */ import dc from 'node:diagnostics_channel'; import { expect } from 'chai'; @@ -6,10 +6,12 @@ import { describe, it } from 'mocha'; import { invariant } from '../jsutils/invariant.js'; +import type { MinimalTracingChannel } from '../diagnostics.js'; import { executeChannel, parseChannel, resolveChannel, + shouldTrace, subscribeChannel, validateChannel, } from '../diagnostics.js'; @@ -40,4 +42,32 @@ describe('diagnostics', () => { dc.channel('tracing:graphql:resolve:start'), ); }); + + describe('shouldTrace', () => { + it('returns false when channel is undefined', () => { + expect(shouldTrace(undefined)).to.equal(false); + }); + + it('reflects the aggregate hasSubscribers on a real tracing channel', () => { + const tc = dc.tracingChannel( + 'shouldTrace:aggregate', + ) as unknown as MinimalTracingChannel; + expect(shouldTrace(tc)).to.equal(false); + + const handler = { + start: () => undefined, + end: () => undefined, + asyncStart: () => undefined, + asyncEnd: () => undefined, + error: () => undefined, + }; + const realTC = dc.tracingChannel('shouldTrace:aggregate'); + realTC.subscribe(handler); + try { + expect(shouldTrace(tc)).to.equal(true); + } finally { + realTC.unsubscribe(handler); + } + }); + }); }); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 36a3cc0f51..b3006d5c69 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -150,10 +150,11 @@ export function shouldTrace( return false; } const aggregate = channel.hasSubscribers; - if (aggregate !== undefined) { - return aggregate; + /* c8 ignore next 3: Bun-only fallback, exercised by integrationTests/diagnostics-bun. */ + if (aggregate === undefined) { + return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers === true); } - return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers === true); + return aggregate; } /** From 4d4a138fa29576fb854937f68db71afb6fd180a4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 27 Apr 2026 12:34:49 -0400 Subject: [PATCH 44/55] restore aggregate-first branch order in shouldTrace V8 inlines executeField slightly better when the never-taken-on-Node branch is the trailing fallback rather than an early-returning if body. The flipped form cost ~5% on introspection; the original ordering plus c8 ignore start/stop over the Bun-only branch keeps both 100% coverage and the perf characteristics. --- src/diagnostics.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index b3006d5c69..57a5d55864 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -150,11 +150,12 @@ export function shouldTrace( return false; } const aggregate = channel.hasSubscribers; - /* c8 ignore next 3: Bun-only fallback, exercised by integrationTests/diagnostics-bun. */ - if (aggregate === undefined) { - return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers === true); + /* c8 ignore start: Bun-only fallback, exercised by integrationTests/diagnostics-bun. */ + if (aggregate !== undefined) { + return aggregate; } - return aggregate; + return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers); + /* c8 ignore stop */ } /** From b3fd003906b9b31fe89142707b7996a985fcdfb4 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 27 Apr 2026 20:40:50 +0300 Subject: [PATCH 45/55] use helpers extracted from this PR now on 17.x.x --- .../__tests__/catchThrownError-test.ts | 22 ------ .../__tests__/interceptMethod-test.ts | 52 -------------- src/__testUtils__/__tests__/spyOn-test.ts | 46 +++++++++++++ src/__testUtils__/catchThrownError.ts | 9 --- src/__testUtils__/expectNoTracingActivity.ts | 68 +++++-------------- src/__testUtils__/interceptMethod.ts | 19 ------ src/__testUtils__/spyOn.ts | 20 +++++- .../__tests__/diagnostics-execute-test.ts | 5 +- .../__tests__/diagnostics-subscribe-test.ts | 5 +- src/language/__tests__/diagnostics-test.ts | 4 +- src/validation/__tests__/diagnostics-test.ts | 4 +- 11 files changed, 88 insertions(+), 166 deletions(-) delete mode 100644 src/__testUtils__/__tests__/catchThrownError-test.ts delete mode 100644 src/__testUtils__/__tests__/interceptMethod-test.ts delete mode 100644 src/__testUtils__/catchThrownError.ts delete mode 100644 src/__testUtils__/interceptMethod.ts diff --git a/src/__testUtils__/__tests__/catchThrownError-test.ts b/src/__testUtils__/__tests__/catchThrownError-test.ts deleted file mode 100644 index 1ca238ba32..0000000000 --- a/src/__testUtils__/__tests__/catchThrownError-test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect } from 'chai'; -import { describe, it } from 'mocha'; - -import { catchThrownError } from '../catchThrownError.js'; - -describe('catchThrownError', () => { - it('returns the thrown value', () => { - const error = new Error('boom'); - - expect( - catchThrownError(() => { - throw error; - }), - ).to.equal(error); - }); - - it('throws when the function does not throw', () => { - expect(() => catchThrownError(() => undefined)).to.throw( - 'Expected function to throw.', - ); - }); -}); diff --git a/src/__testUtils__/__tests__/interceptMethod-test.ts b/src/__testUtils__/__tests__/interceptMethod-test.ts deleted file mode 100644 index d9dc9f070f..0000000000 --- a/src/__testUtils__/__tests__/interceptMethod-test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect } from 'chai'; -import { describe, it } from 'mocha'; - -import { interceptMethod } from '../interceptMethod.js'; - -describe('interceptMethod', () => { - it('wraps a method and preserves this binding', () => { - const calls: Array = []; - const target = { - value: 3, - add(delta: number): number { - return this.value + delta; - }, - }; - - const restore = interceptMethod( - target, - 'add', - (original) => - function interceptedAdd(this: unknown, ...args: Array) { - const [delta] = args as [number]; - calls.push(delta); - return original.call(this, delta * 2); - }, - ); - - expect(target.add(4)).to.equal(11); - expect(calls).to.deep.equal([4]); - - restore(); - - expect(target.add(4)).to.equal(7); - }); - - it('restores the original method', () => { - const target = { - value(): string { - return 'original'; - }, - }; - const original = target.value; - - const restore = interceptMethod(target, 'value', () => () => 'wrapped'); - - expect(target.value()).to.equal('wrapped'); - - restore(); - - expect(target.value).to.equal(original); - expect(target.value()).to.equal('original'); - }); -}); diff --git a/src/__testUtils__/__tests__/spyOn-test.ts b/src/__testUtils__/__tests__/spyOn-test.ts index 65102dae5c..2e6208c343 100644 --- a/src/__testUtils__/__tests__/spyOn-test.ts +++ b/src/__testUtils__/__tests__/spyOn-test.ts @@ -65,4 +65,50 @@ describe('spyOnMethod', () => { "Cannot spy on 'maybeMethod' because it is not a function.", ); }); + + it('restores the original method', () => { + const obj = { + add(a: number, b: number) { + return a + b; + }, + }; + + const originalAdd = obj.add; + const spy = spyOnMethod(obj, 'add'); + + expect(obj.add).to.not.equal(originalAdd); + expect(spy.callCount).to.equal(0); + + obj.add(1, 2); + expect(spy.callCount).to.equal(1); + + spy.restore(); + + expect(obj.add).to.equal(originalAdd); + obj.add(3, 4); + expect(spy.callCount).to.equal(1); // no longer tracked + }); + + it('restores inherited methods by removing the own-property spy', () => { + class Base { + add(a: number, b: number) { + return a + b; + } + } + const instance = new Base(); + + expect(Object.hasOwn(instance, 'add')).to.equal(false); + + const spy = spyOnMethod(instance, 'add'); + expect(Object.hasOwn(instance, 'add')).to.equal(true); + + instance.add(1, 2); + expect(spy.callCount).to.equal(1); + + spy.restore(); + + expect(Object.hasOwn(instance, 'add')).to.equal(false); + instance.add(3, 4); + expect(spy.callCount).to.equal(1); // no longer tracked, prototype method again + }); }); diff --git a/src/__testUtils__/catchThrownError.ts b/src/__testUtils__/catchThrownError.ts deleted file mode 100644 index e1c2ea9830..0000000000 --- a/src/__testUtils__/catchThrownError.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function catchThrownError(fn: () => unknown): unknown { - try { - fn(); - } catch (error) { - return error; - } - - throw new Error('Expected function to throw.'); -} diff --git a/src/__testUtils__/expectNoTracingActivity.ts b/src/__testUtils__/expectNoTracingActivity.ts index da0f410939..28c19c55e0 100644 --- a/src/__testUtils__/expectNoTracingActivity.ts +++ b/src/__testUtils__/expectNoTracingActivity.ts @@ -3,11 +3,12 @@ import { expect } from 'chai'; import type { MinimalTracingChannel } from '../diagnostics.js'; import { tracingSubChannels } from './diagnosticsTracing.js'; -import { interceptMethod } from './interceptMethod.js'; +import type { MethodSpy } from './spyOn.js'; +import { spyOnMethod } from './spyOn.js'; /** * Assert that a graphql tracing channel stays on its zero-subscriber fast path. - * The test installs wrappers around the real tracing methods and verifies none + * The test installs spies around the real tracing methods and verifies none * of them were touched while `fn` ran. */ export async function expectNoTracingActivity( @@ -16,65 +17,28 @@ export async function expectNoTracingActivity( ): Promise> { expect(channel.hasSubscribers).to.equal(false); - const calls: Array = []; - const restore: Array<() => void> = []; - - restore.push( - interceptMethod( - channel, - 'traceSync', - (original) => - // c8 ignore next 5 - function interceptedTraceSync( - this: unknown, - ...args: Array - ): unknown { - calls.push('traceSync'); - return original.apply(this, args); - }, - ), - ); + const namedSpies: Array<[string, MethodSpy]> = []; + namedSpies.push(['traceSync', spyOnMethod(channel, 'traceSync')]); for (const phase of tracingSubChannels) { const subChannel = channel[phase]; - restore.push( - interceptMethod( - subChannel, - 'publish', - (original) => - function interceptedPublish( - this: unknown, - ...args: Array - ): unknown { - calls.push(`${phase}.publish`); - return original.apply(this, args); - }, - ), - ); - restore.push( - interceptMethod( - subChannel, - 'runStores', - (original) => - // c8 ignore next 6 - function interceptedRunStores( - this: unknown, - ...args: Array - ): unknown { - calls.push(`${phase}.runStores`); - return original.apply(this, args); - }, - ), - ); + namedSpies.push([`${phase}.publish`, spyOnMethod(subChannel, 'publish')]); + namedSpies.push([ + `${phase}.runStores`, + spyOnMethod(subChannel, 'runStores'), + ]); } try { const result = await fn(); - expect(calls).to.deep.equal([]); + const calledMethods = namedSpies + .filter(([, spy]) => spy.callCount > 0) + .map(([name]) => name); + expect(calledMethods).to.deep.equal([]); return result; } finally { - while (restore.length > 0) { - restore.pop()?.(); + for (const [, spy] of namedSpies) { + spy.restore(); } } } diff --git a/src/__testUtils__/interceptMethod.ts b/src/__testUtils__/interceptMethod.ts deleted file mode 100644 index 2487086141..0000000000 --- a/src/__testUtils__/interceptMethod.ts +++ /dev/null @@ -1,19 +0,0 @@ -type InterceptedMethod = (this: unknown, ...args: Array) => unknown; - -/** - * Replace one method on an object for the duration of a test and return a - * restore callback for putting the original method back. - */ -export function interceptMethod( - target: object, - key: string, - createReplacement: (original: InterceptedMethod) => InterceptedMethod, -): () => void { - const objectTarget = target as { [key: string]: unknown }; - const original = objectTarget[key] as InterceptedMethod; - objectTarget[key] = createReplacement(original); - - return () => { - objectTarget[key] = original; - }; -} diff --git a/src/__testUtils__/spyOn.ts b/src/__testUtils__/spyOn.ts index 934db41b2f..9ac38e3450 100644 --- a/src/__testUtils__/spyOn.ts +++ b/src/__testUtils__/spyOn.ts @@ -2,6 +2,7 @@ type AnyFn = (...args: Array) => any; export interface MethodSpy { readonly callCount: number; + restore: () => void; } export type SpyFn = T & MethodSpy; @@ -29,6 +30,7 @@ export function spyOnMethod( key: keyof T, ): MethodSpy { const original = target[key]; + const wasOwnProperty = Object.hasOwn(target, key); if (typeof original !== 'function') { throw new Error( @@ -36,7 +38,21 @@ export function spyOnMethod( ); } - const spy = spyOn(original as unknown as AnyFn); + const spy = spyOn(original as AnyFn); target[key] = spy as T[keyof T]; - return spy; + + const methodSpy: MethodSpy = { + get callCount() { + return spy.callCount; + }, + restore() { + if (wasOwnProperty) { + target[key] = original; + } else { + delete target[key]; + } + }, + }; + + return methodSpy; } diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index 3e6631a79a..be19ab2905 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -1,10 +1,10 @@ import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; -import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; import { expectEvents } from '../../__testUtils__/expectEvents.js'; import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; import { expectPromise } from '../../__testUtils__/expectPromise.js'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; @@ -315,8 +315,7 @@ describe('execute diagnostics channel', () => { await expectEvents( executeChannel, - () => - catchThrownError(() => execute({ schema: invalidSchema, document })), + () => expectToThrow(() => execute({ schema: invalidSchema, document })), (error) => [ { channel: 'start', diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts index 48bb24c96d..7c717bb752 100644 --- a/src/execution/__tests__/diagnostics-subscribe-test.ts +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -1,9 +1,9 @@ import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; -import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; import { expectEvents } from '../../__testUtils__/expectEvents.js'; import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; @@ -192,8 +192,7 @@ describe('subscribe diagnostics channel', () => { await expectEvents( subscribeChannel, - () => - catchThrownError(() => subscribe({ schema: invalidSchema, document })), + () => expectToThrow(() => subscribe({ schema: invalidSchema, document })), (error) => [ { channel: 'start', diff --git a/src/language/__tests__/diagnostics-test.ts b/src/language/__tests__/diagnostics-test.ts index 3abdb68ced..8e067561bd 100644 --- a/src/language/__tests__/diagnostics-test.ts +++ b/src/language/__tests__/diagnostics-test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; import { expectEvents } from '../../__testUtils__/expectEvents.js'; import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { parse } from '../parser.js'; @@ -29,7 +29,7 @@ describe('parse diagnostics channel', () => { await expectEvents( parseChannel, - () => catchThrownError(() => parse(source)), + () => expectToThrow(() => parse(source)), (error) => [ { channel: 'start', context: { source } }, { diff --git a/src/validation/__tests__/diagnostics-test.ts b/src/validation/__tests__/diagnostics-test.ts index 512ab7e65e..d96e2c9138 100644 --- a/src/validation/__tests__/diagnostics-test.ts +++ b/src/validation/__tests__/diagnostics-test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; -import { catchThrownError } from '../../__testUtils__/catchThrownError.js'; import { expectEvents } from '../../__testUtils__/expectEvents.js'; import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { parse } from '../../language/parser.js'; @@ -57,7 +57,7 @@ describe('validate diagnostics channel', () => { await expectEvents( validateChannel, - () => catchThrownError(() => validate(context.schema, context.document)), + () => expectToThrow(() => validate(context.schema, context.document)), (error) => [ { channel: 'start', From 72398ebac5a87cf0a0dee409cd47209a9654f749 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 27 Apr 2026 20:50:44 +0300 Subject: [PATCH 46/55] narrow ignore --- src/diagnostics.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 57a5d55864..31f7b1f6e4 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -150,10 +150,11 @@ export function shouldTrace( return false; } const aggregate = channel.hasSubscribers; - /* c8 ignore start: Bun-only fallback, exercised by integrationTests/diagnostics-bun. */ if (aggregate !== undefined) { return aggregate; + /* c8 ignore start */ } + // Bun-only fallback, exercised by integrationTests/diagnostics-bun. return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers); /* c8 ignore stop */ } From 47cfa62983b45c62d0248a2fae693e856b2527c1 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 27 Apr 2026 22:58:53 +0300 Subject: [PATCH 47/55] just iterate --- src/diagnostics.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 31f7b1f6e4..3f0dcd0d57 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -155,7 +155,12 @@ export function shouldTrace( /* c8 ignore start */ } // Bun-only fallback, exercised by integrationTests/diagnostics-bun. - return SUB_CHANNEL_KEYS.some((key) => channel[key].hasSubscribers); + for (const key of SUB_CHANNEL_KEYS) { + if (channel[key].hasSubscribers) { + return true; + } + } + return false; /* c8 ignore stop */ } From f5f77720f75b4a889d59651b1aa4524d2d716a56 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 20:51:41 +0300 Subject: [PATCH 48/55] simplify context build --- src/execution/execute.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 759f7ef60f..16feb4e2a7 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -784,26 +784,12 @@ function buildExecuteCtxFromValidatedArgs( return { operation: args.operation, schema: args.schema, - variableValues: getOriginalVariableValues(args), + variableValues: args.variableValues.sources, operationName: args.operation.name?.value, operationType: args.operation.operation, }; } -function getOriginalVariableValues( - args: ValidatedExecutionArgs, -): Maybe<{ readonly [variable: string]: unknown }> { - const originalVariableValues: { [variable: string]: unknown } = {}; - for (const [variableName, source] of Object.entries( - args.variableValues.sources, - )) { - if (Object.hasOwn(source, 'value')) { - originalVariableValues[variableName] = source.value; - } - } - - return originalVariableValues; -} function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); From d922bdd9169f7ab4d32ae4df288d295a494d6cbf Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 21:39:49 +0300 Subject: [PATCH 49/55] revert "simplification" --- src/execution/execute.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 16feb4e2a7..4b7cbc55a6 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -781,10 +781,19 @@ function assertEventStream(result: unknown): AsyncIterable { function buildExecuteCtxFromValidatedArgs( args: ValidatedExecutionArgs, ): object { + let originalVariableValues: Maybe<{ readonly [variable: string]: unknown }>; + let hasResolvedOriginalVariableValues = false; + return { operation: args.operation, schema: args.schema, - variableValues: args.variableValues.sources, + get variableValues() { + if (!hasResolvedOriginalVariableValues) { + originalVariableValues = getOriginalVariableValues(args); + hasResolvedOriginalVariableValues = true; + } + return originalVariableValues; + }, operationName: args.operation.name?.value, operationType: args.operation.operation, }; From f8a3d9c7f875308a7d4b8bd40b2ee0d9188a4db7 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 21:30:50 +0300 Subject: [PATCH 50/55] introduce execute:rootSelectionSet and subscribe:perEventExecutor --- integrationTests/diagnostics-bun/test.js | 46 ++++++ .../diagnostics-deno-with-deno-build/test.js | 46 ++++++ .../diagnostics-deno-with-node-build/test.js | 46 ++++++ integrationTests/diagnostics-node20/test.js | 46 ++++++ src/__tests__/diagnostics-test.ts | 7 +- src/diagnostics.ts | 14 +- src/execution/Executor.ts | 55 ++++++- .../__tests__/diagnostics-execute-test.ts | 134 +++++++++++------- .../__tests__/diagnostics-subscribe-test.ts | 83 +++++++++++ src/execution/execute.ts | 14 ++ 10 files changed, 432 insertions(+), 59 deletions(-) diff --git a/integrationTests/diagnostics-bun/test.js b/integrationTests/diagnostics-bun/test.js index dd1d8e71ba..9d7eb8419d 100644 --- a/integrationTests/diagnostics-bun/test.js +++ b/integrationTests/diagnostics-bun/test.js @@ -141,6 +141,51 @@ function runExecuteCase() { } } +function runExecuteRootSelectionSetCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + const operation = document.definitions[0]; + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + operation: msg.operation, + schema: msg.schema, + }), + end: (msg) => events.push({ kind: 'end', result: msg.result }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute:rootSelectionSet'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].operation, operation); + assert.equal(events[0].schema, schema); + assert.equal(events[1].result, result); + } finally { + channel.unsubscribe(handler); + } +} + async function runSubscribeCase() { async function* ticks() { yield { tick: 'one' }; @@ -278,6 +323,7 @@ async function main() { runParseCases(); runValidateCase(); runExecuteCase(); + runExecuteRootSelectionSetCase(); await runSubscribeCase(); runResolveCase(); await runAlsPropagationCase(); diff --git a/integrationTests/diagnostics-deno-with-deno-build/test.js b/integrationTests/diagnostics-deno-with-deno-build/test.js index dd1d8e71ba..9d7eb8419d 100644 --- a/integrationTests/diagnostics-deno-with-deno-build/test.js +++ b/integrationTests/diagnostics-deno-with-deno-build/test.js @@ -141,6 +141,51 @@ function runExecuteCase() { } } +function runExecuteRootSelectionSetCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + const operation = document.definitions[0]; + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + operation: msg.operation, + schema: msg.schema, + }), + end: (msg) => events.push({ kind: 'end', result: msg.result }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute:rootSelectionSet'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].operation, operation); + assert.equal(events[0].schema, schema); + assert.equal(events[1].result, result); + } finally { + channel.unsubscribe(handler); + } +} + async function runSubscribeCase() { async function* ticks() { yield { tick: 'one' }; @@ -278,6 +323,7 @@ async function main() { runParseCases(); runValidateCase(); runExecuteCase(); + runExecuteRootSelectionSetCase(); await runSubscribeCase(); runResolveCase(); await runAlsPropagationCase(); diff --git a/integrationTests/diagnostics-deno-with-node-build/test.js b/integrationTests/diagnostics-deno-with-node-build/test.js index dd1d8e71ba..9d7eb8419d 100644 --- a/integrationTests/diagnostics-deno-with-node-build/test.js +++ b/integrationTests/diagnostics-deno-with-node-build/test.js @@ -141,6 +141,51 @@ function runExecuteCase() { } } +function runExecuteRootSelectionSetCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + const operation = document.definitions[0]; + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + operation: msg.operation, + schema: msg.schema, + }), + end: (msg) => events.push({ kind: 'end', result: msg.result }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute:rootSelectionSet'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].operation, operation); + assert.equal(events[0].schema, schema); + assert.equal(events[1].result, result); + } finally { + channel.unsubscribe(handler); + } +} + async function runSubscribeCase() { async function* ticks() { yield { tick: 'one' }; @@ -278,6 +323,7 @@ async function main() { runParseCases(); runValidateCase(); runExecuteCase(); + runExecuteRootSelectionSetCase(); await runSubscribeCase(); runResolveCase(); await runAlsPropagationCase(); diff --git a/integrationTests/diagnostics-node20/test.js b/integrationTests/diagnostics-node20/test.js index dd1d8e71ba..9d7eb8419d 100644 --- a/integrationTests/diagnostics-node20/test.js +++ b/integrationTests/diagnostics-node20/test.js @@ -141,6 +141,51 @@ function runExecuteCase() { } } +function runExecuteRootSelectionSetCase() { + const schema = buildSchema(`type Query { hello: String }`); + const document = parse('query Greeting { hello }'); + const operation = document.definitions[0]; + + const events = []; + const handler = { + start: (msg) => + events.push({ + kind: 'start', + operationType: msg.operationType, + operationName: msg.operationName, + operation: msg.operation, + schema: msg.schema, + }), + end: (msg) => events.push({ kind: 'end', result: msg.result }), + asyncStart: () => events.push({ kind: 'asyncStart' }), + asyncEnd: () => events.push({ kind: 'asyncEnd' }), + error: (msg) => events.push({ kind: 'error', error: msg.error }), + }; + + const channel = dc.tracingChannel('graphql:execute:rootSelectionSet'); + channel.subscribe(handler); + + try { + const result = execute({ + schema, + document, + rootValue: { hello: 'world' }, + }); + assert.equal(result.data.hello, 'world'); + assert.deepEqual( + events.map((e) => e.kind), + ['start', 'end'], + ); + assert.equal(events[0].operationType, 'query'); + assert.equal(events[0].operationName, 'Greeting'); + assert.equal(events[0].operation, operation); + assert.equal(events[0].schema, schema); + assert.equal(events[1].result, result); + } finally { + channel.unsubscribe(handler); + } +} + async function runSubscribeCase() { async function* ticks() { yield { tick: 'one' }; @@ -278,6 +323,7 @@ async function main() { runParseCases(); runValidateCase(); runExecuteCase(); + runExecuteRootSelectionSetCase(); await runSubscribeCase(); runResolveCase(); await runAlsPropagationCase(); diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index e076406cc4..ebde313e60 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -9,6 +9,7 @@ import { invariant } from '../jsutils/invariant.js'; import type { MinimalTracingChannel } from '../diagnostics.js'; import { executeChannel, + executeRootSelectionSetChannel, parseChannel, resolveChannel, shouldTrace, @@ -17,10 +18,11 @@ import { } from '../diagnostics.js'; describe('diagnostics', () => { - it('auto-registers the five graphql tracing channels', () => { + it('auto-registers the graphql tracing channels', () => { invariant(parseChannel !== undefined); invariant(validateChannel !== undefined); invariant(executeChannel !== undefined); + invariant(executeRootSelectionSetChannel !== undefined); invariant(subscribeChannel !== undefined); invariant(resolveChannel !== undefined); @@ -35,6 +37,9 @@ describe('diagnostics', () => { expect(executeChannel.start).to.equal( dc.channel('tracing:graphql:execute:start'), ); + expect(executeRootSelectionSetChannel.start).to.equal( + dc.channel('tracing:graphql:execute:rootSelectionSet:start'), + ); expect(subscribeChannel.start).to.equal( dc.channel('tracing:graphql:subscribe:start'), ); diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 3f0dcd0d57..56bee58ac7 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -4,11 +4,11 @@ * * graphql-js publishes lifecycle events on a set of named tracing channels * that APM tools can subscribe to in order to observe parse, validate, - * execute, subscribe, and resolver behavior. At module load time graphql-js - * resolves `node:diagnostics_channel` itself so APMs do not need to interact - * with the graphql API to enable tracing. On runtimes that do not expose - * `node:diagnostics_channel` (e.g., browsers) the load silently no-ops and - * emission sites short-circuit. + * execute, subscribe, and resolver behavior, plus selected executor internals. + * At module load time graphql-js resolves `node:diagnostics_channel` itself so + * APMs do not need to interact with the graphql API to enable tracing. On + * runtimes that do not expose `node:diagnostics_channel` (e.g., browsers) the + * load silently no-ops and emission sites short-circuit. */ import { isPromise } from './jsutils/isPromise.js'; @@ -67,6 +67,7 @@ interface DiagnosticsChannelModule { */ export interface GraphQLChannels { execute: MinimalTracingChannel; + executeRootSelectionSet: MinimalTracingChannel; parse: MinimalTracingChannel; validate: MinimalTracingChannel; resolve: MinimalTracingChannel; @@ -123,6 +124,9 @@ export const validateChannel: MinimalTracingChannel | undefined = export const executeChannel: MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:execute'); /** @internal */ +export const executeRootSelectionSetChannel: MinimalTracingChannel | undefined = + dc?.tracingChannel('graphql:execute:rootSelectionSet'); +/** @internal */ export const subscribeChannel: MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:subscribe'); /** @internal */ diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 5c00d6a2e2..0e0d307789 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -3,6 +3,7 @@ import { invariant } from '../jsutils/invariant.ts'; import { isAsyncIterable } from '../jsutils/isAsyncIterable.ts'; import { isIterableObject } from '../jsutils/isIterableObject.ts'; import { isPromise, isPromiseLike } from '../jsutils/isPromise.ts'; +import type { Maybe } from '../jsutils/Maybe.ts'; import { memoize2 } from '../jsutils/memoize2.ts'; import { memoize3 } from '../jsutils/memoize3.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; @@ -45,7 +46,12 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; -import { resolveChannel, shouldTrace, traceMixed } from '../diagnostics.ts'; +import { + executeRootSelectionSetChannel, + resolveChannel, + shouldTrace, + traceMixed, +} from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; @@ -273,6 +279,53 @@ export class Executor< executeRootSelectionSet( serially?: boolean, + ): PromiseOrValue { + if (!shouldTrace(executeRootSelectionSetChannel)) { + return this.executeRootSelectionSetImpl(serially); + } + return traceMixed( + executeRootSelectionSetChannel, + this.buildExecuteCtxFromValidatedArgs(this.validatedExecutionArgs), + () => this.executeRootSelectionSetImpl(serially), + ); + } + + /** + * Build an operation-scoped diagnostics context from ValidatedExecutionArgs. + * Used after the operation has already been resolved during argument + * validation. The original document is not available at this point, only the + * resolved operation; subscribers that need the document should read it from + * the graphql:execute or graphql:subscribe contexts. + */ + buildExecuteCtxFromValidatedArgs(args: ValidatedExecutionArgs): object { + let originalVariableValues: Maybe<{ [variable: string]: unknown }>; + let hasResolvedOriginalVariableValues = false; + + return { + operation: args.operation, + schema: args.schema, + get variableValues(): Maybe<{ readonly [variable: string]: unknown }> { + if (!hasResolvedOriginalVariableValues) { + originalVariableValues = {}; + for (const [variableName, source] of Object.entries( + args.variableValues.sources, + )) { + if (Object.hasOwn(source, 'value')) { + originalVariableValues[variableName] = source.value; + } + } + + hasResolvedOriginalVariableValues = true; + } + return originalVariableValues; + }, + operationName: args.operation.name?.value, + operationType: args.operation.operation, + }; + } + + executeRootSelectionSetImpl( + serially?: boolean, ): PromiseOrValue { const externalAbortSignal = this.validatedExecutionArgs.externalAbortSignal; let removeExternalAbortListener: (() => void) | undefined; diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index be19ab2905..10ea3a2a17 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -1,4 +1,4 @@ -import { assert, expect } from 'chai'; +import { expect } from 'chai'; import { describe, it } from 'mocha'; import { expectEvents } from '../../__testUtils__/expectEvents.js'; @@ -8,8 +8,6 @@ import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; -import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; - import { parse } from '../../language/parser.js'; import { GraphQLObjectType } from '../../type/definition.js'; @@ -22,7 +20,6 @@ import { execute, executeIgnoringIncremental, executeSync, - subscribe, } from '../execute.js'; const schema = buildSchema(` @@ -37,6 +34,9 @@ const schema = buildSchema(` `); const executeChannel = getTracingChannel('graphql:execute'); +const executeRootSelectionSetChannel = getTracingChannel( + 'graphql:execute:rootSelectionSet', +); describe('execute diagnostics channel', () => { it('emits start and end around a synchronous execute', async () => { @@ -353,53 +353,43 @@ describe('execute diagnostics channel', () => { ); }); - it('emits for each subscription event with resolved operation ctx', async () => { - async function* tickGenerator() { - await Promise.resolve(); - yield { tick: 'one' }; - yield { tick: 'two' }; - } + it('does not call tracing methods when no subscribers are attached', async () => { + const document = parse('{ sync }'); + const result = await expectNoTracingActivity(executeChannel, () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), + ); + expect(result).to.deep.equal({ data: { sync: 'hello' } }); + }); +}); - const document = parse('subscription S($tick: String) { tick }'); +describe('execute root selection set diagnostics channel', () => { + it('emits start and end around a synchronous root selection set', async () => { + const document = parse('query Q($sync: String) { sync }'); const operation = document.definitions[0]; - const variableValues = { tick: 'ignored by the field' }; + const variableValues = { sync: 'ignored by the field' }; await expectEvents( - executeChannel, - async () => { - const subscription = await subscribe({ + executeRootSelectionSetChannel, + () => + execute({ schema, document, - rootValue: { tick: tickGenerator }, + rootValue: { sync: () => 'hello' }, variableValues, - }); - assert(isAsyncIterable(subscription)); - - const firstResult = await subscription.next(); - expect(firstResult).to.deep.equal({ - done: false, - value: { data: { tick: 'one' } }, - }); - const secondResult = await subscription.next(); - expect(secondResult).to.deep.equal({ - done: false, - value: { data: { tick: 'two' } }, - }); - const returned = subscription.return?.(); - if (returned !== undefined) { - await returned; - } - return [firstResult, secondResult] as const; - }, - ([firstResult, secondResult]) => [ + }), + (result) => [ { channel: 'start', context: { operation, schema, variableValues, - operationName: 'S', - operationType: 'subscription', + operationName: 'Q', + operationType: 'query', }, }, { @@ -408,19 +398,37 @@ describe('execute diagnostics channel', () => { operation, schema, variableValues, - operationName: 'S', - operationType: 'subscription', - result: firstResult.value, + operationName: 'Q', + operationType: 'query', + result, }, }, + ], + ); + }); + + it('emits the full async lifecycle when the root selection set returns a promise', async () => { + const document = parse('query { async }'); + const operation = document.definitions[0]; + const variableValues = {}; + + await expectEvents( + executeRootSelectionSetChannel, + () => + execute({ + schema, + document, + rootValue: { async: () => Promise.resolve('hello-async') }, + }), + (result) => [ { channel: 'start', context: { operation, schema, variableValues, - operationName: 'S', - operationType: 'subscription', + operationName: undefined, + operationType: 'query', }, }, { @@ -429,9 +437,29 @@ describe('execute diagnostics channel', () => { operation, schema, variableValues, - operationName: 'S', - operationType: 'subscription', - result: secondResult.value, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncStart', + context: { + operation, + schema, + variableValues, + operationName: undefined, + operationType: 'query', + }, + }, + { + channel: 'asyncEnd', + context: { + operation, + schema, + variableValues, + operationName: undefined, + operationType: 'query', + result, }, }, ], @@ -440,12 +468,14 @@ describe('execute diagnostics channel', () => { it('does not call tracing methods when no subscribers are attached', async () => { const document = parse('{ sync }'); - const result = await expectNoTracingActivity(executeChannel, () => - execute({ - schema, - document, - rootValue: { sync: () => 'hello' }, - }), + const result = await expectNoTracingActivity( + executeRootSelectionSetChannel, + () => + execute({ + schema, + document, + rootValue: { sync: () => 'hello' }, + }), ); expect(result).to.deep.equal({ data: { sync: 'hello' } }); }); diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts index 7c717bb752..f2e7a4fa82 100644 --- a/src/execution/__tests__/diagnostics-subscribe-test.ts +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -27,6 +27,9 @@ const schema = buildSchema(` `); const subscribeChannel = getTracingChannel('graphql:subscribe'); +const executeRootSelectionSetChannel = getTracingChannel( + 'graphql:execute:rootSelectionSet', +); async function* twoTicks(): AsyncIterable<{ tick: string }> { await Promise.resolve(); @@ -150,6 +153,86 @@ describe('subscribe diagnostics channel', () => { ); }); + it('emits execute root selection set events for each event with the default per-event executor', async () => { + const document = parse('subscription S($tick: String) { tick }'); + const operation = document.definitions[0]; + const variableValues = { tick: 'ignored by the field' }; + + await expectEvents( + executeRootSelectionSetChannel, + async () => { + const subscription = await subscribe({ + schema, + document, + rootValue: { tick: twoTicks }, + variableValues, + }); + assert(isAsyncIterable(subscription)); + + const firstResult = await subscription.next(); + expect(firstResult).to.deep.equal({ + done: false, + value: { data: { tick: 'one' } }, + }); + const secondResult = await subscription.next(); + expect(secondResult).to.deep.equal({ + done: false, + value: { data: { tick: 'two' } }, + }); + + const returned = subscription.return?.(); + if (returned !== undefined) { + await returned; + } + return [firstResult.value, secondResult.value] as const; + }, + ([firstResult, secondResult]) => [ + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: firstResult, + }, + }, + { + channel: 'start', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + }, + }, + { + channel: 'end', + context: { + operation, + schema, + variableValues, + operationName: 'S', + operationType: 'subscription', + result: secondResult, + }, + }, + ], + ); + }); + it('emits only start and end for a synchronous validation failure', async () => { const document = parse('fragment F on Subscription { tick }'); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 4b7cbc55a6..f8b5f09c02 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -799,6 +799,20 @@ function buildExecuteCtxFromValidatedArgs( }; } +function getOriginalVariableValues( + args: ValidatedExecutionArgs, +): Maybe<{ readonly [variable: string]: unknown }> { + const originalVariableValues: { [variable: string]: unknown } = {}; + for (const [variableName, source] of Object.entries( + args.variableValues.sources, + )) { + if (Object.hasOwn(source, 'value')) { + originalVariableValues[variableName] = source.value; + } + } + + return originalVariableValues; +} function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); From bd295d4b9d5d02588e2e5f874cce410a8b57e4fc Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 22:48:50 +0300 Subject: [PATCH 51/55] chore(diagnostics): standardize context field ordering - Reorder context fields in diagnostics integration tests to follow ExecutionArgs order - Move schema before document/operation in all context snapshots - Add variableValues to subscribe context snapshots - Add args field to resolve context snapshots - Use OperationTypeNode enum instead of string literals for operationType --- integrationTests/diagnostics-bun/test.js | 22 +++-- .../diagnostics-deno-with-deno-build/test.js | 22 +++-- .../diagnostics-deno-with-node-build/test.js | 22 +++-- integrationTests/diagnostics-node20/test.js | 22 +++-- .../__tests__/diagnostics-execute-test.ts | 97 ++++++++++--------- .../__tests__/diagnostics-subscribe-test.ts | 73 +++++++------- src/execution/execute.ts | 2 +- src/validation/__tests__/diagnostics-test.ts | 2 +- 8 files changed, 144 insertions(+), 118 deletions(-) diff --git a/integrationTests/diagnostics-bun/test.js b/integrationTests/diagnostics-bun/test.js index 9d7eb8419d..dc07e85514 100644 --- a/integrationTests/diagnostics-bun/test.js +++ b/integrationTests/diagnostics-bun/test.js @@ -107,10 +107,11 @@ function runExecuteCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - document: msg.document, schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -151,10 +152,11 @@ function runExecuteRootSelectionSetCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - operation: msg.operation, schema: msg.schema, + operation: msg.operation, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: (msg) => events.push({ kind: 'end', result: msg.result }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -205,8 +207,11 @@ async function runSubscribeCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, + schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -249,8 +254,9 @@ function runResolveCase() { fieldName: msg.fieldName, parentType: msg.parentType, fieldType: msg.fieldType, - fieldPath: msg.fieldPath, + args: msg.args, isDefaultResolver: msg.isDefaultResolver, + fieldPath: msg.fieldPath, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), diff --git a/integrationTests/diagnostics-deno-with-deno-build/test.js b/integrationTests/diagnostics-deno-with-deno-build/test.js index 9d7eb8419d..dc07e85514 100644 --- a/integrationTests/diagnostics-deno-with-deno-build/test.js +++ b/integrationTests/diagnostics-deno-with-deno-build/test.js @@ -107,10 +107,11 @@ function runExecuteCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - document: msg.document, schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -151,10 +152,11 @@ function runExecuteRootSelectionSetCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - operation: msg.operation, schema: msg.schema, + operation: msg.operation, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: (msg) => events.push({ kind: 'end', result: msg.result }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -205,8 +207,11 @@ async function runSubscribeCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, + schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -249,8 +254,9 @@ function runResolveCase() { fieldName: msg.fieldName, parentType: msg.parentType, fieldType: msg.fieldType, - fieldPath: msg.fieldPath, + args: msg.args, isDefaultResolver: msg.isDefaultResolver, + fieldPath: msg.fieldPath, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), diff --git a/integrationTests/diagnostics-deno-with-node-build/test.js b/integrationTests/diagnostics-deno-with-node-build/test.js index 9d7eb8419d..dc07e85514 100644 --- a/integrationTests/diagnostics-deno-with-node-build/test.js +++ b/integrationTests/diagnostics-deno-with-node-build/test.js @@ -107,10 +107,11 @@ function runExecuteCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - document: msg.document, schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -151,10 +152,11 @@ function runExecuteRootSelectionSetCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - operation: msg.operation, schema: msg.schema, + operation: msg.operation, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: (msg) => events.push({ kind: 'end', result: msg.result }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -205,8 +207,11 @@ async function runSubscribeCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, + schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -249,8 +254,9 @@ function runResolveCase() { fieldName: msg.fieldName, parentType: msg.parentType, fieldType: msg.fieldType, - fieldPath: msg.fieldPath, + args: msg.args, isDefaultResolver: msg.isDefaultResolver, + fieldPath: msg.fieldPath, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), diff --git a/integrationTests/diagnostics-node20/test.js b/integrationTests/diagnostics-node20/test.js index 9d7eb8419d..dc07e85514 100644 --- a/integrationTests/diagnostics-node20/test.js +++ b/integrationTests/diagnostics-node20/test.js @@ -107,10 +107,11 @@ function runExecuteCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - document: msg.document, schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -151,10 +152,11 @@ function runExecuteRootSelectionSetCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, - operationName: msg.operationName, - operation: msg.operation, schema: msg.schema, + operation: msg.operation, + variableValues: msg.variableValues, + operationName: msg.operationName, + operationType: msg.operationType, }), end: (msg) => events.push({ kind: 'end', result: msg.result }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -205,8 +207,11 @@ async function runSubscribeCase() { start: (msg) => events.push({ kind: 'start', - operationType: msg.operationType, + schema: msg.schema, + document: msg.document, + variableValues: msg.variableValues, operationName: msg.operationName, + operationType: msg.operationType, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), @@ -249,8 +254,9 @@ function runResolveCase() { fieldName: msg.fieldName, parentType: msg.parentType, fieldType: msg.fieldType, - fieldPath: msg.fieldPath, + args: msg.args, isDefaultResolver: msg.isDefaultResolver, + fieldPath: msg.fieldPath, }), end: () => events.push({ kind: 'end' }), asyncStart: () => events.push({ kind: 'asyncStart' }), diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index 10ea3a2a17..17be0ae263 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -8,6 +8,7 @@ import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; +import { OperationTypeNode } from '../../language/ast.js'; import { parse } from '../../language/parser.js'; import { GraphQLObjectType } from '../../type/definition.js'; @@ -56,21 +57,21 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - document, schema, + document, variableValues, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, @@ -93,41 +94,41 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'asyncStart', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'asyncEnd', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, @@ -180,52 +181,52 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema: asyncDeferSchema, + document, variableValues: undefined, operationName: 'Deferred', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - document, schema: asyncDeferSchema, + document, variableValues: undefined, operationName: 'Deferred', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'asyncStart', context: { - document, schema: asyncDeferSchema, + document, variableValues: undefined, operationName: 'Deferred', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'error', context: { - document, schema: asyncDeferSchema, + document, variableValues: undefined, operationName: 'Deferred', - operationType: 'query', + operationType: OperationTypeNode.QUERY, error, }, }, { channel: 'asyncEnd', context: { - document, schema: asyncDeferSchema, + document, variableValues: undefined, operationName: 'Deferred', - operationType: 'query', + operationType: OperationTypeNode.QUERY, error, }, }, @@ -244,21 +245,21 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, @@ -281,21 +282,21 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, @@ -320,32 +321,32 @@ describe('execute diagnostics channel', () => { { channel: 'start', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'error', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, error, }, }, { channel: 'end', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, error, }, }, @@ -385,21 +386,21 @@ describe('execute root selection set diagnostics channel', () => { { channel: 'start', context: { - operation, schema, + operation, variableValues, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - operation, schema, + operation, variableValues, operationName: 'Q', - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, @@ -424,41 +425,41 @@ describe('execute root selection set diagnostics channel', () => { { channel: 'start', context: { - operation, schema, + operation, variableValues, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'end', context: { - operation, schema, + operation, variableValues, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'asyncStart', context: { - operation, schema, + operation, variableValues, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, }, }, { channel: 'asyncEnd', context: { - operation, schema, + operation, variableValues, operationName: undefined, - operationType: 'query', + operationType: OperationTypeNode.QUERY, result, }, }, diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts index f2e7a4fa82..3c7949c75c 100644 --- a/src/execution/__tests__/diagnostics-subscribe-test.ts +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -8,6 +8,7 @@ import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; +import { OperationTypeNode } from '../../language/ast.js'; import { parse } from '../../language/parser.js'; import type { GraphQLSchema } from '../../type/schema.js'; @@ -63,21 +64,21 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'end', context: { - document, schema, + document, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, result, }, }, @@ -111,41 +112,41 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'asyncStart', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'asyncEnd', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, result, }, }, @@ -190,42 +191,42 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - operation, schema, + operation, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'end', context: { - operation, schema, + operation, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, result: firstResult, }, }, { channel: 'start', context: { - operation, schema, + operation, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'end', context: { - operation, schema, + operation, variableValues, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, result: secondResult, }, }, @@ -247,8 +248,8 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, operationType: undefined, @@ -257,8 +258,8 @@ describe('subscribe diagnostics channel', () => { { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: undefined, operationType: undefined, @@ -280,32 +281,32 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'error', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, error, }, }, { channel: 'end', context: { - document, schema: invalidSchema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, error, }, }, @@ -334,41 +335,41 @@ describe('subscribe diagnostics channel', () => { { channel: 'start', context: { - document, schema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'end', context: { - document, schema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'asyncStart', context: { - document, schema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, }, }, { channel: 'asyncEnd', context: { - document, schema, + document, variableValues: undefined, operationName: 'S', - operationType: 'subscription', + operationType: OperationTypeNode.SUBSCRIPTION, result, }, }, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index f8b5f09c02..3448b2e23b 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -102,8 +102,8 @@ function buildExecuteCtxFromArgs(args: ExecutionArgs): object { return operation; }; return { - document: args.document, schema: args.schema, + document: args.document, variableValues: args.variableValues, get operationName() { return args.operationName ?? resolveOperation()?.name?.value; diff --git a/src/validation/__tests__/diagnostics-test.ts b/src/validation/__tests__/diagnostics-test.ts index d96e2c9138..7ae899669c 100644 --- a/src/validation/__tests__/diagnostics-test.ts +++ b/src/validation/__tests__/diagnostics-test.ts @@ -51,8 +51,8 @@ describe('validate diagnostics channel', () => { it('emits start, error, and end when validate throws on an invalid schema', async () => { const context = { - document: parse('{ field }'), schema: {} as GraphQLSchema, + document: parse('{ field }'), }; await expectEvents( From cbeace1e56f653326b6f0869d97a3eb7449c0ccf Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 22:49:38 +0300 Subject: [PATCH 52/55] feat(diagnostics): add strict types for all Ctx types - Define GraphQL*Ctx interfaces for parse, validate, execute, subscribe, and resolve channels - Add GraphQLChannelContextByName mapping type for channel-to-context correlation - Genericize MinimalTracingChannel, TestTracingChannel, and test utilities with TContext - Add type-safe overloads to getTracingChannel() for keyed access - Export all Ctx types and GraphQLChannelContextByName from main entry point - Update Executor to return typed contexts (GraphQLExecuteRootSelectionSetCtx, GraphQLResolveCtx) - Update execute/subscribe to use strongly-typed context builders - Add TypeScript integration test validating exported types --- src/__testUtils__/diagnosticsTracing.ts | 14 +- src/__testUtils__/expectEvents.ts | 28 ++- src/__testUtils__/expectNoTracingActivity.ts | 4 +- src/__testUtils__/getTracingChannel.ts | 6 + src/diagnostics.ts | 172 ++++++++++++++---- src/execution/Executor.ts | 12 +- .../__tests__/diagnostics-execute-test.ts | 5 +- .../__tests__/diagnostics-subscribe-test.ts | 5 +- src/execution/execute.ts | 13 +- src/index.ts | 11 +- 10 files changed, 206 insertions(+), 64 deletions(-) diff --git a/src/__testUtils__/diagnosticsTracing.ts b/src/__testUtils__/diagnosticsTracing.ts index ade53406cf..59df473022 100644 --- a/src/__testUtils__/diagnosticsTracing.ts +++ b/src/__testUtils__/diagnosticsTracing.ts @@ -10,14 +10,14 @@ export type TracingSubChannelRecord = { [Channel in TracingSubChannel]: TValue; }; -export type TracingSubscriptionHandler = TracingSubChannelRecord< - (context: unknown) => void ->; +export type TracingSubscriptionHandler = + TracingSubChannelRecord<(context: TContext) => void>; -export type TestTracingChannel = MinimalTracingChannel & { - subscribe: (handler: TracingSubscriptionHandler) => void; - unsubscribe: (handler: TracingSubscriptionHandler) => void; -}; +export type TestTracingChannel = + MinimalTracingChannel & { + subscribe: (handler: TracingSubscriptionHandler) => void; + unsubscribe: (handler: TracingSubscriptionHandler) => void; + }; export const tracingSubChannels: ReadonlyArray = [ 'start', diff --git a/src/__testUtils__/expectEvents.ts b/src/__testUtils__/expectEvents.ts index 552a98547d..d876613161 100644 --- a/src/__testUtils__/expectEvents.ts +++ b/src/__testUtils__/expectEvents.ts @@ -16,29 +16,39 @@ export type CollectedEvent = { }; }[TracingSubChannel]; -type ExpectedEventsFactory = ( +export type CollectedEventFor = { + [Channel in TracingSubChannel]: { + channel: Channel; + context: TContext; + }; +}[TracingSubChannel]; + +type ExpectedEventsFactory = ( result: Awaited, -) => ReadonlyArray; +) => ReadonlyArray>; /** * Collect graphql tracing events while `fn` runs, build the expected event * list from the callback result, and always unsubscribe before returning. */ -export async function expectEvents( - channel: TestTracingChannel, +export async function expectEvents( + channel: TestTracingChannel, fn: () => TResult, - getExpectedEvents: ExpectedEventsFactory, + getExpectedEvents: ExpectedEventsFactory, ): Promise { - const events: Array = []; - const handler = {} as TracingSubChannelRecord<(context: unknown) => void>; + const events: Array> = []; + const handler = {} as TracingSubChannelRecord<(context: TContext) => void>; for (const tracingSubChannel of tracingSubChannels) { - handler[tracingSubChannel] = (context: unknown) => { + handler[tracingSubChannel] = (context: TContext) => { const snapshot = typeof context === 'object' && context !== null ? { ...context } : context; - events.push({ channel: tracingSubChannel, context: snapshot }); + events.push({ + channel: tracingSubChannel, + context: snapshot, + }); }; } diff --git a/src/__testUtils__/expectNoTracingActivity.ts b/src/__testUtils__/expectNoTracingActivity.ts index 28c19c55e0..61f35c1260 100644 --- a/src/__testUtils__/expectNoTracingActivity.ts +++ b/src/__testUtils__/expectNoTracingActivity.ts @@ -11,8 +11,8 @@ import { spyOnMethod } from './spyOn.js'; * The test installs spies around the real tracing methods and verifies none * of them were touched while `fn` ran. */ -export async function expectNoTracingActivity( - channel: MinimalTracingChannel, +export async function expectNoTracingActivity( + channel: MinimalTracingChannel, fn: () => T | Promise, ): Promise> { expect(channel.hasSubscribers).to.equal(false); diff --git a/src/__testUtils__/getTracingChannel.ts b/src/__testUtils__/getTracingChannel.ts index 411b8a5113..dc9fad966f 100644 --- a/src/__testUtils__/getTracingChannel.ts +++ b/src/__testUtils__/getTracingChannel.ts @@ -1,6 +1,8 @@ /* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ import dc from 'node:diagnostics_channel'; +import type { GraphQLChannelContextByName } from '../diagnostics.js'; + import type { TestTracingChannel } from './diagnosticsTracing.js'; /** @@ -8,6 +10,10 @@ import type { TestTracingChannel } from './diagnosticsTracing.js'; * `node:diagnostics_channel`. graphql-js publishes on the same channels at * module load. */ +export function getTracingChannel< + TName extends keyof GraphQLChannelContextByName, +>(name: TName): TestTracingChannel; +export function getTracingChannel(name: string): TestTracingChannel; export function getTracingChannel(name: string): TestTracingChannel { return dc.tracingChannel(name) as TestTracingChannel; } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 56bee58ac7..704fc7901b 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -12,6 +12,22 @@ */ import { isPromise } from './jsutils/isPromise.js'; +import type { Maybe } from './jsutils/Maybe.js'; +import type { ObjMap } from './jsutils/ObjMap.js'; + +import type { GraphQLError } from './error/GraphQLError.js'; + +import type { + DocumentNode, + OperationDefinitionNode, + OperationTypeNode, +} from './language/ast.js'; +import type { Source } from './language/source.js'; + +import type { GraphQLSchema } from './type/schema.js'; + +import type { ExecutionResult } from './execution/Executor.js'; +import type { ExperimentalIncrementalExecutionResults } from './execution/incremental/IncrementalExecutor.js'; /** * Structural subset of `DiagnosticsChannel` sufficient for publishing and @@ -19,9 +35,9 @@ import { isPromise } from './jsutils/isPromise.js'; * * @internal */ -export interface MinimalChannel { +export interface MinimalChannel { readonly hasSubscribers?: boolean; - publish: (message: unknown) => void; + publish: (message: TMessage) => void; runStores: ( context: ContextType, fn: (this: ContextType, ...args: Array) => T, @@ -37,26 +53,109 @@ export interface MinimalChannel { * * @internal */ -export interface MinimalTracingChannel { +export interface MinimalTracingChannel { // `undefined` accommodates runtimes (e.g. Bun) that ship `tracingChannel` // without exposing the aggregate `hasSubscribers` getter. readonly hasSubscribers: boolean | undefined; - readonly start: MinimalChannel; - readonly end: MinimalChannel; - readonly asyncStart: MinimalChannel; - readonly asyncEnd: MinimalChannel; - readonly error: MinimalChannel; + readonly start: MinimalChannel; + readonly end: MinimalChannel; + readonly asyncStart: MinimalChannel; + readonly asyncEnd: MinimalChannel; + readonly error: MinimalChannel; traceSync: ( fn: (...args: Array) => T, - ctx: object, + ctx: TContext extends object ? TContext : object, thisArg?: unknown, ...args: Array ) => T; } interface DiagnosticsChannelModule { - tracingChannel: (name: string) => MinimalTracingChannel; + tracingChannel: ( + name: string, + ) => MinimalTracingChannel; +} + +/** + * Context published on `graphql:parse`. + */ +export interface GraphQLParseCtx { + source: string | Source; + error?: unknown; + result?: DocumentNode; +} + +/** + * Context published on `graphql:validate`. + */ +export interface GraphQLValidateCtx { + schema: GraphQLSchema; + document: DocumentNode; + error?: unknown; + result?: ReadonlyArray; +} + +/** + * Context published on `graphql:execute`. + */ +export interface GraphQLExecuteCtx { + schema: GraphQLSchema; + document: DocumentNode; + variableValues: Maybe<{ readonly [variable: string]: unknown }>; + operationName: string | undefined; + operationType: OperationTypeNode | undefined; + error?: unknown; + result?: ExecutionResult | ExperimentalIncrementalExecutionResults; +} + +/** + * Context published on `graphql:execute:rootSelectionSet`. + */ +export interface GraphQLExecuteRootSelectionSetCtx { + schema: GraphQLSchema; + operation: OperationDefinitionNode; + variableValues: Maybe<{ readonly [variable: string]: unknown }>; + operationName: string | undefined; + operationType: OperationTypeNode; + error?: unknown; + result?: ExecutionResult | ExperimentalIncrementalExecutionResults; +} + +/** + * Context published on `graphql:subscribe`. + */ +export interface GraphQLSubscribeCtx { + schema: GraphQLSchema; + document: DocumentNode; + variableValues: Maybe<{ readonly [variable: string]: unknown }>; + operationName: string | undefined; + operationType: OperationTypeNode | undefined; + error?: unknown; + result?: AsyncGenerator | ExecutionResult; +} + +/** + * Context published on `graphql:resolve`. + */ +export interface GraphQLResolveCtx { + fieldName: string; + parentType: string; + fieldType: string; + args: ObjMap; + isDefaultResolver: boolean; + fieldPath: string; + error?: unknown; + result?: unknown; +} + +export interface GraphQLChannelContextByName { + 'graphql:parse': GraphQLParseCtx; + 'graphql:validate': GraphQLValidateCtx; + 'graphql:execute': GraphQLExecuteCtx; + 'graphql:execute:rootSelectionSet': GraphQLExecuteRootSelectionSetCtx; + 'graphql:subscribe': GraphQLSubscribeCtx; + 'graphql:resolve': GraphQLResolveCtx; } /** @@ -66,12 +165,12 @@ interface DiagnosticsChannelModule { * by name. */ export interface GraphQLChannels { - execute: MinimalTracingChannel; - executeRootSelectionSet: MinimalTracingChannel; - parse: MinimalTracingChannel; - validate: MinimalTracingChannel; - resolve: MinimalTracingChannel; - subscribe: MinimalTracingChannel; + execute: MinimalTracingChannel; + executeRootSelectionSet: MinimalTracingChannel; + parse: MinimalTracingChannel; + validate: MinimalTracingChannel; + resolve: MinimalTracingChannel; + subscribe: MinimalTracingChannel; } function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { @@ -115,23 +214,28 @@ const dc = resolveDiagnosticsChannel(); * * @internal */ -export const parseChannel: MinimalTracingChannel | undefined = +export const parseChannel: MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:parse'); /** @internal */ -export const validateChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:validate'); +export const validateChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:validate'); /** @internal */ -export const executeChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:execute'); +export const executeChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:execute'); /** @internal */ -export const executeRootSelectionSetChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:execute:rootSelectionSet'); +export const executeRootSelectionSetChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:execute:rootSelectionSet'); /** @internal */ -export const subscribeChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:subscribe'); +export const subscribeChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:subscribe'); /** @internal */ -export const resolveChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:resolve'); +export const resolveChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:resolve'); const SUB_CHANNEL_KEYS: ReadonlyArray< 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error' @@ -147,9 +251,9 @@ const SUB_CHANNEL_KEYS: ReadonlyArray< * * @internal */ -export function shouldTrace( - channel: MinimalTracingChannel | undefined, -): channel is MinimalTracingChannel { +export function shouldTrace( + channel: MinimalTracingChannel | undefined, +): channel is MinimalTracingChannel { if (channel == null) { return false; } @@ -174,12 +278,12 @@ export function shouldTrace( * * @internal */ -export function traceMixed( - channel: MinimalTracingChannel, - ctxInput: object, +export function traceMixed( + channel: MinimalTracingChannel, + ctxInput: TContext extends object ? TContext : object, fn: () => T | Promise, ): T | Promise { - const ctx = ctxInput as { error?: unknown; result?: unknown }; + const ctx = ctxInput as TContext & { error?: unknown; result?: unknown }; return channel.start.runStores(ctx, () => { let result: T | Promise; diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 0e0d307789..c3648b5f30 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -46,6 +46,10 @@ import { } from '../type/definition.ts'; import type { GraphQLSchema } from '../type/schema.ts'; +import type { + GraphQLExecuteRootSelectionSetCtx, + GraphQLResolveCtx, +} from '../diagnostics.js'; import { executeRootSelectionSetChannel, resolveChannel, @@ -297,13 +301,15 @@ export class Executor< * resolved operation; subscribers that need the document should read it from * the graphql:execute or graphql:subscribe contexts. */ - buildExecuteCtxFromValidatedArgs(args: ValidatedExecutionArgs): object { + buildExecuteCtxFromValidatedArgs( + args: ValidatedExecutionArgs, + ): GraphQLExecuteRootSelectionSetCtx { let originalVariableValues: Maybe<{ [variable: string]: unknown }>; let hasResolvedOriginalVariableValues = false; return { - operation: args.operation, schema: args.schema, + operation: args.operation, get variableValues(): Maybe<{ readonly [variable: string]: unknown }> { if (!hasResolvedOriginalVariableValues) { originalVariableValues = {}; @@ -716,7 +722,7 @@ export class Executor< args: ObjMap, info: GraphQLResolveInfo, isDefaultResolver: boolean, - ): object { + ): GraphQLResolveCtx { let cachedFieldPath: string | undefined; return { fieldName: info.fieldName, diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index 17be0ae263..a9625eabf3 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -8,6 +8,7 @@ import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; +import type { OperationDefinitionNode } from '../../language/ast.js'; import { OperationTypeNode } from '../../language/ast.js'; import { parse } from '../../language/parser.js'; @@ -370,7 +371,7 @@ describe('execute diagnostics channel', () => { describe('execute root selection set diagnostics channel', () => { it('emits start and end around a synchronous root selection set', async () => { const document = parse('query Q($sync: String) { sync }'); - const operation = document.definitions[0]; + const operation = document.definitions[0] as OperationDefinitionNode; const variableValues = { sync: 'ignored by the field' }; await expectEvents( @@ -410,7 +411,7 @@ describe('execute root selection set diagnostics channel', () => { it('emits the full async lifecycle when the root selection set returns a promise', async () => { const document = parse('query { async }'); - const operation = document.definitions[0]; + const operation = document.definitions[0] as OperationDefinitionNode; const variableValues = {}; await expectEvents( diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts index 3c7949c75c..17e6380c50 100644 --- a/src/execution/__tests__/diagnostics-subscribe-test.ts +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -8,6 +8,7 @@ import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; +import type { OperationDefinitionNode } from '../../language/ast.js'; import { OperationTypeNode } from '../../language/ast.js'; import { parse } from '../../language/parser.js'; @@ -156,7 +157,7 @@ describe('subscribe diagnostics channel', () => { it('emits execute root selection set events for each event with the default per-event executor', async () => { const document = parse('subscription S($tick: String) { tick }'); - const operation = document.definitions[0]; + const operation = document.definitions[0] as OperationDefinitionNode; const variableValues = { tick: 'ignored by the field' }; await expectEvents( @@ -175,11 +176,13 @@ describe('subscribe diagnostics channel', () => { done: false, value: { data: { tick: 'one' } }, }); + assert(!firstResult.done, 'Expected first subscription event.'); const secondResult = await subscription.next(); expect(secondResult).to.deep.equal({ done: false, value: { data: { tick: 'two' } }, }); + assert(!secondResult.done, 'Expected second subscription event.'); const returned = subscription.return?.(); if (returned !== undefined) { diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 3448b2e23b..f3a0da890d 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -30,6 +30,7 @@ import type { GraphQLSchema } from '../type/schema.ts'; import { getOperationAST } from '../utilities/getOperationAST.ts'; +import type { GraphQLExecuteCtx } from '../diagnostics.js'; import { executeChannel, shouldTrace, @@ -83,7 +84,7 @@ export function execute(args: ExecutionArgs): PromiseOrValue { if (!shouldTrace(executeChannel)) { return executeImpl(args); } - return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => executeImpl(args), ); } @@ -93,7 +94,9 @@ export function execute(args: ExecutionArgs): PromiseOrValue { * resolution of the operation AST to a lazy getter so the cost of walking * the document is only paid if a subscriber reads it. */ -function buildExecuteCtxFromArgs(args: ExecutionArgs): object { +function buildOperationCtxFromArgs( + args: ExecutionArgs, +): Omit { let operation: OperationDefinitionNode | null | undefined; const resolveOperation = (): OperationDefinitionNode | null | undefined => { if (operation === undefined) { @@ -146,7 +149,7 @@ export function experimentalExecuteIncrementally( if (!shouldTrace(executeChannel)) { return experimentalExecuteIncrementallyImpl(args); } - return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => experimentalExecuteIncrementallyImpl(args), ); } @@ -171,7 +174,7 @@ export function executeIgnoringIncremental( if (!shouldTrace(executeChannel)) { return executeIgnoringIncrementalImpl(args); } - return traceMixed(executeChannel, buildExecuteCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => executeIgnoringIncrementalImpl(args), ); } @@ -285,7 +288,7 @@ export function subscribe( if (!shouldTrace(subscribeChannel)) { return subscribeImpl(args); } - return traceMixed(subscribeChannel, buildExecuteCtxFromArgs(args), () => + return traceMixed(subscribeChannel, buildOperationCtxFromArgs(args), () => subscribeImpl(args), ); } diff --git a/src/index.ts b/src/index.ts index fe25855b88..0037552afa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,16 @@ export { enableDevMode, isDevModeEnabled } from './devMode.ts'; // Tracing channel types for subscribers that want to strongly type the // `graphql:*` channel context payloads. Channels are auto-registered on // `node:diagnostics_channel` at module load. -export type { GraphQLChannels } from './diagnostics.js'; +export type { + GraphQLChannelContextByName, + GraphQLChannels, + GraphQLExecuteCtx, + GraphQLExecuteRootSelectionSetCtx, + GraphQLParseCtx, + GraphQLResolveCtx, + GraphQLSubscribeCtx, + GraphQLValidateCtx, +} from './diagnostics.js'; // The primary entry point into fulfilling a GraphQL request. export type { GraphQLArgs } from './graphql.ts'; From 674b94d0855a627b150ab326fa99aaca3e270447 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 5 May 2026 23:13:54 +0300 Subject: [PATCH 53/55] rename ctx => context --- integrationTests/diagnostics-bun/test.js | 6 +- .../diagnostics-deno-with-deno-build/test.js | 6 +- .../diagnostics-deno-with-node-build/test.js | 6 +- integrationTests/diagnostics-node20/test.js | 6 +- src/diagnostics.ts | 84 ++++++++++--------- src/execution/Executor.ts | 16 ++-- src/execution/execute.ts | 14 ++-- src/index.ts | 12 +-- 8 files changed, 81 insertions(+), 69 deletions(-) diff --git a/integrationTests/diagnostics-bun/test.js b/integrationTests/diagnostics-bun/test.js index dc07e85514..bcd0bbdb4f 100644 --- a/integrationTests/diagnostics-bun/test.js +++ b/integrationTests/diagnostics-bun/test.js @@ -294,10 +294,12 @@ async function runAlsPropagationCase() { // A subscriber that binds a store on the `start` sub-channel should be able // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). // This is what APMs use to parent child spans to the current operation - // without threading state through the ctx object. + // without threading state through the context object. const als = new AsyncLocalStorage(); const channel = dc.tracingChannel('graphql:execute'); - channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + channel.start.bindStore(als, (context) => ({ + operationName: context.operationName, + })); const seen = {}; const handler = { diff --git a/integrationTests/diagnostics-deno-with-deno-build/test.js b/integrationTests/diagnostics-deno-with-deno-build/test.js index dc07e85514..bcd0bbdb4f 100644 --- a/integrationTests/diagnostics-deno-with-deno-build/test.js +++ b/integrationTests/diagnostics-deno-with-deno-build/test.js @@ -294,10 +294,12 @@ async function runAlsPropagationCase() { // A subscriber that binds a store on the `start` sub-channel should be able // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). // This is what APMs use to parent child spans to the current operation - // without threading state through the ctx object. + // without threading state through the context object. const als = new AsyncLocalStorage(); const channel = dc.tracingChannel('graphql:execute'); - channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + channel.start.bindStore(als, (context) => ({ + operationName: context.operationName, + })); const seen = {}; const handler = { diff --git a/integrationTests/diagnostics-deno-with-node-build/test.js b/integrationTests/diagnostics-deno-with-node-build/test.js index dc07e85514..bcd0bbdb4f 100644 --- a/integrationTests/diagnostics-deno-with-node-build/test.js +++ b/integrationTests/diagnostics-deno-with-node-build/test.js @@ -294,10 +294,12 @@ async function runAlsPropagationCase() { // A subscriber that binds a store on the `start` sub-channel should be able // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). // This is what APMs use to parent child spans to the current operation - // without threading state through the ctx object. + // without threading state through the context object. const als = new AsyncLocalStorage(); const channel = dc.tracingChannel('graphql:execute'); - channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + channel.start.bindStore(als, (context) => ({ + operationName: context.operationName, + })); const seen = {}; const handler = { diff --git a/integrationTests/diagnostics-node20/test.js b/integrationTests/diagnostics-node20/test.js index dc07e85514..bcd0bbdb4f 100644 --- a/integrationTests/diagnostics-node20/test.js +++ b/integrationTests/diagnostics-node20/test.js @@ -294,10 +294,12 @@ async function runAlsPropagationCase() { // A subscriber that binds a store on the `start` sub-channel should be able // to read it in every lifecycle handler (start, end, asyncStart, asyncEnd). // This is what APMs use to parent child spans to the current operation - // without threading state through the ctx object. + // without threading state through the context object. const als = new AsyncLocalStorage(); const channel = dc.tracingChannel('graphql:execute'); - channel.start.bindStore(als, (ctx) => ({ operationName: ctx.operationName })); + channel.start.bindStore(als, (context) => ({ + operationName: context.operationName, + })); const seen = {}; const handler = { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 704fc7901b..ae6324a3e9 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -65,7 +65,7 @@ export interface MinimalTracingChannel { traceSync: ( fn: (...args: Array) => T, - ctx: TContext extends object ? TContext : object, + context: TContext extends object ? TContext : object, thisArg?: unknown, ...args: Array ) => T; @@ -80,7 +80,7 @@ interface DiagnosticsChannelModule { /** * Context published on `graphql:parse`. */ -export interface GraphQLParseCtx { +export interface GraphQLParseContext { source: string | Source; error?: unknown; result?: DocumentNode; @@ -89,7 +89,7 @@ export interface GraphQLParseCtx { /** * Context published on `graphql:validate`. */ -export interface GraphQLValidateCtx { +export interface GraphQLValidateContext { schema: GraphQLSchema; document: DocumentNode; error?: unknown; @@ -99,7 +99,7 @@ export interface GraphQLValidateCtx { /** * Context published on `graphql:execute`. */ -export interface GraphQLExecuteCtx { +export interface GraphQLExecuteContext { schema: GraphQLSchema; document: DocumentNode; variableValues: Maybe<{ readonly [variable: string]: unknown }>; @@ -112,7 +112,7 @@ export interface GraphQLExecuteCtx { /** * Context published on `graphql:execute:rootSelectionSet`. */ -export interface GraphQLExecuteRootSelectionSetCtx { +export interface GraphQLExecuteRootSelectionSetContext { schema: GraphQLSchema; operation: OperationDefinitionNode; variableValues: Maybe<{ readonly [variable: string]: unknown }>; @@ -125,7 +125,7 @@ export interface GraphQLExecuteRootSelectionSetCtx { /** * Context published on `graphql:subscribe`. */ -export interface GraphQLSubscribeCtx { +export interface GraphQLSubscribeContext { schema: GraphQLSchema; document: DocumentNode; variableValues: Maybe<{ readonly [variable: string]: unknown }>; @@ -138,7 +138,7 @@ export interface GraphQLSubscribeCtx { /** * Context published on `graphql:resolve`. */ -export interface GraphQLResolveCtx { +export interface GraphQLResolveContext { fieldName: string; parentType: string; fieldType: string; @@ -150,12 +150,12 @@ export interface GraphQLResolveCtx { } export interface GraphQLChannelContextByName { - 'graphql:parse': GraphQLParseCtx; - 'graphql:validate': GraphQLValidateCtx; - 'graphql:execute': GraphQLExecuteCtx; - 'graphql:execute:rootSelectionSet': GraphQLExecuteRootSelectionSetCtx; - 'graphql:subscribe': GraphQLSubscribeCtx; - 'graphql:resolve': GraphQLResolveCtx; + 'graphql:parse': GraphQLParseContext; + 'graphql:validate': GraphQLValidateContext; + 'graphql:execute': GraphQLExecuteContext; + 'graphql:execute:rootSelectionSet': GraphQLExecuteRootSelectionSetContext; + 'graphql:subscribe': GraphQLSubscribeContext; + 'graphql:resolve': GraphQLResolveContext; } /** @@ -165,12 +165,12 @@ export interface GraphQLChannelContextByName { * by name. */ export interface GraphQLChannels { - execute: MinimalTracingChannel; - executeRootSelectionSet: MinimalTracingChannel; - parse: MinimalTracingChannel; - validate: MinimalTracingChannel; - resolve: MinimalTracingChannel; - subscribe: MinimalTracingChannel; + execute: MinimalTracingChannel; + executeRootSelectionSet: MinimalTracingChannel; + parse: MinimalTracingChannel; + validate: MinimalTracingChannel; + resolve: MinimalTracingChannel; + subscribe: MinimalTracingChannel; } function resolveDiagnosticsChannel(): DiagnosticsChannelModule | undefined { @@ -214,27 +214,28 @@ const dc = resolveDiagnosticsChannel(); * * @internal */ -export const parseChannel: MinimalTracingChannel | undefined = - dc?.tracingChannel('graphql:parse'); +export const parseChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:parse'); /** @internal */ export const validateChannel: - | MinimalTracingChannel + | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:validate'); /** @internal */ export const executeChannel: - | MinimalTracingChannel + | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:execute'); /** @internal */ export const executeRootSelectionSetChannel: - | MinimalTracingChannel + | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:execute:rootSelectionSet'); /** @internal */ export const subscribeChannel: - | MinimalTracingChannel + | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:subscribe'); /** @internal */ export const resolveChannel: - | MinimalTracingChannel + | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:resolve'); const SUB_CHANNEL_KEYS: ReadonlyArray< @@ -280,45 +281,48 @@ export function shouldTrace( */ export function traceMixed( channel: MinimalTracingChannel, - ctxInput: TContext extends object ? TContext : object, + contextInput: TContext extends object ? TContext : object, fn: () => T | Promise, ): T | Promise { - const ctx = ctxInput as TContext & { error?: unknown; result?: unknown }; + const context = contextInput as TContext & { + error?: unknown; + result?: unknown; + }; - return channel.start.runStores(ctx, () => { + return channel.start.runStores(context, () => { let result: T | Promise; try { result = fn(); } catch (err) { - ctx.error = err; - channel.error.publish(ctx); - channel.end.publish(ctx); + context.error = err; + channel.error.publish(context); + channel.end.publish(context); throw err; } if (!isPromise(result)) { - ctx.result = result; - channel.end.publish(ctx); + context.result = result; + channel.end.publish(context); return result; } - channel.end.publish(ctx); - channel.asyncStart.publish(ctx); + channel.end.publish(context); + channel.asyncStart.publish(context); return result .then( (value) => { - ctx.result = value; + context.result = value; return value; }, (err: unknown) => { - ctx.error = err; - channel.error.publish(ctx); + context.error = err; + channel.error.publish(context); throw err; }, ) .finally(() => { - channel.asyncEnd.publish(ctx); + channel.asyncEnd.publish(context); }); }); } diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index c3648b5f30..a722a8b04f 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -47,8 +47,8 @@ import { import type { GraphQLSchema } from '../type/schema.ts'; import type { - GraphQLExecuteRootSelectionSetCtx, - GraphQLResolveCtx, + GraphQLExecuteRootSelectionSetContext, + GraphQLResolveContext, } from '../diagnostics.js'; import { executeRootSelectionSetChannel, @@ -289,7 +289,7 @@ export class Executor< } return traceMixed( executeRootSelectionSetChannel, - this.buildExecuteCtxFromValidatedArgs(this.validatedExecutionArgs), + this.buildExecuteContextFromValidatedArgs(this.validatedExecutionArgs), () => this.executeRootSelectionSetImpl(serially), ); } @@ -301,9 +301,9 @@ export class Executor< * resolved operation; subscribers that need the document should read it from * the graphql:execute or graphql:subscribe contexts. */ - buildExecuteCtxFromValidatedArgs( + buildExecuteContextFromValidatedArgs( args: ValidatedExecutionArgs, - ): GraphQLExecuteRootSelectionSetCtx { + ): GraphQLExecuteRootSelectionSetContext { let originalVariableValues: Maybe<{ [variable: string]: unknown }>; let hasResolvedOriginalVariableValues = false; @@ -643,7 +643,7 @@ export class Executor< resolveFn = (s, args, c, info) => traceMixed( channel, - this.buildResolveCtx(args, info, fieldDef.resolve === undefined), + this.buildResolveContext(args, info, fieldDef.resolve === undefined), () => originalResolveFn(s, args, c, info), ); } @@ -718,11 +718,11 @@ export class Executor< * path is O(depth) and APMs that depth-filter or skip default resolvers * often never read it. `args` is passed through by reference. */ - buildResolveCtx( + buildResolveContext( args: ObjMap, info: GraphQLResolveInfo, isDefaultResolver: boolean, - ): GraphQLResolveCtx { + ): GraphQLResolveContext { let cachedFieldPath: string | undefined; return { fieldName: info.fieldName, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index f3a0da890d..a60144db89 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -30,7 +30,7 @@ import type { GraphQLSchema } from '../type/schema.ts'; import { getOperationAST } from '../utilities/getOperationAST.ts'; -import type { GraphQLExecuteCtx } from '../diagnostics.js'; +import type { GraphQLExecuteContext } from '../diagnostics.js'; import { executeChannel, shouldTrace, @@ -84,7 +84,7 @@ export function execute(args: ExecutionArgs): PromiseOrValue { if (!shouldTrace(executeChannel)) { return executeImpl(args); } - return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationContextFromArgs(args), () => executeImpl(args), ); } @@ -94,9 +94,9 @@ export function execute(args: ExecutionArgs): PromiseOrValue { * resolution of the operation AST to a lazy getter so the cost of walking * the document is only paid if a subscriber reads it. */ -function buildOperationCtxFromArgs( +function buildOperationContextFromArgs( args: ExecutionArgs, -): Omit { +): Omit { let operation: OperationDefinitionNode | null | undefined; const resolveOperation = (): OperationDefinitionNode | null | undefined => { if (operation === undefined) { @@ -149,7 +149,7 @@ export function experimentalExecuteIncrementally( if (!shouldTrace(executeChannel)) { return experimentalExecuteIncrementallyImpl(args); } - return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationContextFromArgs(args), () => experimentalExecuteIncrementallyImpl(args), ); } @@ -174,7 +174,7 @@ export function executeIgnoringIncremental( if (!shouldTrace(executeChannel)) { return executeIgnoringIncrementalImpl(args); } - return traceMixed(executeChannel, buildOperationCtxFromArgs(args), () => + return traceMixed(executeChannel, buildOperationContextFromArgs(args), () => executeIgnoringIncrementalImpl(args), ); } @@ -288,7 +288,7 @@ export function subscribe( if (!shouldTrace(subscribeChannel)) { return subscribeImpl(args); } - return traceMixed(subscribeChannel, buildOperationCtxFromArgs(args), () => + return traceMixed(subscribeChannel, buildOperationContextFromArgs(args), () => subscribeImpl(args), ); } diff --git a/src/index.ts b/src/index.ts index 0037552afa..6d1a79c697 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,12 +38,12 @@ export { enableDevMode, isDevModeEnabled } from './devMode.ts'; export type { GraphQLChannelContextByName, GraphQLChannels, - GraphQLExecuteCtx, - GraphQLExecuteRootSelectionSetCtx, - GraphQLParseCtx, - GraphQLResolveCtx, - GraphQLSubscribeCtx, - GraphQLValidateCtx, + GraphQLExecuteContext, + GraphQLExecuteRootSelectionSetContext, + GraphQLParseContext, + GraphQLResolveContext, + GraphQLSubscribeContext, + GraphQLValidateContext, } from './diagnostics.js'; // The primary entry point into fulfilling a GraphQL request. From 4d28cce638149fb594f22dd379ef33f9912dcd87 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 14 May 2026 16:53:28 -0400 Subject: [PATCH 54/55] fix: adapt diagnostics to v17 after rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix .js → .ts import extensions across all diagnostics files - Migrate test files from mocha to node:test - Remove redundant executeRootSelectionSet tracing from mapSourceToResponseEvent (already traced inside Executor) - Add test for custom per-event executor tracing --- .../__tests__/expectEvents-test.ts | 7 +- .../__tests__/expectNoTracingActivity-test.ts | 7 +- src/__testUtils__/diagnosticsTracing.ts | 2 +- src/__testUtils__/expectEvents.ts | 6 +- src/__testUtils__/expectNoTracingActivity.ts | 8 +- src/__testUtils__/getTracingChannel.ts | 6 +- src/__tests__/diagnostics-test.ts | 10 +- src/diagnostics.ts | 18 +-- src/execution/Executor.ts | 2 +- .../__tests__/diagnostics-execute-test.ts | 31 ++--- .../__tests__/diagnostics-resolve-test.ts | 21 +-- .../__tests__/diagnostics-subscribe-test.ts | 127 ++++++++++++++++-- src/execution/execute.ts | 56 +------- src/index.ts | 2 +- src/language/__tests__/diagnostics-test.ts | 13 +- src/language/parser.ts | 2 +- src/type/validate.ts | 4 +- src/validation/__tests__/diagnostics-test.ts | 19 +-- 18 files changed, 199 insertions(+), 142 deletions(-) diff --git a/src/__testUtils__/__tests__/expectEvents-test.ts b/src/__testUtils__/__tests__/expectEvents-test.ts index 678da828c1..84addbf99e 100644 --- a/src/__testUtils__/__tests__/expectEvents-test.ts +++ b/src/__testUtils__/__tests__/expectEvents-test.ts @@ -1,8 +1,9 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../expectEvents.js'; -import { expectPromise } from '../expectPromise.js'; +import { expectEvents } from '../expectEvents.ts'; +import { expectPromise } from '../expectPromise.ts'; type TestTracingChannel = Parameters[0]; diff --git a/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts index b47452dddd..a9adafe47b 100644 --- a/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts +++ b/src/__testUtils__/__tests__/expectNoTracingActivity-test.ts @@ -1,8 +1,9 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectNoTracingActivity } from '../expectNoTracingActivity.js'; -import { expectPromise } from '../expectPromise.js'; +import { expectNoTracingActivity } from '../expectNoTracingActivity.ts'; +import { expectPromise } from '../expectPromise.ts'; type TestTracingChannel = Parameters[0]; diff --git a/src/__testUtils__/diagnosticsTracing.ts b/src/__testUtils__/diagnosticsTracing.ts index 59df473022..7de9a7c221 100644 --- a/src/__testUtils__/diagnosticsTracing.ts +++ b/src/__testUtils__/diagnosticsTracing.ts @@ -1,4 +1,4 @@ -import type { MinimalChannel, MinimalTracingChannel } from '../diagnostics.js'; +import type { MinimalChannel, MinimalTracingChannel } from '../diagnostics.ts'; export type TracingSubChannel = { [Key in keyof MinimalTracingChannel]: MinimalTracingChannel[Key] extends MinimalChannel diff --git a/src/__testUtils__/expectEvents.ts b/src/__testUtils__/expectEvents.ts index d876613161..bf6b9d142b 100644 --- a/src/__testUtils__/expectEvents.ts +++ b/src/__testUtils__/expectEvents.ts @@ -1,13 +1,13 @@ import { expect } from 'chai'; -import type { MinimalTracingChannel } from '../diagnostics.js'; +import type { MinimalTracingChannel } from '../diagnostics.ts'; import type { TestTracingChannel, TracingSubChannel, TracingSubChannelRecord, -} from './diagnosticsTracing.js'; -import { tracingSubChannels } from './diagnosticsTracing.js'; +} from './diagnosticsTracing.ts'; +import { tracingSubChannels } from './diagnosticsTracing.ts'; export type CollectedEvent = { [Channel in TracingSubChannel]: { diff --git a/src/__testUtils__/expectNoTracingActivity.ts b/src/__testUtils__/expectNoTracingActivity.ts index 61f35c1260..121dfa6e42 100644 --- a/src/__testUtils__/expectNoTracingActivity.ts +++ b/src/__testUtils__/expectNoTracingActivity.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; -import type { MinimalTracingChannel } from '../diagnostics.js'; +import type { MinimalTracingChannel } from '../diagnostics.ts'; -import { tracingSubChannels } from './diagnosticsTracing.js'; -import type { MethodSpy } from './spyOn.js'; -import { spyOnMethod } from './spyOn.js'; +import { tracingSubChannels } from './diagnosticsTracing.ts'; +import type { MethodSpy } from './spyOn.ts'; +import { spyOnMethod } from './spyOn.ts'; /** * Assert that a graphql tracing channel stays on its zero-subscriber fast path. diff --git a/src/__testUtils__/getTracingChannel.ts b/src/__testUtils__/getTracingChannel.ts index dc9fad966f..4a93b5bc04 100644 --- a/src/__testUtils__/getTracingChannel.ts +++ b/src/__testUtils__/getTracingChannel.ts @@ -1,9 +1,9 @@ -/* eslint-disable n/no-unsupported-features/node-builtins, import/no-nodejs-modules */ +/* eslint-disable n/no-unsupported-features/node-builtins */ import dc from 'node:diagnostics_channel'; -import type { GraphQLChannelContextByName } from '../diagnostics.js'; +import type { GraphQLChannelContextByName } from '../diagnostics.ts'; -import type { TestTracingChannel } from './diagnosticsTracing.js'; +import type { TestTracingChannel } from './diagnosticsTracing.ts'; /** * Resolve a graphql tracing channel by name on the real diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index ebde313e60..393abcd829 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -1,12 +1,12 @@ -/* eslint-disable import/no-nodejs-modules, n/no-unsupported-features/node-builtins */ +/* eslint-disable n/no-unsupported-features/node-builtins */ import dc from 'node:diagnostics_channel'; +import { describe, it } from 'node:test'; import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { invariant } from '../jsutils/invariant.js'; +import { invariant } from '../jsutils/invariant.ts'; -import type { MinimalTracingChannel } from '../diagnostics.js'; +import type { MinimalTracingChannel } from '../diagnostics.ts'; import { executeChannel, executeRootSelectionSetChannel, @@ -15,7 +15,7 @@ import { shouldTrace, subscribeChannel, validateChannel, -} from '../diagnostics.js'; +} from '../diagnostics.ts'; describe('diagnostics', () => { it('auto-registers the graphql tracing channels', () => { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index ae6324a3e9..4164617b95 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -11,23 +11,23 @@ * load silently no-ops and emission sites short-circuit. */ -import { isPromise } from './jsutils/isPromise.js'; -import type { Maybe } from './jsutils/Maybe.js'; -import type { ObjMap } from './jsutils/ObjMap.js'; +import { isPromise } from './jsutils/isPromise.ts'; +import type { Maybe } from './jsutils/Maybe.ts'; +import type { ObjMap } from './jsutils/ObjMap.ts'; -import type { GraphQLError } from './error/GraphQLError.js'; +import type { GraphQLError } from './error/GraphQLError.ts'; import type { DocumentNode, OperationDefinitionNode, OperationTypeNode, -} from './language/ast.js'; -import type { Source } from './language/source.js'; +} from './language/ast.ts'; +import type { Source } from './language/source.ts'; -import type { GraphQLSchema } from './type/schema.js'; +import type { GraphQLSchema } from './type/schema.ts'; -import type { ExecutionResult } from './execution/Executor.js'; -import type { ExperimentalIncrementalExecutionResults } from './execution/incremental/IncrementalExecutor.js'; +import type { ExecutionResult } from './execution/Executor.ts'; +import type { ExperimentalIncrementalExecutionResults } from './execution/incremental/IncrementalExecutor.ts'; /** * Structural subset of `DiagnosticsChannel` sufficient for publishing and diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index a722a8b04f..9e28e74d2f 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -49,7 +49,7 @@ import type { GraphQLSchema } from '../type/schema.ts'; import type { GraphQLExecuteRootSelectionSetContext, GraphQLResolveContext, -} from '../diagnostics.js'; +} from '../diagnostics.ts'; import { executeRootSelectionSetChannel, resolveChannel, diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index a9625eabf3..f1bedfeecb 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -1,28 +1,29 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { expectPromise } from '../../__testUtils__/expectPromise.js'; -import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; -import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts'; +import { expectPromise } from '../../__testUtils__/expectPromise.ts'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.ts'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts'; +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.ts'; -import type { OperationDefinitionNode } from '../../language/ast.js'; -import { OperationTypeNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import type { OperationDefinitionNode } from '../../language/ast.ts'; +import { OperationTypeNode } from '../../language/ast.ts'; +import { parse } from '../../language/parser.ts'; -import { GraphQLObjectType } from '../../type/definition.js'; -import { GraphQLString } from '../../type/scalars.js'; -import { GraphQLSchema } from '../../type/schema.js'; +import { GraphQLObjectType } from '../../type/definition.ts'; +import { GraphQLString } from '../../type/scalars.ts'; +import { GraphQLSchema } from '../../type/schema.ts'; -import { buildSchema } from '../../utilities/buildASTSchema.js'; +import { buildSchema } from '../../utilities/buildASTSchema.ts'; import { execute, executeIgnoringIncremental, executeSync, -} from '../execute.js'; +} from '../execute.ts'; const schema = buildSchema(` type Query { diff --git a/src/execution/__tests__/diagnostics-resolve-test.ts b/src/execution/__tests__/diagnostics-resolve-test.ts index 281efd6d72..d05a5d28ff 100644 --- a/src/execution/__tests__/diagnostics-resolve-test.ts +++ b/src/execution/__tests__/diagnostics-resolve-test.ts @@ -1,19 +1,20 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts'; -import { parse } from '../../language/parser.js'; +import { parse } from '../../language/parser.ts'; -import { GraphQLObjectType } from '../../type/definition.js'; -import { GraphQLString } from '../../type/scalars.js'; -import { GraphQLSchema } from '../../type/schema.js'; +import { GraphQLObjectType } from '../../type/definition.ts'; +import { GraphQLString } from '../../type/scalars.ts'; +import { GraphQLSchema } from '../../type/schema.ts'; -import { buildSchema } from '../../utilities/buildASTSchema.js'; +import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { execute } from '../execute.js'; +import { execute } from '../execute.ts'; const schema = buildSchema(` type Query { diff --git a/src/execution/__tests__/diagnostics-subscribe-test.ts b/src/execution/__tests__/diagnostics-subscribe-test.ts index 17e6380c50..ee74071379 100644 --- a/src/execution/__tests__/diagnostics-subscribe-test.ts +++ b/src/execution/__tests__/diagnostics-subscribe-test.ts @@ -1,22 +1,29 @@ +import { describe, it } from 'node:test'; + import { assert, expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.ts'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts'; -import { isAsyncIterable } from '../../jsutils/isAsyncIterable.js'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable.ts'; -import type { OperationDefinitionNode } from '../../language/ast.js'; -import { OperationTypeNode } from '../../language/ast.js'; -import { parse } from '../../language/parser.js'; +import type { OperationDefinitionNode } from '../../language/ast.ts'; +import { OperationTypeNode } from '../../language/ast.ts'; +import { parse } from '../../language/parser.ts'; -import type { GraphQLSchema } from '../../type/schema.js'; +import type { GraphQLSchema } from '../../type/schema.ts'; -import { buildSchema } from '../../utilities/buildASTSchema.js'; +import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { subscribe } from '../execute.js'; +import { + createSourceEventStream, + executeSubscriptionEvent, + mapSourceToResponseEvent, + subscribe, + validateSubscriptionArgs, +} from '../execute.ts'; const schema = buildSchema(` type Query { @@ -237,6 +244,102 @@ describe('subscribe diagnostics channel', () => { ); }); + it('emits execute root selection set events for each event with a custom per-event executor', async () => { + const document = parse('subscription S($tick: String) { tick }'); + const operation = document.definitions[0] as OperationDefinitionNode; + const variableValues = { tick: 'ignored by the field' }; + + await expectEvents( + executeRootSelectionSetChannel, + async () => { + const validatedArgs = validateSubscriptionArgs({ + schema, + document, + rootValue: { tick: twoTicks }, + variableValues, + }); + if (!('schema' in validatedArgs)) { + throw new Error('Unexpected validation errors'); + } + + const sourceEventStream = await createSourceEventStream(validatedArgs); + assert(isAsyncIterable(sourceEventStream)); + + const customExecutor: typeof executeSubscriptionEvent = (args) => + executeSubscriptionEvent(args); + + const responseStream = mapSourceToResponseEvent( + validatedArgs, + sourceEventStream, + customExecutor, + ); + + const firstResult = await responseStream.next(); + expect(firstResult).to.deep.equal({ + done: false, + value: { data: { tick: 'one' } }, + }); + assert(!firstResult.done, 'Expected first subscription event.'); + const secondResult = await responseStream.next(); + expect(secondResult).to.deep.equal({ + done: false, + value: { data: { tick: 'two' } }, + }); + assert(!secondResult.done, 'Expected second subscription event.'); + + const returned = responseStream.return?.(); + if (returned !== undefined) { + await returned; + } + return [firstResult.value, secondResult.value] as const; + }, + ([firstResult, secondResult]) => [ + { + channel: 'start', + context: { + schema, + operation, + variableValues, + operationName: 'S', + operationType: OperationTypeNode.SUBSCRIPTION, + }, + }, + { + channel: 'end', + context: { + schema, + operation, + variableValues, + operationName: 'S', + operationType: OperationTypeNode.SUBSCRIPTION, + result: firstResult, + }, + }, + { + channel: 'start', + context: { + schema, + operation, + variableValues, + operationName: 'S', + operationType: OperationTypeNode.SUBSCRIPTION, + }, + }, + { + channel: 'end', + context: { + schema, + operation, + variableValues, + operationName: 'S', + operationType: OperationTypeNode.SUBSCRIPTION, + result: secondResult, + }, + }, + ], + ); + }); + it('emits only start and end for a synchronous validation failure', async () => { const document = parse('fragment F on Subscription { tick }'); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index a60144db89..d4127ff2ce 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -30,7 +30,7 @@ import type { GraphQLSchema } from '../type/schema.ts'; import { getOperationAST } from '../utilities/getOperationAST.ts'; -import type { GraphQLExecuteContext } from '../diagnostics.js'; +import type { GraphQLExecuteContext } from '../diagnostics.ts'; import { executeChannel, shouldTrace, @@ -623,21 +623,12 @@ export function mapSourceToResponseEvent( sourceEventStream: AsyncIterable, rootSelectionSetExecutor: RootSelectionSetExecutor = executeSubscriptionEvent, ): AsyncGenerator { - // For each payload yielded from a subscription, map it over the normal - // GraphQL `execute` function, with `payload` as the rootValue. function mapFn(payload: unknown): PromiseOrValue { const perEventExecutionArgs: ValidatedSubscriptionArgs = { ...validatedExecutionArgs, rootValue: payload, }; - if (!shouldTrace(executeChannel)) { - return rootSelectionSetExecutor(perEventExecutionArgs); - } - return traceMixed( - executeChannel, - buildExecuteCtxFromValidatedArgs(validatedExecutionArgs), - () => rootSelectionSetExecutor(perEventExecutionArgs), - ); + return rootSelectionSetExecutor(perEventExecutionArgs); } const externalAbortSignal = validatedExecutionArgs.externalAbortSignal; @@ -774,49 +765,6 @@ function assertEventStream(result: unknown): AsyncIterable { return result; } -/** - * Build a graphql:execute channel context from ValidatedExecutionArgs. - * Used by executeSubscriptionEvent, where the operation has already been - * resolved during argument validation. The original document is not - * available at this point, only the resolved operation; subscribers that - * need the document should read it from the graphql:subscribe context. - */ -function buildExecuteCtxFromValidatedArgs( - args: ValidatedExecutionArgs, -): object { - let originalVariableValues: Maybe<{ readonly [variable: string]: unknown }>; - let hasResolvedOriginalVariableValues = false; - - return { - operation: args.operation, - schema: args.schema, - get variableValues() { - if (!hasResolvedOriginalVariableValues) { - originalVariableValues = getOriginalVariableValues(args); - hasResolvedOriginalVariableValues = true; - } - return originalVariableValues; - }, - operationName: args.operation.name?.value, - operationType: args.operation.operation, - }; -} - -function getOriginalVariableValues( - args: ValidatedExecutionArgs, -): Maybe<{ readonly [variable: string]: unknown }> { - const originalVariableValues: { [variable: string]: unknown } = {}; - for (const [variableName, source] of Object.entries( - args.variableValues.sources, - )) { - if (Object.hasOwn(source, 'value')) { - originalVariableValues[variableName] = source.value; - } - } - - return originalVariableValues; -} - function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray { return fieldDetailsList.map((fieldDetails) => fieldDetails.node); } diff --git a/src/index.ts b/src/index.ts index 6d1a79c697..d93eefd305 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,7 @@ export type { GraphQLResolveContext, GraphQLSubscribeContext, GraphQLValidateContext, -} from './diagnostics.js'; +} from './diagnostics.ts'; // The primary entry point into fulfilling a GraphQL request. export type { GraphQLArgs } from './graphql.ts'; diff --git a/src/language/__tests__/diagnostics-test.ts b/src/language/__tests__/diagnostics-test.ts index 8e067561bd..d6b3a1cb6a 100644 --- a/src/language/__tests__/diagnostics-test.ts +++ b/src/language/__tests__/diagnostics-test.ts @@ -1,12 +1,13 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.ts'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts'; -import { parse } from '../parser.js'; +import { parse } from '../parser.ts'; const parseChannel = getTracingChannel('graphql:parse'); diff --git a/src/language/parser.ts b/src/language/parser.ts index 47e40eb1e7..5108078b08 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -3,7 +3,7 @@ import type { Maybe } from '../jsutils/Maybe.ts'; import type { GraphQLError } from '../error/GraphQLError.ts'; import { syntaxError } from '../error/syntaxError.ts'; -import { parseChannel, shouldTrace } from '../diagnostics.js'; +import { parseChannel, shouldTrace } from '../diagnostics.ts'; import type { ArgumentCoordinateNode, diff --git a/src/type/validate.ts b/src/type/validate.ts index fe0f8de4dc..9597b2d2e2 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -742,7 +742,7 @@ function validateOneOfInputObjectField( function createInputObjectNonNullCircularRefsValidator( context: SchemaValidationContext, ): (inputObj: GraphQLInputObjectType) => void { - // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.js'. + // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.ts'. // Tracks already visited types to maintain O(N) and to ensure that cycles // are not redundantly reported. const visitedTypes = new Set(); @@ -804,7 +804,7 @@ function createInputObjectNonNullCircularRefsValidator( function createInputObjectDefaultValueCircularRefsValidator( context: SchemaValidationContext, ): (inputObj: GraphQLInputObjectType) => void { - // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.js'. + // Modified copy of algorithm from 'src/validation/rules/NoFragmentCycles.ts'. // Tracks already visited types to maintain O(N) and to ensure that cycles // are not redundantly reported. const visitedFields = Object.create(null); diff --git a/src/validation/__tests__/diagnostics-test.ts b/src/validation/__tests__/diagnostics-test.ts index 7ae899669c..3029617c63 100644 --- a/src/validation/__tests__/diagnostics-test.ts +++ b/src/validation/__tests__/diagnostics-test.ts @@ -1,18 +1,19 @@ +import { describe, it } from 'node:test'; + import { expect } from 'chai'; -import { describe, it } from 'mocha'; -import { expectEvents } from '../../__testUtils__/expectEvents.js'; -import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.js'; -import { expectToThrow } from '../../__testUtils__/expectToThrow.js'; -import { getTracingChannel } from '../../__testUtils__/getTracingChannel.js'; +import { expectEvents } from '../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../__testUtils__/expectNoTracingActivity.ts'; +import { expectToThrow } from '../../__testUtils__/expectToThrow.ts'; +import { getTracingChannel } from '../../__testUtils__/getTracingChannel.ts'; -import { parse } from '../../language/parser.js'; +import { parse } from '../../language/parser.ts'; -import type { GraphQLSchema } from '../../type/schema.js'; +import type { GraphQLSchema } from '../../type/schema.ts'; -import { buildSchema } from '../../utilities/buildASTSchema.js'; +import { buildSchema } from '../../utilities/buildASTSchema.ts'; -import { validate } from '../validate.js'; +import { validate } from '../validate.ts'; const schema = buildSchema(` type Query { From 1fcccd82436e3a87b768e4cb197d401a95db4477 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 15 May 2026 08:58:01 -0400 Subject: [PATCH 55/55] refactor: store rawVariableValues on ValidatedExecutionArgs Instead of reconstructing the original variable values from variableValues.sources, store a reference to the raw input and use it directly in the diagnostics context. --- src/execution/Executor.ts | 20 ++----------------- .../__tests__/diagnostics-execute-test.ts | 9 ++++----- src/execution/execute.ts | 1 + 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 9e28e74d2f..822c8b0e11 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -130,6 +130,7 @@ export interface ValidatedExecutionArgs { externalAbortSignal: AbortSignal | undefined; enableEarlyExecution: boolean; hooks: ExecutionHooks | undefined; + rawVariableValues: Maybe<{ readonly [variable: string]: unknown }>; } export interface ValidatedSubscriptionArgs extends ValidatedExecutionArgs { @@ -304,27 +305,10 @@ export class Executor< buildExecuteContextFromValidatedArgs( args: ValidatedExecutionArgs, ): GraphQLExecuteRootSelectionSetContext { - let originalVariableValues: Maybe<{ [variable: string]: unknown }>; - let hasResolvedOriginalVariableValues = false; - return { schema: args.schema, operation: args.operation, - get variableValues(): Maybe<{ readonly [variable: string]: unknown }> { - if (!hasResolvedOriginalVariableValues) { - originalVariableValues = {}; - for (const [variableName, source] of Object.entries( - args.variableValues.sources, - )) { - if (Object.hasOwn(source, 'value')) { - originalVariableValues[variableName] = source.value; - } - } - - hasResolvedOriginalVariableValues = true; - } - return originalVariableValues; - }, + variableValues: args.rawVariableValues, operationName: args.operation.name?.value, operationType: args.operation.operation, }; diff --git a/src/execution/__tests__/diagnostics-execute-test.ts b/src/execution/__tests__/diagnostics-execute-test.ts index f1bedfeecb..b110db6e0c 100644 --- a/src/execution/__tests__/diagnostics-execute-test.ts +++ b/src/execution/__tests__/diagnostics-execute-test.ts @@ -413,7 +413,6 @@ describe('execute root selection set diagnostics channel', () => { it('emits the full async lifecycle when the root selection set returns a promise', async () => { const document = parse('query { async }'); const operation = document.definitions[0] as OperationDefinitionNode; - const variableValues = {}; await expectEvents( executeRootSelectionSetChannel, @@ -429,7 +428,7 @@ describe('execute root selection set diagnostics channel', () => { context: { schema, operation, - variableValues, + variableValues: undefined, operationName: undefined, operationType: OperationTypeNode.QUERY, }, @@ -439,7 +438,7 @@ describe('execute root selection set diagnostics channel', () => { context: { schema, operation, - variableValues, + variableValues: undefined, operationName: undefined, operationType: OperationTypeNode.QUERY, }, @@ -449,7 +448,7 @@ describe('execute root selection set diagnostics channel', () => { context: { schema, operation, - variableValues, + variableValues: undefined, operationName: undefined, operationType: OperationTypeNode.QUERY, }, @@ -459,7 +458,7 @@ describe('execute root selection set diagnostics channel', () => { context: { schema, operation, - variableValues, + variableValues: undefined, operationName: undefined, operationType: OperationTypeNode.QUERY, result, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index d4127ff2ce..b498277d3e 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -512,6 +512,7 @@ export function validateExecutionArgs( externalAbortSignal: externalAbortSignal ?? undefined, enableEarlyExecution: enableEarlyExecution === true, hooks: hooks ?? undefined, + rawVariableValues: rawVariableValues ?? undefined, }; }