diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index e96de345d202..cecf1e5cad8a 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -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); diff --git a/packages/core/test/lib/instrument/console.test.ts b/packages/core/test/lib/instrument/console.test.ts new file mode 100644 index 000000000000..2499a231712d --- /dev/null +++ b/packages/core/test/lib/instrument/console.test.ts @@ -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(); + }); +}); diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 2ae03ae3f204..341e583e90d1 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -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; diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index 1c724d2c29f6..b2f1deee7f4a 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -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'; @@ -117,7 +118,6 @@ export { profiler, consoleLoggingIntegration, createConsolaReporter, - consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, spanStreamingIntegration, diff --git a/packages/node-core/src/integrations/console.ts b/packages/node-core/src/integrations/console.ts new file mode 100644 index 000000000000..d958e00bdf12 --- /dev/null +++ b/packages/node-core/src/integrations/console.ts @@ -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 = {}) => { + 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 { + 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; + } + 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); + }; + }); + } +} diff --git a/packages/node-core/src/light/sdk.ts b/packages/node-core/src/light/sdk.ts index 77b62e9ab2f9..1d57da67a0ab 100644 --- a/packages/node-core/src/light/sdk.ts +++ b/packages/node-core/src/light/sdk.ts @@ -1,7 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, - consoleIntegration, consoleSandbox, debug, envToBool, @@ -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'; diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 5ae840e6e976..52271ee62363 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -1,7 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, - consoleIntegration, consoleSandbox, conversationIdIntegration, debug, @@ -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'; diff --git a/packages/node-core/test/integrations/console.test.ts b/packages/node-core/test/integrations/console.test.ts new file mode 100644 index 000000000000..0355fe2d076b --- /dev/null +++ b/packages/node-core/test/integrations/console.test.ts @@ -0,0 +1,259 @@ +// Set LAMBDA_TASK_ROOT before any imports so consoleIntegration uses patchWithDefineProperty +process.env.LAMBDA_TASK_ROOT = '/var/task'; + +import { afterAll, describe, expect, it, vi } from 'vitest'; +import type { WrappedFunction } from '@sentry/core'; +import { + addConsoleInstrumentationHandler, + consoleSandbox, + markFunctionWrapped, + originalConsoleMethods, + GLOBAL_OBJ, +} from '@sentry/core'; +import { consoleIntegration } from '../../src/integrations/console'; + +// Capture the real native method before any patches are installed. +// This simulates external code doing `const log = console.log` before Sentry init. +// oxlint-disable-next-line no-console +const nativeConsoleLog = console.log; + +afterAll(() => { + delete process.env.LAMBDA_TASK_ROOT; +}); + +describe('consoleIntegration in Lambda (patchWithDefineProperty)', () => { + it('calls registered handler when console.log is called', () => { + const handler = vi.fn(); + // Setup the integration so it calls maybeInstrument with the Lambda strategy + consoleIntegration().setup?.({ on: vi.fn() } as any); + + addConsoleInstrumentationHandler(handler); + + GLOBAL_OBJ.console.log('test'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test'], level: 'log' })); + }); + + describe('external replacement (e.g. Lambda runtime overwriting console)', () => { + it('keeps firing the handler after console.log is replaced externally', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + GLOBAL_OBJ.console.log = vi.fn(); + handler.mockClear(); + + GLOBAL_OBJ.console.log('after replacement'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['after replacement'], level: 'log' })); + }); + + it('calls the external replacement as the underlying method', () => { + addConsoleInstrumentationHandler(vi.fn()); + + const lambdaLogger = vi.fn(); + GLOBAL_OBJ.console.log = lambdaLogger; + + GLOBAL_OBJ.console.log('hello'); + + expect(lambdaLogger).toHaveBeenCalledWith('hello'); + }); + + it('always delegates to the latest replacement', () => { + addConsoleInstrumentationHandler(vi.fn()); + + const first = vi.fn(); + const second = vi.fn(); + + GLOBAL_OBJ.console.log = first; + GLOBAL_OBJ.console.log = second; + + GLOBAL_OBJ.console.log('latest'); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith('latest'); + }); + + it('does not mutate originalConsoleMethods (kept safe for consoleSandbox)', () => { + addConsoleInstrumentationHandler(vi.fn()); + + const nativeLog = originalConsoleMethods.log; + GLOBAL_OBJ.console.log = vi.fn(); + + expect(originalConsoleMethods.log).toBe(nativeLog); + }); + }); + + describe('__sentry_original__ detection', () => { + it('accepts a function with __sentry_original__ without re-wrapping', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + const otherWrapper = vi.fn(); + markFunctionWrapped(otherWrapper as unknown as WrappedFunction, vi.fn() as unknown as WrappedFunction); + + GLOBAL_OBJ.console.log = otherWrapper; + + expect(GLOBAL_OBJ.console.log).toBe(otherWrapper); + }); + + it('does not fire our handler when a __sentry_original__ wrapper is installed', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + const otherWrapper = vi.fn(); + markFunctionWrapped(otherWrapper as unknown as WrappedFunction, vi.fn() as unknown as WrappedFunction); + + GLOBAL_OBJ.console.log = otherWrapper; + handler.mockClear(); + + GLOBAL_OBJ.console.log('via other wrapper'); + + expect(handler).not.toHaveBeenCalled(); + expect(otherWrapper).toHaveBeenCalledWith('via other wrapper'); + }); + + it('re-wraps a plain function without __sentry_original__', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + GLOBAL_OBJ.console.log = vi.fn(); + handler.mockClear(); + + GLOBAL_OBJ.console.log('plain'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['plain'], level: 'log' })); + }); + }); + + describe('consoleSandbox interaction', () => { + it('does not fire the handler inside consoleSandbox', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + handler.mockClear(); + + consoleSandbox(() => { + GLOBAL_OBJ.console.log('sandbox message'); + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('resumes firing the handler after consoleSandbox returns', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + consoleSandbox(() => { + GLOBAL_OBJ.console.log('inside sandbox'); + }); + handler.mockClear(); + + GLOBAL_OBJ.console.log('after sandbox'); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['after sandbox'], level: 'log' })); + expect(handler).not.toHaveBeenCalledWith(expect.objectContaining({ args: ['inside sandbox'], level: 'log' })); + }); + + it('does not fire the handler inside consoleSandbox after a Lambda-style replacement', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + GLOBAL_OBJ.console.log = vi.fn(); + handler.mockClear(); + + consoleSandbox(() => { + GLOBAL_OBJ.console.log('sandbox after lambda'); + }); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('third-party capture-and-call wrapping', () => { + it('does not cause infinite recursion when a third party wraps console with the capture pattern', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + handler.mockClear(); + + const prevLog = GLOBAL_OBJ.console.log; + const thirdPartyExtra = vi.fn(); + GLOBAL_OBJ.console.log = (...args: any[]) => { + prevLog(...args); + thirdPartyExtra(...args); + }; + + expect(() => GLOBAL_OBJ.console.log('should not overflow')).not.toThrow(); + + expect(thirdPartyExtra).toHaveBeenCalledWith('should not overflow'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['should not overflow'], level: 'log' })); + }); + + it('fires the handler exactly once on re-entrant calls', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + handler.mockClear(); + + const callOrder: string[] = []; + + const prevLog = GLOBAL_OBJ.console.log; + GLOBAL_OBJ.console.log = (...args: any[]) => { + callOrder.push('delegate-before-prev'); + prevLog(...args); + callOrder.push('delegate-after-prev'); + }; + + handler.mockImplementation(() => { + callOrder.push('handler'); + }); + + GLOBAL_OBJ.console.log('re-entrant test'); + + // The handler fires exactly once — on the first (outer) entry. + // The re-entrant call through prev() must NOT trigger it a second time. + expect(handler).toHaveBeenCalledTimes(1); + + // Verify the full call order: + // 1. wrapper enters → triggerHandlers → handler fires + // 2. wrapper calls consoleDelegate (third-party fn) + // 3. third-party fn calls prev() → re-enters wrapper → nativeMethod (no handler) + // 4. third-party fn continues after prev() + expect(callOrder).toEqual(['handler', 'delegate-before-prev', 'delegate-after-prev']); + }); + + it('consoleSandbox still bypasses the handler after third-party wrapping', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + const prevLog = GLOBAL_OBJ.console.log; + GLOBAL_OBJ.console.log = (...args: any[]) => { + prevLog(...args); + }; + handler.mockClear(); + + consoleSandbox(() => { + GLOBAL_OBJ.console.log('should bypass'); + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('keeps firing the handler when console.log is set back to the original native method', () => { + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + + // Simulate Lambda-style replacement + GLOBAL_OBJ.console.log = vi.fn(); + handler.mockClear(); + + // Simulate external code restoring a native method reference it captured + // before Sentry init — this should NOT clobber the wrapper. + GLOBAL_OBJ.console.log = nativeConsoleLog; + + GLOBAL_OBJ.console.log('after restore to original'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ args: ['after restore to original'], level: 'log' }), + ); + }); + }); +}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index ce9458079980..3bd5e1edba1c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -137,7 +137,6 @@ export { profiler, consoleLoggingIntegration, createConsolaReporter, - consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, spanStreamingIntegration, @@ -192,6 +191,7 @@ export { processSessionIntegration, nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions, + consoleIntegration, pinoIntegration, createSentryWinstonTransport, SentryContextManager,