diagnostics_channel and tracing

Node.js · Expert · 15 min

It is how modern Node and libraries expose observability without monkey-patching.

APM and tracing tools used to monkey-patch library internals to measure them — fragile and version-dependent. `node:diagnostics_channel` replaces that with an explicit publish/subscribe bus for diagnostic data.

  • A library creates a named channel, dc.channel("mylib:query"), and calls channel.publish(message) at interesting points.
  • A tool calls dc.subscribe("mylib:query", handler) to receive those messages.
  • channel.hasSubscribers is false when nobody listens, so the library can skip building messages entirely; publishing with no subscribers is essentially free.

Name channels with a module prefix (undici:request:create, mylib:query) to avoid collisions. Subscribers run synchronously inside publish, so they must be fast and must not throw — exceptions are reported as uncaught but don't break the publisher.

For operations with a start and an end, `TracingChannel` standardises five channels — start, end, asyncStart, asyncEnd, error — and helpers that wrap a function: tracingChannel.tracePromise(fn, context) publishes them around a promise-returning call. The same mutable context object flows through all events, so a subscriber can store a start time in start and compute duration in asyncEnd.

Node core already publishes: http.client.request.start, http.server.request.start, net socket events, worker_threads creation, and undici (the engine behind fetch) publishes request lifecycle channels. OpenTelemetry instrumentations increasingly subscribe here.

Combine it with AsyncLocalStorage: channel.bindStore(als, transform) sets a store when the channel's events run, so spans become the active context for code inside.

If you author a library, adding a few channels is a cheap, high-value way to make it observable.

Example

import dc from "node:diagnostics_channel";

// --- library side ---
const queryChannel = dc.tracingChannel("mydb:query");

async function query(sql) {
  return queryChannel.tracePromise(async () => {
    await new Promise((r) => setTimeout(r, 20));      // pretend to hit a DB
    if (sql.includes("DROP")) throw new Error("nope");
    return [{ ok: 1 }];
  }, { sql });                                         // context shared by all events
}

// --- tool side (e.g. an APM agent) ---
queryChannel.subscribe({
  start(ctx) { ctx.startedAt = performance.now(); },
  asyncEnd(ctx) {
    const ms = (performance.now() - ctx.startedAt).toFixed(1);
    console.log(`${ctx.sql} took ${ms} ms${ctx.error ? " (failed)" : ""}`);
  },
  error(ctx) { console.log("error:", ctx.error.message); },
});

await query("SELECT 1");
await query("DROP TABLE x").catch(() => {});

Remember

  • Libraries publish to named channels; tools subscribe — no monkey-patching
  • Publishing without subscribers is nearly free; check hasSubscribers before building messages
  • TracingChannel standardises start/end/asyncStart/asyncEnd/error with a shared context
  • Node core and undici already publish channels

Watch out for

  • Doing slow work or throwing inside a subscriber, which runs synchronously in the publisher's path.

Check yourself

  1. What problem does diagnostics_channel primarily solve?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/diagnostics_channel.html