Diagnostics: channels, async context, and profiles
diagnostics_channel is in-process pub/sub that costs nothing unless subscribed, AsyncLocalStorage carries a request id across await without threading args, and --cpu-prof writes a profile on exit — instrument without monkey-patching.
A checkout service had clean structured logs, but every line was an island: level=error msg="charge failed" with no way to tie it to the request that triggered it. The team had threaded a traceId argument through the HTTP handler and the order service, but two layers down a library callback dropped it, and from there every log line was anonymous. During a partial outage they had thousands of error lines and no way to reconstruct a single user’s journey. The fix wasn’t another log field by hand — it was AsyncLocalStorage, which carries the id across every await and callback automatically, so the deep logger reads it without anyone passing it.
diagnostics_channel: instrument without monkey-patching
The old way to add tracing or metrics to a library was to monkey-patch its prototype — wrap http.request, swap a method on the driver — which breaks silently on the next upgrade and couples your observability code to internals. node:diagnostics_channel replaces that with a built-in, in-process pub/sub bus. A producer publishes named messages; subscribers receive them synchronously. Core, undici, and http already expose channels, so you instrument them without touching their code.
The performance property that makes it safe to leave in hot paths is hasSubscribers. A publish with no listeners is essentially free, so a library can guard the cost of building the message and pay nothing when nobody is watching.
import diagnostics_channel from "node:diagnostics_channel";
const channel = diagnostics_channel.channel("app:db:query");
// producer (in the DB wrapper) — pay to build the payload only if observed
function runQuery(sql, params) {
if (channel.hasSubscribers) {
channel.publish({ sql, params, startedAt: performance.now() });
}
return driver.query(sql, params);
}
// subscriber (in your tracing setup) — attach with zero coupling to the wrapper
channel.subscribe((message) => {
metrics.increment("db.query", { sql: message.sql });
});The tradeoff versus an event emitter or a wrapper: channels are synchronous and unordered across producers, so a subscriber must be fast and must not throw into the producer’s call. The win is decoupling — you can subscribe to undici’s built-in request channels for distributed tracing without a single line of patching, and the instrumentation survives library upgrades.
AsyncLocalStorage: request context that survives await
The hook’s bug — a traceId dropped two layers down — is the canonical case for AsyncLocalStorage (from node:async_hooks). It gives you a store that is scoped to an asynchronous call tree: you call als.run(store, fn) once at the request boundary, and any code reached from fn — across await, setTimeout, promise chains, library callbacks — can call als.getStore() and read the same store. You never pass the id as an argument again.
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage();
// at the boundary: open a context for this request
app.use((req, res, next) => {
const store = { traceId: req.headers["x-trace-id"] ?? crypto.randomUUID() };
als.run(store, () => next());
});
// anywhere downstream, however deep, with no plumbing:
function log(msg) {
const { traceId } = als.getStore() ?? {};
logger.info({ traceId, msg });
}Contrast this with a module-level variable: a global is shared across all concurrent requests, so under load request B overwrites request A’s id and your traces interleave into nonsense. AsyncLocalStorage keeps a separate store per async context, which is exactly the isolation a server needs. The cost is real but bounded — it rides on async_hooks, so there is per-async-operation overhead; keep the store small (ids and flags, not large objects) and you pay single-digit-percent in typical request handlers.
| Tool | What it gives | Cost / when to reach for it |
|---|---|---|
diagnostics_channel | In-process pub/sub to instrument core/undici/http with no patching | ~free when no subscribers (hasSubscribers); always-on tracing/metrics hooks |
AsyncLocalStorage | Request-scoped store readable across await/callbacks | Bounded per-op overhead; carry trace/request ids and tenant flags |
async_hooks | Raw init/before/after/destroy lifecycle of async resources | Real per-resource CPU cost; use sparingly, prefer ALS for context |
—cpu-prof / —heap-prof | A .cpuprofile/.heapprofile written on exit, opened in DevTools | Profiling overhead while on; on-demand CPU/heap investigation |
| signals (SIGTERM/SIGINT/SIGUSR1) | Lifecycle hooks: graceful shutdown, Ctrl-C, open inspector | Free; handle SIGTERM to drain before orchestrators kill you |
▸Why this works
AsyncLocalStorage is built on async_hooks, but they are not the same tool. async_hooks exposes the raw lifecycle of every async resource — init, before, after, destroy — and a busy hook fires on every timer, socket, and promise, which is why naive use can cost double-digit percent CPU. AsyncLocalStorage is the one well-optimized consumer of that machinery you actually want for context. The rule: reach for AsyncLocalStorage for request-scoped data, and only drop to raw async_hooks for deep resource tracking you can’t get otherwise — and measure when you do.
Signals and on-demand profiling
When a container misses a deployment rollout or a process leaks memory in staging, you need to act on a live process without restarting it — and that’s exactly what signals and on-demand profiling give you. A long-running process talks to its operator through signals. Orchestrators send SIGTERM to ask for a graceful shutdown — you get a window to stop accepting new work, drain in-flight requests, close DB pools, and exit; if you ignore it, Kubernetes follows with SIGKILL after the grace period (30s by default) and cuts you off mid-request. SIGINT is Ctrl-C in a terminal. SIGUSR1 is special: Node opens the inspector on receipt by default, so you can attach a debugger to a live process without a restart.
// graceful shutdown: stop accepting → drain → close deps → exit
process.on("SIGTERM", async () => {
server.close(); // stop accepting new connections
await drainInFlight(); // let active requests finish
await db.end(); // close pools / flush
process.exit(0);
});When you need to see why a process is slow or leaking, profile it on demand. Launch with --cpu-prof (or --heap-prof) and Node writes a .cpuprofile (or .heapprofile) on exit that you open in Chrome DevTools’ Performance/Memory panel; --diagnostic-dir controls where the files land. For a hard crash, enable core dumps (ulimit -c unlimited, and --abort-on-uncaught-exception to dump on an uncaught throw) and inspect the dump post-mortem with llnode/lldb. The throughline: none of these requires editing your application code, so they’re safe to add to a misbehaving production process.
A web service must attach a trace id at the request boundary and have a logger five call-layers down emit it, including inside a third-party library's async callbacks. How should the id be propagated?
Why is a module-level global variable wrong for holding a per-request trace id, while AsyncLocalStorage is right?
A library publishes to a diagnostics_channel on every DB query but the team measures no overhead in production where nothing subscribes. Why is the publish nearly free?
Order a correct SIGTERM graceful shutdown, from receiving the signal to a clean exit:
- 1 Receive SIGTERM from the orchestrator (the request to shut down)
- 2 Stop accepting new connections / work (server.close, leave the load balancer)
- 3 Drain in-flight requests — let active work finish within the grace window
- 4 Close dependencies: end DB pools, flush buffers, close queues
- 5 process.exit(0) before the orchestrator's SIGKILL deadline
- 01What makes diagnostics_channel cheap enough to leave in a hot path, and what is its tradeoff versus monkey-patching a library?
- 02Why does a module-level global fail to hold a per-request trace id under concurrency, and how does AsyncLocalStorage fix it without changing every function signature?
Node ships first-class diagnostics that don’t require editing your app. diagnostics_channel is an in-process pub/sub bus: producers publish named messages and subscribers receive them synchronously, and because a producer guards with hasSubscribers, an unobserved publish is essentially free — so you instrument core, undici, and http for tracing or metrics without monkey-patching prototypes that break on upgrade. For request-scoped context, AsyncLocalStorage (built on async_hooks) keeps a separate store per async call tree: als.run(store, fn) at the boundary, als.getStore() anywhere downstream across every await and callback, which a shared module-level global can’t do because concurrent requests would overwrite each other’s id. Use raw async_hooks sparingly — its init/before/after/destroy lifecycle fires on every async resource and costs real CPU — and keep the ALS store small. Talk to the operator through signals: handle SIGTERM to stop accepting, drain in-flight work, close dependencies, and process.exit(0) before the orchestrator’s SIGKILL deadline; SIGINT is Ctrl-C and SIGUSR1 opens the inspector. And investigate on demand: --cpu-prof/--heap-prof write a profile on exit for DevTools, --diagnostic-dir places the files, and core dumps with llnode/lldb cover hard crashes — all without touching application code. Now when you see anonymous log lines with no trace, a process killed mid-request by a SIGKILL, or a CPU spike you can’t explain — you’ll know which of these tools to reach for without guessing.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.