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

Filter by extension

Filter by extension


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

Fix duplicate terminal output when Next.js `captureOutput` is enabled: pretty-print writes use the native stdout handle registered at patch time and passthrough is skipped unless `silent: true`. Next.js dev stacks are source-mapped to original TypeScript (like Nitro) via a Next-only enricher that does not bundle nitropack/youch; stored stacks are compacted in dev (production stacks are kept intact) and useless `.next`/`node:` snippet previews are skipped. The primary `at` line now points at your route/handler file instead of Next `route-modules` internals.
2 changes: 1 addition & 1 deletion apps/docs/content/3.integrate/frameworks/02.nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ 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. Known Next.js Edge bundler warnings are filtered by default so they are not re-emitted as evlog errors.
`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. When `silent` is `false` (the default), captured output is shown once as structured terminal output — the raw write is not duplicated. Set `silent: true` to keep the original passthrough alongside drain delivery. Known Next.js Edge bundler warnings are filtered by default so they are not re-emitted as evlog errors.
::

### Configuration
Expand Down
25 changes: 20 additions & 5 deletions packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,28 @@ import { buildAuditFields, consumeAuditForceKeep, finalizeAudit } from './audit'
import { markGloballyRedacted, redactEvent, resolveRedactConfig } from './redact'
import type { PluginRunner } from './shared/plugin'
import { createPluginRunner, getEmptyPluginRunner } from './shared/plugin'
import { buildErrorEntries, PRETTY_ERROR_TREE_SPACER, registerPrettyErrorSnippetReader } from './shared/pretty-error'
import { buildErrorEntries, compactStackForStorage, PRETTY_ERROR_TREE_SPACER, registerPrettyErrorSnippetReader } from './shared/pretty-error'
import type { ResolvedPrettyError } from './shared/dev-terminal'
import { resolveDevTerminal } from './shared/dev-terminal'
import { EvlogError } from './error'
import { colors, cssColors, detectEnvironment, escapeFormatString, formatDuration, getConsoleMethod, getCssLevelColor, getLevelColor, isBrowser, isDev, isLevelEnabled, matchesPattern } from './utils'

const nativeStdoutWrite =
typeof process !== 'undefined' && typeof process.stdout?.write === 'function'
? process.stdout.write.bind(process.stdout)
: undefined

/** Cross-bundle global slot for the native stdout write registered by captureOutput patching. */
export interface EvlogProcessOutputGlobal {
__evlogNativeStdoutWrite?: typeof process.stdout.write
}

function writePrettyStdout(text: string): void {
const global = globalThis as EvlogProcessOutputGlobal
const write = global.__evlogNativeStdoutWrite ?? nativeStdoutWrite
write?.(text)
}

function isPlainObject(val: unknown): val is Record<string, unknown> {
return val !== null && typeof val === 'object' && !Array.isArray(val)
}
Expand Down Expand Up @@ -563,12 +579,11 @@ function flushPrettyLines(lines: string[]): void {
if (lines.length === 0) return
const text = `${lines.join('\n')}\n`
if (
typeof process !== 'undefined'
&& typeof process.stdout?.write === 'function'
nativeStdoutWrite
&& !isBrowser()
&& process.env.VITEST !== 'true'
) {
process.stdout.write(text)
writePrettyStdout(text)
return
}
console.log(lines.join('\n'))
Expand Down Expand Up @@ -875,7 +890,7 @@ export function createLogger<T extends object = Record<string, unknown>>(initial
const errorObj: Record<string, unknown> = {
name: err.name,
message: err.message,
stack: err.stack,
stack: isDev() ? compactStackForStorage(err.stack) : err.stack,
}
const errRecord = err as unknown as Record<string, unknown>
for (const k of ['code', 'status', 'statusText', 'statusCode', 'statusMessage', 'data', 'cause', 'internal'] as const) {
Expand Down
13 changes: 13 additions & 0 deletions packages/evlog/src/next/enrich-error-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Source-map stack enrichment for Next.js dev — isolated from Nitro to avoid bundling nitropack/youch.
*/
export async function enrichNextErrorStackForDev(
error: Error,
options: { pretty?: boolean } = {},
): Promise<void> {
if (process.env.NODE_ENV === 'production') return
if (options.pretty === false) return

const { enrichErrorStackFromNextDev } = await import('../shared/enrich-error-stack-next.node')
enrichErrorStackFromNextDev(error)
}
Comment on lines +1 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing JSDoc on public API.

Per coding guidelines, public APIs in packages/evlog/src/**/*.{ts,tsx} require JSDoc comments. The exported enrichNextErrorStackForDev function lacks documentation.

📝 Suggested JSDoc
-/**
- * Source-map stack enrichment for Next.js dev — isolated from Nitro to avoid bundling nitropack/youch.
- */
+/**
+ * Rewrite `error.stack` with source-mapped frames in Next.js dev.
+ * Isolated from Nitro to avoid bundling nitropack/youch.
+ *
+ * `@param` error - The error whose stack should be enriched.
+ * `@param` options - Configuration options.
+ * `@param` options.pretty - When false, skip enrichment. Defaults to true in dev.
+ */
 export async function enrichNextErrorStackForDev(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/evlog/src/next/enrich-error-stack.ts` around lines 1 - 13, Add a
