Skip to content

Commit e3ebe9f

Browse files
mnismtclaudeHugoRCD
authored
feat(next): support instrumentation.ts hooks (#188)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Hugo Richard <hugo.richard@vercel.com>
1 parent 4385dbc commit e3ebe9f

13 files changed

Lines changed: 698 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"evlog": patch
3+
---
4+
5+
Add `defineNodeInstrumentation()` for Next.js root `instrumentation.ts`: gate on `NEXT_RUNTIME === 'nodejs'`, cache the dynamic `import()` of `lib/evlog` between `register` and `onRequestError`, and export `NextInstrumentationRequest` / `NextInstrumentationErrorContext` types.

apps/docs/content/2.frameworks/02.nextjs.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,114 @@ export const GET = withEvlog(async () => {
6363
})
6464
```
6565

66+
## Instrumentation
67+
68+
Next.js supports an [`instrumentation.ts`](https://nextjs.org/docs/app/guides/instrumentation) file at the project root for server startup hooks and error reporting. evlog provides `createInstrumentation()` to integrate with this pattern.
69+
70+
::callout{icon="i-lucide-info" color="info"}
71+
These two APIs serve different purposes and can be used independently or together:
72+
73+
- **`createEvlog()`** — per-request wide events via `withEvlog()`
74+
- **`createInstrumentation()`** — server startup (`register()`) + unhandled error reporting (`onRequestError()`) across all routes, including SSR and RSC
75+
- Both can coexist: `register()` initializes and locks the logger first, so `createEvlog()` respects it. Each can have its own `drain`.
76+
::
77+
78+
### 1. Add instrumentation exports to your evlog instance
79+
80+
```typescript [lib/evlog.ts]
81+
import { createInstrumentation } from 'evlog/next/instrumentation'
82+
import { createFsDrain } from 'evlog/fs'
83+
84+
export const { register, onRequestError } = createInstrumentation({
85+
service: 'my-app',
86+
drain: createFsDrain(),
87+
captureOutput: true,
88+
})
89+
```
90+
91+
### 2. Wire up instrumentation.ts
92+
93+
Next.js evaluates `instrumentation.ts` in both Node.js and Edge runtimes. Load your real `lib/evlog.ts` only when `NEXT_RUNTIME === 'nodejs'` so Edge bundles never pull Node-only drains (fs, adapters, etc.).
94+
95+
**Recommended**`defineNodeInstrumentation` gates the Node runtime, dynamic-imports your module once (cached), and forwards `register` / `onRequestError`:
96+
97+
```typescript [instrumentation.ts]
98+
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
99+
100+
export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
101+
```
102+
103+
**Manual** — same behavior with explicit handlers; use this if you want full control in the root file (extra branches, per-error logic, or a different import strategy). Without a shared helper, each `onRequestError` typically re-runs `import('./lib/evlog')` unless you add your own cache.
104+
105+
```typescript [instrumentation.ts]
106+
export async function register() {
107+
if (process.env.NEXT_RUNTIME === 'nodejs') {
108+
const { register } = await import('./lib/evlog')
109+
await register()
110+
}
111+
}
112+
113+
export async function onRequestError(
114+
error: { digest?: string } & Error,
115+
request: { path: string; method: string; headers: Record<string, string> },
116+
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
117+
) {
118+
if (process.env.NEXT_RUNTIME === 'nodejs') {
119+
const { onRequestError } = await import('./lib/evlog')
120+
await onRequestError(error, request, context)
121+
}
122+
}
123+
```
124+
125+
Both styles are supported: the helper is optional sugar, not a takeover. `defineNodeInstrumentation` only forwards Next’s two hooks to whatever you export from `lib/evlog` — it does not prevent other work in your app.
126+
127+
### Custom behavior (evlog + your code)
128+
129+
- **Root `instrumentation.ts`** — Next’s stable surface here is `register` and `onRequestError`. The evlog helper exports exactly those; it does not reserve the whole file. If you need **additional** top-level exports later (when Next documents them), use the **manual** wiring and compose by hand, or keep evlog’s hooks minimal and put everything else in `lib/evlog.ts`.
130+
- **`lib/evlog.ts` (recommended for composition)** — wrap evlog’s handlers so you stay free to add startup work, metrics, or extra logging without fighting the helper:
131+
132+
```typescript [lib/evlog.ts]
133+
import { createInstrumentation } from 'evlog/next/instrumentation'
134+
135+
const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
136+
service: 'my-app',
137+
drain: myDrain,
138+
})
139+
140+
export async function register() {
141+
await evlogRegister()
142+
// e.g. OpenTelemetry, feature flags, custom one-off init
143+
}
144+
145+
export function onRequestError(
146+
error: { digest?: string } & Error,
147+
request: { path: string; method: string; headers: Record<string, string> },
148+
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
149+
) {
150+
evlogOnRequestError(error, request, context)
151+
// optional: your own side effects (metrics, etc.)
152+
}
153+
```
154+
155+
Then keep `instrumentation.ts` as a thin import (`defineNodeInstrumentation` or manual) that only loads `./lib/evlog` on Node — your customization lives next to `createEvlog()` in one place.
156+
157+
Next.js automatically calls these exports:
158+
159+
- `register()` — Runs once when the server starts. Initializes the evlog logger with your configured drain, sampling, and options. When `captureOutput` is enabled, `stdout` and `stderr` writes are captured as structured log events.
160+
- `onRequestError()` — Called on every unhandled request error. Emits a structured error log with the error message, digest, stack trace, request path/method, and routing context (`routerKind`, `routePath`, `routeType`, `renderSource`).
161+
162+
::callout{icon="i-lucide-info" color="info"}
163+
`captureOutput` only activates in the Node.js runtime (`NEXT_RUNTIME === 'nodejs'`). It patches `process.stdout.write` and `process.stderr.write` to emit structured `log.info` / `log.error` events alongside the original output.
164+
::
165+
166+
### Configuration
167+
168+
The `createInstrumentation()` factory accepts global logger options (`enabled`, `service`, `env`, `pretty`, `silent`, `sampling`, `stringify`, `drain`) plus:
169+
170+
| Option | Type | Default | Description |
171+
|--------|------|---------|-------------|
172+
| `captureOutput` | `boolean` | `false` | Capture stdout/stderr as structured log events |
173+
66174
## Production Configuration
67175

68176
A real-world `lib/evlog.ts` with enrichers, batched drain, tail sampling, and route-based service names:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
2+
3+
export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))

apps/next-playground/lib/evlog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { DrainContext } from 'evlog'
22
import { createEvlog } from 'evlog/next'
3+
import { createInstrumentation } from 'evlog/next/instrumentation'
34
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
45
import { createDrainPipeline } from 'evlog/pipeline'
56
import { createAxiomDrain } from 'evlog/axiom'
67
import { createBetterStackDrain } from 'evlog/better-stack'
8+
import { createFsDrain } from 'evlog/fs'
79

810
const enrichers = [createUserAgentEnricher(), createRequestSizeEnricher()]
911

@@ -19,6 +21,12 @@ const drain = pipeline(async (batch) => {
1921
await Promise.allSettled([axiom(batch), betterStack(batch)])
2022
})
2123

24+
export const { register, onRequestError } = createInstrumentation({
25+
service: 'next-playground',
26+
drain: createFsDrain(),
27+
captureOutput: true,
28+
})
29+
2230
export const { withEvlog, useLogger, log, createEvlogError } = createEvlog({
2331
service: 'next-playground',
2432
sampling: {

examples/nextjs/instrumentation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'
2+
3+
export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))

examples/nextjs/lib/evlog.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DrainContext } from 'evlog'
22
import { createEvlog } from 'evlog/next'
3+
import { createInstrumentation } from 'evlog/next/instrumentation'
34
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
45
import { createDrainPipeline } from 'evlog/pipeline'
56

@@ -16,6 +17,11 @@ const drain = pipeline((batch) => {
1617
}
1718
})
1819

20+
export const { register, onRequestError } = createInstrumentation({
21+
service: 'nextjs-example',
22+
drain,
23+
})
24+
1925
export const { withEvlog, useLogger, log, createError } = createEvlog({
2026
service: 'nextjs-example',
2127

packages/evlog/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@
118118
"import": "./dist/next/client.mjs",
119119
"default": "./dist/next/client.mjs"
120120
},
121+
"./next/instrumentation": {
122+
"types": "./dist/next/instrumentation.d.mts",
123+
"import": "./dist/next/instrumentation.mjs",
124+
"default": "./dist/next/instrumentation.mjs"
125+
},
121126
"./hono": {
122127
"types": "./dist/hono/index.d.mts",
123128
"import": "./dist/hono/index.mjs",
@@ -229,6 +234,9 @@
229234
"next/client": [
230235
"./dist/next/client.d.mts"
231236
],
237+
"next/instrumentation": [
238+
"./dist/next/instrumentation.d.mts"
239+
],
232240
"hono": [
233241
"./dist/hono/index.d.mts"
234242
],

packages/evlog/src/logger.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ let globalStringify = true
3535
let globalDrain: ((ctx: DrainContext) => void | Promise<void>) | undefined
3636
let globalEnabled = true
3737
let globalSilent = false
38+
let _locked = false
3839

3940
/**
4041
* Initialize the logger with configuration.
@@ -70,6 +71,22 @@ export function isEnabled(): boolean {
7071
return globalEnabled
7172
}
7273

74+
/**
75+
* @internal Lock the logger to prevent re-initialization.
76+
* Called by instrumentation register() after setting up the logger with drain.
77+
* Prevents configureHandler() from overwriting the drain config.
78+
*/
79+
export function lockLogger(): void {
80+
_locked = true
81+
}
82+
83+
/**
84+
* @internal Check if the logger has been locked by instrumentation.
85+
*/
86+
export function isLoggerLocked(): boolean {
87+
return _locked
88+
}
89+
7390
/**
7491
* @internal Get the globally configured drain callback.
7592
* Used by framework middleware to fall back to the global drain

packages/evlog/src/next/handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DrainContext, EnrichContext, TailSamplingContext, WideEvent } from '../types'
2-
import { createRequestLogger, getGlobalDrain, initLogger, isEnabled } from '../logger'
2+
import { createRequestLogger, getGlobalDrain, initLogger, isEnabled, isLoggerLocked } from '../logger'
33
import { filterSafeHeaders } from '../utils'
44
import { shouldLog, getServiceForPath } from '../shared/routes'
55
import { EvlogError } from '../error'
@@ -20,6 +20,10 @@ export function configureHandler(options: NextEvlogOptions): void {
2020
state.options = options
2121
state.initialized = true
2222

23+
// Skip if instrumentation register() already configured the logger.
24+
// Re-initializing would wipe the global drain.
25+
if (isLoggerLocked()) return
26+
2327
// Don't pass drain to initLogger — the global drain fires inside emitWideEvent
2428
// which doesn't have request/header context. Instead, we call drain ourselves
2529
// in callEnrichAndDrain after enrich, with full context.

0 commit comments

Comments
 (0)