Repository navigation
feat: publish wide events on a node:diagnostics_channel
#495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" } | ||
| } | ||
| ``` | ||
|
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.