🎉 Explore GraphQLConf 2026 • May 19-21 • Fremont, CA • View the schedule
DocumentationTracing Channels

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

ChannelFires forContext type
graphql:parseEach parse() callGraphQLParseContext
graphql:validateEach validate() callGraphQLValidateContext
graphql:executeEach execute() / graphql() operationGraphQLExecuteContext
graphql:execute:variableCoercionVariable coercion within an operationGraphQLExecuteVariableCoercionContext
graphql:execute:rootSelectionSetRoot selection set execution (also once per emitted subscription event)GraphQLExecuteRootSelectionSetContext
graphql:subscribeEach subscribe() setupGraphQLSubscribeContext
graphql:resolveEach field resolver invocationGraphQLResolveContext
⚠️

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: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 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.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 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: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.
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, 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 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.