Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/diagnostics-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"evlog": minor
---

feat: publish wide events on a `node:diagnostics_channel`

New opt-in entry point `evlog/diagnostics`. Call `enableDiagnosticsChannel()` once at startup and every emitted wide event is published on the `evlog.event` channel:

```ts
// server/plugins/evlog-diagnostics.ts
import { enableDiagnosticsChannel } from 'evlog/diagnostics'

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

A consumer then subscribes by channel name alone, with no evlog import and no entry in `initLogger()`:

```ts
import { subscribe } from 'node:diagnostics_channel'

subscribe('evlog.event', ({ event }) => metrics.timing('http.request', event.durationMs))
```

`subscribeToWideEvents()` is exported for consumers that already depend on evlog and want the payload typed.

Subscribers receive the same object drains receive — post-audit, post-redaction, post-enrich — and must treat it as read-only. They run synchronously and are not awaited: this is an observation side channel, not a transport. On Cloudflare, Workers forwards every channel message to a Tail Worker, so enabling it gets wide events out of an isolate with no drain and no `waitUntil`.

Off by default, and free when off — `node:diagnostics_channel` is loaded lazily so it never enters the main bundle graph, and with the channel enabled but unsubscribed the emit path benchmarks identically to having it disabled.
8 changes: 5 additions & 3 deletions apps/docs/content/6.extend/0.overview.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Extend evlog
description: Observe what flows through the pipeline (stream, fs reader, consumer recipes), plug into the pipeline (plugins, enrichers, tail sampling, identity headers), or build your own bricks (custom drains, drain pipeline, custom framework integration).
description: Observe what flows through the pipeline (stream, fs reader, diagnostics channel, consumer recipes), plug into the pipeline (plugins, enrichers, tail sampling, identity headers), or build your own bricks (custom drains, drain pipeline, custom framework integration).
navigation:
title: Overview
icon: i-lucide-blocks
Expand Down Expand Up @@ -43,21 +43,23 @@ your app code
┌─────────────────────────────────────────┐ ┌─────────────────────┐
│ stream (in-process + SSE bridge) │ │ custom drains │
│ fs reader (NDJSON history) │ │ drain pipeline │
│ consumer recipes (devtools, dashboards)│ │ (batch + fanout) │
│ diagnostics channel (evlog.event) │ │ (batch + fanout) │
│ consumer recipes (devtools, dashboards)│ │ │
└─────────────────────────────────────────┘ └─────────────────────┘
```

## Three ways to extend

### Observe the pipeline (no mutation)

Subscribe to events without changing what gets emitted. The stream is the live feed, the fs reader is the historic log, and consumer recipes show you how to wire either to a devtool, dashboard, CLI tail, or `curl` + `jq`.
Subscribe to events without changing what gets emitted. The stream is the live feed, the fs reader is the historic log, the diagnostics channel lets a consumer subscribe by channel name alone, and consumer recipes show you how to wire any of them to a devtool, dashboard, CLI tail, or `curl` + `jq`.

| You want to… | Use |
| --- | --- |
| Subscribe to live events (in-process or over SSE for browsers / CLIs) | [Stream](/extend/stream) |
| Replay or tail historic events from disk | [FS reader](/extend/fs-reader) |
| Build a small consumer panel, devtool, or pipe to `curl` + `jq` | [Consumer recipes](/extend/consumer-recipes) |
| Let a consumer subscribe without importing evlog, or reach a Cloudflare Tail Worker | [Diagnostics channel](/extend/diagnostics-channel) |

### Plug into the pipeline

Expand Down
281 changes: 281 additions & 0 deletions apps/docs/content/6.extend/11.diagnostics-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
---
title: Diagnostics Channel
description: 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.
navigation:
title: Diagnostics channel
icon: i-lucide-radio-tower
links:
- label: Plugins
icon: i-lucide-blocks
to: /extend/plugins
color: neutral
variant: subtle
- label: Stream
icon: i-lucide-radio
to: /extend/stream
color: neutral
variant: subtle
---

[`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](/extend/plugins) or a [drain](/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:

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

export default defineNitroPlugin(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]
// 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()
```
::

`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.

::callout{icon="i-lucide-info" color="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](/extend/custom-drains); both see the same event.
::

## 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](/learn/wide-events) 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:

```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" }
}
Comment thread
HugoRCD marked this conversation as resolved.
```

Events emitted outside a request (`log.info({ ... })`, `createLogger().emit()`) arrive without the HTTP fields, and events from `log.fork()` carry `operation` and `_parentRequestId`.

::callout{icon="i-lucide-triangle-alert" color="warning"}
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.

## 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](/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 — 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` — `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](/extend/plugins) — it is narrower on purpose:

| You want to… | Use |
| --- | --- |
| Ship events to a backend, with batching and retry | [Custom drain](/extend/custom-drains) |
| Add fields to the event before it drains | [Enricher](/extend/custom-enrichers) 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.
23 changes: 23 additions & 0 deletions packages/evlog/bench/core/logger.bench.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { bench, describe } from 'vitest'
import { createLogger, createRequestLogger } from '../../src/logger'
import { enableDiagnosticsChannel } from '../../src/diagnostics'
import { registerWideEventPublisher } from '../../src/shared/wideEventChannel'
import { initSilentLogger, PAYLOADS } from './_fixtures'

initSilentLogger()

/** Registered once so the enabled/disabled benches differ only by the publish. */
const disableChannel = await enableDiagnosticsChannel()
disableChannel()

describe('createLogger', () => {
bench('no initial context', () => {
createLogger()
Expand Down Expand Up @@ -115,3 +121,20 @@ describe('log.set() payload sizes', () => {
log.emit()
})
})

function emitOnce(): void {
const log = createRequestLogger({ method: 'POST', path: '/api/checkout' })
log.set({ user: { id: '123', plan: 'pro' } })
log.emit({ status: 200 })
}

describe('diagnostics channel', () => {
bench('emit — channel disabled', emitOnce, {
setup: () => void registerWideEventPublisher(null),
})

bench('emit — channel enabled, no subscriber', emitOnce, {
setup: async () => void await enableDiagnosticsChannel(),
teardown: () => void registerWideEventPublisher(null),
})
})
Loading
Loading