---
title: "Diagnostics Channel"
description: "Publish every wide event on a node:diagnostics_channel, so a consumer subscribes without importing evlog and Cloudflare can Tail Worker it."
canonical_url: "https://www.evlog.dev/extend/diagnostics-channel"
---
# Diagnostics Channel

> Publish every wide event on a node:diagnostics_channel, so a consumer subscribes without importing evlog and Cloudflare can Tail Worker it.

[`node:diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) 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](https://www.evlog.dev/extend/plugins) or a [drain](https://www.evlog.dev/extend/custom-drains):

- **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](#cloudflare-workers), 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](#when-a-plugin-is-the-better-tool).

## Enabling it

It is off by default. Turn it on once, at startup:

```typescript
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:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-diagnostics.ts
import { enableDiagnosticsChannel } from 'evlog/diagnostics'

export default defineNitroPlugin(async () => {
  await enableDiagnosticsChannel()
})
```

```typescript [Nitro]server/plugins/evlog-diagnostics.ts
import { definePlugin } from 'nitro'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'

export default definePlugin(async () => {
  await enableDiagnosticsChannel()
})
```

```typescript [Next.js]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,
  }
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'

await enableDiagnosticsChannel()

export const { handle, handleError } = createEvlogHooks()
```

```typescript [Hono]src/index.ts
// 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())
```

```typescript [Cloudflare Workers]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 })
})
```

```typescript [Standalone]
// Any Node, Bun or Deno entry point
import { initLogger } from 'evlog'
import { enableDiagnosticsChannel } from 'evlog/diagnostics'

initLogger({ env: { service: 'worker' } })
await enableDiagnosticsChannel()
```

</framework-tabs>

`enableDiagnosticsChannel()` is async because it loads `node:diagnostics_channel` lazily, which 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.

<callout color="info" icon="i-lucide-info">

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](https://www.evlog.dev/extend/custom-drains); both see the same event.

</callout>

## Subscribing

The point of the channel is that a consumer needs nothing from evlog but the channel name:

```typescript [metrics.ts]
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:

```typescript [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](https://www.evlog.dev/learn/wide-events) are typed `unknown`, and events emitted outside a request carry no HTTP fields at all, so 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:

```json
{
  "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`.

<callout color="warning" icon="i-lucide-triangle-alert">

The event is the live object, not a copy, so 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.

</callout>

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:

```typescript [acme-apm/src/evlog.ts]
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,
  })
})
```

```typescript [the host app]
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:

```typescript [observability.ts]
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](https://www.evlog.dev/extend/plugins) 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](https://developers.cloudflare.com/workers/observability/logs/tail-workers/). Enable the channel in your Worker and the wide events leave the isolate with no drain, no `waitUntil`, and their own CPU budget, and the Tail Worker does the shipping, after the response has already gone out:

```typescript [tail-worker/index.ts]
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`, with `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](https://www.evlog.dev/extend/plugins). It is narrower on purpose:

<table>
<thead>
  <tr>
    <th>
      You want to…
    </th>
    
    <th>
      Use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Ship events to a backend, with batching and retry
    </td>
    
    <td>
      <a href="https://www.evlog.dev/extend/custom-drains">
        Custom drain
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      Add fields to the event before it drains
    </td>
    
    <td>
      <a href="https://www.evlog.dev/extend/custom-enrichers">
        Enricher
      </a>
      
       or a plugin
    </td>
  </tr>
  
  <tr>
    <td>
      Fan out to several in-process consumers
    </td>
    
    <td>
      Plugins — <code>
        initLogger({ plugins: [a, b, c] })
      </code>
      
       already does this
    </td>
  </tr>
  
  <tr>
    <td>
      Subscribe from a package that must not depend on evlog
    </td>
    
    <td>
      This channel
    </td>
  </tr>
  
  <tr>
    <td>
      Get events out of a Cloudflare Worker without a drain
    </td>
    
    <td>
      This channel
    </td>
  </tr>
</tbody>
</table>

`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, so the channel saves it a line of configuration, not a dependency.

---

- [Plugins](https://www.evlog.dev/extend/plugins)
- [Stream](https://www.evlog.dev/extend/stream)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
