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
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
127 changes: 127 additions & 0 deletions packages/node-core/src/integrations/console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
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 {
const consoleObj = GLOBAL_OBJ?.console;
if (!consoleObj) {
return;
}

CONSOLE_LEVELS.forEach((level: ConsoleLevel) => {
if (level in consoleObj) {
patchWithDefineProperty(consoleObj, level);
}
});
}

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

let delegate: Function = nativeMethod;
let savedDelegate: Function | undefined;
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. We must
// use `nativeMethod` (not `delegate`) to break the cycle, and we intentionally skip `triggerHandlers` to avoid duplicate
// breadcrumbs. The outer invocation already triggered the handlers for this console call.
nativeMethod.apply(consoleObj, args);
return;
}
Comment thread
isaacs marked this conversation as resolved.
isExecuting = true;
try {
triggerHandlers('console', { args, level } as HandlerDataConsole);
delegate.apply(consoleObj, args);
} finally {
isExecuting = false;
}
};
markFunctionWrapped(wrapper as unknown as WrappedFunction, nativeMethod as unknown as WrappedFunction);

// consoleSandbox reads originalConsoleMethods[level] to temporarily bypass instrumentation. We replace it with a distinct reference (.bind creates a
// new function identity) so the setter can tell apart "consoleSandbox bypass" from "external code restoring a native method captured before Sentry init."
const sandboxBypass = nativeMethod.bind(consoleObj);
originalConsoleMethods[level] = sandboxBypass;

try {
let current: any = wrapper;

Object.defineProperty(consoleObj, level, {
configurable: true,
enumerable: true,
get() {
return current;
},
set(newValue) {
if (newValue === wrapper) {
// consoleSandbox restoring the wrapper: recover the saved delegate.
if (savedDelegate !== undefined) {
delegate = savedDelegate;
savedDelegate = undefined;
}
current = wrapper;
} else if (newValue === sandboxBypass) {
// consoleSandbox entering bypass: save delegate, let getter return sandboxBypass directly so calls skip the wrapper entirely.
savedDelegate = delegate;
current = sandboxBypass;
} else if (typeof newValue === 'function' && !(newValue as WrappedFunction).__sentry_original__) {
delegate = newValue;
current = wrapper;
} else {
current = newValue;
}
},
});
} catch {
// Fall back to fill-based patching if defineProperty fails
fill(consoleObj, level, function (originalConsoleMethod: () => any): Function {
originalConsoleMethods[level] = originalConsoleMethod;

return function (this: Console, ...args: any[]): void {
triggerHandlers('console', { args, level } as HandlerDataConsole);
originalConsoleMethods[level]?.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