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
Throw createError() from inside a procedure for structured errors with why, fix, and link:
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:
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():
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