evlog 首先是一个功能完备的通用日志记录器,并将 wide events 作为同一 API 的原生扩展。本文将它与 TypeScript 开发者通常会考虑的三个日志库——pino、winston 和 consola——进行正面对比,这样你就能清楚地知道你会得到什么、哪些保持不变,以及目前缺少什么(如果有的话)。
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
| 功能 | evlog | pino | consola | winston | 赢家 |
|---|---|---|---|---|---|
| Standard levels | Yes | Yes | Yes | Yes | All |
| Custom levels | No | Yes | Yes | Yes | pino, consola, winston |
| Structured fields per call | Yes | Yes | Partial | Yes | evlog, pino, winston |
| Child loggers / persistent bindings | Yes | Yes | Yes | Yes | All |
| Pretty in dev / JSON in prod (auto) | Built-in | via pino-pretty | Built-in | Manual | evlog, consola |
| Browser-safe build | Yes | No | Yes | No | evlog, consola |
| Sub-operation logger (log.fork) | Yes | No | No | No | evlog |
| Source distinction (server / client) | Yes | No | No | No | evlog |
| Runtime level mutation | Partial | Yes | Yes | Yes | pino, consola, winston |
| Plugin / serializer system | No | Yes | No | Yes | pino, winston |
| Wide events (one per operation) | Yes | No | No | No | evlog |
| Structured errors (why / fix / link) | Yes | No | No | No | evlog |
生产特性
| 功能 | evlog | pino | consola | winston | 赢家 |
|---|---|---|---|---|---|
| 内置 PII 脱敏(生产环境自动) | 内置 | 手动 | 否 | 否 | evlog |
| 头部 + 尾部采样 | 内置 | 手动 | 否 | 否 | evlog |
| 用于传输日志的异步 I/O | 通过 drains | Worker thread | 否 | Worker thread | pino、winston |
| Drain pipeline(批处理 / 重试 / 扇出) | 内置 | 通过 transports | 否 | 通过 transports | evlog |
| 多目标扇出 | 是 | 是 | 否 | 是 | 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 |
占用与生态
| 功能 | evlog | pino | consola | winston | 赢家 |
|---|---|---|---|---|---|
| 零传递依赖 | 是 | 1 个依赖 | 否 | 否 | evlog |
| 打包体积(gzip) | ~6 kB | ~6 kB | ~12 kB | ~50 kB | evlog |
| wide event 生命周期吞吐量 | 1.58M ops/s | 206K ops/s | n/a | 112K ops/s | evlog |
| 框架自动初始化(13+ 集成) | 是 | 仅 HTTP | 否 | 否 | evlog |
| 客户端 → 服务端日志传输 | 是 | 否 | 否 | 否 | 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 都允许你定义 trace、notice、fatal 等级别。我们是有意只选择四个级别的(大多数团队从来不会用超过四个),但如果你现有的流水线依赖 fatal 或 trace,你需要把它们映射到最接近的 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
}
import { createLogger as createWinston, format, transports } from 'winston'
const log = createWinston({
defaultMeta: { service: 'checkout' },
format: format.combine(format.timestamp(), format.json()),
transports: [new transports.Console()],
})
log.info({ event: 'checkout_started', flow: 'checkout' })
try {
const cart = await getCart(userId)
log.info({ flow: 'checkout', cart: { items: cart.items.length, total: cart.total } })
const charge = await stripe.charge(cart.total)
log.info({ flow: 'checkout', stripe: { chargeId: charge.id } })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
log.error({ flow: 'checkout', err })
throw err
}
import { consola } from 'consola'
const log = consola.withTag('checkout')
log.info('开始结账流程')
try {
const cart = await getCart(userId)
log.info('购物车已加载', { items: cart.items.length, total: cart.total })
const charge = await stripe.charge(cart.total)
log.info('扣款成功', { chargeId: charge.id })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
log.error('结账失败', err)
throw err
}
console.log('[checkout] 开始结账流程')
try {
const cart = await getCart(userId)
console.log('[checkout] 购物车已加载', { items: cart.items.length, total: cart.total })
const charge = await stripe.charge(cart.total)
console.log('[checkout] 扣款成功', { chargeId: charge.id })
if (!charge.success) {
throw new Error(`Payment failed: ${charge.decline_reason}`)
}
} catch (err) {
console.error('[checkout] 失败', err)
throw err
}
这四种最终都会变成这样——无论来源库是什么,代码都相同:
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 拼接。 - 错误携带
why和fix。 使用createError而不是new Error,意味着你的客户端(以及值班人员)能拿到可执行的上下文,而不只是堆栈信息。 - 配置只需一行。 不需要 formatter 接线,不需要 transport 组装,也不需要
pino-pretty的 peer 依赖。启动时调用一次initLogger就完成了。
反向方向:什么时候不该选择 evlog
对自己诚实一点。如果出现以下情况,就不要切换:
- 你发布的是一个已经属于 pino 生态系统的库(
pino-http、pino-pretty、pino-multi-stream插件),切换后会失去相关工具支持。 - 你有一个自定义的 pino transport(例如你在 2021 年写的一个 worker-thread Datadog forwarder),并且不想把它重新实现为 evlog drain。大多数内置适配器都覆盖了常见目标,但自定义协议意味着需要迁移。
- 你只在 CLI 中记录日志,并且使用 consola 只是为了漂亮的终端输出。evlog 的漂亮输出已经不错,但在 spinner、prompt 和 box 渲染方面还达不到 consola 的水平。两者可以一起用:evlog 负责发送到 drain 的事件,consola 负责 prompts / TUI。
下一步
- Simple Logging —
log.*API、迁移选项卡和模式 - Wide Events — 当你按操作累积上下文时能解锁什么
- Performance Benchmarks — 上面这些数字背后的方法
- Standalone TypeScript — 不依赖 Web 框架的脚本、worker 和库