Skip to content

Commit cc9ccb8

Browse files
committed
docs(orpc): rewrite framework page to match other integrations
1 parent 0c03927 commit cc9ccb8

1 file changed

Lines changed: 66 additions & 128 deletions

File tree

  • apps/docs/content/3.integrate/frameworks

apps/docs/content/3.integrate/frameworks/15.orpc.md

Lines changed: 66 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ links:
1212
variant: subtle
1313
---
1414

15-
`evlog/orpc` ships two primitives that together turn every oRPC procedure call into a single wide event:
16-
17-
- `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.
18-
- `evlog()` — an oRPC procedure middleware that tags the wide event with the procedure path (`operation`) and forwards the logger via `context.log`.
15+
`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`.
1916

2017
::prompt
2118
---
@@ -31,9 +28,10 @@ Set up evlog in my oRPC app.
3128

3229
- Install evlog: pnpm add evlog
3330
- Call initLogger({ env: { service: 'my-rpc' } }) at startup
34-
- Wrap your RPCHandler with withEvlog() from 'evlog/orpc'
31+
- Wrap your RPCHandler / OpenAPIHandler with withEvlog() from 'evlog/orpc'
3532
- Add os.use(evlog()) on your base procedure for typed context.log + per-procedure operation
3633
- Declare EvlogOrpcContext on your base context to type context.log
34+
- Throw evlog errors (createError or defineErrorCatalog) directly from procedures — evlog/orpc bridges them to ORPCError
3735
- Pass drain, enrich, include, and keep options to withEvlog()
3836

3937
Docs: https://www.evlog.dev/integrate/frameworks/orpc
@@ -60,21 +58,23 @@ npm install evlog @orpc/server
6058
```
6159
::
6260

63-
### 2. Wrap the handler and the procedure base
61+
### 2. Initialize and wire the wrappers
6462

6563
```typescript [server/orpc.ts]
6664
import { os } from '@orpc/server'
6765
import { RPCHandler } from '@orpc/server/fetch'
6866
import { initLogger } from 'evlog'
6967
import { evlog, withEvlog, type EvlogOrpcContext } from 'evlog/orpc'
7068

71-
initLogger({ env: { service: 'my-rpc' } })
69+
initLogger({
70+
env: { service: 'my-rpc' },
71+
})
7272

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

7575
const router = {
76-
ping: base.handler(({ context }) => {
77-
context.log.set({ pinged: true })
76+
health: base.handler(({ context }) => {
77+
context.log.set({ route: 'health' })
7878
return { ok: true }
7979
}),
8080
}
@@ -91,11 +91,11 @@ export default async function fetch(request: Request) {
9191
**Using Vite?** The [`evlog/vite` plugin](/reference/vite-plugin) replaces the `initLogger()` call with compile-time auto-initialization, strips `log.debug()` from production builds, and injects source locations.
9292
::
9393

94-
`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`.
94+
`EvlogOrpcContext` declares `log: RequestLogger` on the procedure context so `context.log` is fully typed in every procedure that descends from `base`.
9595

9696
## Wide Events
9797

98-
Build context up over the procedure call. One request = one wide event:
98+
Build up context progressively through your handler. One request = one wide event:
9999

100100
```typescript [server/orpc.ts]
101101
const getUser = base
@@ -107,170 +107,109 @@ const getUser = base
107107
context.log.set({ user: { name: user.name, plan: user.plan } })
108108

109109
const orders = await db.findOrders(input.id)
110-
context.log.set({ orders: { count: orders.length } })
110+
context.log.set({ orders: { count: orders.length, totalRevenue: sum(orders) } })
111111

112112
return { user, orders }
113113
})
114114
```
115115

116-
Output:
116+
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'`):
117117

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

126-
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.
127-
128-
## useLogger() — accessing the logger off-context
126+
## useLogger()
129127

130-
When you don't have direct access to `context` (utility modules, deep service functions), use `useLogger()`:
128+
Use `useLogger()` to access the request-scoped logger from anywhere in the call stack without passing the context through your service layer:
131129

132-
```typescript [server/services/billing.ts]
130+
```typescript [server/services/user.ts]
133131
import { useLogger } from 'evlog/orpc'
134132

