参考

evlog 与 pino、winston、consola 对比

evlog 与 pino、winston 和 consola 的并排比较。功能对照矩阵、诚实的差距,以及迁移示例,帮助你无缝切换而不会有意外。

evlog 首先是一个功能完备的通用日志记录器,并将 wide events 作为同一 API 的原生扩展。本文将它与 TypeScript 开发者通常会考虑的三个日志库——pinowinstonconsola——进行正面对比,这样你就能清楚地知道你会得到什么、哪些保持不变,以及目前缺少什么(如果有的话)。

TL;DR

  • 如果你想要与 pino 同级别的吞吐量,同时内置结构化错误、脱敏和 wide events,那么选择 evlog 而不是 pino。 而且你不想自己去组装 pino + pino-pretty + pino-http + 自定义 transports。
  • 在任何新的 TypeScript 项目中,选择 evlog 而不是 winston。 winston 更老、更慢(见 benchmarks),而且不具备任何现代特性(类型化事件、脱敏、结构化错误、AI SDK 集成)。
  • 一旦你的代码不再只是 CLI,就选择 evlog 而不是 consola。 consola 很适合终端美化输出,但它不提供 drain 管道、采样或 wide events。
  • 只有在你处于极热路径、每秒向 /dev/null 发出几十万条一次性日志,并且已经有一个不想迁移的自定义 transport 时,才继续使用 pino。 evlog 在 wide event 生命周期上仍然快 7.7 倍,但在原始 info('hello world') 吞吐量上 pino 可能略胜一筹。

功能对比

用三张表代替一大块内容。右侧的 赢家 列让你一眼就能看出每一行的赢家;单元格使用语义化词语(“内置”、“手动”、“通过 X”)而不是通用的“是”,这样你无需阅读规范就能看出实现成本。

将鼠标悬停(或在移动端轻触)在任何功能名称旁边的 信息图标 上,即可查看一行说明,了解该行实际比较的内容。

核心 API

功能evlogpinoconsolawinston赢家
Standard levelsYesYesYesYesAll
Custom levelsNoYesYesYespino, consola, winston
Structured fields per callYesYesPartialYesevlog, pino, winston
Child loggers / persistent bindingsYesYesYesYesAll
Pretty in dev / JSON in prod (auto)Built-invia pino-prettyBuilt-inManualevlog, consola
Browser-safe buildYesNoYesNoevlog, consola
Sub-operation logger (log.fork)YesNoNoNoevlog
Source distinction (server / client)YesNoNoNoevlog
Runtime level mutationPartialYesYesYespino, consola, winston
Plugin / serializer systemNoYesNoYespino, winston
Wide events (one per operation)YesNoNoNoevlog
Structured errors (why / fix / link)YesNoNoNoevlog

生产特性

功能evlogpinoconsolawinston赢家
内置 PII 脱敏(生产环境自动)内置手动evlog
头部 + 尾部采样内置手动evlog
用于传输日志的异步 I/O通过 drainsWorker threadWorker threadpino、winston
Drain pipeline(批处理 / 重试 / 扇出)内置通过 transports通过 transportsevlog
多目标扇出evlog、pino、winston
审计日志(防篡改链)内置evlog
内置丰富器(UA / Geo / Trace / Size)内置evlog
敏感请求头过滤内置手动手动evlog
W3C trace 上下文(traceparent)内置evlog
AI SDK 集成(token / tools / streaming)内置evlog
Better Auth 集成内置evlog
自托管存储(NuxtHub 适配器)内置evlog
Edge / Workers 运行时内置部分evlog

占用与生态

功能evlogpinoconsolawinston赢家
零传递依赖1 个依赖evlog
打包体积(gzip)~6 kB~6 kB~12 kB~50 kBevlog
wide event 生命周期吞吐量1.58M ops/s206K ops/sn/a112K ops/sevlog
框架自动初始化(13+ 集成)仅 HTTPevlog
客户端 → 服务端日志传输evlog
Vite 插件(自动替换 console.log)evlog
路径过滤(包含 / 排除 glob)内置手动手动evlog
AI 代理技能(Cursor / Claude / ChatGPT)evlog

在这三张表中合计(共 33 行):evlog 直接赢得 23 行,平局 6 行,输掉 4 行——自定义级别、运行时级别变更、插件/序列化器系统,以及基于 worker thread 的异步 I/O。所有这四项失利都在下面的 诚实差距 中有说明,这样你就知道自己在权衡什么。

See packages/evlog/bench/ for the open-source benchmarks behind the throughput numbers, and the Performance page for the full breakdown.

诚实的空白(当前)

我们宁愿你先读这份清单,也不希望你用踩坑的方式才发现这些限制。每一项都可能是未来的 Linear 任务——但目前它们都还没有阻塞我们已经发布 evlog 的工作负载。

log.* 没有持久绑定简写

pino 有 log.child({ component: 'auth' }),它会返回一个新的 logger,同时继承父级的 bindings 和子级的 bindings。evlog 简单的 log.* API 是全局的;如果要附加持久上下文,你需要创建一个宽事件 logger:

