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
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.
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:
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:
{
"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 / 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:
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