JSDoc comment for the exported function enrichNextErrorStackForDev describing
its purpose, parameters, and return type; place it immediately above the
function declaration and document that it enriches stack traces for Next.js dev
only (no-op in production), annotate the options param (pretty?: boolean) and
that the function returns a Promise<void>, and mention any side effects (dynamic
import of ../shared/enrich-error-stack-next.node and calling
enrichErrorStackFromNextDev).

Source: Coding guidelines

5 changes: 4 additions & 1 deletion packages/evlog/src/next/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { shouldLog, getServiceForPath } from '../shared/routes'
import { bindStreamingResponseLifecycle, shouldDeferEmitForResponse } from '../shared/streamResponse'
import { filterSafeHeaders } from '../utils'
import { EvlogError } from '../error'
import { enrichNextErrorStackForDev } from './enrich-error-stack'
import type { NextEvlogOptions } from './types'
import { evlogStorage } from './storage'

Expand Down Expand Up @@ -252,7 +253,9 @@ export function createWithEvlog(options: NextEvlogOptions) {

return result as Awaited<TReturn>
} catch (error) {
logger.error(error instanceof Error ? error : new Error(String(error)))
const err = error instanceof Error ? error : new Error(String(error))
await enrichNextErrorStackForDev(err, { pretty: state.options.pretty })
logger.error(err)

const errorStatus = (error as { status?: number }).status
?? (error as { statusCode?: number }).statusCode
Expand Down
14 changes: 12 additions & 2 deletions packages/evlog/src/next/instrumentation-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from './instrumentation-gate'

type LoggerModule = typeof import('../logger')
type EvlogProcessOutputGlobal = import('../logger').EvlogProcessOutputGlobal

/** Options for capturing process stdout/stderr as structured log events. */
export interface CaptureOutputOptions {
Expand Down Expand Up @@ -79,6 +80,7 @@ let activeCaptureOutput:
ignore: Array<string | RegExp>
stdout: boolean
stderr: boolean
silent: boolean
}
| undefined

Expand Down Expand Up @@ -108,18 +110,20 @@ function shouldIgnoreCapturedOutput(message: string, ignore: Array<string | RegE
})
}

function applyCaptureOutput(config: CaptureOutputOptions, logApi: Log): void {
function applyCaptureOutput(config: CaptureOutputOptions, logApi: Log, silent: boolean): void {
activeCaptureOutput = {
log: logApi,
ignore: config.ignore ?? DEFAULT_CAPTURE_OUTPUT_IGNORE,
stdout: config.stdout !== false,
stderr: config.stderr !== false,
silent,
}

const proc = globalThis.process

if (activeCaptureOutput.stdout && !stdoutPatched) {
const originalStdoutWrite = proc.stdout.write.bind(proc.stdout)
;(globalThis as EvlogProcessOutputGlobal).__evlogNativeStdoutWrite = originalStdoutWrite
stdoutPatched = true
proc.stdout.write = function(chunk: unknown, ...args: unknown[]): boolean {
const message = String(chunk).trimEnd()
Expand All @@ -136,6 +140,9 @@ function applyCaptureOutput(config: CaptureOutputOptions, logApi: Log): void {
} finally {
patching = false
}
if (!active.silent) {
return true
}
}
return originalStdoutWrite(chunk as string, ...args as [])
} as typeof process.stdout.write
Expand All @@ -159,6 +166,9 @@ function applyCaptureOutput(config: CaptureOutputOptions, logApi: Log): void {
} finally {
patching = false
}
if (!active.silent) {
return true
}
}
return originalStderrWrite(chunk as string, ...args as [])
} as typeof process.stderr.write
Expand Down Expand Up @@ -197,7 +207,7 @@ export function createInstrumentation(options: InstrumentationOptions = {}): Ins
lockLogger()

