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().
What it's for
Two things you cannot do with a plugin or a drain:
- Ship an evlog integration from a package that does not depend on evlog. A subscriber only needs the channel name, so a vendor SDK, an internal shared library, or an APM agent can consume wide events without a peer dependency, a version constraint, or a line in your
initLogger()call. - Get events out of a Cloudflare Worker without a drain. Workers forwards every channel message to a Tail Worker, which runs after the response with its own CPU budget — no
waitUntil, no drain competing with your request.
For everything else — batching, retry, adding fields, fanning out to several in-process consumers — a plugin or a drain is the better tool. There is a comparison at the bottom of this page.
Enabling it
It is off by default. Turn it on once, at startup:
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
await enableDiagnosticsChannel()
The call takes no options and is the same on every framework — only where your app runs startup code differs:
// server/plugins/evlog-diagnostics.ts
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
export default defineNitroPlugin(async () => {
await enableDiagnosticsChannel()
})
// instrumentation.ts
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
export const { register, onRequestError } = defineNodeInstrumentation(async () => {
const { createInstrumentation } = await import('evlog/next/instrumentation/create')
const { enableDiagnosticsChannel } = await import('evlog/diagnostics')
const { register: evlogRegister, onRequestError } = createInstrumentation({ service: 'my-app' })
return {
async register() {
await evlogRegister()
await enableDiagnosticsChannel()
},
onRequestError,
}
})
// src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
await enableDiagnosticsChannel()
export const { handle, handleError } = createEvlogHooks()
// Same shape for Express, Fastify, Elysia, NestJS and oRPC:
// enable once in the server entry, before the app starts serving.
import { Hono } from 'hono'
import { evlog } from 'evlog/hono'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
await enableDiagnosticsChannel()
const app = new Hono()
app.use(evlog())
// src/worker.ts — needs the nodejs_compat flag
import { initWorkersLogger, withEvlog } from 'evlog/workers'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
initWorkersLogger({ env: { service: 'my-worker' } })
await enableDiagnosticsChannel()
export default withEvlog(async (request, env, ctx, log) => {
log.set({ action: 'handle_request' })
return Response.json({ ok: true })
})
// Any Node, Bun or Deno entry point
import { initLogger } from 'evlog'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'
initLogger({ env: { service: 'worker' } })
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. Frameworks whose entry point cannot use top-level await should call it from the same place they call initLogger(), and await it there.
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 { channel } from 'node:diagnostics_channel'
/** The published message. Declared locally so this file needs no evlog import. */
type EvlogMessage = { event: Record<string, unknown> & { level: string; service: string } }
channel('evlog.event').subscribe((message) => {
const { event } = message as EvlogMessage
if (event.level === 'error') metrics.increment('errors', { path: String(event.path ?? 'unknown') })
})
Node types the published message as unknown, so narrow it once at the top of your handler. channel(name).subscribe() is used rather than the module-level subscribe() because it exists on every Node that evlog supports.
If you already depend on evlog and want the payload typed:
import { subscribeToWideEvents } from 'evlog/diagnostics'
const stop = await subscribeToWideEvents((event) => {
// ^? WideEvent
if (typeof event.status === 'number' && event.status >= 500) {
alerts.push({ path: String(event.path ?? '-'), requestId: String(event.requestId ?? '-') })
}
})
Fields beyond the base event are typed unknown, and events emitted outside a request carry no HTTP fields at all — narrow before using them rather than casting.
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.
What you can build
An integration that does not depend on evlog
A package that subscribes needs the channel name and nothing else — no evlog dependency, no peer range to keep in sync, no wiring in the host app beyond importing it:
import { channel } from 'node:diagnostics_channel'
type EvlogMessage = {
event: Record<string, unknown> & { timestamp: string; level: string; service: string }
}
channel('evlog.event').subscribe((message) => {
const { event } = message as EvlogMessage
acme.ingest({
at: event.timestamp,
level: event.level,
service: event.service,
attributes: event,
})
})
import 'acme-apm/evlog'
The same shape works for an internal library shared across services: one package subscribes, every service that imports it reports, and none of them touch initLogger().
Counters and alerts next to your app
A subscriber is a plain function, so anything you can compute in-process you can compute here — without giving up the drain slot, which stays free for shipping events to your backend:
import { channel } from 'node:diagnostics_channel'
const errorsByPath = new Map<string, number>()
channel('evlog.event').subscribe((message) => {
const { event } = message as { event: Record<string, unknown> & { level: string } }
if (event.level !== 'error') return
const path = String(event.path ?? 'unknown')
errorsByPath.set(path, (errorsByPath.get(path) ?? 0) + 1)
if (typeof event.status === 'number' && event.status >= 500) {
void pager.notify(`5xx on ${path}`, { requestId: event.requestId })
}
})
export function errorCounts(): Record<string, number> {
return Object.fromEntries(errorsByPath)
}
A plugin does this too, and is the better choice when the code lives in your own app. The channel wins when the subscriber ships as a separate package, or when it has to attach without editing the app's logger configuration.
Cloudflare Workers
Workers forwards every diagnostics channel message to a Tail Worker. Enable the channel in your Worker and the wide events leave the isolate with no drain, no waitUntil, and their own CPU budget — the Tail Worker does the shipping, after the response has already gone out:
export default {
async tail(events) {
for (const event of events) {
for (const messageData of event.diagnosticsChannelEvents) {
if (messageData.channel !== 'evlog.event') continue
await fetch('https://logs.example.com/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messageData.message.event),
})
}
}
},
}
Each entry carries timestamp, channel and message — message being what evlog published, so the wide event is at messageData.message.event. Requires the nodejs_compat flag, and messages go through the structured clone algorithm, so only cloneable values survive.
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.