Node Diagnostic Channels: Instrument Hot Paths Without Monkey Patches

Node Diagnostic Channels: Instrument Hot Paths Without Monkey Patches

Monkey-patching http.request or wrapping every pg.query leaks into behavior and breaks on library upgrades. Node’s diagnostics_channel lets you subscribe to named channels (undici:request:*, custom app channels) with near-zero overhead when no subscribers exist — production-safe instrumentation without patching.

⚡ TL;DR: Publish on stable channel names from shared clients; subscribe once at process boot for metrics/traces; keep subscribers sync and tiny; avoid capturing huge bodies. Pair with Node.js Event Loop Lag p99 and OpenTelemetry for LLMs.

Why monkey patches fail in production

Teams reach for Module.prototype wrappers or require-cache hijacks because they are familiar. In production those patches:

  • Break when a library switches from http to undici or from callbacks to promises
  • Double-instrument under Nest/Express middleware stacks that already wrap the same APIs
  • Capture full request bodies and leak PII into metrics backends
  • Throw from inside a patched path and turn a slow query into a hard 500

Diagnostic channels invert the model: the library (or your facade) publishes; observers subscribe. If nobody is listening, publish is nearly free.

Publish from your client facade

// lib/db.ts
import dc from "node:diagnostics_channel";

const chQuery = dc.channel("acme:db:query");
const chError = dc.channel("acme:db:error");

export async function query<T>(sql: string, params: unknown[] = []) {
  const start = performance.now();
  try {
    const rows = await pool.query(sql, params);
    if (chQuery.hasSubscribers) {
      chQuery.publish({
        sql: sql.slice(0, 80), // ✅ bound size
        ms: performance.now() - start,
        rows: rows.rowCount,
      });
    }
    return rows as T;
  } catch (err) {
    if (chError.hasSubscribers) chError.publish({ sql: sql.slice(0, 80), err });
    throw err;
  }
}

Keep the facade as the only place that talks to the pool. Feature code calls query(), never pool.query() directly — otherwise you reintroduce uninstrumented paths.

Subscribe once at boot

// instrumentation/db-metrics.ts
import dc from "node:diagnostics_channel";

dc.subscribe("acme:db:query", (msg: any) => {
  // ✅ sync, allocation-light
  metrics.histogram("db.query.ms", msg.ms, { bound: msg.sql });
});

dc.subscribe("acme:db:error", (msg: any) => {
  metrics.incr("db.query.errors");
  logger.warn("db_error", { sql: msg.sql, err: String(msg.err) });
});

Import instrumentation before the app listens. For undici, subscribe to undici:request:create / undici:request:headers rather than patching fetch — complements undici dispatcher pools.

Built-in channels worth wiring first

Channel family What you get Typical metric
undici:request:* Outbound HTTP timing http.client.ms
acme:db:query SQL latency + row counts db.query.ms
acme:cache:* Hit/miss + serialize cost cache.op.ms
acme:agent:tool Coding-agent tool calls agent.tool.ms

Document the catalog in-repo (docs/channels.md) with SemVer rules: additive fields OK; renaming a channel is a major bump.

Tracing bridge without OpenTelemetry monkey patches

// instrumentation/otel-bridge.ts
import dc from "node:diagnostics_channel";
import { trace, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("acme-db");

dc.subscribe("acme:db:query", (msg: any) => {
  const span = tracer.startSpan("db.query");
  span.setAttribute("db.statement.preview", msg.sql);
  span.setAttribute("db.duration_ms", msg.ms);
  span.end();
});

dc.subscribe("acme:db:error", (msg: any) => {
  const span = tracer.startSpan("db.query.error");
  span.setStatus({ code: SpanStatusCode.ERROR, message: String(msg.err) });
  span.end();
});

This keeps OTEL optional: if the bridge file is not imported in a given environment, channels still work for Prometheus-only setups. See OpenTelemetry for LLMs for span attribute conventions.

Test hygiene: subscribe and unsubscribe

import dc from "node:diagnostics_channel";
import { afterEach, test } from "node:test";

const seen: unknown[] = [];
function onQuery(msg: unknown) {
  seen.push(msg);
}

test("query publishes timing", async () => {
  dc.subscribe("acme:db:query", onQuery);
  await query("select 1");
  assert.equal(seen.length, 1);
});

afterEach(() => {
  dc.unsubscribe("acme:db:query", onQuery);
  seen.length = 0;
});

❌ Subscribing inside each request (leaks listeners across tests and warm Lambdas). ✅ Boot-time subscriptions with explicit unsubscribe in tests.

Rules that keep this safe

Rule Why
Check hasSubscribers Zero cost when disabled
Sync subscribers only Async work → separate queue
Bound payload size No full SQL/PII bodies
Stable channel names SemVer your channel catalog
No throws from subscribers Errors must not break queries

If a subscriber must do I/O (ship to a sidecar), push onto a bounded ring buffer and drain from a background interval — never await inside the subscribe callback.

Rolling out without a big bang

  1. Add facade publish behind hasSubscribers (no observers yet) — ship
  2. Add metrics subscribers in staging; compare cardinality vs patched baseline
  3. Flip production feature flag that imports instrumentation/*
  4. Delete monkey patches only after two weeks of matching dashboards

Closing checklist

✅ Dos
– ✅ Facade publish + boot subscribe
– ✅ hasSubscribers guards
– ✅ Bound, PII-safe payloads
– ✅ Prefer channels over monkey patches
– ✅ Document channel catalog next to metrics
– ✅ Unsubscribe in unit tests
– ✅ Bridge to OTEL optionally, not mandatorily

❌ Don’ts
– ❌ Don’t patch http/https globals in prod
– ❌ Don’t do I/O inside subscribers
– ❌ Don’t publish unbounded rowsets
– ❌ Don’t forget to unsubscribe in unit tests
– ❌ Don’t invent a new channel name per microservice clone

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply