学习

宽事件(Wide Events)

在任意工作单元上累积上下文并发出一个全面的事件。适用于 HTTP 请求、脚本、后端任务、队列工作者和工作流。

宽事件是 evlog 的核心概念。不是在代码库中分散记录日志,而是累积任意工作单元的上下文(无论是请求、脚本、任务还是工作流),并发出一个全面且集中的日志事件。

不在使用 HTTP 框架?请参阅 Standalone TypeScriptCloudflare Workers —— 宽事件在请求生命周期之外同样适用。

将我的请求处理程序转换为宽事件

为什么使用宽事件?

narrow logs → wide event·idle
before·6 narrow log linesstdout
10:23:45.001INFOrequest started
10:23:45.012INFOauthenticated
10:23:45.024DEBUGdb query
10:23:45.069INFOstripe charge
10:23:45.180INFOorder created
10:23:45.234INFOresponse sent
matching to one request needs IDs everywhere
after·1 wide eventdrain
{
  requestId: "req_8a2c",
  status:    200,
  method: "POST",
  user: { id: 42, plan: "pro" },
  cart: { items: 3, total: 9999 },
  charge: { id: "ch_…", amount: 9999 },
  order: { id: "ord_889" },
  duration: 234
}
filter on any field, no joins, no IDs to chase
collapsed0 / 6 lines
emitted
storage cost17% vs narrow

传统日志会制造噪音:

src/service.ts
logger.info('Job started')
logger.info('User authenticated', { userId: user.id })
logger.info('Fetching data', { source: 'postgres' })
logger.info('Processing records')
logger.info('Processing complete')
logger.info('Job finished', { duration: 234 })

这种方式存在以下问题:

  • 上下文分散:信息分散在多条日志中
  • 难以关联:需要将日志匹配到操作时必须到处使用 ID
  • 噪音过多:每个操作产生 10 条以上日志会使排查问题更困难
  • 不完整:如果发生错误,部分日志可能缺失

宽事件解决了这些问题:

import { useLogger } from 'evlog'

const log = useLogger(event)

log.set({ user: { id: 1, plan: 'pro' } })
log.set({ cart: { id: 42, items: 3, total: 9999 } })
log.set({ payment: { method: 'card', status: 'success' } })

一条日志,包含所有上下文。足以理解发生了什么。

创建宽事件

createLogger(通用用途)

在脚本、后端任务、队列工作者、定时任务或任何需要手动管理生命周期的操作中使用 createLogger()

scripts/migrate-users.ts
import { initLogger, createLogger } from 'evlog'

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

const log = createLogger({ task: 'user-migration' })

const users = await db.query('SELECT * FROM legacy_users')
log.set({ found: users.length })

let migrated = 0
for (const user of users) {
  await newDb.upsert({ id: user.id, email: user.email, plan: user.plan })
  migrated++
}

log.set({ migrated, status: 'complete' })
log.emit()

createRequestLogger(HTTP 上下文)

在框架集成之外处理 HTTP 请求时使用 createRequestLogger()。它是 createLogger 的轻量包装器,预填充了 methodpathrequestId

src/worker.ts
import { initLogger, createRequestLogger } from 'evlog'

initLogger({ env: { service: 'my-worker' } })

const log = createRequestLogger({ method: 'POST', path: '/api/checkout' })

log.set({ user: { id: 1, plan: 'pro' } })
log.set({ cart: { items: 3, total: 9999 } })

log.emit()
createLoggercreateRequestLogger 都需要手动调用 log.emit()。事件直到你调用它才会被发出。

useLogger(获取请求日志记录器)

在使用框架集成(Nuxt、Hono、Express 等)时,中间件会自动在每个请求上创建一个宽事件日志。useLogger(event) 从请求上下文中获取该日志:

server/api/checkout.post.ts
import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  log.set({ user: { id: 1, plan: 'pro' } })
  log.set({ cart: { items: 3, total: 9999 } })

  return { success: true }
  // 在响应结束时自动发出
})
useLogger 不会创建日志记录器,它获取的是框架中间件已经附加到事件上的日志。中间件会自动处理创建和发出。在 Nuxt 中,useLogger 是自动导入的。

发出后:密封与后台工作

当宽事件被发出(在请求结束时自动发出,或当你手动调用 log.emit() 时),该日志记录器实例会被密封。后续的 seterrorinfowarn 调用不会更新已经发送到接收端的事件。它们会被忽略,并且 evlog 会在控制台打印一条 [evlog] 警告,显示被丢弃的键。这也适用于头采样丢弃事件的情况(emit() 返回 null):该工作单元的日志记录器仍然会被密封。

这对于存活时间超过处理程序的异步工作(即发即弃的 promise、setTimeout、已启动但未等待的任务)很重要。在许多运行时中,AsyncLocalStorage 会继续返回相同的请求日志记录器,因此即使 HTTP 响应——以及宽事件——已经结束,useLogger() 仍然会成功。如果没有警告,这看起来像是静默的数据丢失。

