---
title: "oRPC"
description: "`withEvlog(handler)` wraps an oRPC handler and the procedure middleware puts the logger on `context.log`, tagging each procedure with its `operation`."
canonical_url: "https://www.evlog.dev/integrate/frameworks/orpc"
---
# oRPC

> `withEvlog(handler)` wraps an oRPC handler and the procedure middleware puts the logger on `context.log`, tagging each procedure with its `operation`.

`evlog/orpc` ships two primitives: `withEvlog(handler)` wraps any oRPC handler (`RPCHandler`, `OpenAPIHandler`) so each request becomes one wide event, and `evlog()` is a procedure middleware that exposes `context.log` and tags the wide event with the procedure path as `operation`.

<callout color="info" icon="i-lucide-info">

**oRPC v2:** The oRPC maintainers have [announced first-party evlog support in v2](https://github.com/middleapi/orpc/discussions/1293#discussioncomment-17032656) (edge-ready). Until v2 ships, `evlog/orpc` is the integration path for oRPC v1, and it remains the entrypoint for evlog's full pipeline (drains, enrichers, tail sampling, structured errors) regardless of how oRPC wires it in later.

</callout>

<prompt :actions="["copy","cursor","claude"]" description="Set up evlog in my oRPC app" icon="i-lucide-network">

Set up evlog in my oRPC app.

- Install evlog: pnpm add evlog
- Create evlog.config.ts with export default defineEvlog({ service: 'my-rpc' })
- Call initLogger(toLoggerConfig(config)) at startup, importing config from evlog.config.ts
- Wrap your RPCHandler / OpenAPIHandler with withEvlog() from 'evlog/orpc'
- Add os.use(evlog()) on your base procedure for typed context.log + per-procedure operation
- Declare EvlogOrpcContext on your base context to type context.log
- Throw evlog errors (createError or defineErrorCatalog) directly from procedures. evlog/orpc bridges them into ORPCError
- Put drain, enrich, include and keep in evlog.config.ts; pass toMiddlewareOptions(config) to withEvlog()

Docs: [https://www.evlog.dev/integrate/frameworks/orpc](https://www.evlog.dev/integrate/frameworks/orpc)
Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview)

</prompt>

## Quick Start

### 1. Install

<code-group>

```bash [pnpm]
pnpm add evlog @orpc/server
```

```bash [bun]
bun add evlog @orpc/server
```

```bash [yarn]
yarn add evlog @orpc/server
```

```bash [npm]
npm install evlog @orpc/server
```

</code-group>

### 2. Create evlog.config.ts

```typescript [evlog.config.ts]
import { defineEvlog } from 'evlog'

export default defineEvlog({
  service: 'my-rpc',
})
```

Every option lives in this file: sampling, drain, `enrich`, route filters. `npx evlog init` writes it, and the wiring below, for you. See [Configuration](https://www.evlog.dev/reference/configuration).

### 3. Initialize and wire the wrappers

```typescript [server/orpc.ts]
import { os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { initLogger, toLoggerConfig, toMiddlewareOptions } from 'evlog'
import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc'
import config from '../evlog.config'

initLogger(toLoggerConfig(config))

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

const router = {
  health: base.handler(({ context }) => {
    context.log.set({ route: 'health' })
    return { ok: true }
  }),
}

const handler = withEvlog(new RPCHandler(router), toMiddlewareOptions(config))

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 })
}
```

<callout color="info" icon="i-custom-vite">

**Using Vite?** The [`evlog/vite` plugin](https://www.evlog.dev/reference/vite-plugin) strips `log.debug()` from production builds and injects source locations. It does not read `evlog.config.ts`, so keep the `initLogger(toLoggerConfig(config))` call: it runs after the plugin's auto-init and replaces it.

</callout>

`EvlogOrpcContext` declares `log: RequestLogger` on the procedure context so `context.log` is fully typed in every procedure that descends from `base`.

## Wide Events

Build up context progressively through your handler. One request = one wide event:

```typescript [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, totalRevenue: sum(orders) } })

    return { user, orders }
  })
```

All fields are merged into a single wide event emitted when the request completes. The `operation` field is filled automatically from the procedure path (nested routers like `users.profile.get` surface as `operation: 'users.profile.get'`):

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

## useLogger()

Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing the context through your service layer:

```typescript [server/services/user.ts]
import { useLogger } from 'evlog/orpc'

export async function findUser(id: string) {
  const log = useLogger()
  log.set({ user: { id } })

  const user = await db.findUser(id)
  log.set({ user: { name: user.name, plan: user.plan } })

  return user
}
```

```typescript [server/orpc.ts]
import { findUser } from './services/user'

const getUser = base
  .input(z.object({ id: z.string() }))
  .handler(async ({ input }) => findUser(input.id))
```

Both `context.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries.

## Background work (`log.fork`)

`useLogger()` returns a logger that also supports `fork` for child wide events, so background work runs under its own wide event. See [After emit](https://www.evlog.dev/learn/wide-events#after-emit-sealing-and-background-work) in Wide events.

```typescript [server/services/user.ts]
import { useLogger } from 'evlog/orpc'

export async function findUser(id: string) {
  const log = useLogger()

  log.fork!('background', async () => {
    const child = useLogger()
    child.set({ step: 'complete' })
  })

  const user = await db.findUser(id)
  log.set({ user: { name: user.name, plan: user.plan } })

  return user
}
```

## Error Handling

Use `createError` for structured errors with `why`, `fix`, and `link` fields. The `evlog()` middleware catches the throw, records it on the wide event, and bridges it to an `ORPCError` so the wire response carries your `code`, `status`, `message`, and the human-guidance fields:

```typescript [server/orpc.ts]
import { createError } from 'evlog'

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

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

The error is captured and logged with both the custom context and structured error fields:

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

Wire response returned to the client:

```json [HTTP 402]
{
  "defined": false,
  "code": "PAYMENT_DECLINED",
  "status": 402,
  "message": "Payment failed",
  "data": {
    "why": "Card declined by issuer",
    "fix": "Try a different payment method",
    "link": "https://docs.example.com/payments/declined"
  }
}
```

<callout color="info" icon="i-lucide-info">

oRPC's error envelope is `{ defined, code, status, message, data }`. Clients deserialize errors as a typed union via `safe()` from `@orpc/client`. evlog follows the protocol, so `why`/`fix`/`link` live under `data` instead of at the response root. The authoring API (`createError` / [`defineErrorCatalog`](https://www.evlog.dev/learn/structured-errors#error-catalogs)) is identical to the rest of evlog.

</callout>

## Configuration

Every option goes in `evlog.config.ts`. See the [Configuration reference](https://www.evlog.dev/reference/configuration) for the full list.

## Drain & Enrichers

Set `drain` and `enrich` in `evlog.config.ts` so they reach the app through `toMiddlewareOptions(config)`. You can also pass them inline in the `withEvlog()` options; an inline key replaces the one from the file instead of merging with it:

```typescript [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 events and retry on failure:

```typescript [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 })
```

<callout color="info" icon="i-lucide-info">

Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](https://www.evlog.dev/extend/drain-pipeline) for all options.

</callout>

## Tail Sampling

Use `keep` to force-retain specific events regardless of head sampling:

```typescript [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 (the request URL), not the procedure name:

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

When a route is filtered out, the wrapper still injects a no-op `context.log`, so procedures never crash on missing fields. The wide event simply isn't emitted, and drain/enrich aren't called.

## Run Locally

```bash [Terminal]
git clone https://github.com/evloghq/evlog.git
cd evlog
pnpm install
pnpm example orpc
```

Open [http://localhost:3000](http://localhost:3000) to explore the interactive test UI.

<card-group>
<card icon="i-simple-icons-github" title="Source Code" to="https://github.com/evloghq/evlog/tree/main/examples/orpc">

Browse the complete oRPC example source on GitHub.

</card>
</card-group>

## Next Steps

Deepen your **oRPC** integration:

- [Wide Events](https://www.evlog.dev/learn/wide-events): Design comprehensive events with context layering
- [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send logs to Axiom, Sentry, PostHog, and more
- [Sampling](https://www.evlog.dev/learn/sampling): Control log volume with head and tail sampling
- [Structured Errors](https://www.evlog.dev/learn/structured-errors): Throw errors with `why`, `fix`, and `link` fields

---

- [Source Code](https://github.com/evloghq/evlog/tree/main/examples/orpc)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
