框架

oRPC

源代码
oRPC 应用中的自动宽事件、结构化错误、drain 适配器、enricher 以及尾部采样。

evlog/orpc 提供两个原语:withEvlog(handler) 会包装任意 oRPC handler(RPCHandlerOpenAPIHandler),使每个请求都成为一个宽事件;而 evlog() 是一个 procedure 中间件,它会暴露 context.log,并将该宽事件以 procedure 路径作为 operation 进行标记。

oRPC v2: oRPC 维护者已宣布在 v2 中提供原生 evlog 支持(已可用于 edge)。在 v2 发布之前,evlog/orpc 是 oRPC v1 的集成路径——无论未来 oRPC 如何接入它,它仍然是 evlog 完整流水线(drain、enricher、尾部采样、结构化错误)的入口。

在我的 oRPC 应用中设置 evlog

快速开始

1. 安装

pnpm add evlog @orpc/server

2. 初始化并连接包装器

server/orpc.ts
import { os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { initLogger } from 'evlog'
import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc'

initLogger({
  env: { service: 'my-rpc' },
})

const base = os.$context<EvlogOrpcContext>().use(evlog())

const router = {
  health: base.handler(({ context }) => {
    context.log.set({ route: 'health' })
    return { ok: true }
  }),
}

const handler = withEvlog(new RPCHandler(router))

export default async function fetch(request: Request) {
  const { matched, response } = await handler.handle(request, { prefix: '/rpc' })
  return matched ? response : new Response('Not Found', { status: 404 })
}
在使用 Vite?evlog/vite 插件 会用编译时自动初始化替代 initLogger() 调用,从生产构建中剥离 log.debug(),并注入源位置。

EvlogOrpcContext 在 procedure context 上声明了 log: RequestLogger,因此从 base 派生的每个 procedure 中,context.log 都具有完整类型。

宽事件

通过你的 handler 逐步构建 context。一次请求 = 一个宽事件:

server/orpc.ts
const getUser = base
  .input(z.object({ id: z.string() }))
  .handler(async ({ input, context }) => {
    context.log.set({ user: { id: input.id } })

    const user = await db.findUser(input.id)
    context.log.set({ user: { name: user.name, plan: user.plan } })

    const orders = await db.findOrders(input.id)
    context.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } })

    return { user, orders }
  })

所有字段都会合并到同一个宽事件中,并在请求完成时发出。operation 字段会根据 procedure 路径自动填充(像 users.profile.get 这样的嵌套路由会显示为 operation: 'users.profile.get'):

Terminal output
14:58:15 INFO [my-rpc] POST /rpc/getUser 200 in 12ms
  ├─ operation: getUser
  ├─ orders: count=2 totalRevenue=6298
  ├─ user: id=usr_123 name=Alice plan=pro
  └─ requestId: 4a8ff3a8-...

useLogger()

使用 useLogger() 可以在调用栈中的任何位置访问请求作用域的 logger,而无需把 context 透传到服务层:

server/services/user.ts
import { useLogger } from 'evlog/orpc'

export async function findUser(id: string) {
  const log = useLogger()
  log.set({ user: { id } })

  const user = await db.findUser(id)
  log.set({ user: { name: user.name, plan: user.plan } })

  return user
}
server/orpc.ts
import { findUser } from './services/user'

const getUser = base
  .input(z.object({ id: z.string() }))
  .handler(async ({ input }) => findUser(input.id))

context.loguseLogger() 都返回同一个 logger 实例。useLogger() 使用 AsyncLocalStorage 在异步边界之间传递 logger。

错误处理

使用 createError 创建带有 whyfixlink 字段的结构化错误。evlog() 中间件会捕获抛出的错误,将其记录到宽事件中,并将其桥接为 ORPCError,从而让网络响应携带你的 codestatusmessage 以及面向用户的指导字段:

server/orpc.ts
import { createError } from 'evlog'

