Extend

Diagnostics Channel

PluginsStream
Publish every wide event on a node:diagnostics_channel so any consumer can subscribe without importing evlog — and so Cloudflare forwards them to a Tail Worker with no drain.

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:

server/plugins/evlog-diagnostics.ts
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.

This is an observation side channel, not a transport. Subscribers run synchronously and are not awaited — no batching, no retry, no 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:

metrics.ts
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:

alerts.ts
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.

The event is the live object, not a copy — mutating it mutates what drains receive. Treat it as read-only. And a subscriber that throws is not contained: 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:

tail-worker/index.ts
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 retryCustom drain
Add fields to the event before it drainsEnricher or a plugin
Fan out to several in-process consumersPlugins — initLogger({ plugins: [a, b, c] }) already does this
Subscribe from a package that must not depend on evlogThis channel
Get events out of a Cloudflare Worker without a drainThis 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.