oRPC
evlog/orpc ships two primitives that together turn every oRPC procedure call into a single wide event:
withEvlog(handler)— wraps anRPCHandler(orOpenAPIHandler) 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 viacontext.log.
Set up evlog in my oRPC app
Quick Start
1. Install
pnpm add evlog @orpc/server
bun add evlog @orpc/server
yarn add evlog @orpc/server
npm install evlog @orpc/server
2. Wrap the handler and the procedure base
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 })
}
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:
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:
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():
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
Author errors with defineErrorCatalog / createError — the same way you do with every other evlog integration. The evlog() procedure middleware bridges them to oRPC at throw time, so the wire response keeps your catalog code, status, message, and the why / fix / link guidance.
Both authoring styles flow through the same bridge:
// Catalog (recommended for shared/reused errors)
throw billingErrors.PAYMENT_DECLINED({ internal: { paymentRef: 'pay_X' } })
// Ad-hoc createError (fine for one-off errors)
throw createError({
message: 'Card declined',
code: 'PAYMENT_DECLINED',
status: 402,
why: '...',
fix: '...',
link: '...',
})
The catalog is just sugar over createError() — both produce an EvlogError, both carry the same metadata, both come out of oRPC with the same wire shape. If createError() is called without code, the wire code falls back to 'EVLOG_ERROR'.
import { defineErrorCatalog } from 'evlog'
export const billingErrors = defineErrorCatalog('billing', {
PAYMENT_DECLINED: {
status: 402,
message: 'Payment declined',
why: 'The card issuer rejected the charge for insufficient funds',
fix: 'Ask the user to use a different card or top up the existing one',
link: 'https://docs.example.com/payments/declined',
},
})
import { z } from 'zod'
import { billingErrors } from './errors'
export const charge = base
.input(z.object({ amount: z.number().int().positive() }))
.handler(({ input, context }) => {
context.log.set({ payment: { amount: input.amount } })
throw billingErrors.PAYMENT_DECLINED({
internal: { paymentRef: 'pay_X', attemptedAmount: input.amount },
})
})
Inside the procedure middleware, evlog catches the EvlogError thrown by the catalog factory, records it on the wide event (so the level is promoted to error), and re-throws an ORPCError carrying the same code/status/message plus why/fix/link under data:
14:58:20 ERROR [my-rpc] POST /payments/charge 402 in 3ms
├─ operation: payments.charge
├─ error: name=EvlogError code=billing.PAYMENT_DECLINED status=402 message=Payment declined
├─ payment: amount=1999
└─ requestId: 880a50ac-...
{
"defined": false,
"code": "billing.PAYMENT_DECLINED",
"status": 402,
"message": "Payment declined",
"data": {
"why": "The card issuer rejected the charge for insufficient funds",
"fix": "Ask the user to use a different card or top up the existing one",
"link": "https://docs.example.com/payments/declined"
}
}
why / fix / link under data and not at the response root? The other evlog framework integrations put those fields at the root next to 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 follows the protocol — the authoring surface (catalogs, createError()) is the same everywhere; only the wire shape varies because oRPC has its own contract.defined: false here means the error was authored with evlog's catalog rather than registered as an oRPC typed error via os.errors({...}). If you want typed-client-side narrowing on the catalog codes, you can also feed the catalog into os.errors() and throw with errors.<NAME>(...); both styles flow through the same wide-event pipeline.
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:
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, ...):
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():
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:
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() on server shutdown to ensure buffered events are sent. See the Pipeline docs for all options.Tail Sampling
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:
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
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.
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, andlinkfields