From 47c9031a81696408e80c9e5b6a7b19dfbe8de38c Mon Sep 17 00:00:00 2001 From: Thanh Doan Date: Sun, 15 Mar 2026 17:01:48 +0700 Subject: [PATCH 1/3] feat: add instrumentation API for next package --- apps/docs/content/2.frameworks/02.nextjs.md | 66 +++++ apps/next-playground/instrumentation.ts | 17 ++ apps/next-playground/lib/evlog.ts | 8 + examples/nextjs/instrumentation.ts | 17 ++ examples/nextjs/lib/evlog.ts | 6 + packages/evlog/package.json | 8 + packages/evlog/src/logger.ts | 17 ++ packages/evlog/src/next/handler.ts | 6 +- packages/evlog/src/next/instrumentation.ts | 111 +++++++ .../evlog/test/next/instrumentation.test.ts | 276 ++++++++++++++++++ packages/evlog/tsdown.config.ts | 1 + 11 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 apps/next-playground/instrumentation.ts create mode 100644 examples/nextjs/instrumentation.ts create mode 100644 packages/evlog/src/next/instrumentation.ts create mode 100644 packages/evlog/test/next/instrumentation.test.ts diff --git a/apps/docs/content/2.frameworks/02.nextjs.md b/apps/docs/content/2.frameworks/02.nextjs.md index 71685a13..0252912d 100644 --- a/apps/docs/content/2.frameworks/02.nextjs.md +++ b/apps/docs/content/2.frameworks/02.nextjs.md @@ -63,6 +63,72 @@ 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. Use dynamic imports gated behind `NEXT_RUNTIME` to avoid Edge bundling errors from Node.js-only modules (fs drain, adapters, etc.): + +```typescript [instrumentation.ts] +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { register } = await import('./lib/evlog') + register() + } +} + +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') + onRequestError(error, request, context) + } +} +``` + +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: diff --git a/apps/next-playground/instrumentation.ts b/apps/next-playground/instrumentation.ts new file mode 100644 index 00000000..c5320f0e --- /dev/null +++ b/apps/next-playground/instrumentation.ts @@ -0,0 +1,17 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { register } = await import('./lib/evlog') + register() + } +} + +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') + onRequestError(error, request, context) + } +} diff --git a/apps/next-playground/lib/evlog.ts b/apps/next-playground/lib/evlog.ts index 0c5cdd0b..5c1cfee2 100644 --- a/apps/next-playground/lib/evlog.ts +++ b/apps/next-playground/lib/evlog.ts @@ -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()] @@ -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: { diff --git a/examples/nextjs/instrumentation.ts b/examples/nextjs/instrumentation.ts new file mode 100644 index 00000000..c5320f0e --- /dev/null +++ b/examples/nextjs/instrumentation.ts @@ -0,0 +1,17 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { register } = await import('./lib/evlog') + register() + } +} + +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') + onRequestError(error, request, context) + } +} diff --git a/examples/nextjs/lib/evlog.ts b/examples/nextjs/lib/evlog.ts index caf3fd28..e772a0b6 100644 --- a/examples/nextjs/lib/evlog.ts +++ b/examples/nextjs/lib/evlog.ts @@ -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' @@ -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', diff --git a/packages/evlog/package.json b/packages/evlog/package.json index b620c845..f9b2166a 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -111,6 +111,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", @@ -199,6 +204,9 @@ "next/client": [ "./dist/next/client.d.mts" ], + "next/instrumentation": [ + "./dist/next/instrumentation.d.mts" + ], "hono": [ "./dist/hono/index.d.mts" ], diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index 554fecad..c72586b5 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -35,6 +35,7 @@ let globalStringify = true let globalDrain: ((ctx: DrainContext) => void | Promise) | undefined let globalEnabled = true let globalSilent = false +let _locked = false /** * Initialize the logger with configuration. @@ -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 diff --git a/packages/evlog/src/next/handler.ts b/packages/evlog/src/next/handler.ts index aae600f9..9584cea9 100644 --- a/packages/evlog/src/next/handler.ts +++ b/packages/evlog/src/next/handler.ts @@ -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' @@ -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. diff --git a/packages/evlog/src/next/instrumentation.ts b/packages/evlog/src/next/instrumentation.ts new file mode 100644 index 00000000..9e116ab1 --- /dev/null +++ b/packages/evlog/src/next/instrumentation.ts @@ -0,0 +1,111 @@ +import type { DrainContext, EnvironmentContext, SamplingConfig } from '../types' +import { initLogger, log, _lockLogger } from '../logger' + +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 + /** 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: { path: string; method: string; headers: Record }, + context: { routerKind: string; routePath: string; routeType: string; renderSource: string }, + ) => 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, + 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, ...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, ...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 } +} diff --git a/packages/evlog/test/next/instrumentation.test.ts b/packages/evlog/test/next/instrumentation.test.ts new file mode 100644 index 00000000..86401420 --- /dev/null +++ b/packages/evlog/test/next/instrumentation.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock next/server to prevent import errors +vi.mock('next/server', () => ({ after: undefined })) + +// Spy on initLogger to verify register() calls it correctly +const initLoggerSpy = vi.fn() +vi.mock('../../src/logger', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + initLogger: (...args: unknown[]) => { + initLoggerSpy(...args) + return mod.initLogger(...(args as Parameters)) + }, + } +}) + +describe('createInstrumentation', () => { + let consoleSpy: ReturnType + let consoleErrorSpy: ReturnType + let originalStdoutWrite: typeof process.stdout.write + let originalStderrWrite: typeof process.stderr.write + let originalNextRuntime: string | undefined + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + originalStdoutWrite = process.stdout.write + originalStderrWrite = process.stderr.write + originalNextRuntime = process.env.NEXT_RUNTIME + initLoggerSpy.mockClear() + }) + + afterEach(() => { + process.stdout.write = originalStdoutWrite + process.stderr.write = originalStderrWrite + if (originalNextRuntime === undefined) { + delete process.env.NEXT_RUNTIME + } else { + process.env.NEXT_RUNTIME = originalNextRuntime + } + vi.restoreAllMocks() + // Reset module state between tests so `registered` flag is fresh + vi.resetModules() + }) + + async function loadModule() { + const mod = await import('../../src/next/instrumentation') + return mod.createInstrumentation + } + + it('register() calls initLogger() with correct config', async () => { + const createInstrumentation = await loadModule() + const drainMock = vi.fn() + const { register } = createInstrumentation({ + service: 'my-app', + pretty: false, + silent: true, + drain: drainMock, + sampling: { rates: { info: 50 } }, + stringify: false, + }) + + register() + + expect(initLoggerSpy).toHaveBeenCalledTimes(1) + const [[config]] = initLoggerSpy.mock.calls + expect(config.env.service).toBe('my-app') + expect(config.pretty).toBe(false) + expect(config.silent).toBe(true) + expect(config.drain).toBe(drainMock) + expect(config.sampling).toEqual({ rates: { info: 50 } }) + expect(config.stringify).toBe(false) + }) + + it('register() with captureOutput patches stdout and stderr', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ + captureOutput: true, + pretty: false, + }) + + register() + + expect(process.stdout.write).not.toBe(originalStdoutWrite) + expect(process.stderr.write).not.toBe(originalStderrWrite) + }) + + it('register() without captureOutput does NOT patch stdout/stderr', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ pretty: false }) + + register() + + expect(process.stdout.write).toBe(originalStdoutWrite) + expect(process.stderr.write).toBe(originalStderrWrite) + }) + + it('edge runtime safety: no patching when NEXT_RUNTIME is not nodejs', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'edge' + + const { register } = createInstrumentation({ + captureOutput: true, + pretty: false, + }) + + register() + + expect(process.stdout.write).toBe(originalStdoutWrite) + expect(process.stderr.write).toBe(originalStderrWrite) + }) + + it('onRequestError() emits structured event with correct fields', async () => { + const createInstrumentation = await loadModule() + const drainMock = vi.fn() + const { register, onRequestError } = createInstrumentation({ + pretty: false, + drain: drainMock, + }) + + register() + + const error = Object.assign(new Error('Something broke'), { digest: 'abc123' }) + const request = { path: '/api/checkout', method: 'POST', headers: {} } + const context = { + routerKind: 'App Router', + routePath: '/api/checkout', + routeType: 'route', + renderSource: 'react-server-components', + } + + onRequestError(error, request, context) + + expect(consoleErrorSpy).toHaveBeenCalled() + const [[output]] = consoleErrorSpy.mock.calls + const parsed = JSON.parse(output) + + expect(parsed.level).toBe('error') + expect(parsed.message).toBe('Something broke') + expect(parsed.digest).toBe('abc123') + expect(parsed.stack).toBeDefined() + expect(parsed.path).toBe('/api/checkout') + expect(parsed.method).toBe('POST') + expect(parsed.routerKind).toBe('App Router') + expect(parsed.routePath).toBe('/api/checkout') + expect(parsed.routeType).toBe('route') + expect(parsed.renderSource).toBe('react-server-components') + }) + + it('events go through drain', async () => { + const createInstrumentation = await loadModule() + const drainMock = vi.fn() + const { register, onRequestError } = createInstrumentation({ + pretty: false, + drain: drainMock, + }) + + register() + + const error = Object.assign(new Error('fail'), { digest: 'x' }) + onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { + routerKind: 'App Router', + routePath: '/test', + routeType: 'page', + renderSource: 'react-server-components', + }) + + // Drain is called fire-and-forget via Promise.resolve().catch() + // Give it a tick to resolve + await vi.waitFor(() => expect(drainMock).toHaveBeenCalledTimes(1)) + + const [[drainCtx]] = drainMock.mock.calls + expect(drainCtx.event).toBeDefined() + expect(drainCtx.event.message).toBe('fail') + }) + + it('re-entrancy guard prevents infinite recursion', async () => { + const createInstrumentation = await loadModule() + process.env.NEXT_RUNTIME = 'nodejs' + + const { register } = createInstrumentation({ + captureOutput: true, + pretty: true, + }) + + register() + + // This should NOT cause infinite recursion: + // stdout.write -> log.info -> pretty print -> console.log -> stdout.write -> GUARD stops + expect(() => { + process.stdout.write('trigger\n') + }).not.toThrow() + }) + + it('register() is idempotent — second call is a no-op', async () => { + const createInstrumentation = await loadModule() + const { register } = createInstrumentation({ pretty: false }) + register() + register() + expect(initLoggerSpy).toHaveBeenCalledTimes(1) + }) + + it('createInstrumentation() with enabled: false', async () => { + const createInstrumentation = await loadModule() + const { register, onRequestError } = createInstrumentation({ + enabled: false, + pretty: false, + }) + + 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: {} }, { + routerKind: 'App Router', + routePath: '/test', + routeType: 'route', + renderSource: 'react-server-components', + }) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + }) + + it('createInstrumentation() with default options', async () => { + const createInstrumentation = await loadModule() + const { register } = createInstrumentation() + expect(() => register()).not.toThrow() + expect(initLoggerSpy).toHaveBeenCalledTimes(1) + }) + + it('onRequestError() with undefined digest', async () => { + const createInstrumentation = await loadModule() + const { register, onRequestError } = createInstrumentation({ pretty: false }) + + register() + + const error = new Error('fail') as { digest?: string } & Error + onRequestError(error, { path: '/test', method: 'GET', headers: {} }, { + routerKind: 'App Router', + routePath: '/test', + routeType: 'route', + renderSource: 'react-server-components', + }) + + expect(consoleErrorSpy).toHaveBeenCalled() + const [[output]] = consoleErrorSpy.mock.calls + const parsed = JSON.parse(output) + expect(parsed.digest).toBeUndefined() + }) + + it('captureOutput with NEXT_RUNTIME undefined', async () => { + const createInstrumentation = await loadModule() + delete process.env.NEXT_RUNTIME + + const { register } = createInstrumentation({ + captureOutput: true, + pretty: false, + }) + + register() + + expect(process.stdout.write).toBe(originalStdoutWrite) + expect(process.stderr.write).toBe(originalStderrWrite) + }) +}) diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index cc912c59..ad0e897f 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ 'browser': 'src/browser.ts', 'next/index': 'src/next/index.ts', 'next/client': 'src/next/client.ts', + 'next/instrumentation': 'src/next/instrumentation.ts', 'hono/index': 'src/hono/index.ts', 'express/index': 'src/express/index.ts', 'elysia/index': 'src/elysia/index.ts', From 33e17854a1ea4746049409ee9da8e6247f5867d5 Mon Sep 17 00:00:00 2001 From: Thanh Doan Date: Tue, 17 Mar 2026 17:40:41 +0700 Subject: [PATCH 2/3] fix: rename _lockLogger to lockLogger and fix lint errors Rename underscore-prefixed function to satisfy camelCase naming convention rule. Also fix space-before-function-paren in instrumentation.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/evlog/src/logger.ts | 2 +- packages/evlog/src/next/instrumentation.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index c72586b5..35319ef2 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -76,7 +76,7 @@ export function isEnabled(): boolean { * Called by instrumentation register() after setting up the logger with drain. * Prevents configureHandler() from overwriting the drain config. */ -export function _lockLogger(): void { +export function lockLogger(): void { _locked = true } diff --git a/packages/evlog/src/next/instrumentation.ts b/packages/evlog/src/next/instrumentation.ts index 9e116ab1..fc5db20d 100644 --- a/packages/evlog/src/next/instrumentation.ts +++ b/packages/evlog/src/next/instrumentation.ts @@ -1,5 +1,5 @@ import type { DrainContext, EnvironmentContext, SamplingConfig } from '../types' -import { initLogger, log, _lockLogger } from '../logger' +import { initLogger, log, lockLogger } from '../logger' export interface InstrumentationOptions { /** Enable or disable all logging globally. @default true */ @@ -52,7 +52,7 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins stringify: options.stringify, drain: options.drain, }) - _lockLogger() + lockLogger() if (options.captureOutput && process.env.NEXT_RUNTIME === 'nodejs') { patchOutput() @@ -64,7 +64,7 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins 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 { + proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean { if (!patching) { patching = true try { @@ -76,7 +76,7 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins return originalStdoutWrite(chunk, ...args as []) } as typeof process.stdout.write - proc.stderr.write = function (chunk: unknown, ...args: unknown[]): boolean { + proc.stderr.write = function(chunk: unknown, ...args: unknown[]): boolean { if (!patching) { patching = true try { From eb6846b4ba46e151053dca8cfccc6e055864d8c0 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Tue, 24 Mar 2026 09:45:41 +0000 Subject: [PATCH 3/3] up --- .../next-define-node-instrumentation.md | 5 ++ apps/docs/content/2.frameworks/02.nextjs.md | 48 +++++++++++- apps/next-playground/instrumentation.ts | 18 +---- examples/nextjs/instrumentation.ts | 18 +---- packages/evlog/src/next/instrumentation.ts | 71 +++++++++++++++++- .../evlog/test/next/instrumentation.test.ts | 75 +++++++++++++++++++ skills/review-logging-patterns/SKILL.md | 6 +- 7 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 .changeset/next-define-node-instrumentation.md diff --git a/.changeset/next-define-node-instrumentation.md b/.changeset/next-define-node-instrumentation.md new file mode 100644 index 00000000..c399fd78 --- /dev/null +++ b/.changeset/next-define-node-instrumentation.md @@ -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. diff --git a/apps/docs/content/2.frameworks/02.nextjs.md b/apps/docs/content/2.frameworks/02.nextjs.md index 0252912d..e4815941 100644 --- a/apps/docs/content/2.frameworks/02.nextjs.md +++ b/apps/docs/content/2.frameworks/02.nextjs.md @@ -90,13 +90,23 @@ export const { register, onRequestError } = createInstrumentation({ ### 2. Wire up instrumentation.ts -Next.js evaluates `instrumentation.ts` in both Node.js and Edge runtimes. Use dynamic imports gated behind `NEXT_RUNTIME` to avoid Edge bundling errors from Node.js-only modules (fs drain, adapters, etc.): +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') - register() + await register() } } @@ -107,11 +117,43 @@ export async function onRequestError( ) { if (process.env.NEXT_RUNTIME === 'nodejs') { const { onRequestError } = await import('./lib/evlog') - onRequestError(error, request, context) + 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 }, + 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. diff --git a/apps/next-playground/instrumentation.ts b/apps/next-playground/instrumentation.ts index c5320f0e..52db6d81 100644 --- a/apps/next-playground/instrumentation.ts +++ b/apps/next-playground/instrumentation.ts @@ -1,17 +1,3 @@ -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - const { register } = await import('./lib/evlog') - register() - } -} +import { defineNodeInstrumentation } from 'evlog/next/instrumentation' -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') - onRequestError(error, request, context) - } -} +export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog')) diff --git a/examples/nextjs/instrumentation.ts b/examples/nextjs/instrumentation.ts index c5320f0e..52db6d81 100644 --- a/examples/nextjs/instrumentation.ts +++ b/examples/nextjs/instrumentation.ts @@ -1,17 +1,3 @@ -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - const { register } = await import('./lib/evlog') - register() - } -} +import { defineNodeInstrumentation } from 'evlog/next/instrumentation' -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') - onRequestError(error, request, context) - } -} +export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog')) diff --git a/packages/evlog/src/next/instrumentation.ts b/packages/evlog/src/next/instrumentation.ts index fc5db20d..562d6caf 100644 --- a/packages/evlog/src/next/instrumentation.ts +++ b/packages/evlog/src/next/instrumentation.ts @@ -1,6 +1,73 @@ import type { DrainContext, EnvironmentContext, 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). + * + * @example + * ```ts + * // instrumentation.ts + * import { defineNodeInstrumentation } from 'evlog/next/instrumentation' + * + * export const { register, onRequestError } = defineNodeInstrumentation(() => import('./lib/evlog')) + * ``` + */ +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 @@ -26,8 +93,8 @@ interface InstrumentationResult { register: () => void onRequestError: ( error: { digest?: string } & Error, - request: { path: string; method: string; headers: Record }, - context: { routerKind: string; routePath: string; routeType: string; renderSource: string }, + request: NextInstrumentationRequest, + context: NextInstrumentationErrorContext, ) => void } diff --git a/packages/evlog/test/next/instrumentation.test.ts b/packages/evlog/test/next/instrumentation.test.ts index 86401420..5c412d37 100644 --- a/packages/evlog/test/next/instrumentation.test.ts +++ b/packages/evlog/test/next/instrumentation.test.ts @@ -274,3 +274,78 @@ describe('createInstrumentation', () => { expect(process.stderr.write).toBe(originalStderrWrite) }) }) + +describe('defineNodeInstrumentation', () => { + let originalNextRuntime: string | undefined + + beforeEach(() => { + originalNextRuntime = process.env.NEXT_RUNTIME + vi.resetModules() + }) + + afterEach(() => { + if (originalNextRuntime === undefined) { + delete process.env.NEXT_RUNTIME + } else { + process.env.NEXT_RUNTIME = originalNextRuntime + } + }) + + it('does not call loader when NEXT_RUNTIME is edge', async () => { + process.env.NEXT_RUNTIME = 'edge' + const loader = vi.fn().mockResolvedValue({ + register: vi.fn(), + onRequestError: vi.fn(), + }) + const { defineNodeInstrumentation } = await import('../../src/next/instrumentation') + const { register, onRequestError } = defineNodeInstrumentation(loader) + await register() + await onRequestError( + new Error('x'), + { path: '/', method: 'GET', headers: {} }, + { + routerKind: 'App Router', + routePath: '/', + routeType: 'route', + renderSource: 'react-server-components', + }, + ) + expect(loader).not.toHaveBeenCalled() + }) + + it('caches loader: one import for register plus multiple onRequestError', async () => { + process.env.NEXT_RUNTIME = 'nodejs' + const registerFn = vi.fn() + const onRequestErrorFn = vi.fn() + const loader = vi.fn().mockResolvedValue({ + register: registerFn, + onRequestError: onRequestErrorFn, + }) + const { defineNodeInstrumentation } = await import('../../src/next/instrumentation') + const { register, onRequestError } = defineNodeInstrumentation(loader) + await register() + await onRequestError( + new Error('a'), + { path: '/a', method: 'GET', headers: {} }, + { + routerKind: 'App Router', + routePath: '/a', + routeType: 'route', + renderSource: 'react-server-components', + }, + ) + await onRequestError( + new Error('b'), + { path: '/b', method: 'GET', headers: {} }, + { + routerKind: 'App Router', + routePath: '/b', + routeType: 'route', + renderSource: 'react-server-components', + }, + ) + expect(loader).toHaveBeenCalledTimes(1) + expect(registerFn).toHaveBeenCalledTimes(1) + expect(onRequestErrorFn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/skills/review-logging-patterns/SKILL.md b/skills/review-logging-patterns/SKILL.md index 232be845..f7c40637 100644 --- a/skills/review-logging-patterns/SKILL.md +++ b/skills/review-logging-patterns/SKILL.md @@ -187,7 +187,11 @@ log.info({ action: 'checkout_click' }) clearIdentity() ``` -**Step 7: Client ingest endpoint** — receives client logs: +**Step 7 (optional): Instrumentation** — startup + global `onRequestError` (SSR/RSC errors outside `withEvlog`). Use `defineNodeInstrumentation(() => import('./lib/evlog'))` in root `instrumentation.ts` to gate Node + cache the import, **or** write `register`/`onRequestError` manually — both are valid. For custom logic, wrap evlog’s `register`/`onRequestError` inside `lib/evlog.ts` (compose with your own init or metrics), then re-export. + +Export `createInstrumentation()` from `lib/evlog.ts` alongside `createEvlog()`. See framework docs for coexistence with `lockLogger`. + +**Step 8: Client ingest endpoint** — receives client logs: ```typescript // app/api/evlog/ingest/route.ts