if (captureOutputOptions && process.env.NEXT_RUNTIME === 'nodejs') {
applyCaptureOutput(captureOutputOptions, log)
applyCaptureOutput(captureOutputOptions, log, options.silent ?? false)
}
registered = true
}).catch((error) => {
Expand Down
177 changes: 177 additions & 0 deletions packages/evlog/src/shared/enrich-error-stack-next.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { isAbsolute, relative } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { isFrameworkRuntimePath } from './pretty-error'

/** Parsed stack frame from Next.js `parseStack`. */
interface NextParsedFrame {
file: string | null
line1: number | null
column1: number | null
methodName: string | null
arguments: string[]
}

type SourceMapConsumerInstance = {
originalPositionFor: (pos: { line: number, column: number }) => {
source: string | null
line: number | null
column: number | null
name: string | null
}
}

const require = createRequire(import.meta.url)

function formatMappedFrame(
methodName: string | null,
sourceURL: string | null,
line1: number | null,
column1: number | null,
): string {
let sourceLocation = line1 !== null ? `:${line1}` : ''
if (column1 !== null && sourceLocation !== '') {
sourceLocation += `:${column1}`
}

let fileLocation: string
if (sourceURL !== null && sourceURL.startsWith('file://') && URL.canParse(sourceURL)) {
fileLocation = relative(process.cwd(), fileURLToPath(sourceURL))
} else if (sourceURL !== null && sourceURL.startsWith('/')) {
fileLocation = relative(process.cwd(), sourceURL)
} else {
fileLocation = sourceURL ?? 'unknown'
}

return methodName
? ` at ${methodName} (${fileLocation}${sourceLocation})`
: ` at ${fileLocation}${sourceLocation}`
}

function shouldSkipMappedSource(source: string): boolean {
const normalized = source.replace(/\\/g, '/')
return normalized.includes('node_modules')
|| normalized.includes('/packages/evlog/')
|| isFrameworkRuntimePath(normalized)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function resolveFrameFile(frame: NextParsedFrame): string | null {
if (!frame.file) return null
if (frame.file.startsWith('file://')) {
try {
return fileURLToPath(frame.file)
} catch {
return frame.file
}
}
if (isAbsolute(frame.file)) return frame.file
return null
}

function getSourceMapConsumer(
frameFile: string,
cache: Map<string, SourceMapConsumerInstance | null>,
): SourceMapConsumerInstance | null {
const cached = cache.get(frameFile)
if (cached !== undefined) return cached

const mapPath = `${frameFile}.map`
if (!existsSync(mapPath)) {
cache.set(frameFile, null)
return null
}

try {
const sourceMapModule = require('next/dist/compiled/source-map') as {
SourceMapConsumer: new(payload: unknown, sourceMapURL: string) => SourceMapConsumerInstance
}
const payload = JSON.parse(readFileSync(mapPath, 'utf8')) as unknown
const chunkUrl = pathToFileURL(frameFile).href
const consumer = new sourceMapModule.SourceMapConsumer(payload, `${chunkUrl}.map`)
cache.set(frameFile, consumer)
return consumer
} catch {
cache.set(frameFile, null)
return null
}
}

function mapFrame(
frame: NextParsedFrame,
cache: Map<string, SourceMapConsumerInstance | null>,
): { frame: NextParsedFrame, skipped: boolean } {
if (frame.file?.startsWith('node:')) {
return { frame, skipped: true }
}

const frameFile = resolveFrameFile(frame)
if (!frameFile || frame.line1 === null) {
return { frame, skipped: false }
}

const consumer = getSourceMapConsumer(frameFile, cache)
if (!consumer) {
if (frameFile.includes('.next/')) {
return { frame, skipped: true }
}
return { frame, skipped: false }
}

const sourcePosition = consumer.originalPositionFor({
line: frame.line1,
column: (frame.column1 ?? 1) - 1,
})

if (!sourcePosition.source || sourcePosition.line === null) {
return { frame, skipped: frameFile.includes('.next/') }
}

if (shouldSkipMappedSource(sourcePosition.source)) {
return { frame, skipped: true }
}

return {
frame: {
...frame,
file: sourcePosition.source,
line1: sourcePosition.line,
column1: sourcePosition.column === null ? null : sourcePosition.column + 1,
methodName: sourcePosition.name ?? frame.methodName,
},
skipped: false,
}
}

/**
* Rewrite `error.stack` with Turbopack/Webpack source-mapped frames in Next.js dev.
* Reads sibling `.map` files for `.next` chunks (same resolution as the dev overlay).
*/
export function enrichErrorStackFromNextDev(error: Error): void {
if (process.env.NODE_ENV === 'production') return
if (!error.stack) return

try {
const { parseStack } = require('next/dist/server/lib/parse-stack') as {
parseStack: (stack: string, distDir?: string) => NextParsedFrame[]
}

const frames = parseStack(error.stack)
if (frames.length === 0) return

const cache = new Map<string, SourceMapConsumerInstance | null>()
const mappedLines: string[] = []

for (const frame of frames) {
const { frame: mapped, skipped } = mapFrame(frame, cache)
if (skipped) continue
mappedLines.push(formatMappedFrame(mapped.methodName, mapped.file, mapped.line1, mapped.column1))
}

if (mappedLines.length === 0) return

error.stack = `${error.name || 'Error'}: ${error.message}\n${mappedLines.join('\n')}`
} catch {
// Next internals unavailable — keep the original stack
}
}
Loading
Loading