From f97fc72c4b79f3112143354d66bfb09a6b9a42c8 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 11 Jun 2026 21:36:35 +0100 Subject: [PATCH 1/3] fix(next): split instrumentation gate and quiet edge dev warnings --- .changeset/next-capture-output-fs-edge.md | 5 + .../3.integrate/adapters/self-hosted/01.fs.md | 6 +- .../3.integrate/frameworks/02.nextjs.md | 114 +++++----- .../app/api/evlog/ingest/route.ts | 5 +- .../app/api/test/browser-ingest/route.ts | 2 +- .../app/api/test/drain/route.ts | 2 +- apps/next-playground/app/page.tsx | 4 +- apps/next-playground/instrumentation.ts | 5 +- apps/next-playground/lib/evlog.ts | 13 +- examples/nextjs/lib/evlog.ts | 2 +- packages/evlog/package.json | 8 + packages/evlog/src/adapters/fs.ts | 15 ++ .../evlog/src/next/instrumentation-create.ts | 191 +++++++++++++++++ .../evlog/src/next/instrumentation-gate.ts | 109 ++++++++++ packages/evlog/src/next/instrumentation.ts | 194 ++---------------- packages/evlog/src/next/stream.ts | 2 +- packages/evlog/test/adapters/fs.test.ts | 19 ++ .../evlog/test/next/instrumentation.test.ts | 139 +++++++++++-- packages/evlog/test/next/stream.test.ts | 2 +- .../__snapshots__/api-surface.test.ts.snap | 5 +- packages/evlog/tsdown.config.ts | 1 + 21 files changed, 564 insertions(+), 279 deletions(-) create mode 100644 .changeset/next-capture-output-fs-edge.md create mode 100644 packages/evlog/src/next/instrumentation-create.ts create mode 100644 packages/evlog/src/next/instrumentation-gate.ts diff --git a/.changeset/next-capture-output-fs-edge.md b/.changeset/next-capture-output-fs-edge.md new file mode 100644 index 00000000..18474275 --- /dev/null +++ b/.changeset/next-capture-output-fs-edge.md @@ -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`. diff --git a/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md b/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md index bd62dc8a..16636f39 100644 --- a/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md +++ b/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md @@ -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' @@ -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 a HTTP adapter for Edge routes. +:: ```typescript [Hono] import { createFsDrain } from 'evlog/fs' diff --git a/apps/docs/content/3.integrate/frameworks/02.nextjs.md b/apps/docs/content/3.integrate/frameworks/02.nextjs.md index 1029f6cb..5f1243b3 100644 --- a/apps/docs/content/3.integrate/frameworks/02.nextjs.md +++ b/apps/docs/content/3.integrate/frameworks/02.nextjs.md @@ -92,84 +92,61 @@ 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 }, - 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 }, - 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: @@ -177,16 +154,33 @@ Next.js automatically calls these exports: - `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 diff --git a/apps/next-playground/app/api/evlog/ingest/route.ts b/apps/next-playground/app/api/evlog/ingest/route.ts index 1fefe257..56023caf 100644 --- a/apps/next-playground/app/api/evlog/ingest/route.ts +++ b/apps/next-playground/app/api/evlog/ingest/route.ts @@ -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 }) } diff --git a/apps/next-playground/app/api/test/browser-ingest/route.ts b/apps/next-playground/app/api/test/browser-ingest/route.ts index 0db40d93..bbfdfb4a 100644 --- a/apps/next-playground/app/api/test/browser-ingest/route.ts +++ b/apps/next-playground/app/api/test/browser-ingest/route.ts @@ -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)) } diff --git a/apps/next-playground/app/api/test/drain/route.ts b/apps/next-playground/app/api/test/drain/route.ts index ab6c18fe..ad496353 100644 --- a/apps/next-playground/app/api/test/drain/route.ts +++ b/apps/next-playground/app/api/test/drain/route.ts @@ -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(), }) }) diff --git a/apps/next-playground/app/page.tsx b/apps/next-playground/app/page.tsx index c9caee5d..5a858e4e 100644 --- a/apps/next-playground/app/page.tsx +++ b/apps/next-playground/app/page.tsx @@ -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', diff --git a/apps/next-playground/instrumentation.ts b/apps/next-playground/instrumentation.ts index 52db6d81..1d8c8184 100644 --- a/apps/next-playground/instrumentation.ts +++ b/apps/next-playground/instrumentation.ts @@ -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, +}) diff --git a/apps/next-playground/lib/evlog.ts b/apps/next-playground/lib/evlog.ts index 5c1cfee2..ed432ecf 100644 --- a/apps/next-playground/lib/evlog.ts +++ b/apps/next-playground/lib/evlog.ts @@ -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' @@ -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({ 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({ diff --git a/examples/nextjs/lib/evlog.ts b/examples/nextjs/lib/evlog.ts index e772a0b6..71710226 100644 --- a/examples/nextjs/lib/evlog.ts +++ b/examples/nextjs/lib/evlog.ts @@ -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' diff --git a/packages/evlog/package.json b/packages/evlog/package.json index a2229af0..fd8c2259 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -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", @@ -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" ], diff --git a/packages/evlog/src/adapters/fs.ts b/packages/evlog/src/adapters/fs.ts index c23b1ad8..15e6d5be 100644 --- a/packages/evlog/src/adapters/fs.ts +++ b/packages/evlog/src/adapters/fs.ts @@ -26,6 +26,17 @@ const FS_FIELDS: ConfigField[] = [ ] const gitignoreWritten = new Set() +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 { const normalized = dir.replace(/[\\/]/g, sep) @@ -139,6 +150,10 @@ export function createFsDrain(overrides?: Partial) { return defineDrain({ name: 'fs', resolve: async () => { + if (isEdgeRuntime()) { + warnFsEdgeRuntimeOnce() + return null + } const resolved = await resolveAdapterConfig('fs', FS_FIELDS, overrides) return { dir: resolved.dir ?? '.evlog/logs', diff --git a/packages/evlog/src/next/instrumentation-create.ts b/packages/evlog/src/next/instrumentation-create.ts new file mode 100644 index 00000000..b2d09d63 --- /dev/null +++ b/packages/evlog/src/next/instrumentation-create.ts @@ -0,0 +1,191 @@ +import type { DrainContext, EnvironmentContext, LogLevel, Log, SamplingConfig } from '../types' +import type { + NextInstrumentationErrorContext, + NextInstrumentationRequest, +} from './instrumentation-gate' + +type LoggerModule = typeof import('../logger') + +/** Options for capturing process stdout/stderr as structured log events. */ +export interface CaptureOutputOptions { + /** Capture stdout writes. @default true */ + stdout?: boolean + /** Capture stderr writes. @default true */ + stderr?: boolean + /** + * Skip re-emitting chunks that match these patterns as log events. + * When omitted, known Next.js Edge bundler warnings are ignored by default. + */ + ignore?: Array +} + +/** Default patterns skipped by {@link CaptureOutputOptions.ignore}. */ +export const DEFAULT_CAPTURE_OUTPUT_IGNORE: Array = [ + 'node-module-in-edge-runtime', + 'Edge Instrumentation', + 'https://nextjs.org/docs/messages/node-module-in-edge-runtime', + 'https://nextjs.org/docs/api-reference/edge-runtime', + 'Ecmascript file had an error', + 'A Node.js module is loaded', + 'A Node.js API is used', + 'not supported in the Edge Runtime', + 'not supported inthe Edge Runtime', + 'Import trace:', +] + +export interface InstrumentationOptions { + /** Enable or disable all logging globally. @default true */ + enabled?: boolean + /** Service name for all logged events. */ + service?: string + /** Environment context overrides. */ + env?: Partial + /** Enable pretty printing. @default true in development */ + pretty?: boolean + /** Suppress built-in console output. @default false */ + silent?: boolean + /** Sampling configuration for filtering logs. */ + sampling?: SamplingConfig + /** Minimum severity for the global `log` API. @default 'debug' */ + minLevel?: LogLevel + /** When pretty is disabled, emit JSON strings or raw objects. @default true */ + stringify?: boolean + /** Drain callback called with every emitted event. */ + drain?: (ctx: DrainContext) => void | Promise + /** Capture stdout/stderr as structured log events (Node.js only). */ + captureOutput?: boolean | CaptureOutputOptions +} + +interface InstrumentationResult { + register: () => void | Promise + onRequestError: ( + error: { digest?: string } & Error, + request: NextInstrumentationRequest, + context: NextInstrumentationErrorContext, + ) => void | Promise +} + +let patching = false +let loggerPromise: Promise | undefined + +function loadLogger(): Promise { + loggerPromise ??= import('../logger') + return loggerPromise +} + +function resolveCaptureOutputOptions( + captureOutput: InstrumentationOptions['captureOutput'], +): CaptureOutputOptions | undefined { + if (!captureOutput) return undefined + if (captureOutput === true) { + return { stdout: true, stderr: true, ignore: DEFAULT_CAPTURE_OUTPUT_IGNORE } + } + return { + stdout: captureOutput.stdout ?? true, + stderr: captureOutput.stderr ?? true, + ignore: captureOutput.ignore ?? DEFAULT_CAPTURE_OUTPUT_IGNORE, + } +} + +function shouldIgnoreCapturedOutput(message: string, ignore: Array): boolean { + return ignore.some((pattern) => { + if (typeof pattern === 'string') return message.includes(pattern) + return pattern.test(message) + }) +} + +/** + * Create Next.js instrumentation hooks (`register`, `onRequestError`). + * + * Load via dynamic `import()` from root `instrumentation.ts` (Node.js runtime only). + * Load via dynamic `import()` from root `instrumentation.ts` with {@link defineNodeInstrumentation}. + */ +export function createInstrumentation(options: InstrumentationOptions = {}): InstrumentationResult { + let registered = false + const captureOutputOptions = resolveCaptureOutputOptions(options.captureOutput) + + function register(): void | Promise { + if (registered) return + registered = true + + return loadLogger().then(({ initLogger, lockLogger, log }) => { + initLogger({ + enabled: options.enabled, + env: { + service: options.service, + ...options.env, + }, + pretty: options.pretty, + silent: options.silent, + sampling: options.sampling, + minLevel: options.minLevel, + stringify: options.stringify, + drain: options.drain, + }) + lockLogger() + + if (captureOutputOptions && process.env.NEXT_RUNTIME === 'nodejs') { + patchOutput(captureOutputOptions, log) + } + }) + } + + function patchOutput(config: CaptureOutputOptions, logApi: Log): void { + const proc = globalThis.process + const originalStdoutWrite = proc.stdout.write.bind(proc.stdout) + const originalStderrWrite = proc.stderr.write.bind(proc.stderr) + const ignore = config.ignore ?? DEFAULT_CAPTURE_OUTPUT_IGNORE + + if (config.stdout !== false) { + proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean { + const message = String(chunk).trimEnd() + if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { + patching = true + try { + logApi.info({ source: 'stdout', message }) + } finally { + patching = false + } + } + return originalStdoutWrite(chunk as string, ...args as []) + } as typeof process.stdout.write + } + + if (config.stderr !== false) { + proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { + const message = String(chunk).trimEnd() + if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { + patching = true + try { + logApi.error({ source: 'stderr', message }) + } finally { + patching = false + } + } + return originalStderrWrite(chunk as string, ...args as []) + } as typeof process.stderr.write + } + } + + function onRequestError( + error: { digest?: string } & Error, + request: { path: string; method: string; headers: Record }, + context: { routerKind: string; routePath: string; routeType: string; renderSource: string }, + ): void | Promise { + return loadLogger().then(({ log }) => { + log.error({ + message: error.message, + digest: error.digest, + stack: error.stack, + path: request.path, + method: request.method, + routerKind: context.routerKind, + routePath: context.routePath, + routeType: context.routeType, + renderSource: context.renderSource, + }) + }) + } + + return { register, onRequestError } +} diff --git a/packages/evlog/src/next/instrumentation-gate.ts b/packages/evlog/src/next/instrumentation-gate.ts new file mode 100644 index 00000000..d0fe3dbb --- /dev/null +++ b/packages/evlog/src/next/instrumentation-gate.ts @@ -0,0 +1,109 @@ +import type { InstrumentationOptions } from './instrumentation-create' + +/** Request payload passed to Next.js `onRequestError` (App Router). */ +export interface NextInstrumentationRequest { + path: string + method: string + headers: Record +} + +/** Routing context passed to Next.js `onRequestError`. */ +export interface NextInstrumentationErrorContext { + routerKind: string + routePath: string + routeType: string + renderSource: string +} + +/** + * What your instrumentation module should export for use with {@link defineNodeInstrumentation} + * (typically the return values of `createInstrumentation()` from `evlog/next/instrumentation/create`). + */ +export interface NodeInstrumentationModule { + register: () => void | Promise + onRequestError: ( + error: { digest?: string } & Error, + request: NextInstrumentationRequest, + context: NextInstrumentationErrorContext, + ) => void | Promise +} + +type CreateInstrumentationModule = typeof import('./instrumentation-create') + +/** @internal Non-literal specifier so Turbopack does not pull the logger into the Edge bundle. */ +const CREATE_ENTRY = ['evlog', 'next', 'instrumentation', 'create'].join('/') + +function importCreateModule(): Promise { + return import(/* webpackIgnore: true */ CREATE_ENTRY) as Promise +} + +function isLoader( + value: (() => Promise) | InstrumentationOptions, +): value is () => Promise { + return typeof value === 'function' +} + +function createOptionsLoader(options: InstrumentationOptions): () => Promise { + return async () => { + const { createInstrumentation } = await importCreateModule() + return createInstrumentation(options) + } +} + +export type NodeInstrumentationHooks = { + register: () => Promise + onRequestError: ( + error: { digest?: string } & Error, + request: NextInstrumentationRequest, + context: NextInstrumentationErrorContext, + ) => Promise +} + +/** Options for {@link defineNodeInstrumentation} or a custom Node-only module loader. */ +export type DefineNodeInstrumentationInput = + | InstrumentationOptions + | (() => Promise) + +/** + * Root `instrumentation.ts` entry: load evlog only in the Node.js runtime so Edge bundles stay clean. + * Caches the dynamic `import()` so `register` and repeated `onRequestError` share one module instance. + * + * @example + * ```ts + * // instrumentation.ts + * import { defineNodeInstrumentation } from 'evlog/next/instrumentation' + * + * export const { register, onRequestError } = defineNodeInstrumentation({ + * service: 'my-app', + * captureOutput: true, + * }) + * ``` + */ +export function defineNodeInstrumentation( + loaderOrOptions: DefineNodeInstrumentationInput, +): NodeInstrumentationHooks { + const loader = isLoader(loaderOrOptions) ? loaderOrOptions : createOptionsLoader(loaderOrOptions) + let cached: Promise | undefined + + function load(): Promise { + cached ??= loader() + return cached + } + + return { + async register() { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + const mod = await load() + await mod.register() + }, + async onRequestError( + error: { digest?: string } & Error, + request: NextInstrumentationRequest, + context: NextInstrumentationErrorContext, + ) { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + const mod = await load() + await mod.onRequestError(error, request, context) + }, + } +} diff --git a/packages/evlog/src/next/instrumentation.ts b/packages/evlog/src/next/instrumentation.ts index 032738ac..762c2a57 100644 --- a/packages/evlog/src/next/instrumentation.ts +++ b/packages/evlog/src/next/instrumentation.ts @@ -1,181 +1,19 @@ -import type { DrainContext, EnvironmentContext, LogLevel, SamplingConfig } from '../types' -import { initLogger, log, lockLogger } from '../logger' - -/** Request payload passed to Next.js `onRequestError` (App Router). */ -export interface NextInstrumentationRequest { - path: string - method: string - headers: Record -} - -/** Routing context passed to Next.js `onRequestError`. */ -export interface NextInstrumentationErrorContext { - routerKind: string - routePath: string - routeType: string - renderSource: string -} - -/** - * What `lib/evlog.ts` should export for use with {@link defineNodeInstrumentation} - * (typically the return values of `createInstrumentation()`). - */ -export interface NodeInstrumentationModule { - register: () => void | Promise - onRequestError: ( - error: { digest?: string } & Error, - request: NextInstrumentationRequest, - context: NextInstrumentationErrorContext, - ) => void | Promise -} - /** - * Root `instrumentation.ts` entry: load your real config only in the Node.js runtime so Edge - * bundles never pull Node-only drains/adapters. Caches the dynamic `import()` so `register` and - * repeated `onRequestError` calls share one module instance (avoids re-importing on every error). + * Edge-safe Next.js instrumentation gate — no logger imports. * - * @example - * ```ts - * // instrumentation.ts - * import { defineNodeInstrumentation } from 'evlog/next/instrumentation' - * - * export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog')) - * ``` + * - Root `instrumentation.ts`: `defineNodeInstrumentation({ service, ... })` from here. + * - Advanced: `createInstrumentation` from `evlog/next/instrumentation/create`. */ -export function defineNodeInstrumentation(loader: () => Promise) { - let cached: Promise | undefined - - function load(): Promise { - cached ??= loader() - return cached - } - - return { - async register() { - if (process.env.NEXT_RUNTIME !== 'nodejs') return - const mod = await load() - await mod.register() - }, - async onRequestError( - error: { digest?: string } & Error, - request: NextInstrumentationRequest, - context: NextInstrumentationErrorContext, - ) { - if (process.env.NEXT_RUNTIME !== 'nodejs') return - const mod = await load() - await mod.onRequestError(error, request, context) - }, - } -} - -export interface InstrumentationOptions { - /** Enable or disable all logging globally. @default true */ - enabled?: boolean - /** Service name for all logged events. */ - service?: string - /** Environment context overrides. */ - env?: Partial - /** Enable pretty printing. @default true in development */ - pretty?: boolean - /** Suppress built-in console output. @default false */ - silent?: boolean - /** Sampling configuration for filtering logs. */ - sampling?: SamplingConfig - /** Minimum severity for the global `log` API. @default 'debug' */ - minLevel?: LogLevel - /** When pretty is disabled, emit JSON strings or raw objects. @default true */ - stringify?: boolean - /** Drain callback called with every emitted event. */ - drain?: (ctx: DrainContext) => void | Promise - /** Capture stdout/stderr output as log events (Node.js only). */ - captureOutput?: boolean -} - -interface InstrumentationResult { - register: () => void - onRequestError: ( - error: { digest?: string } & Error, - request: NextInstrumentationRequest, - context: NextInstrumentationErrorContext, - ) => void -} - -let patching = false - -export function createInstrumentation(options: InstrumentationOptions = {}): InstrumentationResult { - let registered = false - - function register(): void { - if (registered) return - registered = true - - initLogger({ - enabled: options.enabled, - env: { - service: options.service, - ...options.env, - }, - pretty: options.pretty, - silent: options.silent, - sampling: options.sampling, - minLevel: options.minLevel, - stringify: options.stringify, - drain: options.drain, - }) - lockLogger() - - if (options.captureOutput && process.env.NEXT_RUNTIME === 'nodejs') { - patchOutput() - } - } - - function patchOutput(): void { - const proc = globalThis.process - const originalStdoutWrite = proc.stdout.write.bind(proc.stdout) - const originalStderrWrite = proc.stderr.write.bind(proc.stderr) - - proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean { - if (!patching) { - patching = true - try { - log.info({ source: 'stdout', message: String(chunk).trimEnd() }) - } finally { - patching = false - } - } - return originalStdoutWrite(chunk as string, ...args as []) - } as typeof process.stdout.write - - proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { - if (!patching) { - patching = true - try { - log.error({ source: 'stderr', message: String(chunk).trimEnd() }) - } finally { - patching = false - } - } - return originalStderrWrite(chunk as string, ...args as []) - } as typeof process.stderr.write - } - - function onRequestError( - error: { digest?: string } & Error, - request: { path: string; method: string; headers: Record }, - context: { routerKind: string; routePath: string; routeType: string; renderSource: string }, - ): void { - log.error({ - message: error.message, - digest: error.digest, - stack: error.stack, - path: request.path, - method: request.method, - routerKind: context.routerKind, - routePath: context.routePath, - routeType: context.routeType, - renderSource: context.renderSource, - }) - } - - return { register, onRequestError } -} +export { + defineNodeInstrumentation, + type DefineNodeInstrumentationInput, + type NextInstrumentationErrorContext, + type NextInstrumentationRequest, + type NodeInstrumentationHooks, + type NodeInstrumentationModule, +} from './instrumentation-gate' + +export type { + CaptureOutputOptions, + InstrumentationOptions, +} from './instrumentation-create' diff --git a/packages/evlog/src/next/stream.ts b/packages/evlog/src/next/stream.ts index 1a2b7045..44157c78 100644 --- a/packages/evlog/src/next/stream.ts +++ b/packages/evlog/src/next/stream.ts @@ -30,7 +30,7 @@ import { startStreamServer, type StreamServerOptions } from '../stream' import type { DrainContext } from '../types' -import { createInstrumentation, type InstrumentationOptions } from './instrumentation' +import { createInstrumentation, type InstrumentationOptions } from './instrumentation-create' export interface StreamedInstrumentationOptions extends InstrumentationOptions { /** diff --git a/packages/evlog/test/adapters/fs.test.ts b/packages/evlog/test/adapters/fs.test.ts index 6a75c623..74d7c2b9 100644 --- a/packages/evlog/test/adapters/fs.test.ts +++ b/packages/evlog/test/adapters/fs.test.ts @@ -315,5 +315,24 @@ describe('fs adapter', () => { const [filePath] = defined(mockedAppendFile.mock.calls[0], 'appendFile call') expect(filePath).toBe(join('.evlog/logs', '2026-03-14.jsonl')) }) + + it('warns once and skips writes on edge runtime', async () => { + process.env.NEXT_RUNTIME = 'edge' + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + vi.resetModules() + const { createFsDrain: createFsDrainFresh } = await import('../../src/adapters/fs') + const drain = createFsDrainFresh({ dir: '.evlog/logs' }) + + await drain(createDrainContext({ action: 'edge_skip' })) + await drain(createDrainContext({ action: 'edge_skip_again' })) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain('[evlog/fs]') + expect(mockedAppendFile).not.toHaveBeenCalled() + + delete process.env.NEXT_RUNTIME + warnSpy.mockRestore() + }) }) }) diff --git a/packages/evlog/test/next/instrumentation.test.ts b/packages/evlog/test/next/instrumentation.test.ts index 5c412d37..86b35d0e 100644 --- a/packages/evlog/test/next/instrumentation.test.ts +++ b/packages/evlog/test/next/instrumentation.test.ts @@ -5,6 +5,8 @@ vi.mock('next/server', () => ({ after: undefined })) // Spy on initLogger to verify register() calls it correctly const initLoggerSpy = vi.fn() +const logInfoSpy = vi.fn() +const logErrorSpy = vi.fn() vi.mock('../../src/logger', async (importOriginal) => { const mod = await importOriginal() return { @@ -13,6 +15,17 @@ vi.mock('../../src/logger', async (importOriginal) => { initLoggerSpy(...args) return mod.initLogger(...(args as Parameters)) }, + log: { + ...mod.log, + info: (...args: unknown[]) => { + logInfoSpy(...args) + return mod.log.info(...(args as Parameters)) + }, + error: (...args: unknown[]) => { + logErrorSpy(...args) + return mod.log.error(...(args as Parameters)) + }, + }, } }) @@ -32,6 +45,8 @@ describe('createInstrumentation', () => { originalStderrWrite = process.stderr.write originalNextRuntime = process.env.NEXT_RUNTIME initLoggerSpy.mockClear() + logInfoSpy.mockClear() + logErrorSpy.mockClear() }) afterEach(() => { @@ -48,10 +63,14 @@ describe('createInstrumentation', () => { }) async function loadModule() { - const mod = await import('../../src/next/instrumentation') + const mod = await import('../../src/next/instrumentation-create') return mod.createInstrumentation } + async function runRegister(register: () => void | Promise) { + await register() + } + it('register() calls initLogger() with correct config', async () => { const createInstrumentation = await loadModule() const drainMock = vi.fn() @@ -64,7 +83,7 @@ describe('createInstrumentation', () => { stringify: false, }) - register() + await runRegister(register) expect(initLoggerSpy).toHaveBeenCalledTimes(1) const [[config]] = initLoggerSpy.mock.calls @@ -85,7 +104,7 @@ describe('createInstrumentation', () => { pretty: false, }) - register() + await runRegister(register) expect(process.stdout.write).not.toBe(originalStdoutWrite) expect(process.stderr.write).not.toBe(originalStderrWrite) @@ -97,7 +116,7 @@ describe('createInstrumentation', () => { const { register } = createInstrumentation({ pretty: false }) - register() + await runRegister(register) expect(process.stdout.write).toBe(originalStdoutWrite) expect(process.stderr.write).toBe(originalStderrWrite) @@ -112,7 +131,7 @@ describe('createInstrumentation', () => { pretty: false, }) - register() + await runRegister(register) expect(process.stdout.write).toBe(originalStdoutWrite) expect(process.stderr.write).toBe(originalStderrWrite) @@ -126,7 +145,7 @@ describe('createInstrumentation', () => { drain: drainMock, }) - register() + await runRegister(register) const error = Object.assign(new Error('Something broke'), { digest: 'abc123' }) const request = { path: '/api/checkout', method: 'POST', headers: {} } @@ -137,7 +156,7 @@ describe('createInstrumentation', () => { renderSource: 'react-server-components', } - onRequestError(error, request, context) + await onRequestError(error, request, context) expect(consoleErrorSpy).toHaveBeenCalled() const [[output]] = consoleErrorSpy.mock.calls @@ -163,10 +182,10 @@ describe('createInstrumentation', () => { drain: drainMock, }) - register() + await runRegister(register) const error = Object.assign(new Error('fail'), { digest: 'x' }) - onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { + await onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { routerKind: 'App Router', routePath: '/test', routeType: 'page', @@ -191,7 +210,7 @@ describe('createInstrumentation', () => { pretty: true, }) - register() + await runRegister(register) // This should NOT cause infinite recursion: // stdout.write -> log.info -> pretty print -> console.log -> stdout.write -> GUARD stops @@ -203,8 +222,8 @@ describe('createInstrumentation', () => { it('register() is idempotent — second call is a no-op', async () => { const createInstrumentation = await loadModule() const { register } = createInstrumentation({ pretty: false }) - register() - register() + await runRegister(register) + await runRegister(register) expect(initLoggerSpy).toHaveBeenCalledTimes(1) }) @@ -215,14 +234,14 @@ describe('createInstrumentation', () => { pretty: false, }) - register() + await runRegister(register) expect(initLoggerSpy).toHaveBeenCalledTimes(1) const [[config]] = initLoggerSpy.mock.calls expect(config.enabled).toBe(false) const error = Object.assign(new Error('fail'), { digest: 'x' }) - onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { + await onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { routerKind: 'App Router', routePath: '/test', routeType: 'route', @@ -235,7 +254,7 @@ describe('createInstrumentation', () => { it('createInstrumentation() with default options', async () => { const createInstrumentation = await loadModule() const { register } = createInstrumentation() - expect(() => register()).not.toThrow() + await expect(runRegister(register)).resolves.toBeUndefined() expect(initLoggerSpy).toHaveBeenCalledTimes(1) }) @@ -243,10 +262,10 @@ describe('createInstrumentation', () => { const createInstrumentation = await loadModule() const { register, onRequestError } = createInstrumentation({ pretty: false }) - register() + await runRegister(register) const error = new Error('fail') as { digest?: string } & Error - onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { + await onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { routerKind: 'App Router', routePath: '/test', routeType: 'route', @@ -268,11 +287,88 @@ describe('createInstrumentation', () => { pretty: false, }) - register() + await runRegister(register) expect(process.stdout.write).toBe(originalStdoutWrite) expect(process.stderr.write).toBe(originalStderrWrite) }) + + it('captureOutput ignores default Next.js edge bundler warnings on stderr', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ + captureOutput: true, + pretty: false, + silent: true, + }) + + await runRegister(register) + + process.stderr.write('A Node.js module is loaded in the Edge Runtime: node-module-in-edge-runtime\n') + expect(logErrorSpy).not.toHaveBeenCalled() + + process.stderr.write('real application stderr\n') + expect(logErrorSpy).toHaveBeenCalledTimes(1) + expect(logErrorSpy.mock.calls[0]?.[0]).toMatchObject({ + source: 'stderr', + message: 'real application stderr', + }) + }) + + it('captureOutput object can disable stdout while keeping stderr', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ + captureOutput: { stdout: false, stderr: true }, + pretty: false, + silent: true, + }) + + await runRegister(register) + + expect(process.stdout.write).toBe(originalStdoutWrite) + expect(process.stderr.write).not.toBe(originalStderrWrite) + + process.stderr.write('stderr only\n') + expect(logErrorSpy).toHaveBeenCalledTimes(1) + expect(logInfoSpy).not.toHaveBeenCalled() + }) + + it('captureOutput custom ignore replaces the default filter', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ + captureOutput: { ignore: ['benign warning'] }, + pretty: false, + silent: true, + }) + + await runRegister(register) + + process.stderr.write('node-module-in-edge-runtime\n') + expect(logErrorSpy).toHaveBeenCalledTimes(1) + + logErrorSpy.mockClear() + process.stderr.write('benign warning from dependency\n') + expect(logErrorSpy).not.toHaveBeenCalled() + }) +}) + +describe('instrumentation entry split', () => { + it('gate entry exports defineNodeInstrumentation only (Edge-safe)', async () => { + const gate = await import('../../src/next/instrumentation') + expect(gate.defineNodeInstrumentation).toBeTypeOf('function') + expect('createInstrumentation' in gate).toBe(false) + }) + + it('create entry exports createInstrumentation and captureOutput types', async () => { + const create = await import('../../src/next/instrumentation-create') + expect(create.createInstrumentation).toBeTypeOf('function') + expect(create.DEFAULT_CAPTURE_OUTPUT_IGNORE.length).toBeGreaterThan(0) + }) }) describe('defineNodeInstrumentation', () => { @@ -291,6 +387,13 @@ describe('defineNodeInstrumentation', () => { } }) + it('options overload returns register and onRequestError hooks', async () => { + const { defineNodeInstrumentation } = await import('../../src/next/instrumentation') + const hooks = defineNodeInstrumentation({ service: 'test-app' }) + expect(hooks.register).toBeTypeOf('function') + expect(hooks.onRequestError).toBeTypeOf('function') + }) + it('does not call loader when NEXT_RUNTIME is edge', async () => { process.env.NEXT_RUNTIME = 'edge' const loader = vi.fn().mockResolvedValue({ diff --git a/packages/evlog/test/next/stream.test.ts b/packages/evlog/test/next/stream.test.ts index f47c1cf6..a7c28256 100644 --- a/packages/evlog/test/next/stream.test.ts +++ b/packages/evlog/test/next/stream.test.ts @@ -13,7 +13,7 @@ const { startStreamServer, innerInit, innerOnRequestError, createInstrumentation }) vi.mock('../../src/stream', () => ({ startStreamServer })) -vi.mock('../../src/next/instrumentation', () => ({ createInstrumentation })) +vi.mock('../../src/next/instrumentation-create', () => ({ createInstrumentation })) describe('defineStreamedInstrumentation', () => { beforeEach(() => { diff --git a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap index 5bcfde30..81dd5cbd 100644 --- a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap +++ b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap @@ -149,9 +149,12 @@ exports[`public API surface > matches snapshot for all subpath exports 1`] = ` "setMinLevel", ], "./next/instrumentation": [ - "createInstrumentation", "defineNodeInstrumentation", ], + "./next/instrumentation/create": [ + "DEFAULT_CAPTURE_OUTPUT_IGNORE", + "createInstrumentation", + ], "./next/stream": [ "defineStreamedInstrumentation", ], diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index 1b7744a0..02de547a 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'next/index': 'src/next/index.ts', 'next/client': 'src/next/client.ts', 'next/instrumentation': 'src/next/instrumentation.ts', + 'next/instrumentation/create': 'src/next/instrumentation-create.ts', 'next/stream': 'src/next/stream.ts', 'hono/index': 'src/hono/index.ts', 'express/index': 'src/express/index.ts', From 7df4ec8c591dacbaadd5c3647004453aa626dd36 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 11 Jun 2026 21:54:42 +0100 Subject: [PATCH 2/3] fix(next): address coderabbit review on instrumentation --- .../3.integrate/adapters/self-hosted/01.fs.md | 2 +- .../evlog/src/next/instrumentation-create.ts | 26 +++++++++++++---- .../evlog/src/next/instrumentation-gate.ts | 9 ++++++ packages/evlog/src/next/stream.ts | 2 +- packages/evlog/test/adapters/fs.test.ts | 28 ++++++++++--------- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md b/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md index 16636f39..a24bc915 100644 --- a/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md +++ b/apps/docs/content/3.integrate/adapters/self-hosted/01.fs.md @@ -82,7 +82,7 @@ export const { withEvlog, useLogger, log, createError } = createEvlog({ ``` ::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 a HTTP adapter for Edge routes. +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' diff --git a/packages/evlog/src/next/instrumentation-create.ts b/packages/evlog/src/next/instrumentation-create.ts index b2d09d63..5d7c406a 100644 --- a/packages/evlog/src/next/instrumentation-create.ts +++ b/packages/evlog/src/next/instrumentation-create.ts @@ -33,6 +33,10 @@ export const DEFAULT_CAPTURE_OUTPUT_IGNORE: Array = [ 'Import trace:', ] +/** + * Configuration for {@link createInstrumentation} and {@link defineNodeInstrumentation}. + * Controls global logger options and optional stdout/stderr capture (Node.js only). + */ export interface InstrumentationOptions { /** Enable or disable all logging globally. @default true */ enabled?: boolean @@ -67,6 +71,8 @@ interface InstrumentationResult { let patching = false let loggerPromise: Promise | undefined +let stdoutPatched = false +let stderrPatched = false function loadLogger(): Promise { loggerPromise ??= import('../logger') @@ -102,13 +108,14 @@ function shouldIgnoreCapturedOutput(message: string, ignore: Array | undefined const captureOutputOptions = resolveCaptureOutputOptions(options.captureOutput) function register(): void | Promise { if (registered) return - registered = true + if (registerPromise) return registerPromise - return loadLogger().then(({ initLogger, lockLogger, log }) => { + registerPromise = loadLogger().then(({ initLogger, lockLogger, log }) => { initLogger({ enabled: options.enabled, env: { @@ -127,16 +134,21 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins if (captureOutputOptions && process.env.NEXT_RUNTIME === 'nodejs') { patchOutput(captureOutputOptions, log) } + registered = true + }).catch((error) => { + registerPromise = undefined + throw error }) + return registerPromise } function patchOutput(config: CaptureOutputOptions, logApi: Log): void { const proc = globalThis.process - const originalStdoutWrite = proc.stdout.write.bind(proc.stdout) - const originalStderrWrite = proc.stderr.write.bind(proc.stderr) const ignore = config.ignore ?? DEFAULT_CAPTURE_OUTPUT_IGNORE - if (config.stdout !== false) { + if (config.stdout !== false && !stdoutPatched) { + const originalStdoutWrite = proc.stdout.write.bind(proc.stdout) + stdoutPatched = true proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean { const message = String(chunk).trimEnd() if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { @@ -151,7 +163,9 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins } as typeof process.stdout.write } - if (config.stderr !== false) { + if (config.stderr !== false && !stderrPatched) { + const originalStderrWrite = proc.stderr.write.bind(proc.stderr) + stderrPatched = true proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { const message = String(chunk).trimEnd() if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { diff --git a/packages/evlog/src/next/instrumentation-gate.ts b/packages/evlog/src/next/instrumentation-gate.ts index d0fe3dbb..04df303b 100644 --- a/packages/evlog/src/next/instrumentation-gate.ts +++ b/packages/evlog/src/next/instrumentation-gate.ts @@ -50,8 +50,17 @@ function createOptionsLoader(options: InstrumentationOptions): () => Promise Promise + /** Next.js global request error handler (Node.js runtime only). */ onRequestError: ( error: { digest?: string } & Error, request: NextInstrumentationRequest, diff --git a/packages/evlog/src/next/stream.ts b/packages/evlog/src/next/stream.ts index 44157c78..0c6e6a07 100644 --- a/packages/evlog/src/next/stream.ts +++ b/packages/evlog/src/next/stream.ts @@ -80,7 +80,7 @@ export function defineStreamedInstrumentation(options: StreamedInstrumentationOp const composedDrain = composeDrains(userDrain, serverDrain) const inner = createInstrumentation({ ...rest, drain: composedDrain }) - inner.register() + await inner.register() } // We intentionally instantiate a "zero-time" inner just for onRequestError — diff --git a/packages/evlog/test/adapters/fs.test.ts b/packages/evlog/test/adapters/fs.test.ts index 74d7c2b9..de9d26d5 100644 --- a/packages/evlog/test/adapters/fs.test.ts +++ b/packages/evlog/test/adapters/fs.test.ts @@ -320,19 +320,21 @@ describe('fs adapter', () => { process.env.NEXT_RUNTIME = 'edge' const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - vi.resetModules() - const { createFsDrain: createFsDrainFresh } = await import('../../src/adapters/fs') - const drain = createFsDrainFresh({ dir: '.evlog/logs' }) - - await drain(createDrainContext({ action: 'edge_skip' })) - await drain(createDrainContext({ action: 'edge_skip_again' })) - - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy.mock.calls[0]?.[0]).toContain('[evlog/fs]') - expect(mockedAppendFile).not.toHaveBeenCalled() - - delete process.env.NEXT_RUNTIME - warnSpy.mockRestore() + try { + vi.resetModules() + const { createFsDrain: createFsDrainFresh } = await import('../../src/adapters/fs') + const drain = createFsDrainFresh({ dir: '.evlog/logs' }) + + await drain(createDrainContext({ action: 'edge_skip' })) + await drain(createDrainContext({ action: 'edge_skip_again' })) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain('[evlog/fs]') + expect(mockedAppendFile).not.toHaveBeenCalled() + } finally { + delete process.env.NEXT_RUNTIME + warnSpy.mockRestore() + } }) }) }) From 05900b5521869a60448d011b1c4a17e2d1578e2f Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 11 Jun 2026 22:08:29 +0100 Subject: [PATCH 3/3] fix(next): let captureOutput wrappers use latest active config --- .../evlog/src/next/instrumentation-create.ts | 106 +++++++++++------- .../evlog/test/next/instrumentation.test.ts | 26 +++++ 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/packages/evlog/src/next/instrumentation-create.ts b/packages/evlog/src/next/instrumentation-create.ts index 5d7c406a..6fc016bd 100644 --- a/packages/evlog/src/next/instrumentation-create.ts +++ b/packages/evlog/src/next/instrumentation-create.ts @@ -73,6 +73,14 @@ let patching = false let loggerPromise: Promise | undefined let stdoutPatched = false let stderrPatched = false +let activeCaptureOutput: + | { + log: Log + ignore: Array + stdout: boolean + stderr: boolean + } + | undefined function loadLogger(): Promise { loggerPromise ??= import('../logger') @@ -100,6 +108,63 @@ function shouldIgnoreCapturedOutput(message: string, ignore: Array 0 + && active?.stdout + && !shouldIgnoreCapturedOutput(message, active.ignore) + ) { + patching = true + try { + active.log.info({ source: 'stdout', message }) + } finally { + patching = false + } + } + return originalStdoutWrite(chunk as string, ...args as []) + } as typeof process.stdout.write + } + + if (activeCaptureOutput.stderr && !stderrPatched) { + const originalStderrWrite = proc.stderr.write.bind(proc.stderr) + stderrPatched = true + proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { + const message = String(chunk).trimEnd() + const active = activeCaptureOutput + if ( + !patching + && message.length > 0 + && active?.stderr + && !shouldIgnoreCapturedOutput(message, active.ignore) + ) { + patching = true + try { + active.log.error({ source: 'stderr', message }) + } finally { + patching = false + } + } + return originalStderrWrite(chunk as string, ...args as []) + } as typeof process.stderr.write + } +} + /** * Create Next.js instrumentation hooks (`register`, `onRequestError`). * @@ -132,7 +197,7 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins lockLogger() if (captureOutputOptions && process.env.NEXT_RUNTIME === 'nodejs') { - patchOutput(captureOutputOptions, log) + applyCaptureOutput(captureOutputOptions, log) } registered = true }).catch((error) => { @@ -142,45 +207,6 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins return registerPromise } - function patchOutput(config: CaptureOutputOptions, logApi: Log): void { - const proc = globalThis.process - const ignore = config.ignore ?? DEFAULT_CAPTURE_OUTPUT_IGNORE - - if (config.stdout !== false && !stdoutPatched) { - const originalStdoutWrite = proc.stdout.write.bind(proc.stdout) - stdoutPatched = true - proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean { - const message = String(chunk).trimEnd() - if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { - patching = true - try { - logApi.info({ source: 'stdout', message }) - } finally { - patching = false - } - } - return originalStdoutWrite(chunk as string, ...args as []) - } as typeof process.stdout.write - } - - if (config.stderr !== false && !stderrPatched) { - const originalStderrWrite = proc.stderr.write.bind(proc.stderr) - stderrPatched = true - proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { - const message = String(chunk).trimEnd() - if (!patching && message.length > 0 && !shouldIgnoreCapturedOutput(message, ignore)) { - patching = true - try { - logApi.error({ source: 'stderr', message }) - } finally { - patching = false - } - } - return originalStderrWrite(chunk as string, ...args as []) - } as typeof process.stderr.write - } - } - function onRequestError( error: { digest?: string } & Error, request: { path: string; method: string; headers: Record }, diff --git a/packages/evlog/test/next/instrumentation.test.ts b/packages/evlog/test/next/instrumentation.test.ts index 86b35d0e..33f4773f 100644 --- a/packages/evlog/test/next/instrumentation.test.ts +++ b/packages/evlog/test/next/instrumentation.test.ts @@ -355,6 +355,32 @@ describe('createInstrumentation', () => { process.stderr.write('benign warning from dependency\n') expect(logErrorSpy).not.toHaveBeenCalled() }) + + it('captureOutput uses the latest registration filters without re-wrapping', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const first = createInstrumentation({ + captureOutput: { ignore: ['stale-filter'] }, + pretty: false, + silent: true, + }) + const second = createInstrumentation({ + captureOutput: { ignore: ['active-filter'] }, + pretty: false, + silent: true, + }) + + await runRegister(first.register) + await runRegister(second.register) + + process.stderr.write('matched active-filter\n') + expect(logErrorSpy).not.toHaveBeenCalled() + + process.stderr.write('unfiltered stderr\n') + expect(logErrorSpy).toHaveBeenCalledTimes(1) + expect(logErrorSpy).toHaveBeenCalledWith({ source: 'stderr', message: 'unfiltered stderr' }) + }) }) describe('instrumentation entry split', () => {