log.fork(label, fn)

对于应该产生其自己宽事件的有意后台工作,当你的集成提供 log.fork(label, fn) 时使用它(Express、Fastify、NestJS、SvelteKit、React Router、Next.js withEvlog、Elysia)。在 fn 内部,useLogger() 会解析为一个日志记录器。当 fn 完成(或抛出错误)时,子记录器会发出一个包含以下内容的事件:

  • operation:你传入的 label
  • _parentRequestId:父请求的 requestId(用于在查询和仪表板中关联)

父宽事件可能在子事件之前发出;它们是按时间顺序排列的两个独立事件。

暂不可用: Hono(没有 c.get('log') + ALS 就没有 useLogger)和 Nitro/Nuxt useLogger(event) —— 使用发出后的警告来捕捉错误;针对事件范围的 fork,后续可能会提供不同的 API。

server/routes/checkout.post.ts
import { evlog, useLogger } from 'evlog/express'

// 在 evlog 中间件后的路由内:
const log = req.log
log.set({ order_dispatched: true })

log.fork?.('process_order', async () => {
  const child = useLogger()
  child.set({ inventory_checked: true })
})

宽事件的结构

一个设计良好的宽事件应包含来自多个层级的上下文。以下示例展示了在处理函数或脚本中应添加的内容。假设 log 已通过 createLoggercreateRequestLoggeruseLogger 创建。

操作上下文

关于操作的基本信息:

import { useLogger } from 'evlog'

const log = useLogger(event)
log.set({
  method: 'POST',
  path: '/api/checkout',
  requestId: 'abc-123-def',
})
在框架集成中,请求上下文(methodpathrequestId)由中间件自动填充。你无需手动设置这些字段。

用户/执行者上下文

触发操作的用户信息:

server/api/checkout.post.ts
log.set({
  userId: user.id,
  email: user.email,
  subscription: user.plan,
  accountAge: daysSince(user.createdAt),
})

业务上下文

与操作相关的领域特定数据:

server/api/checkout.post.ts
log.set({
  cart: {
    id: cart.id,
    items: cart.items.length,
    total: cart.total,
    currency: 'USD',
  },
  shipping: {
    method: 'express',
    country: address.country,
  },
  coupon: appliedCoupon?.code,
})

结果

操作的结果:

log.set({
  status: 200,
  duration: Date.now() - startTime,
  success: true,
})

最佳实践

使用有意义的键名

server/api/orders.post.ts
// 避免使用通用键名
log.set({ data: { id: 123 } })

// 使用具体、描述性的键名
log.set({ order: { id: 123, status: 'pending' } })

对相关数据进行分组

server/api/checkout.post.ts
// 平坦结构难以阅读
log.set({
  userId: 1,
  userEmail: 'a@b.com',
  cartId: 2,
  cartTotal: 100,
})

// 分组结构更清晰
log.set({
  user: { id: 1, email: 'a@b.com' },
  cart: { id: 2, total: 100 },
})

增量添加上下文

随着信息收集逐步调用 log.set()

import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  const user = await getUser(event)
  log.set({ user: { id: user.id, plan: user.plan } })

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

  const payment = await processPayment(cart)
  log.set({ payment: { method: payment.method, status: payment.status } })

  return { success: true }
})

优雅地处理错误

当发生错误时,宽事件仍然会携带错误上下文进行发出:

import { useLogger } from 'evlog'

export default defineEventHandler(async (event) => {
  const log = useLogger(event)

  try {
    const result = await processPayment(cart)
    return result
  } catch (err) {
    log.set({
      error: {
        message: err.message,
        code: err.code,
        type: err.constructor.name,
      },
    })
    throw err
  }
})

手动设置级别

log.error(err) 会用 { name, message, stack } 填充 error 字段,并将宽事件提升为 level: 'error'。当你想自己控制 error 字段——例如类型化错误码、不要堆栈,或更丰富的自定义结构——请使用 log.setLevel() 来提升级别,而不触碰上下文:

log.setLevel('error')
log.set({
  error: {
    code: 'PAYMENT_DECLINED',
    reason: 'insufficient_funds',
  },
})

setLevel() 接受 'error' | 'warn' | 'info' | 'debug',并且优先于从 .error() / .warn() 计算出的级别。将它与 log.set() 结合使用,可以在保持宽事件整洁的同时,仍然走错误级别的采样和分发。

输出格式

evlog 会根据环境自动在纯文本(美观)和 JSON 格式之间切换。这是默认行为,无需配置。

[INFO] POST /api/checkout (234ms)
  user: { id: 1, plan: 'pro' }
  cart: { items: 3, total: 9999 }
  payment: { method: 'card', status: 'success' }

后续步骤