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-capture-output-fs-edge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"evlog": patch
---

Split Next.js instrumentation into an Edge-safe gate (`evlog/next/instrumentation`) and a Node-only factory (`evlog/next/instrumentation/create`) so root `instrumentation.ts` no longer pulls the logger, audit, or file-system helpers into the Edge bundle. `defineNodeInstrumentation` now accepts an options object directly (no `import().then()` in user code). Filter known Next.js Edge bundler warnings from `captureOutput` (`CaptureOutputOptions`: `stdout`, `stderr`, `ignore`). The FS adapter warns once and skips writes when `NEXT_RUNTIME` is `edge`.
6 changes: 5 additions & 1 deletion apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default defineNitroPlugin((nitroApp) => {
})
```
```typescript [Next.js]
// lib/evlog.ts
// lib/evlog.ts — Node.js routes only; keep evlog/fs out of root instrumentation.ts
import { createEvlog } from 'evlog/next'
import { createFsDrain } from 'evlog/fs'

Expand All @@ -80,6 +80,10 @@ export const { withEvlog, useLogger, log, createError } = createEvlog({
drain: createFsDrain(),
})
```

::callout{icon="i-lucide-info" color="info"}
The FS adapter requires Node.js (`node:fs`). On the Edge runtime it logs a one-time `[evlog/fs]` warning and skips writes. Use `evlog/memory` or an HTTP adapter for Edge routes.
::
```typescript [Hono]
import { createFsDrain } from 'evlog/fs'

Expand Down
114 changes: 54 additions & 60 deletions apps/docs/content/3.integrate/frameworks/02.nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,101 +92,95 @@ These two APIs serve different purposes and can be used independently or togethe
- 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
### 1. Split instrumentation from route config

Keep Node-only imports (`evlog/fs`, heavy adapters) out of root `instrumentation.ts`. Use `defineNodeInstrumentation` with an options object — evlog loads `createInstrumentation` on Node.js only, without a visible `import()` in your file.

- Root `instrumentation.ts` → `defineNodeInstrumentation({ service, ... })`
- `lib/evlog.ts` → `createEvlog()` and Node-only drains for API routes

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

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

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

