Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
3 changes: 1 addition & 2 deletions packages/core/src/instrument/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ function instrumentConsole(): void {
originalConsoleMethods[level] = originalConsoleMethod;

return function (...args: any[]): void {
const handlerData: HandlerDataConsole = { args, level };
triggerHandlers('console', handlerData);
triggerHandlers('console', { args, level } as HandlerDataConsole);

const log = originalConsoleMethods[level];
log?.apply(GLOBAL_OBJ.console, args);
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/lib/instrument/console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { addConsoleInstrumentationHandler } from '../../../src/instrument/console';
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';

describe('addConsoleInstrumentationHandler', () => {
it.each(['log', 'warn', 'error', 'debug', 'info'] as const)(
'calls registered handler when console.%s is called',
level => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

GLOBAL_OBJ.console[level]('test message');

expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test message'], level }));
},
);

it('calls through to the underlying console method without throwing', () => {
addConsoleInstrumentationHandler(vi.fn());
expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow();
});
});
1 change: 1 addition & 0 deletions packages/nextjs/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export declare function init(

export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
export declare const consoleIntegration: typeof serverSdk.consoleIntegration;
export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan;

Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/common-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { systemErrorIntegration } from './integrations/systemError';
export { childProcessIntegration } from './integrations/childProcess';
export { createSentryWinstonTransport } from './integrations/winston';
export { pinoIntegration } from './integrations/pino';
export { consoleIntegration } from './integrations/console';

// SDK utilities
export { getSentryRelease, defaultStackParser } from './sdk/api';
Expand Down Expand Up @@ -117,7 +118,6 @@ export {
profiler,
consoleLoggingIntegration,
createConsolaReporter,
consoleIntegration,
wrapMcpServerWithSentry,
featureFlagsIntegration,
spanStreamingIntegration,
Expand Down
119 changes: 119 additions & 0 deletions packages/node-core/src/integrations/console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */
import type { ConsoleLevel, HandlerDataConsole, WrappedFunction } from '@sentry/core';
import {
CONSOLE_LEVELS,
GLOBAL_OBJ,
consoleIntegration as coreConsoleIntegration,
defineIntegration,
fill,
markFunctionWrapped,
maybeInstrument,
originalConsoleMethods,
triggerHandlers,
} from '@sentry/core';

interface ConsoleIntegrationOptions {
levels: ConsoleLevel[];
}

/**
* Node-specific console integration that captures breadcrumbs and handles
* the AWS Lambda runtime replacing console methods after our patch.
*
* In Lambda, console methods are patched via `Object.defineProperty` so that
* external replacements (by the Lambda runtime) are absorbed as the delegate
* while our wrapper stays in place. Outside Lambda, this delegates entirely
* to the core `consoleIntegration` which uses the simpler `fill`-based patch.
*/
export const consoleIntegration = defineIntegration((options: Partial<ConsoleIntegrationOptions> = {}) => {
return {
name: 'Console',
setup(client) {
if (process.env.LAMBDA_TASK_ROOT) {
maybeInstrument('console', instrumentConsoleLambda);
}

// Delegate breadcrumb handling to the core console integration.
const core = coreConsoleIntegration(options);
core.setup?.(client);
},
};
});

function instrumentConsoleLambda(): void {
if (!('console' in GLOBAL_OBJ)) {
return;
}

CONSOLE_LEVELS.forEach(function (level: ConsoleLevel): void {
if (!(level in GLOBAL_OBJ.console)) {
return;
}

patchWithDefineProperty(level);
});
}

function patchWithDefineProperty(level: ConsoleLevel): void {
const nativeMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void;
originalConsoleMethods[level] = nativeMethod;

let consoleDelegate: Function = nativeMethod;
let isExecuting = false;

const wrapper = function (...args: any[]): void {
Comment thread
isaacs marked this conversation as resolved.
if (isExecuting) {
// Re-entrant call: a third party captured `wrapper` via the getter and calls it
// from inside their replacement (e.g. `const prev = console.log; console.log = (...a) => { prev(...a); }`).
// Calling `consoleDelegate` here would recurse, so fall back to the native method.
nativeMethod.apply(GLOBAL_OBJ.console, args);
return;
}
Comment thread
isaacs marked this conversation as resolved.
isExecuting = true;
try {
triggerHandlers('console', { args, level } as HandlerDataConsole);
consoleDelegate.apply(GLOBAL_OBJ.console, args);
} finally {
isExecuting = false;
}
};
markFunctionWrapped(wrapper as unknown as WrappedFunction, nativeMethod as unknown as WrappedFunction);

try {
let current: any = wrapper;

Object.defineProperty(GLOBAL_OBJ.console, level, {
configurable: true,
enumerable: true,
get() {
return current;
},
set(newValue) {
if (
typeof newValue === 'function' &&
newValue !== wrapper &&
newValue !== originalConsoleMethods[level] &&
!(newValue as WrappedFunction).__sentry_original__
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clobbering the wrapper if it's a sentry wrapped function makes sense (since that's likely ourselves doing it), but I'm unclear why you're allowing it to be set back to the originalConsoleMethods[level].

That would mean:

const original = console.log
// Sentry setup happens
console.log = someAwsThing;
// later...
console.log = original; // lose the Sentry instrumentation!

It seems like setting it to the original should just set the consoleDelegate, no?

Copy link
Copy Markdown
Member Author

@s1gr1d s1gr1d Apr 20, 2026

Choose a reason for hiding this comment

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

True, that's probably a bug. I'm gonna look into that. Currently, it makes sure that the consoleSandbox still works.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated and added a test for that - consoleSandbox also still works.

) {
consoleDelegate = newValue;
current = wrapper;
} else {
current = newValue;
}
},
});
} catch {
// Fall back to fill-based patching if defineProperty fails
fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function {
originalConsoleMethods[level] = originalConsoleMethod;

return function (...args: any[]): void {
triggerHandlers('console', { args, level } as HandlerDataConsole);

const log = originalConsoleMethods[level];
log?.apply(GLOBAL_OBJ.console, args);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
log?.apply(GLOBAL_OBJ.console, args);
return log?.apply(this, args);

};
});
}
}
2 changes: 1 addition & 1 deletion packages/node-core/src/light/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import {
applySdkMetadata,
consoleIntegration,
consoleSandbox,
debug,
envToBool,
Expand All @@ -25,6 +24,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
import { processSessionIntegration } from '../integrations/processSession';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
import { consoleIntegration } from '../integrations/console';
import { systemErrorIntegration } from '../integrations/systemError';
import { defaultStackParser, getSentryRelease } from '../sdk/api';
import { makeNodeTransport } from '../transports';
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import {
applySdkMetadata,
consoleIntegration,
consoleSandbox,
conversationIdIntegration,
debug,
Expand Down Expand Up @@ -35,6 +34,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
import { processSessionIntegration } from '../integrations/processSession';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
import { consoleIntegration } from '../integrations/console';
import { systemErrorIntegration } from '../integrations/systemError';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
Expand Down
Loading
Loading