Frameworks

oRPC

Source Code
Automatic wide events, structured errors, drain adapters, enrichers, and tail sampling in oRPC applications.

evlog/orpc ships two primitives: withEvlog(handler) wraps any oRPC handler (RPCHandler, OpenAPIHandler) so each request becomes one wide event, and evlog() is a procedure middleware that exposes context.log and tags the wide event with the procedure path as operation.

Set up evlog in my oRPC app

Quick Start

1. Install

pnpm add evlog @orpc/server

2. Initialize and wire the wrappers

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 })
}
Using Vite? The evlog/vite plugin replaces the initLogger() call with compile-time auto-initialization, strips log.debug() from production builds, and injects source locations.

EvlogOrpcContext declares log: RequestLogger on the procedure context so context.log is fully typed in every procedure that descends from base.

Wide Events

Build up context progressively through your handler. One request = one wide event:

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

All fields are merged into a single wide event emitted when the request completes. The operation field is filled automatically from the procedure path (nested routers like users.profile.get surface as 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()

Use useLogger() to access the request-scoped logger from anywhere in the call stack without passing the context through your service layer:

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

Both context.log and useLogger() return the same logger instance. useLogger() uses AsyncLocalStorage to propagate the logger across async boundaries.

Error Handling

Use createError for structured errors with why, fix, and link fields. The evlog() middleware catches the throw, records it on the wide event, and bridges it to an ORPCError so the wire response carries your code, status, message, and the human-guidance fields:

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

The error is captured and logged with both the custom context and structured error fields:

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-...

Wire response returned to the client:

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's error envelope is { defined, code, status, message, data } — clients deserialize errors as a typed union via safe() from @orpc/client. evlog follows the protocol, so why/fix/link live under data instead of at the response root. The authoring API (createError / defineErrorCatalog) is identical to the rest of evlog.

Configuration

See the Configuration reference for all available options (initLogger, middleware options, sampling, silent mode, etc.).

Drain & Enrichers

Configure drain adapters and enrichers directly in the withEvlog() options:

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

Pipeline (Batching & Retry)

For production, wrap your adapter with createDrainPipeline to batch events and retry on failure:

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 })
Call drain.flush() on server shutdown to ensure all buffered events are sent. See the Pipeline docs for all options.

Tail Sampling

Use keep to force-retain specific events regardless of head sampling:

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

Route Filtering

include / exclude match against the HTTP path (the request URL), not the procedure name:

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

When a route is filtered out, the wrapper still injects a no-op context.log so procedures never crash on missing fields — the wide event simply isn't emitted and drain/enrich aren't called.

Run Locally

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

Open http://localhost:3000 to explore the interactive test UI.

Source Code

Browse the complete oRPC example source on GitHub.

Next Steps

Deepen your oRPC integration:

  • Wide Events: Design comprehensive events with context layering
  • Adapters: Send logs to Axiom, Sentry, PostHog, and more
  • Sampling: Control log volume with head and tail sampling
  • Structured Errors: Throw errors with why, fix, and link fields