export const { register, onRequestError } = createInstrumentation({
export const { withEvlog, useLogger, log, createError } = createEvlog({
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.).
Next.js evaluates `instrumentation.ts` in both Node.js and Edge runtimes. `defineNodeInstrumentation` gates on `NEXT_RUNTIME === 'nodejs'` and loads the Node-only factory internally.

### Custom behavior (evlog + your code)

**Recommended**: `defineNodeInstrumentation` gates the Node runtime, dynamic-imports your module once (cached), and forwards `register` / `onRequestError`:
Pass a **loader callback** when you need extra startup work alongside evlog:

```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 const { register, onRequestError } = defineNodeInstrumentation(async () => {
const { createInstrumentation } = await import('evlog/next/instrumentation/create')
const { register: evlogRegister, onRequestError: evlogOnRequestError } = createInstrumentation({
service: 'my-app',
captureOutput: true,
})

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)
return {
async register() {
await evlogRegister()
// e.g. OpenTelemetry, feature flags, custom one-off init
},
onRequestError: evlogOnRequestError,
}
}
```

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.
Keep `lib/evlog.ts` for `createEvlog()` and Node-only drains. Route handlers import `@/lib/evlog`.

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.
`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. Known Next.js Edge bundler warnings are filtered by default so they are not re-emitted as evlog errors.
::

### Configuration

The `createInstrumentation()` factory accepts global logger options (`enabled`, `service`, `env`, `pretty`, `silent`, `sampling`, `stringify`, `drain`) plus:
`defineNodeInstrumentation()` and `createInstrumentation()` accept 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 |
| `captureOutput` | `boolean \| CaptureOutputOptions` | `false` | Capture stdout/stderr as structured log events |

`CaptureOutputOptions` fields:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `stdout` | `boolean` | `true` | Capture stdout writes |
| `stderr` | `boolean` | `true` | Capture stderr writes |
| `ignore` | `(string \| RegExp)[]` | Next.js Edge bundler warnings | Skip re-emitting matching chunks as log events |

```typescript [instrumentation.ts]
defineNodeInstrumentation({
captureOutput: {
stderr: true,
ignore: [/my-noisy-dep/, 'benign warning'],
},
})
```

## Production Configuration

Expand Down
5 changes: 3 additions & 2 deletions apps/next-playground/app/api/evlog/ingest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ export async function POST(request: NextRequest) {
source: 'client',
}

// Log the ingested client event server-side
console.log('[CLIENT LOG]', JSON.stringify(wideEvent))
if (process.env.NODE_ENV === 'development') {
console.log('[CLIENT LOG]', JSON.stringify(wideEvent))
}

return new Response(null, { status: 204 })
}
2 changes: 1 addition & 1 deletion apps/next-playground/app/api/test/browser-ingest/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export async function POST(request: Request) {
const body = await request.json()

if (Array.isArray(body)) {
if (process.env.NODE_ENV === 'development' && Array.isArray(body)) {
for (const entry of body) {
console.log('[BROWSER DRAIN]', JSON.stringify(entry))
}
Expand Down
2 changes: 1 addition & 1 deletion apps/next-playground/app/api/test/drain/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const GET = withEvlog(async () => {

return Response.json({
success: true,
message: 'Drain test event emitted — check your terminal and configured adapters',
message: 'Drain test event emitted — check .evlog/logs/ and configured adapters',
timestamp: new Date().toISOString(),
})
})
4 changes: 2 additions & 2 deletions apps/next-playground/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,12 @@ const sections: TestSection[] = [
id: 'drains',
label: 'Drains',
title: 'Drain Adapters',
description: 'Test the full drain pipeline end-to-end. Events flow through enrich → pipeline → drain. Check your terminal for [DRAIN] output.',
description: 'Test the full drain pipeline end-to-end. Events flow through enrich → pipeline → fs, Axiom, and Better Stack drains.',
tests: [
{
id: 'drain-emit',
label: 'Emit Drain Event',
description: 'Sets context, emits wide event through the full pipeline. Watch for [DRAIN] in terminal.',
description: 'Sets context, emits wide event through the full pipeline. Check .evlog/logs/ or your configured adapters.',
endpoint: '/api/test/drain',
method: 'GET',
color: 'success',
Expand Down
5 changes: 4 additions & 1 deletion apps/next-playground/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { defineNodeInstrumentation } from 'evlog/next/instrumentation'

export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog'))
export const { register, onRequestError } = defineNodeInstrumentation({
service: 'next-playground',
captureOutput: true,
})
13 changes: 2 additions & 11 deletions apps/next-playground/lib/evlog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
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'
Expand All @@ -9,22 +8,14 @@ import { createFsDrain } from 'evlog/fs'

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

const fs = createFsDrain()
const axiom = createAxiomDrain()
const betterStack = createBetterStackDrain()

const pipeline = createDrainPipeline<DrainContext>({ batch: { size: 5, intervalMs: 2000 } })

const drain = pipeline(async (batch) => {
for (const ctx of batch) {
console.log('[DRAIN]', JSON.stringify(ctx.event))
}
await Promise.allSettled([axiom(batch), betterStack(batch)])
})

export const { register, onRequestError } = createInstrumentation({
service: 'next-playground',
drain: createFsDrain(),
captureOutput: true,
await Promise.allSettled([fs(batch), axiom(batch), betterStack(batch)])
})

export const { withEvlog, useLogger, log, createEvlogError } = createEvlog({
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/lib/evlog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DrainContext } from 'evlog'
import { createEvlog } from 'evlog/next'
import { createInstrumentation } from 'evlog/next/instrumentation'
import { createInstrumentation } from 'evlog/next/instrumentation/create'
import { createUserAgentEnricher, createRequestSizeEnricher } from 'evlog/enrichers'
import { createDrainPipeline } from 'evlog/pipeline'

Expand Down
8 changes: 8 additions & 0 deletions packages/evlog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@
"import": "./dist/next/instrumentation.mjs",
"default": "./dist/next/instrumentation.mjs"
},
"./next/instrumentation/create": {
"types": "./dist/next/instrumentation/create.d.mts",
"import": "./dist/next/instrumentation/create.mjs",
"default": "./dist/next/instrumentation/create.mjs"
},
"./next/stream": {
"types": "./dist/next/stream.d.mts",
"import": "./dist/next/stream.mjs",
Expand Down Expand Up @@ -290,6 +295,9 @@
"next/instrumentation": [
"./dist/next/instrumentation.d.mts"
],
"next/instrumentation/create": [
"./dist/next/instrumentation/create.d.mts"
],
"next/stream": [
"./dist/next/stream.d.mts"
],
Expand Down
15 changes: 15 additions & 0 deletions packages/evlog/src/adapters/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ const FS_FIELDS: ConfigField<FsConfig>[] = [
]

const gitignoreWritten = new Set<string>()
let warnedFsEdgeRuntime = false

function isEdgeRuntime(): boolean {
return process.env.NEXT_RUNTIME === 'edge'
}

function warnFsEdgeRuntimeOnce(): void {
if (warnedFsEdgeRuntime) return
warnedFsEdgeRuntime = true
console.warn('[evlog/fs] File system drain is not available on the Edge runtime. Use evlog/memory or a HTTP adapter instead.')
}

async function ensureGitignore(dir: string): Promise<void> {
const normalized = dir.replace(/[\\/]/g, sep)
Expand Down Expand Up @@ -139,6 +150,10 @@ export function createFsDrain(overrides?: Partial<FsConfig>) {
return defineDrain<FsConfig>({
name: 'fs',
resolve: async () => {
if (isEdgeRuntime()) {
warnFsEdgeRuntimeOnce()
return null
}
const resolved = await resolveAdapterConfig<FsConfig>('fs', FS_FIELDS, overrides)
return {
dir: resolved.dir ?? '.evlog/logs',
Expand Down
Loading
Loading