扩展

诊断通道

插件
在 node:diagnostics_channel 上发布每个宽事件,使消费者无需导入 evlog 即可订阅,并让 Cloudflare 将其交给 Tail Worker。

node:diagnostics_channel 是运行时内置的、用于插桩的发布/订阅机制。evlog 可以在 evlog.event 频道上发布每个宽事件,因此消费者只需通过频道名称订阅:无需导入 evlog,也无需在 initLogger() 中添加条目。

用途

以下两件事无法通过 插件排出器 完成:

  • 从不依赖 evlog 的包中发布 evlog 集成。 订阅者只需要频道名称,因此供应商 SDK、内部共享库或 APM 代理都可以消费宽事件,而无需对等依赖、版本约束或在 initLogger() 调用中添加一行配置。
  • 在没有 drain 的情况下从 Cloudflare Worker 中导出事件。 Workers 会将每条频道消息转发给 Tail Worker,它会在响应发送后运行,并拥有自己的 CPU 预算,无需 waitUntil,也不会有 drain 与请求竞争。

对于其他所有情况(批处理、重试、添加字段、向多个进程内消费者分发),插件或 drain 是更好的工具。本页面底部有一份对比

启用它

默认情况下处于关闭状态。启动时启用一次:

import { enableDiagnosticsChannel } from 'evlog/diagnostics'

await enableDiagnosticsChannel()

该调用不接受任何选项,在每个框架中的用法都相同。只有应用运行启动代码的位置不同:

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

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

enableDiagnosticsChannel() 是异步的,因为它会延迟加载 node:diagnostics_channel,这使得在 Convex、workerd 和其他非 Node 目标中,内置模块不会被打包到主 bundle 中。Promise 完成前发出的事件不会被发布,因此应在启动时调用,而不是在请求处理过程中调用。入口点无法使用顶层 await 的框架,应在调用 initLogger() 的同一位置调用它,并在那里等待其完成。

这是一个观测侧通道,而不是传输机制。订阅者会同步运行,且不会被等待:没有批处理、没有重试、没有 waitUntil。如需将事件传送到后端,请使用 drain;两者看到的是同一个事件。

订阅

该频道的要点是,消费者除了频道名称外,不需要从 evlog 获取任何内容:

metrics.ts
import { channel } from 'node:diagnostics_channel'

/** 发布的消息。在本地声明,因此此文件无需导入 evlog。 */
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 会将发布的消息类型标记为 unknown,因此请在处理程序顶部将其缩小一次。使用 channel(name).subscribe() 而不是模块级别的 subscribe(),是因为前者存在于 evlog 支持的每个 Node 版本中。

如果你已经依赖 evlog,并希望为载荷添加类型:

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 ?? '-') })
  }
})

基础事件之外的字段类型为 unknown,而在请求外发出的事件完全不包含 HTTP 字段,因此请先缩小类型再使用它们,而不是进行类型断言。

订阅者收到的内容

与 drain 接收的对象相同:经过审计、脱敏和 enrich 之后的对象。请求会携带 enrichers 添加的所有内容:地理位置、用户代理、trace 上下文:

{
  "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" }
}

在请求之外发出的事件(log.info({ ... })createLogger().emit())到达时不包含 HTTP 字段,而来自 log.fork() 的事件则会携带 operation_parentRequestId

该事件是实时对象,而不是副本,因此修改它也会修改 drain 接收到的内容。请将其视为只读对象。此外,抛出异常的订阅者不会被隔离Channel.publish() 会在下一次 tick 将其作为未捕获异常重新抛出,而这在大多数应用中都是致命的。请确保订阅者能够处理所有情况。

在 pretty 模式(开发环境默认模式)下,像 log.info('auth', 'User logged in') 这样的带标签日志会直接写入控制台,永远不会成为宽事件,因此不会出现在 channel 上。宽事件本身在两种模式下都会发布。

你可以构建的内容

不依赖 evlog 的集成

需要订阅的包只需要频道名称,不需要其他内容:无需 evlog 依赖,无需需要保持同步的对等版本范围,宿主应用除了导入它之外也无需进行其他配置:

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,
  })
})
宿主应用
import 'acme-apm/evlog'

同样的形式也适用于跨服务共享的内部库:一个包负责订阅,每个导入它的服务都会上报,而且它们都不需要接触 initLogger()

在应用旁边统计计数并设置告警

订阅者是一个普通函数,因此你可以在进程内计算的任何内容都可以在这里计算,无需占用 drain 插槽,这样它就可以继续负责将事件发送到你的后端:

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)
}

插件也可以完成同样的工作;当代码位于你自己的应用中时,这是更好的选择。当订阅者作为独立包发布,或必须在不编辑应用日志配置的情况下附加时,频道则更具优势。

Cloudflare Workers

Workers 会将每条诊断频道消息转发给 Tail Worker。在 Worker 中启用该频道后,宽事件无需 drain 或 waitUntil 即可离开隔离区,并拥有自己的 CPU 预算;Tail Worker 会在响应已经发出后负责传送这些事件:

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),
        })
      }
    }
  },
}

每个条目都包含 timestampchannelmessage,其中 message 就是 evlog 发布的内容,因此宽事件位于 messageData.message.event。需要 nodejs_compat 标志,并且消息会经过结构化克隆算法,因此只有可克隆的值能够保留。

插件是更好工具的情况

该频道不是 插件 的替代品。它有意保持更窄的范围:

你想要……使用
将事件发送到后端,并进行批处理和重试自定义 drain
在事件被 drain 之前为其添加字段Enricher 或插件
将事件分发给多个进程内消费者插件——initLogger({ plugins: [a, b, c] }) 已经支持此功能
从必须依赖 evlog 的包中订阅此通道
在没有 drain 的情况下从 Cloudflare Worker 中导出事件此通道

diagnostics_channel 仅限进程内使用:外部不会有任何内容附加到正在运行的进程上。无论如何,订阅者的代码都必须由你的应用加载,因此该频道省去的是一行配置,而不是一个依赖。