From af45b956c4112c0bc180f29eba9f6e9d3bd5dcb0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 2 Jun 2026 10:53:18 -0400 Subject: [PATCH 1/5] docs: add Node.js tracing channels guide Add a Runtime Features guide covering the graphql:* diagnostics channels: use cases, subscribing, the channel list and context types, the sync/async lifecycle, how the channels nest within a request, a resolver-timing example using bindStore, and subscription tracing. --- website/pages/docs/_meta.ts | 1 + website/pages/docs/tracing-channels.mdx | 291 ++++++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 website/pages/docs/tracing-channels.mdx diff --git a/website/pages/docs/_meta.ts b/website/pages/docs/_meta.ts index dffed77ed8..8a32a1f04c 100644 --- a/website/pages/docs/_meta.ts +++ b/website/pages/docs/_meta.ts @@ -44,6 +44,7 @@ const meta = { 'advanced-execution-pipelines': '', 'abort-signals': '', 'execution-hooks': '', + 'tracing-channels': '', '-- 5': { type: 'separator', title: 'Advanced Guides', diff --git a/website/pages/docs/tracing-channels.mdx b/website/pages/docs/tracing-channels.mdx new file mode 100644 index 0000000000..3f702a24be --- /dev/null +++ b/website/pages/docs/tracing-channels.mdx @@ -0,0 +1,291 @@ +--- +title: Tracing with Diagnostics Channels +sidebarTitle: Tracing Channels +--- + +import { Callout } from 'nextra/components'; + +# Tracing with Diagnostics Channels + + + Tracing channels are available in GraphQL.js v17. They build on Node's + [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) + `TracingChannel` API. + + +GraphQL.js publishes lifecycle events on a set of named tracing channels that +observability tools (APM agents, tracers, custom logging) can subscribe to in +order to watch parsing, validation, variable coercion, execution, subscription +setup, and individual resolver calls. + +Tracing is built on `node:diagnostics_channel`, so it is decoupled from the +GraphQL.js API surface. You do not pass a tracer into `execute()` or wrap your +schema. Instead, a subscriber attaches to a channel by name and GraphQL.js +publishes to it whenever work happens. + +Typical uses include: + +- **Distributed tracing**: open a span per operation, root field, or resolver + and export it to your APM or tracing backend. +- **Performance monitoring**: time resolvers to surface slow fields and N+1 + access patterns. +- **Metrics**: count operations and record latency or error rates per + operation, field, or stage. +- **Error tracking**: capture parse, validation, coercion, and resolver errors + along with their payloads. +- **Structured logging and auditing**: log each operation with its outcome. + +## Subscribing to a channel + +GraphQL.js resolves `node:diagnostics_channel` itself at module load and +publishes automatically, so there is no tracer to install or schema to wrap. A +subscriber just imports `node:diagnostics_channel` and attaches to a channel by +name: + +```js +import dc from 'node:diagnostics_channel'; + +const channel = dc.tracingChannel('graphql:execute'); + +channel.subscribe({ + start(message) { + // Runs when execution begins. + }, + end(message) { + // Runs when the synchronous portion of execution finishes. + }, + asyncStart(message) { + // Runs when an asynchronous execution continuation begins. + }, + asyncEnd(message) { + // Runs when asynchronous execution settles. + }, + error(message) { + // Runs when the operation throws or rejects. `message.error` is the cause. + }, +}); +``` + +On runtimes that do not expose `node:diagnostics_channel` (browsers, for +example) the module load silently no-ops and every emission site +short-circuits, so bundling GraphQL.js for the browser carries no tracing +overhead and no error. + +## The channels + +| Channel | Fires for | Context type | +| --- | --- | --- | +| `graphql:parse` | Each `parse()` call | [`GraphQLParseContext`](/api-v17/graphql#graphqlparsecontext) | +| `graphql:validate` | Each `validate()` call | [`GraphQLValidateContext`](/api-v17/graphql#graphqlvalidatecontext) | +| `graphql:execute` | Each `execute()` / `graphql()` operation | [`GraphQLExecuteContext`](/api-v17/graphql#graphqlexecutecontext) | +| `graphql:execute:variableCoercion` | Variable coercion within an operation | [`GraphQLExecuteVariableCoercionContext`](/api-v17/graphql#graphqlexecutevariablecoercioncontext) | +| `graphql:execute:rootSelectionSet` | Root selection set execution (also once per emitted subscription event) | [`GraphQLExecuteRootSelectionSetContext`](/api-v17/graphql#graphqlexecuterootselectionsetcontext) | +| `graphql:subscribe` | Each `subscribe()` setup | [`GraphQLSubscribeContext`](/api-v17/graphql#graphqlsubscribecontext) | +| `graphql:resolve` | Each field resolver invocation | [`GraphQLResolveContext`](/api-v17/graphql#graphqlresolvecontext) | + + + `graphql:resolve` fires for every resolved field, including fields served by + the default resolver, so a single response can produce a large number of + events. There is no way to subscribe to a subset of fields, so keep the + handlers cheap and do any heavy work off the hot path. + + +### Context payloads + +Every context object gains an `error` property when the operation fails and a +`result` property when it succeeds; the other fields are present from `start`. + +See the [Diagnostics +reference](/api-v17/graphql#category-diagnostics) for the complete set of +fields on each context type. The context types are also exported from the +`graphql` package if you want to type message payloads in TypeScript. + +## The lifecycle events + +Each tracing channel exposes five sub-channels. GraphQL.js publishes them in a +fixed order depending on whether the traced operation is synchronous or returns +a promise. + +- **Synchronous success:** `start` → `end` +- **Synchronous failure:** `start` → `error` → `end` +- **Asynchronous success:** `start` → `end` → `asyncStart` → `asyncEnd` +- **Asynchronous failure:** `start` → `end` → `asyncStart` → `error` → `asyncEnd` + +`start` and `end` bracket the synchronous portion of the call. When the +operation continues asynchronously, `asyncStart` fires as the async +continuation begins and `asyncEnd` fires once it settles. `error` always fires +before the terminal `end`/`asyncEnd` and carries the cause on `message.error`. + +GraphQL.js runs the traced work inside the `start` channel's `runStores`, so an +`AsyncLocalStorage` bound to that channel with `channel.start.bindStore()` +stays entered across the full async lifecycle. This is what lets a tracer open +a span in `start` and close it in `asyncEnd`. + + + A subscriber that attaches partway through an in-flight operation will not + see a matching `start`, and any `AsyncLocalStorage` context it expects will be + missing. Attach subscribers before the operations you want to observe. + + + + For incremental delivery (`@defer` and `@stream`), `graphql:execute` + completes when the initial result is ready, not when the deferred and + streamed payloads finish. Those later payloads are not `graphql:execute` + events; the deferred fields still fire `graphql:resolve` as they execute. Do + not treat the `graphql:execute` duration as the full request lifetime for + incremental responses. + + +## How the channels fit together + +A single request moves through the channels in a fixed order. `parse` and +`validate` run first and independently, and they only fire if you call +`parse()` and `validate()` (the `graphql()` harness does, but calling +`execute()` with an already-parsed document fires only `execute` and below). +Everything else nests inside `graphql:execute`. + +For this operation: + +```graphql +query Q($id: ID!) { + user(id: $id) { + name + posts { + title + } + } +} +``` + +the channels fire like this, shown by their full `start` to `asyncEnd` lifetime: + +```text +graphql:parse parse the document +graphql:validate validate against the schema +graphql:execute the operation +├─ graphql:execute:variableCoercion coerce $id +└─ graphql:execute:rootSelectionSet execute the root selection set + ├─ graphql:resolve user + ├─ graphql:resolve user.name + ├─ graphql:resolve user.posts + ├─ graphql:resolve user.posts.0.title + └─ graphql:resolve user.posts.1.title +``` + +`variableCoercion` and `rootSelectionSet` nest inside `execute`, and a +`graphql:resolve` event fires for every resolved field within +`rootSelectionSet`. The resolve events are siblings, not nested in one another: +`graphql:resolve` traces the resolver call itself, not the execution of that +field's children. Reconstruct the field hierarchy from each event's +`fieldPath`, shown above, not from channel nesting. + +Because each level follows the [lifecycle above](#the-lifecycle-events), the +synchronous `end` of `execute` and `rootSelectionSet` fires before the +resolvers settle; their full duration runs from `start` to `asyncEnd`. + +## Example: timing every resolver + +```js +import dc from 'node:diagnostics_channel'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +const store = new AsyncLocalStorage(); +const channel = dc.tracingChannel('graphql:resolve'); +const timings = []; + +// Scoped to a single resolver call: available in `end`/`asyncEnd`, cleaned up +// afterward, and safe across concurrent resolvers. +channel.start.bindStore(store, (message) => { + const record = { + field: `${message.parentType}.${message.fieldName}`, + startedAt: performance.now(), + }; + timings.push(record); + return record; +}); + +channel.subscribe({ + end(message) { + // Fires for both synchronous and asynchronous resolvers. + update(message.error); + }, + asyncEnd(message) { + update(message.error); + }, +}); + +function update(error) { + const record = store.getStore(); + if (record === undefined) return; + record.duration = performance.now() - record.startedAt; + record.error = error; +} +``` + +Binding the store to `start` lets GraphQL.js scope the store to each resolver +call and clean it up afterward. `end` fires for every resolver, while +`asyncEnd` fires only for resolvers that return a promise, and always after +`end`. Recording the duration in place (rather than pushing a new entry from +each handler) is what keeps the asynchronous path from being counted twice. + +## Tracing subscriptions + +`graphql:subscribe` wraps the subscription *setup*: building the event source +from the subscription's root field. The result on success is the response +stream. + +Each event that flows through the subscription is executed separately, so +`graphql:execute:rootSelectionSet` fires once **per emitted event**. The two +channels describe different units of work: + +- `graphql:subscribe` fires **once per subscription**. Subscribe to it to + observe the subscription's lifetime and any failure setting it up. +- `graphql:execute:rootSelectionSet` fires **once per delivered payload**. + Subscribe to it to time and trace the execution of each individual event. + +```js +import dc from 'node:diagnostics_channel'; + +// One event per subscription: setup and overall lifetime. +dc.tracingChannel('graphql:subscribe').subscribe({ + start(message) { + openSubscriptionSpan(message.operationName); + }, + error(message) { + // Setup failed, e.g. the subscription root field threw. + failSubscriptionSpan(message.error); + }, + // ... +}); + +// One event per delivered payload. `rootSelectionSet` also fires for queries +// and mutations, so filter on the operation type. +dc.tracingChannel('graphql:execute:rootSelectionSet').subscribe({ + start(message) { + if (message.operationType === 'subscription') { + openEventSpan(message.operationName); + } + }, + asyncEnd(message) { + if (message.operationType === 'subscription') { + closeEventSpan(message.result); + } + }, + // ... +}); +``` + +Listen to both when you want the subscribe event as the parent span and each +delivered payload as a child; listen to just one when you only care about the +subscription's lifetime or only about per-event execution. + +## Notes and limitations + +- Tracing relies on `node:diagnostics_channel`. Node, Deno, and Bun expose it; + browsers do not, where tracing is a no-op. +- `graphql:execute:variableCoercion` runs synchronously inside argument + validation, so it only ever fires `start`/`end` (and `error` on an abrupt + throw). When coercion produces variable errors it does not throw; the errors + are reported on the context `result` instead, mirroring `graphql:validate`. +- These channels report observability events. They are not hooks for altering + execution; mutating a context payload does not change GraphQL.js behavior. From ff387267902be92734c2334073a465947e33970e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 2 Jun 2026 11:09:55 -0400 Subject: [PATCH 2/5] docs: link the tracing channels guide from the v17 upgrade guide --- website/pages/upgrade-guides/v16-v17.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/pages/upgrade-guides/v16-v17.mdx b/website/pages/upgrade-guides/v16-v17.mdx index 713440d3ab..1f87211cc9 100644 --- a/website/pages/upgrade-guides/v16-v17.mdx +++ b/website/pages/upgrade-guides/v16-v17.mdx @@ -464,9 +464,11 @@ such as `graphql:parse`, `graphql:validate`, `graphql:execute`, execution code. The channels are resolved at module load and no-op on runtimes that do not -provide `node:diagnostics_channel`. Import `graphql/diagnostics` for the -channel objects and TypeScript context types for strongly typed subscribers. -See the [Diagnostics API reference](/api-v17/graphql#category-diagnostics) for +provide `node:diagnostics_channel`. GraphQL.js also exports TypeScript context +types for strongly typed subscribers. + +See the [Tracing Channels guide](/docs/tracing-channels) for a full walkthrough, +and the [Diagnostics API reference](/api-v17/graphql#category-diagnostics) for the channel names and payload shapes. ## Input Coercion, Defaults, and Custom Scalars From 1f281155ce5b7eb9027d96a8aed4bce8f24d1dd7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 2 Jun 2026 11:42:47 -0400 Subject: [PATCH 3/5] chore: allow api-v17 anchor links and walkthrough in spellcheck --- cspell.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.yml b/cspell.yml index c4339b18e2..c220a7f6f1 100644 --- a/cspell.yml +++ b/cspell.yml @@ -50,6 +50,7 @@ overrides: ignoreRegExpList: - u\{[0-9a-f]{1,8}\} - href="/api-v1[67]/[^"]+" + - \(/api-v1[67]/[^)]+\) words: - backticks @@ -63,6 +64,7 @@ words: - tsdoc - worktree - worktrees + - walkthrough - instanceof - apos - middot From de2f3509803cbb1ad472c80485ee85c881496cf2 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 3 Jun 2026 00:29:38 +0300 Subject: [PATCH 4/5] docs: align tracing channel article style --- website/pages/docs/tracing-channels.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/website/pages/docs/tracing-channels.mdx b/website/pages/docs/tracing-channels.mdx index 3f702a24be..451c770182 100644 --- a/website/pages/docs/tracing-channels.mdx +++ b/website/pages/docs/tracing-channels.mdx @@ -8,7 +8,7 @@ import { Callout } from 'nextra/components'; # Tracing with Diagnostics Channels - Tracing channels are available in GraphQL.js v17. They build on Node's + Tracing channels are available in GraphQL.js v17. They build on Node.js [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) `TracingChannel` API. @@ -35,7 +35,7 @@ Typical uses include: along with their payloads. - **Structured logging and auditing**: log each operation with its outcome. -## Subscribing to a channel +## Subscribing to a Channel GraphQL.js resolves `node:diagnostics_channel` itself at module load and publishes automatically, so there is no tracer to install or schema to wrap. A @@ -71,7 +71,7 @@ example) the module load silently no-ops and every emission site short-circuits, so bundling GraphQL.js for the browser carries no tracing overhead and no error. -## The channels +## The Channels | Channel | Fires for | Context type | | --- | --- | --- | @@ -90,7 +90,7 @@ overhead and no error. handlers cheap and do any heavy work off the hot path. -### Context payloads +### Context Payloads Every context object gains an `error` property when the operation fails and a `result` property when it succeeds; the other fields are present from `start`. @@ -100,7 +100,7 @@ reference](/api-v17/graphql#category-diagnostics) for the complete set of fields on each context type. The context types are also exported from the `graphql` package if you want to type message payloads in TypeScript. -## The lifecycle events +## The Lifecycle Events Each tracing channel exposes five sub-channels. GraphQL.js publishes them in a fixed order depending on whether the traced operation is synchronous or returns @@ -136,7 +136,7 @@ a span in `start` and close it in `asyncEnd`. incremental responses. -## How the channels fit together +## How the Channels Fit Together A single request moves through the channels in a fixed order. `parse` and `validate` run first and independently, and they only fire if you call @@ -183,7 +183,7 @@ Because each level follows the [lifecycle above](#the-lifecycle-events), the synchronous `end` of `execute` and `rootSelectionSet` fires before the resolvers settle; their full duration runs from `start` to `asyncEnd`. -## Example: timing every resolver +## Example: Timing Every Resolver ```js import dc from 'node:diagnostics_channel'; @@ -228,7 +228,7 @@ call and clean it up afterward. `end` fires for every resolver, while `end`. Recording the duration in place (rather than pushing a new entry from each handler) is what keeps the asynchronous path from being counted twice. -## Tracing subscriptions +## Tracing Subscriptions `graphql:subscribe` wraps the subscription *setup*: building the event source from the subscription's root field. The result on success is the response @@ -279,7 +279,7 @@ Listen to both when you want the subscribe event as the parent span and each delivered payload as a child; listen to just one when you only care about the subscription's lifetime or only about per-event execution. -## Notes and limitations +## Notes and Limitations - Tracing relies on `node:diagnostics_channel`. Node, Deno, and Bun expose it; browsers do not, where tracing is a no-op. From f445315a5cb152751560301c61bd2d5e6ab5912c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 3 Jun 2026 00:30:17 +0300 Subject: [PATCH 5/5] docs: clarify tracing error result semantics --- website/pages/docs/tracing-channels.mdx | 90 ++++++++++++++++++++----- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/website/pages/docs/tracing-channels.mdx b/website/pages/docs/tracing-channels.mdx index 451c770182..92c6707d66 100644 --- a/website/pages/docs/tracing-channels.mdx +++ b/website/pages/docs/tracing-channels.mdx @@ -61,7 +61,7 @@ channel.subscribe({ // Runs when asynchronous execution settles. }, error(message) { - // Runs when the operation throws or rejects. `message.error` is the cause. + // Runs when the traced call throws or rejects. `message.error` is the cause. }, }); ``` @@ -92,19 +92,50 @@ overhead and no error. ### Context Payloads -Every context object gains an `error` property when the operation fails and a -`result` property when it succeeds; the other fields are present from `start`. +Every context object receives its channel-specific fields at `start`. When the +traced call completes normally, the terminal event receives a `result` +property. When the traced call throws or rejects, the `error` sub-channel fires +and the terminal event receives an `error` property. + +Here, "traced call" means the JavaScript unit wrapped by the channel, not +necessarily a GraphQL operation. It may be a parser call, validation call, +execution call, variable coercion step, subscription setup, root selection set, +or individual resolver call. See the [Diagnostics reference](/api-v17/graphql#category-diagnostics) for the complete set of fields on each context type. The context types are also exported from the `graphql` package if you want to type message payloads in TypeScript. +### GraphQL Errors and Tracing Errors + +The tracing `error` lifecycle event is not the same thing as a `GraphQLError` +returned by GraphQL.js. It fires when the traced call throws or rejects. +Several GraphQL.js channels can instead complete normally and put +`GraphQLError` values in the context `result`: + +- `graphql:validate` returns its validation error array as `result`. +- `graphql:execute`, `graphql:execute:rootSelectionSet`, and + `graphql:subscribe` may return an `ExecutionResult` with `errors`. +- `graphql:execute:variableCoercion` may return a coercion result with + `errors`. + +A resolver error is the main wrinkle. If a resolver throws or rejects, the +`graphql:resolve` event for that resolver emits the tracing `error` lifecycle +event. Execution may still catch that resolver error, format it as a GraphQL +field error, and include it in the result reported by `graphql:execute`, +`graphql:execute:rootSelectionSet`, or a subscription response event. The same +underlying resolver failure can therefore appear both as `message.error` on +`graphql:resolve` and as a formatted GraphQL error in an enclosing result. +Likewise, a subscription source resolver that throws or rejects is reported as +an error result on `graphql:subscribe`, not as the `graphql:subscribe` tracing +`error` lifecycle event. + ## The Lifecycle Events Each tracing channel exposes five sub-channels. GraphQL.js publishes them in a -fixed order depending on whether the traced operation is synchronous or returns -a promise. +fixed order depending on whether the traced call is synchronous or returns a +promise. - **Synchronous success:** `start` → `end` - **Synchronous failure:** `start` → `error` → `end` @@ -112,9 +143,10 @@ a promise. - **Asynchronous failure:** `start` → `end` → `asyncStart` → `error` → `asyncEnd` `start` and `end` bracket the synchronous portion of the call. When the -operation continues asynchronously, `asyncStart` fires as the async -continuation begins and `asyncEnd` fires once it settles. `error` always fires -before the terminal `end`/`asyncEnd` and carries the cause on `message.error`. +traced call continues asynchronously, `asyncStart` fires as the async +continuation begins and `asyncEnd` fires once it settles. When the traced call +throws or rejects, `error` fires before the terminal `end`/`asyncEnd` and +carries the cause on `message.error`. GraphQL.js runs the traced work inside the `start` channel's `runStores`, so an `AsyncLocalStorage` bound to that channel with `channel.start.bindStore()` @@ -157,7 +189,7 @@ query Q($id: ID!) { } ``` -the channels fire like this, shown by their full `start` to `asyncEnd` lifetime: +the channels fire like this, shown by their full lifecycle: ```text graphql:parse parse the document @@ -246,28 +278,47 @@ channels describe different units of work: ```js import dc from 'node:diagnostics_channel'; -// One event per subscription: setup and overall lifetime. +// Subscription setup and overall lifetime. dc.tracingChannel('graphql:subscribe').subscribe({ start(message) { openSubscriptionSpan(message.operationName); }, + end(message) { + // Synchronous setup finished. `result` may be the response stream or an + // ExecutionResult with setup errors. + if ('result' in message) { + closeSubscriptionSpan(message.result); + } + }, + asyncEnd(message) { + // Asynchronous setup finished. `result` may be the response stream or an + // ExecutionResult with setup errors. + if ('result' in message) { + closeSubscriptionSpan(message.result); + } + }, error(message) { - // Setup failed, e.g. the subscription root field threw. + // The subscribe call failed abruptly, e.g. an unexpected runtime error. failSubscriptionSpan(message.error); }, // ... }); -// One event per delivered payload. `rootSelectionSet` also fires for queries -// and mutations, so filter on the operation type. +// Per-payload execution. `rootSelectionSet` also fires for queries and +// mutations, so filter on the operation type. dc.tracingChannel('graphql:execute:rootSelectionSet').subscribe({ start(message) { if (message.operationType === 'subscription') { openEventSpan(message.operationName); } }, + end(message) { + if (message.operationType === 'subscription' && 'result' in message) { + closeEventSpan(message.result); + } + }, asyncEnd(message) { - if (message.operationType === 'subscription') { + if (message.operationType === 'subscription' && 'result' in message) { closeEventSpan(message.result); } }, @@ -283,9 +334,12 @@ subscription's lifetime or only about per-event execution. - Tracing relies on `node:diagnostics_channel`. Node, Deno, and Bun expose it; browsers do not, where tracing is a no-op. -- `graphql:execute:variableCoercion` runs synchronously inside argument - validation, so it only ever fires `start`/`end` (and `error` on an abrupt - throw). When coercion produces variable errors it does not throw; the errors - are reported on the context `result` instead, mirroring `graphql:validate`. +- `graphql:parse`, `graphql:validate`, and + `graphql:execute:variableCoercion` are sync-only channels. They only emit + `start`/`end`, plus `error` if the traced call throws. +- Some GraphQL errors are returned in a context `result` rather than through + the tracing `error` lifecycle event. See + [GraphQL Errors and Tracing Errors](#graphql-errors-and-tracing-errors) for + the returned-error channels and the resolver-error wrinkle. - These channels report observability events. They are not hooks for altering execution; mutating a context payload does not change GraphQL.js behavior.