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

The idiomatic oRPC pattern is to declare typed errors via os.errors({ ... }) and throw them with errors.<NAME>(...) from a handler. Each typed error carries a stable code, an HTTP status, and a Zod-validated data payload — and the defined: true flag in the response tells the client this is a contract error, not an unexpected one.

server/orpc.ts
import { os } from '@orpc/server'
import { z } from 'zod'
import { evlog, type EvlogOrpcContext } from 'evlog/orpc'

const errors = {
  PAYMENT_DECLINED: {
    status: 402,
    message: 'Payment declined',
    data: z.object({
      reason: z.enum(['insufficient_funds', 'card_expired', 'fraud_suspected']),
      retryable: z.boolean(),
    }),
  },
} as const

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

export const charge = base
  .input(z.object({ amount: z.number().int().positive() }))
  .handler(({ input, context, errors }) => {
    context.log.set({ payment: { amount: input.amount } })
    throw errors.PAYMENT_DECLINED({
      data: { reason: 'insufficient_funds', retryable: true },
    })
  })

The evlog() middleware catches the throw, calls log.error() to promote the wide event to level: 'error', and re-throws so oRPC's own pipeline serializes the response. Token economy on the wide event:

Terminal output
14:58:20 ERROR [my-rpc] POST /payments/charge 402 in 3ms
  ├─ operation: charge
  ├─ error: name=ORPCError message=Payment declined code=PAYMENT_DECLINED status=402 data={reason:insufficient_funds,retryable:true}
  ├─ payment: amount=1999
  └─ requestId: 880a50ac-...

Response body returned to the client:

HTTP 402
{
  "defined": true,
  "code": "PAYMENT_DECLINED",
  "status": 402,
  "message": "Payment declined",
  "data": {
    "reason": "insufficient_funds",
    "retryable": true,
    "why": "The card issuer rejected the charge for insufficient funds",
    "fix": "Ask the user to use a different card",
    "link": "https://docs.example.com/payments/declined"
  }
}
Why are why / fix / link inside data and not at the root? The other evlog framework integrations (Express, Hono, ...) put those fields at the response root, alongside message and status. oRPC's wire format is fixed: every error is serialized as { defined, code, status, message, data } so that typed clients (safe() from @orpc/client) can deserialize them as a typed union. Anything user-provided lives inside data. evlog could rewrite the response with an adapterInterceptor, but that would silently break the RPC contract for typed clients. We follow the protocol instead: think of data as the structured-error payload, with the same intent as parseError() everywhere else.

If you already use evlog's createError() elsewhere (audit trails, non-RPC paths) and want to throw it from inside a procedure, the middleware still captures it on the wide event — but the response will be wrapped as an INTERNAL_SERVER_ERROR by oRPC's default handler. Prefer os.errors() inside procedures and keep createError() for everything outside the RPC boundary.

Middleware Composition

evlog() plays well with other oRPC middleware. Chain them with .use() — every middleware sees context.log so each layer can append its own keys to the wide event without coordinating with the next:

server/orpc.ts
const base = os
  .$context<EvlogOrpcContext>()
  .errors(errors)
  .use(evlog())

const authed = base.use(async ({ context, next }) => {
  const user = await verifyApiKey(context)
  context.log.set({ auth: { ok: true, userId: user.id, role: user.role } })
  return next({ context: { ...context, user } })
})

export const deleteResource = authed
  .input(z.object({ id: z.string() }))
  .handler(({ input, context, errors }) => {
    if (context.user.role !== 'superadmin') {
      throw errors.FORBIDDEN({ data: { requiredRole: 'superadmin' } })
    }
    context.log.set({ deletedId: input.id, by: context.user.id })
    return { ok: true }
  })

A nested router groups procedures under a path; operation on the wide event reflects the full nesting (users.profile.get, payments.charge, ...):

server/orpc.ts
const router = {
  health: base.handler(() => ({ ok: true })),
  users: {
    list: base.handler(/* … */),
    get: base.input(/* … */).handler(/* … */),
  },
  payments: {
    charge: authed.input(/* … */).handler(/* … */),
  },
}

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