135-
export async function chargeCard(amount: number) {
133+
export async function findUser(id: string) {
136134
const log = useLogger()
137-
log.set({ payment: { amount } })
138-
//
139-
}
140-
```
135+
log.set({ user: { id } })
141136

142-
`useLogger()` resolves to the same logger as `context.log` and throws when called outside of a request that flowed through `withEvlog()`.
137+
const user = await db.findUser(id)
138+
log.set({ user: { name: user.name, plan: user.plan } })
143139

144-
## Error Handling
145-
146-
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.
147-
148-
Both authoring styles flow through the same bridge:
140+
return user
141+
}
142+
```
149143

150-
```typescript
151-
// Catalog (recommended for shared/reused errors)
152-
throw billingErrors.PAYMENT_DECLINED({ internal: { paymentRef: 'pay_X' } })
144+
```typescript [server/orpc.ts]
145+
import { findUser } from './services/user'
153146

154-
// Ad-hoc createError (fine for one-off errors)
155-
throw createError({
156-
message: 'Card declined',
157-
code: 'PAYMENT_DECLINED',
158-
status: 402,
159-
why: '...',
160-
fix: '...',
161-
link: '...',
162-
})
147+
const getUser = base
148+
.input(z.object({ id: z.string() }))
149+
.handler(async ({ input }) => findUser(input.id))
163150
```
164151

165-
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'`.
152+
Both `context.log` and `useLogger()` return the same logger instance. `useLogger()` uses `AsyncLocalStorage` to propagate the logger across async boundaries.
166153

167-
```typescript [server/errors.ts]
168-
import { defineErrorCatalog } from 'evlog'
154+
## Error Handling
169155

170-
export const billingErrors = defineErrorCatalog('billing', {
171-
PAYMENT_DECLINED: {
172-
status: 402,
173-
message: 'Payment declined',
174-
why: 'The card issuer rejected the charge for insufficient funds',
175-
fix: 'Ask the user to use a different card or top up the existing one',
176-
link: 'https://docs.example.com/payments/declined',
177-
},
178-
})
179-
```
156+
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:
180157

181158
```typescript [server/orpc.ts]
182-
import { z } from 'zod'
183-
import { billingErrors } from './errors'
184-
185-
export const charge = base
186-
.input(z.object({ amount: z.number().int().positive() }))
187-
.handler(({ input, context }) => {
188-
context.log.set({ payment: { amount: input.amount } })
189-
throw billingErrors.PAYMENT_DECLINED({
190-
internal: { paymentRef: 'pay_X', attemptedAmount: input.amount },
159+
import { createError } from 'evlog'
160+
161+
const checkout = base
162+
.handler(({ context }) => {
163+
context.log.set({ cart: { items: 3, total: 9999 } })
164+
165+
throw createError({
166+
message: 'Payment failed',
167+
code: 'PAYMENT_DECLINED',
168+
status: 402,
169+
why: 'Card declined by issuer',
170+
fix: 'Try a different payment method',
171+
link: 'https://docs.example.com/payments/declined',
191172
})
192173
})
193174
```
194175

195-
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`:
176+
The error is captured and logged with both the custom context and structured error fields:
196177

197178
```bash [Terminal output]
198-
14:58:20 ERROR [my-rpc] POST /payments/charge 402 in 3ms
199-
├─ operation: payments.charge
200-
├─ error: name=EvlogError code=billing.PAYMENT_DECLINED status=402 message=Payment declined
201-
├─ payment: amount=1999
179+
14:58:20 ERROR [my-rpc] POST /rpc/checkout 402 in 3ms
180+
├─ operation: checkout
181+
├─ error: name=EvlogError code=PAYMENT_DECLINED status=402 message=Payment failed
182+
├─ cart: items=3 total=9999
202183
└─ requestId: 880a50ac-...
203184
```
204185

186+
Wire response returned to the client:
187+
205188
```json [HTTP 402]
206189
{
207190
"defined": false,
208-
"code": "billing.PAYMENT_DECLINED",
191+
"code": "PAYMENT_DECLINED",
209192
"status": 402,
210-
"message": "Payment declined",
193+
"message": "Payment failed",
211194
"data": {
212-
"why": "The card issuer rejected the charge for insufficient funds",
213-
"fix": "Ask the user to use a different card or top up the existing one",
195+
"why": "Card declined by issuer",
196+
"fix": "Try a different payment method",
214197
"link": "https://docs.example.com/payments/declined"
215198
}
216199
}
217200
```
218201

219202
::callout{icon="i-lucide-info" color="info"}
220-
**Why are `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.
203+
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`](/learn/structured-errors#error-catalogs)) is identical to the rest of evlog.
221204
::
222205

223-
`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.
224-
225-
## Middleware Composition
226-
227-
`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:
228-
229-
```typescript [server/orpc.ts]
230-
const base = os
231-
.$context<EvlogOrpcContext>()
232-
.errors(errors)
233-
.use(evlog())
234-
235-
const authed = base.use(async ({ context, next }) => {
236-
const user = await verifyApiKey(context)
237-
context.log.set({ auth: { ok: true, userId: user.id, role: user.role } })
238-
return next({ context: { ...context, user } })
239-
})
240-
241-
export const deleteResource = authed
242-
.input(z.object({ id: z.string() }))
243-
.handler(({ input, context, errors }) => {
244-
if (context.user.role !== 'superadmin') {
245-
throw errors.FORBIDDEN({ data: { requiredRole: 'superadmin' } })
246-
}
247-
context.log.set({ deletedId: input.id, by: context.user.id })
248-
return { ok: true }
249-
})
250-
```
251-
252-
A nested router groups procedures under a path; `operation` on the wide event reflects the full nesting (`users.profile.get`, `payments.charge`, ...):
253-
254-
```typescript [server/orpc.ts]
255-
const router = {
256-
health: base.handler(() => ({ ok: true })),
257-
users: {
258-
list: base.handler(/**/),
259-
get: base.input(/**/).handler(/**/),
260-
},
261-
payments: {
262-
charge: authed.input(/**/).handler(/**/),
263-
},
264-
}
265-
```
266-
267206
## Configuration
268207

269208
See the [Configuration reference](/reference/configuration) for all available options (`initLogger`, middleware options, sampling, silent mode, etc.).
270209

271210
## Drain & Enrichers
272211

273-
Pass adapters and enrichers directly to `withEvlog()`:
212+
Configure drain adapters and enrichers directly in the `withEvlog()` options:
274213

275214
```typescript [server/orpc.ts]
276215
import { createAxiomDrain } from 'evlog/axiom'
@@ -289,7 +228,7 @@ const handler = withEvlog(new RPCHandler(router), {
289228

290229
### Pipeline (Batching & Retry)
291230

292-
For production, wrap your adapter with `createDrainPipeline` to batch and retry:
231+
For production, wrap your adapter with `createDrainPipeline` to batch events and retry on failure:
293232

294233
```typescript [server/orpc.ts]
295234
import type { DrainContext } from 'evlog'
@@ -306,11 +245,13 @@ const handler = withEvlog(new RPCHandler(router), { drain })
306245
```
307246

308247
::callout{icon="i-lucide-info" color="info"}
309-
Call `drain.flush()` on server shutdown to ensure buffered events are sent. See the [Pipeline docs](/extend/drain-pipeline) for all options.
248+
Call `drain.flush()` on server shutdown to ensure all buffered events are sent. See the [Pipeline docs](/extend/drain-pipeline) for all options.
310249
::
311250

312251
## Tail Sampling
313252

253+
Use `keep` to force-retain specific events regardless of head sampling:
254+
314255
```typescript [server/orpc.ts]
315256
const handler = withEvlog(new RPCHandler(router), {
316257
drain: createAxiomDrain(),
@@ -322,23 +263,20 @@ const handler = withEvlog(new RPCHandler(router), {
322263

323264
## Route Filtering
324265

325-
`include` / `exclude` match against the **HTTP path** (`request.url.pathname`), not the procedure name:
266+
`include` / `exclude` match against the HTTP path (the request URL), not the procedure name:
326267

327268
```typescript [server/orpc.ts]
328269
const handler = withEvlog(new RPCHandler(router), {
329270
include: ['/rpc/**'],
330-
exclude: ['/rpc/_internal/**'],
271+
exclude: ['/rpc/_internal/**', '/health'],
331272
routes: {
332273
'/rpc/auth/**': { service: 'auth-service' },
274+
'/rpc/payment/**': { service: 'payment-service' },
333275
},
334276
})
335277
```
336278

337-
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.
338-
339-
## Streaming Procedures
340-
341-
oRPC's [Event Iterator](https://orpc.dev/docs/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.
279+
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.
342280

343281
## Run Locally
344282

0 commit comments

Comments
 (0)