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 callschannel.publish(message)at interesting points. - A tool calls
dc.subscribe("mylib:query", handler)to receive those messages. channel.hasSubscribersis 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
hasSubscribersbefore 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.