Tracing with Diagnostics Channels
Tracing channels are available in GraphQL.js v17. They build on Node.js
diagnostics_channel
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:
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 traced call 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 |
graphql:validate | Each validate() call | GraphQLValidateContext |
graphql:execute | Each execute() / graphql() operation | GraphQLExecuteContext |
graphql:execute:variableCoercion | Variable coercion within an operation | GraphQLExecuteVariableCoercionContext |
graphql:execute:rootSelectionSet | Root selection set execution (also once per emitted subscription event) | GraphQLExecuteRootSelectionSetContext |
graphql:subscribe | Each subscribe() setup | GraphQLSubscribeContext |
graphql:resolve | Each field resolver invocation | 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 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 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:validatereturns its validation error array asresult.graphql:execute,graphql:execute:rootSelectionSet, andgraphql:subscribemay return anExecutionResultwitherrors.graphql:execute:variableCoercionmay return a coercion result witherrors.
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 call 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
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()
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:
query Q($id: ID!) {
user(id: $id) {
name
posts {
title
}
}
}the channels fire like this, shown by their full lifecycle:
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.titlevariableCoercion 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
synchronous end of execute and rootSelectionSet fires before the
resolvers settle; their full duration runs from start to asyncEnd.
Example: Timing Every Resolver
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:subscribefires once per subscription. Subscribe to it to observe the subscription’s lifetime and any failure setting it up.graphql:execute:rootSelectionSetfires once per delivered payload. Subscribe to it to time and trace the execution of each individual event.
import dc from 'node:diagnostics_channel';
// 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) {
// The subscribe call failed abruptly, e.g. an unexpected runtime error.
failSubscriptionSpan(message.error);
},
// ...
});
// 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' && 'result' in message) {
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:parse,graphql:validate, andgraphql:execute:variableCoercionare sync-only channels. They only emitstart/end, pluserrorif the traced call throws.- Some GraphQL errors are returned in a context
resultrather than through the tracingerrorlifecycle event. See 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.