Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -356,5 +356,73 @@ describe('console-exit patches', () => {
{ method: 'log', input: '[BRIGHT]: with null signal' },
])
})

it('should output console.trace with a clean stack trace without internal wrapper frames', async () => {
async function testForWorker() {
let capturedOutput = ''

// Replace console.log to capture the trace output (console.trace uses console.log internally after our patch)
console.log = function (...args) {
capturedOutput = args.join(' ')
reportResult({
type: 'console-call',
method: 'log',
input: capturedOutput,
})
}

// Install patches - this wraps console.trace
require('next/dist/server/node-environment-extensions/console-dim.external')

// Call console.trace - this should output a clean stack trace
console.trace('Test message')

// Report whether the stack trace contains internal wrapper frames
const hasInternalFrames = capturedOutput.includes(
'/node-environment-extensions/'
)
reportResult({
type: 'serialized',
key: 'hasInternalFrames',
data: JSON.stringify(hasInternalFrames),
})

// Report whether the stack trace contains the expected "Trace:" prefix
const hasTracePrefix = capturedOutput.startsWith('Trace:')
reportResult({
type: 'serialized',
key: 'hasTracePrefix',
data: JSON.stringify(hasTracePrefix),
})

// Report whether the stack trace contains the test message
const hasTestMessage = capturedOutput.includes('Test message')
reportResult({
type: 'serialized',
key: 'hasTestMessage',
data: JSON.stringify(hasTestMessage),
})

// Report whether the stack trace contains "at" lines (actual stack frames)
const hasStackFrames = capturedOutput.includes(' at ')
reportResult({
type: 'serialized',
key: 'hasStackFrames',
data: JSON.stringify(hasStackFrames),
})
}

const { data, exitCode } = await runWorkerCode(testForWorker)

expect(exitCode).toBe(0)
// The stack trace should NOT contain internal wrapper frames
expect(data.hasInternalFrames).toBe(false)
// The output should have the "Trace:" prefix
expect(data.hasTracePrefix).toBe(true)
// The output should contain the test message
expect(data.hasTestMessage).toBe(true)
// The output should contain actual stack frames
expect(data.hasStackFrames).toBe(true)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,28 @@ function convertToDimmedArgs(
}
}

/**
* For console.trace, we need special handling because it captures the stack trace
* at the point where it's called. When wrapped by multiple patching functions,
* the stack trace includes all the wrapper frames which pollutes the output.
*
* This function captures a clean stack trace, filters out internal Next.js frames,
* and formats it like console.trace would.
*/
function getCleanStackTrace(): string {
const err = new Error()
const stack = err.stack || ''
const lines = stack.split('\n')

// Filter out internal wrapper frames from node-environment-extensions
const filteredLines = lines.filter((line) => {
return !line.includes('/node-environment-extensions/')
})

// Remove the "Error" header line and join
return filteredLines.slice(1).join('\n')
}

// Based on https://github.com/facebook/react/blob/28dc0776be2e1370fe217549d32aee2519f0cf05/packages/react-server/src/ReactFlightServer.js#L248
function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
const descriptor = Object.getOwnPropertyDescriptor(console, methodName)
Expand All @@ -181,6 +203,13 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
const wrapperMethod = function (this: typeof console, ...args: any[]) {
const consoleStore = consoleAsyncStorage.getStore()

// Special handling for console.trace: capture the stack trace early
// before the wrapper chain pollutes it, then use console.log to output
let traceStack: string | undefined
if (methodName === 'trace') {
traceStack = getCleanStackTrace()
}

// First we see if there is a cache signal for our current scope. If we're in a client render it'll
// come from the client React cacheSignal implementation. If we are in a server render it'll come from
// the server React cacheSignal implementation. Any particular console call will be in one, the other, or neither
Expand All @@ -200,18 +229,26 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else if (consoleStore?.dim === true) {
return applyWithDimming.call(
this,
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else {
return originalMethod.apply(this, args)
return applyMethod.call(
this,
originalMethod,
methodName,
args,
traceStack
)
}
}

Expand Down Expand Up @@ -239,7 +276,8 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
}
// intentional fallthrough
Expand All @@ -255,10 +293,17 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
consoleStore,
originalMethod,
methodName,
args
args,
traceStack
)
} else {
return originalMethod.apply(this, args)
return applyMethod.call(
this,
originalMethod,
methodName,
args,
traceStack
)
}
default:
workUnitStore satisfies never
Expand All @@ -273,13 +318,48 @@ function patchConsoleMethod(methodName: InterceptableConsoleMethod): void {
}
}

/**
* Helper to apply the console method, with special handling for console.trace
*/
function applyMethod<F extends (this: Console, ...args: any[]) => any>(
this: Console,
method: F,
methodName: InterceptableConsoleMethod,
args: Parameters<F>,
traceStack?: string
): ReturnType<F> {
if (methodName === 'trace' && traceStack !== undefined) {
// For console.trace, output the label + clean stack using console.log
// This goes through the wrapper chain for proper file logging
const label = args.length > 0 ? `Trace: ${args.join(' ')}` : 'Trace'
return console.log(`${label}\n${traceStack}`) as ReturnType<F>
Copy link
Contributor

@vercel vercel bot Jan 9, 2026

Choose a reason for hiding this comment

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

Console.trace output loses async storage context when calling console.log(), breaking proper file logging and dimming behavior

Fix on Vercel

}
return method.apply(this, args)
}

function applyWithDimming<F extends (this: Console, ...args: any[]) => any>(
this: Console,
consoleStore: undefined | ConsoleStore,
method: F,
methodName: InterceptableConsoleMethod,
args: Parameters<F>
args: Parameters<F>,
traceStack?: string
): ReturnType<F> {
// Special handling for console.trace with clean stack
if (methodName === 'trace' && traceStack !== undefined) {
const label = args.length > 0 ? `Trace: ${args.join(' ')}` : 'Trace'
const traceOutput = `${label}\n${traceStack}`
// Use console.log with the dimmed trace output
Copy link
Contributor

@vercel vercel bot Jan 9, 2026

Choose a reason for hiding this comment

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

Double dimming of console.trace output when dim=true: trace output is converted to dimmed ANSI codes, then passed to the patched console.log which applies the dimming conversion again, resulting in malformed output

Fix on Vercel

const dimmedArgs = convertToDimmedArgs('log', [traceOutput])
if (consoleStore?.dim === true) {
return console.log(...dimmedArgs) as ReturnType<F>
} else {
return consoleAsyncStorage.run(DIMMED_STORE, () =>
console.log(...dimmedArgs)
) as ReturnType<F>
}
}

if (consoleStore?.dim === true) {
return method.apply(this, convertToDimmedArgs(methodName, args))
} else {
Expand Down
Loading