node:diagnostics_channel is the runtime's built-in pub/sub for instrumentation. evlog can publish every wide event on the evlog.event channel, so a consumer subscribes by channel name alone — no evlog import, no entry in initLogger().
It is off by default. Turn it on once, at startup:
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
export default defineNitroPlugin(async () => {
await enableDiagnosticsChannel()
})
enableDiagnosticsChannel() is async because it loads node:diagnostics_channel lazily — that is what keeps the built-in out of the main bundle for Convex, workerd and other non-Node targets. Events emitted before the promise settles are not published, so call it at startup rather than inside a request.
waitUntil. For delivery to a backend, use a drain; both see the same event.Subscribing
The point of the channel is that a consumer needs nothing from evlog but the channel name:
import { subscribe } from 'node:diagnostics_channel'
subscribe('evlog.event', ({ event }) => {
if (event.level === 'error') metrics.increment('errors', { path: event.path })
})
If you already depend on evlog and want the payload typed:
import { subscribeToWideEvents } from 'evlog/diagnostics'
const stop = await subscribeToWideEvents((event) => {
// ^? WideEvent
metrics.timing('http.request', event.durationMs ?? 0, { path: event.path as string })
})
What a subscriber receives
The same object a drain receives: post-audit, post-redaction, post-enrich. Requests carry everything enrichers added — geo, user agent, trace context:
{
"timestamp": "2026-08-02T10:23:45.612Z",
"level": "error",
"service": "checkout",
"environment": "production",
"method": "POST",
"path": "/api/checkout",
"status": 500,
"duration": "1.20s",
"requestId": "4a8ff3a8-...",
"user": { "id": "usr_123", "plan": "premium" },
"error": { "name": "PaymentDeclined", "message": "Card declined" }
}
Events emitted outside a request (log.info({ ... }), createLogger().emit()) arrive without the HTTP fields, and events from log.fork() carry operation and _parentRequestId.
Channel.publish() re-raises it as an uncaught exception on the next tick, which is fatal in most apps. Keep subscribers total.In pretty mode (the dev default), tagged logs like log.info('auth', 'User logged in') are written straight to the console and never become wide events, so they do not appear on the channel. Wide events themselves are published in both modes.
Cloudflare Workers
Workers forwards every diagnostics channel message to a Tail Worker automatically. Enable the channel and your wide events leave the isolate with no drain, no waitUntil, and their own CPU budget:
export default {
tail(events) {
for (const event of events) {
for (const message of event.diagnosticsChannelEvents ?? []) {
if (message.channel === 'evlog.event') forward(message.message.event)
}
}
},
}
Requires the nodejs_compat flag, and forwarded values must be structured-cloneable.
When a plugin is the better tool
The channel is not a replacement for plugins — it is narrower on purpose:
| You want to… | Use |
|---|---|
| Ship events to a backend, with batching and retry | Custom drain |
| Add fields to the event before it drains | Enricher or a plugin |
| Fan out to several in-process consumers | Plugins — initLogger({ plugins: [a, b, c] }) already does this |
| Subscribe from a package that must not depend on evlog | This channel |
| Get events out of a Cloudflare Worker without a drain | This channel |
diagnostics_channel is in-process: nothing attaches to a running process from the outside. A subscriber's code has to be loaded by your app either way — the channel saves it a line of configuration, not a dependency.
Custom framework
Build evlog support for an HTTP framework (or non-HTTP runtime) without a built-in integration. Use defineFrameworkIntegration for the (ctx, next) middleware shape, or createMiddlewareLogger / createRequestLogger for everything else.
FS reader
Replay and tail the local NDJSON drain with readFsLogs and tailFsLogs — works in-process or from any external Node tool, survives restarts.