const checkout = base
  .handler(({ context }) => {
    context.log.set({ cart: { items: 3, total: 9999 } })

    throw createError({
      message: 'Payment failed',
      code: 'PAYMENT_DECLINED',
      status: 402,
      why: 'Card declined by issuer',
      fix: 'Try a different payment method',
      link: 'https://docs.example.com/payments/declined',
    })
  })

错误会被捕获,并连同自定义 context 和结构化错误字段一起记录:

Terminal output
14:58:20 ERROR [my-rpc] POST /rpc/checkout 402 in 3ms
  ├─ operation: checkout
  ├─ error: name=EvlogError code=PAYMENT_DECLINED status=402 message=Payment failed
  ├─ cart: items=3 total=9999
  └─ requestId: 880a50ac-...

返回给客户端的网络响应:

HTTP 402
{
  "defined": false,
  "code": "PAYMENT_DECLINED",
  "status": 402,
  "message": "Payment failed",
  "data": {
    "why": "Card declined by issuer",
    "fix": "Try a different payment method",
    "link": "https://docs.example.com/payments/declined"
  }
}
oRPC 的错误封装格式是 { defined, code, status, message, data } ——客户端通过 @orpc/clientsafe() 将错误反序列化为一个有类型的联合类型。evlog 遵循该协议,因此 why/fix/link 位于 data 下,而不是响应根部。编写 API(createError / defineErrorCatalog)与 evlog 其余部分完全一致。

配置

请参阅配置参考了解所有可用选项(initLogger、中间件选项、采样、静默模式等)。

Drain 与 Enrichers

直接在 withEvlog() 选项中配置 drain 适配器和 enricher:

server/orpc.ts
import { createAxiomDrain } from 'evlog/axiom'
import { createUserAgentEnricher } from 'evlog/enrichers'

const userAgent = createUserAgentEnricher()

const handler = withEvlog(new RPCHandler(router), {
  drain: createAxiomDrain(),
  enrich: (ctx) => {
    userAgent(ctx)
    ctx.event.region = process.env.FLY_REGION
  },
})

流水线(批处理与重试)

在生产环境中,用 createDrainPipeline 包装你的适配器,以批量发送事件并在失败时重试:

server/orpc.ts
import type { DrainContext } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'

const pipeline = createDrainPipeline<DrainContext>({
  batch: { size: 50, intervalMs: 5000 },
  retry: { maxAttempts: 3 },
})
const drain = pipeline(createAxiomDrain())

const handler = withEvlog(new RPCHandler(router), { drain })
在服务器关闭时调用 drain.flush(),以确保所有缓冲的事件都被发送。有关全部选项,请参阅流水线文档

尾部采样

使用 keep 强制保留特定事件,而不受头部采样影响:

server/orpc.ts
const handler = withEvlog(new RPCHandler(router), {
  drain: createAxiomDrain(),
  keep: (ctx) => {
    if (ctx.duration && ctx.duration > 2000) ctx.shouldKeep = true
  },
})

路由过滤

include / exclude 匹配的是 HTTP 路径(请求 URL),而不是 procedure 名称:

server/orpc.ts
const handler = withEvlog(new RPCHandler(router), {
  include: ['/rpc/**'],
  exclude: ['/rpc/_internal/**', '/health'],
  routes: {
    '/rpc/auth/**': { service: 'auth-service' },
    '/rpc/payment/**': { service: 'payment-service' },
  },
})

当某条路由被过滤掉时,包装器仍然会注入一个无操作的 context.log,因此 procedure 永远不会因为缺少字段而崩溃——宽事件只是不会被发出,drain/enrich 也不会被调用。

本地运行

Terminal
git clone https://github.com/hugorcd/evlog.git
cd evlog
pnpm install
pnpm run example:orpc

打开 http://localhost:3000 来探索交互式测试界面。

源代码

在 GitHub 上浏览完整的 oRPC 示例源码。

下一步

进一步深化你的 oRPC 集成:

  • 宽事件:通过上下文分层设计全面的事件
  • 适配器:将日志发送到 Axiom、Sentry、PostHog 等
  • 采样:通过头部和尾部采样控制日志量
  • 结构化错误:抛出包含 whyfixlink 字段的错误