import { createLogger } from 'evlog'

const log = createLogger({ component: 'auth' })
log.set({ userId: 42 })
log.emit()

或者,在框架集成中,请求中间件会帮你做这件事。这个方案可行,但它和 pino.child 的易用形态并不完全一样。log.child(bindings) 这样的简写很可能是下一步会加入的功能。

minLevel is set once at startup (global log.*)

You configure minLevel in initLogger({ minLevel: 'info' }) and that's it for the process lifetime on the global log.* API. pino lets you mutate logger.level = 'debug' at runtime (handy for --verbose flags or hot-reload).

On request-scoped wide-event loggers, log.setLevel('error' | 'warn' | 'info' | 'debug') promotes the event level explicitly without touching the error context — useful when you control the error shape yourself. Client-side setMinLevel() works the same way for the browser log API`.

不支持自定义级别

evlog 只提供 debug / info / warn / error,仅此而已。pino、consola 和 winston 都允许你定义 tracenoticefatal 等级别。我们是有意只选择四个级别的(大多数团队从来不会用超过四个),但如果你现有的流水线依赖 fataltrace,你需要把它们映射到最接近的 evlog 级别。

log.* 不支持多流 / transport 数组

pino 可以通过 pino.multistream 将单条日志同时输出到多个目标。evlog 通过drain 管道实现同样的能力(一个 drain 扇出到 N 个 adapter,批处理和重试在所有 adapter 之间共享)——但它的心智模型不同。如果你现有代码是围绕“流 A 以 debug 级别输出到 stdout,流 B 输出 warn+ 到文件,流 C 输出 error+ 到 Sentry”来组织的,那么你需要改为在 drain 层进行路由。

没有 formatter / serializer 插件系统

pino 有 serializers,用于把常见类型(错误、请求、响应)转换为 JSON。evlog 通过脱敏层和内置错误序列化(createError + parseError)处理常见情况;对于任何自定义需求(例如屏蔽某个特定字段或转换载荷),你需要在自定义 drain 中实现,或者在调用 log.set 之前处理。

从以下库迁移

请选择与你当前 logger 相匹配的标签页。每个标签页展示的是该库自身 API 下的 迁移前 代码。标签页下面是唯一的 迁移后 代码片段——无论你来自哪里,evlog 代码都相同。

import pino from 'pino'

const log = pino({ name: 'checkout' })
const child = log.child({ flow: 'checkout' })

child.info({ event: 'checkout_started' })

try {
  const cart = await getCart(userId)
  child.info({ cart: { items: cart.items.length, total: cart.total } }, 'cart loaded')

  const charge = await stripe.charge(cart.total)
  child.info({ stripe: { chargeId: charge.id } }, 'charge ok')

  if (!charge.success) {
    throw new Error(`Payment failed: ${charge.decline_reason}`)
  }
} catch (err) {
  child.error({ err }, 'checkout failed')
  throw err
}

这四种最终都会变成这样——无论来源库是什么,代码都相同:

After (evlog)
import { initLogger, createLogger, createError } from 'evlog'

initLogger({ env: { service: 'checkout' } })

const log = createLogger({ flow: 'checkout' })

try {
  const cart = await getCart(userId)
  log.set({ cart: { items: cart.items.length, total: cart.total } })

  const charge = await stripe.charge(cart.total)
  log.set({ stripe: { chargeId: charge.id } })

  if (!charge.success) {
    throw createError({
      message: '支付失败',
      status: 402,
      why: charge.decline_reason,
      fix: '尝试其他支付方式',
    })
  }
} catch (err) {
  log.error(err as Error)
  throw err
} finally {
  log.emit()
}

每次迁移都会发生三件事:

  • N 条日志 → 1 条宽事件。 每个请求中的 3-4 次调用会变成 log.set 的累积,以及最后一次 log.emit。你的仪表盘会得到一行可查询记录,而不是按 request id 拼接。
  • 错误携带 whyfix 使用 createError 而不是 new Error,意味着你的客户端(以及值班人员)能拿到可执行的上下文,而不只是堆栈信息。
  • 配置只需一行。 不需要 formatter 接线,不需要 transport 组装,也不需要 pino-pretty 的 peer 依赖。启动时调用一次 initLogger 就完成了。

反向方向:什么时候不该选择 evlog

对自己诚实一点。如果出现以下情况,就不要切换:

  • 你发布的是一个已经属于 pino 生态系统的库(pino-httppino-prettypino-multi-stream 插件),切换后会失去相关工具支持。
  • 你有一个自定义的 pino transport(例如你在 2021 年写的一个 worker-thread Datadog forwarder),并且不想把它重新实现为 evlog drain。大多数内置适配器都覆盖了常见目标,但自定义协议意味着需要迁移。
  • 你只在 CLI 中记录日志,并且使用 consola 只是为了漂亮的终端输出。evlog 的漂亮输出已经不错,但在 spinner、prompt 和 box 渲染方面还达不到 consola 的水平。两者可以一起用:evlog 负责发送到 drain 的事件,consola 负责 prompts / TUI。

下一步