JavaScript Basics: Add Optional Diagnostics Channels
A library can report useful events without deciding where to log them. Node.js diagnostics_channel connects a named publisher to optional subscribers in the same runtime. Use it for observation such as counters or debugging details, while keeping the application result independent of whether anyone listens.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in modules and assertions, with no package installation. They make no network requests and leave no data files behind. The output below is the observed output.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Publish a named event and remove its subscriber
Save this as publish-diagnostic-event.mjs:
import assert from 'node:assert/strict';
import { channel, subscribe, unsubscribe } from 'node:diagnostics_channel';
const name = 'example-report:completed';
const completed = channel(name);
const received = [];
function observe(message, channelName) {
received.push({ rows: message.rows, channelName });
}
subscribe(name, observe);
try {
completed.publish({ rows: 3 });
assert.deepEqual(received, [{ rows: 3, channelName: name }]);
console.log(`${received[0].channelName}: ${received[0].rows} rows`);
} finally {
unsubscribe(name, observe);
}
completed.publish({ rows: 99 });
assert.equal(received.length, 1);
assert.equal(completed.hasSubscribers, false);
console.log(`received after unsubscribe: ${received.length}`);Run it:
node publish-diagnostic-event.mjsOutput:
example-report:completed: 3 rows
received after unsubscribe: 1The assertion immediately after publish succeeds because handlers run synchronously. Keep the original function reference so unsubscribe() can remove it. The prefixed channel name reduces accidental collisions with other code; document both the name and message fields for consumers.
2. Avoid building diagnostics that nobody will read
Save this as prepare-diagnostics-on-demand.mjs:
import assert from 'node:assert/strict';
import { channel, subscribe, unsubscribe } from 'node:diagnostics_channel';
const name = 'example-report:preview';
const preview = channel(name);
let prepared = 0;
let observed;
function report(values) {
if (!preview.hasSubscribers) return;
prepared += 1;
preview.publish({ sorted: [...values].sort((a, b) => a - b) });
}
function observe(message) {
observed = message.sorted;
}
report([9, 2, 5]);
assert.equal(prepared, 0);
console.log(`preparations without listeners: ${prepared}`);
subscribe(name, observe);
try {
report([9, 2, 5]);
assert.equal(prepared, 1);
assert.deepEqual(observed, [2, 5, 9]);
console.log(`preparations with listener: ${prepared}`);
console.log(`preview: ${observed.join(',')}`);
} finally {
unsubscribe(name, observe);
}Run it:
node prepare-diagnostics-on-demand.mjsOutput:
preparations without listeners: 0
preparations with listener: 1
preview: 2,5,9The counter proves that copying and sorting are skipped when the channel has no listeners. Place the hasSubscribers check before constructing an expensive diagnostic payload. Only optional reporting belongs behind this condition; necessary validation or business work must still happen without subscribers.
3. Collect independent summaries without retaining every event
Save this as summarize-diagnostic-events.mjs:
import assert from 'node:assert/strict';
import { channel, subscribe, unsubscribe } from 'node:diagnostics_channel';
const name = 'example-report:batch';
const batches = channel(name);
const totals = { batches: 0, rows: 0 };
let failures = 0;
function countRows(message) {
totals.batches += 1;
totals.rows += message.rows;
}
function countFailures(message) {
if (!message.ok) failures += 1;
}
subscribe(name, countRows);
subscribe(name, countFailures);
try {
for (const message of [
{ rows: 4, ok: true },
{ rows: 0, ok: false },
{ rows: 6, ok: true },
]) {
batches.publish(message);
}
} finally {
unsubscribe(name, countRows);
unsubscribe(name, countFailures);
}
assert.deepEqual(totals, { batches: 3, rows: 10 });
assert.equal(failures, 1);
assert.equal(batches.hasSubscribers, false);
console.log(`batches: ${totals.batches}; rows: ${totals.rows}`);
console.log(`failures: ${failures}`);
console.log(`subscribers remain: ${batches.hasSubscribers}`);Run it:
node summarize-diagnostic-events.mjsOutput:
batches: 3; rows: 10
failures: 1
subscribers remain: falseThe producer sends one event while two observers compute separate summaries. Neither stores the messages or mutates them. The final assertions run after both observers have been removed, checking the summary and the cleanup together. In a real module, keep the agreed message shape small and stable.
Practical limits
This is an in-process diagnostics API, not a durable queue or a cross-process message bus. Synchronous subscribers add work to the publishing call. Node warns that a thrown subscriber error triggers uncaughtException, so handle recoverable observer failures inside the observer and keep handlers short. Do not publish credentials or entire private request objects merely because the channel is named for diagnostics. These examples use application-owned channels, not Node’s built-in tracing channels.
Clean up
In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:
rm -- publish-diagnostic-event.mjs prepare-diagnostics-on-demand.mjs summarize-diagnostic-events.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.