Frameworks

oRPC

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

evlog/orpc ships two primitives that together turn every oRPC procedure call into a single wide event:

  • withEvlog(handler) — wraps an RPCHandler (or OpenAPIHandler) so each HTTP request creates a request-scoped logger and emits one wide event when the response completes.
  • evlog() — an oRPC procedure middleware that tags the wide event with the procedure path (operation) and forwards the logger via context.log.

Set up evlog in my oRPC app

Quick Start

1. Install

pnpm add evlog @orpc/server

2. Wrap the handler and the procedure base

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 = {
  ping: base.handler(({ context }) => {
    context.log.set({ pinged: true })
    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 — the wrapper injects it for every matched request. os.use(evlog()) on the base then exposes typed context.log to every procedure that descends from base.

Wide Events

Build context up over the procedure call. 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 } })

    return { user, orders }
  })

Output:

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

The operation field comes from the procedure path joined with .. Nested routers like router.users.profile.get surface as operation: 'users.profile.get', which makes filtering by procedure trivial in your observability backend.

useLogger() — accessing the logger off-context

When you don't have direct access to context (utility modules, deep service functions), use useLogger():

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

export async function chargeCard(amount: number) {
  const log = useLogger()
  log.set({ payment: { amount } })
  // …
}

useLogger() resolves to the same logger as context.log and throws when called outside of a request that flowed through withEvlog().

Error Handling

Throw createError() from inside a procedure for structured errors with why, fix, and link:

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

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

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

The procedure middleware catches the throw, calls log.error() to promote the wide event level, and re-throws so oRPC's own error path (interceptors, ORPCError serialization) still runs untouched. The wide event ends up at:

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

For a custom HTTP envelope, plug an onError interceptor on your RPCHandler and use parseError() to extract the structured fields.

Configuration

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

Drain & Enrichers

Pass adapters and enrichers directly to withEvlog():

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 and retry:

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 buffered events are sent. See the Pipeline docs for all options.

Tail 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 (request.url.pathname), not the procedure name:

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

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

Streaming Procedures

oRPC's Event Iterator lets procedures stream chunks back over Server-Sent Events. The wrapper emits the wide event when handler.handle() returns the Response, which is before the stream has fully drained. Token counts or per-chunk fields written via context.log.set() after the procedure returns are dropped (and surface a [evlog] warning) — accumulate them inside the procedure body before yielding the iterator, or use a separate drain pipeline for stream metrics.

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