Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/next-define-node-instrumentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"evlog": patch
---

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.
108 changes: 108 additions & 0 deletions apps/docs/content/2.frameworks/02.nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,114 @@ export const GET = withEvlog(async () => {
})
```

## Instrumentation

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.

::callout{icon="i-lucide-info" color="info"}
These two APIs serve different purposes and can be used independently or together:

- **`createEvlog()`** β€” per-request wide events via `withEvlog()`
- **`createInstrumentation()`** β€” server startup (`register()`) + unhandled error reporting (`onRequestError()`) across all routes, including SSR and RSC
- Both can coexist: `register()` initializes and locks the logger first, so `createEvlog()` respects it. Each can have its own `drain`.
::

### 1. Add instrumentation exports to your evlog instance

```typescript [lib/evlog.ts]
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createFsDrain } from 'evlog/fs'

export const { register, onRequestError } = createInstrumentation({
service: 'my-app',
drain: createFsDrain(),
captureOutput: true,
})
```

### 2. Wire up instrumentation.ts

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.).

**Recommended** β€” `defineNodeInstrumentation` gates the Node runtime, dynamic-imports your module once (cached), and forwards `register` / `onRequestError`:

```typescript [instrumentation.ts]
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
```

**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.

```typescript [instrumentation.ts]
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { register } = await import('./lib/evlog')
await register()
}
}

export async function onRequestError(
error: { digest?: string } & Error,
request: { path: string; method: string; headers: Record<string, string> },
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { onRequestError } = await import('./lib/evlog')
await onRequestError(error, request, context)
}
}
```

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.

### Custom behavior (evlog + your code)

- **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`.
- **`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:

```typescript [lib/evlog.ts]
import { createInstrumentation } from 'evlog/next/instrumentation'

const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
service: 'my-app',
drain: myDrain,
})

export async function register() {
await evlogRegister()
// e.g. OpenTelemetry, feature flags, custom one-off init
}

export function onRequestError(
error: { digest?: string } & Error,
request: { path: string; method: string; headers: Record<string, string> },
context: { routerKind: string; routePath: string; routeType: string; renderSource: string },
) {
evlogOnRequestError(error, request, context)
// optional: your own side effects (metrics, etc.)
}
```

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.

Next.js automatically calls these exports:

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

::callout{icon="i-lucide-info" color="info"}
`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.
::

### Configuration

The `createInstrumentation()` factory accepts global logger options (`enabled`, `service`, `env`, `pretty`, `silent`, `sampling`, `stringify`, `drain`) plus:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `captureOutput` | `boolean` | `false` | Capture stdout/stderr as structured log events |

## Production Configuration

A real-world `lib/evlog.ts` with enrichers, batched drain, tail sampling, and route-based service names:
Expand Down
3 changes: 3 additions & 0 deletions apps/next-playground/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
8 changes: 8 additions & 0 deletions apps/next-playground/lib/evlog.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createDrainPipeline } from 'evlog/pipeline'
import { createAxiomDrain } from 'evlog/axiom'
import { createBetterStackDrain } from 'evlog/better-stack'
import { createFsDrain } from 'evlog/fs'

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

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

export const { register, onRequestError } = createInstrumentation({
service: 'next-playground',
drain: createFsDrain(),
captureOutput: true,
})

export const { withEvlog, useLogger, log, createEvlogError } = createEvlog({
service: 'next-playground',
sampling: {
Expand Down
3 changes: 3 additions & 0 deletions examples/nextjs/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
6 changes: 6 additions & 0 deletions examples/nextjs/lib/evlog.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createDrainPipeline } from 'evlog/pipeline'

Expand All @@ -16,6 +17,11 @@ const drain = pipeline((batch) => {
}
})

export const { register, onRequestError } = createInstrumentation({
service: 'nextjs-example',
drain,
})

export const { withEvlog, useLogger, log, createError } = createEvlog({
service: 'nextjs-example',

Expand Down
8 changes: 8 additions & 0 deletions packages/evlog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@
"import": "./dist/next/client.mjs",
"default": "./dist/next/client.mjs"
},
"./next/instrumentation": {
"types": "./dist/next/instrumentation.d.mts",
"import": "./dist/next/instrumentation.mjs",
"default": "./dist/next/instrumentation.mjs"
},
"./hono": {
"types": "./dist/hono/index.d.mts",
"import": "./dist/hono/index.mjs",
Expand Down Expand Up @@ -229,6 +234,9 @@
"next/client": [
"./dist/next/client.d.mts"
],
"next/instrumentation": [
"./dist/next/instrumentation.d.mts"
],
"hono": [
"./dist/hono/index.d.mts"
],
Expand Down
17 changes: 17 additions & 0 deletions packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ let globalStringify = true
let globalDrain: ((ctx: DrainContext) => void | Promise<void>) | undefined
let globalEnabled = true
let globalSilent = false
let _locked = false

/**
* Initialize the logger with configuration.
Expand Down Expand Up @@ -70,6 +71,22 @@ export function isEnabled(): boolean {
return globalEnabled
}

/**
* @internal Lock the logger to prevent re-initialization.
* Called by instrumentation register() after setting up the logger with drain.
* Prevents configureHandler() from overwriting the drain config.
*/
export function lockLogger(): void {
_locked = true
}

/**
* @internal Check if the logger has been locked by instrumentation.
*/
export function isLoggerLocked(): boolean {
return _locked
}

/**
* @internal Get the globally configured drain callback.
* Used by framework middleware to fall back to the global drain
Expand Down
6 changes: 5 additions & 1 deletion packages/evlog/src/next/handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { DrainContext, EnrichContext, TailSamplingContext, WideEvent } from '../types'
import { createRequestLogger, getGlobalDrain, initLogger, isEnabled } from '../logger'
import { createRequestLogger, getGlobalDrain, initLogger, isEnabled, isLoggerLocked } from '../logger'
import { filterSafeHeaders } from '../utils'
import { shouldLog, getServiceForPath } from '../shared/routes'
import { EvlogError } from '../error'
Expand All @@ -20,6 +20,10 @@ export function configureHandler(options: NextEvlogOptions): void {
state.options = options
state.initialized = true

// Skip if instrumentation register() already configured the logger.
// Re-initializing would wipe the global drain.
if (isLoggerLocked()) return

// Don't pass drain to initLogger β€” the global drain fires inside emitWideEvent
// which doesn't have request/header context. Instead, we call drain ourselves
// in callEnrichAndDrain after enrich, with full context.
Expand Down
Loading
Loading