From 88c6f55cab247d3d04ee9c2e03c2c559f96f69d2 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:27:51 +0200 Subject: [PATCH 01/13] add tests --- .../lib/instrument/console-lambda.test.ts | 143 ++++++++++++++++++ .../core/test/lib/instrument/console.test.ts | 22 +++ 2 files changed, 165 insertions(+) create mode 100644 packages/core/test/lib/instrument/console-lambda.test.ts create mode 100644 packages/core/test/lib/instrument/console.test.ts diff --git a/packages/core/test/lib/instrument/console-lambda.test.ts b/packages/core/test/lib/instrument/console-lambda.test.ts new file mode 100644 index 000000000000..3a5712641eeb --- /dev/null +++ b/packages/core/test/lib/instrument/console-lambda.test.ts @@ -0,0 +1,143 @@ +// Set LAMBDA_TASK_ROOT before any imports so instrumentConsole uses patchWithDefineProperty +process.env.LAMBDA_TASK_ROOT = '/var/task'; + +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { addConsoleInstrumentationHandler } from '../../../src/instrument/console'; +import type { WrappedFunction } from '../../../src/types-hoist/wrappedfunction'; +import { consoleSandbox, originalConsoleMethods } from '../../../src/utils/debug-logger'; +import { markFunctionWrapped } from '../../../src/utils/object'; +import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; + +afterAll(() => { + delete process.env.LAMBDA_TASK_ROOT; +}); + +describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', () => { + it('calls registered handler when console.log is called', () => { + const handler = vi.fn(); + 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('updates originalConsoleMethods to point to the replacement', () => { + addConsoleInstrumentationHandler(vi.fn()); + + const lambdaLogger = vi.fn(); + GLOBAL_OBJ.console.log = lambdaLogger; + + expect(originalConsoleMethods.log).toBe(lambdaLogger); + }); + }); + + 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' })); + }); + }); +}); 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(); + }); +}); From d1cfd60f095c472b55b50b8cfa6aa0e1b2559658 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:48:17 +0200 Subject: [PATCH 02/13] fix(console): Re-patch console in AWS Lambda runtimes --- packages/core/src/instrument/console.ts | 75 +++++++++++++++++++++---- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index e96de345d202..1ea4314e2ca8 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-types */ import type { ConsoleLevel, HandlerDataConsole } from '../types-hoist/instrument'; +import type { WrappedFunction } from '../types-hoist/wrappedfunction'; import { CONSOLE_LEVELS, originalConsoleMethods } from '../utils/debug-logger'; -import { fill } from '../utils/object'; +import { fill, markFunctionWrapped } from '../utils/object'; import { GLOBAL_OBJ } from '../utils/worldwide'; import { addHandler, maybeInstrument, triggerHandlers } from './handlers'; @@ -28,16 +29,70 @@ function instrumentConsole(): void { return; } - fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { - originalConsoleMethods[level] = originalConsoleMethod; + if (typeof process !== 'undefined' && !!process.env.LAMBDA_TASK_ROOT) { + // The AWS Lambda runtime replaces console methods AFTER our patch, which overwrites them. + patchWithDefineProperty(level); + } else { + patchWithFill(level); + } + }); +} - return function (...args: any[]): void { - const handlerData: HandlerDataConsole = { args, level }; - triggerHandlers('console', handlerData); +function patchWithFill(level: ConsoleLevel): void { + fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { + originalConsoleMethods[level] = originalConsoleMethod; - const log = originalConsoleMethods[level]; - log?.apply(GLOBAL_OBJ.console, args); - }; - }); + return function (...args: any[]): void { + triggerHandlers('console', { args, level } as HandlerDataConsole); + + const log = originalConsoleMethods[level]; + log?.apply(GLOBAL_OBJ.console, args); + }; }); } + +function patchWithDefineProperty(level: ConsoleLevel): void { + const originalMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void; + originalConsoleMethods[level] = originalMethod; + + let underlying: Function = originalMethod; + + const wrapper = function (...args: any[]): void { + triggerHandlers('console', { args, level }); + underlying.apply(GLOBAL_OBJ.console, args); + }; + markFunctionWrapped(wrapper as unknown as WrappedFunction, originalMethod as unknown as WrappedFunction); + + try { + let current: any = wrapper; + + Object.defineProperty(GLOBAL_OBJ.console, level, { + configurable: true, + enumerable: true, + get() { + return current; + }, + // When `console[level]` is set to a new value, we want to check if it's something not done by us but by e.g. the Lambda runtime. + set(newValue) { + if ( + typeof newValue === 'function' && + // Ignore if it's set to the wrapper (e.g. by our own patch or consoleSandbox), which would cause an infinite loop. + newValue !== wrapper && + // Function is not one of our wrappers (which have __sentry_original__) and not the original (stored in originalConsoleMethods) + newValue !== originalConsoleMethods[level] && + !(newValue as WrappedFunction).__sentry_original__ + ) { + underlying = newValue; + originalConsoleMethods[level] = newValue; + current = wrapper; + } else { + // Accept as-is: consoleSandbox restores, other Sentry wrappers, or non-functions + current = newValue; + } + }, + }); + } catch { + // In case defineProperty fails (e.g. in older browsers), fall back to fill-style patching + patchWithFill(level); + } +} From 487e21037a9cda1a3c900c0b2b51a244e4b28852 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:36:49 +0200 Subject: [PATCH 03/13] increase size limit --- .size-limit.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 351c85ccca79..5f85cc83fac8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -131,7 +131,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '28 KB', + limit: '30 KB', }, // React SDK (ESM) { @@ -196,7 +196,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '45 KB', + limit: '47 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', @@ -234,14 +234,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '83.5 KB', + limit: '84 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '130 KB', + limit: '132 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '211 KB', + limit: '213 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -276,7 +276,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '251 KB', + limit: '253 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', From dc977442f8f8c14e527484ae67bbad23bd534757 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:31:27 +0200 Subject: [PATCH 04/13] add test against infinite recursion --- .../lib/instrument/console-lambda.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core/test/lib/instrument/console-lambda.test.ts b/packages/core/test/lib/instrument/console-lambda.test.ts index 3a5712641eeb..62b82492622c 100644 --- a/packages/core/test/lib/instrument/console-lambda.test.ts +++ b/packages/core/test/lib/instrument/console-lambda.test.ts @@ -140,4 +140,26 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['after sandbox'], level: 'log' })); }); }); + + describe('third-party capture-and-call wrapping', () => { + it('does not cause infinite recursion when a third party wraps console with the capture pattern', () => { + addConsoleInstrumentationHandler(vi.fn()); + + // This is the extremely common pattern used by logging libraries, test frameworks, etc: + // const prevLog = console.log; + // console.log = (...args) => { prevLog(...args); doSomethingElse(); } + + const prevLog = GLOBAL_OBJ.console.log; // captures `wrapper` via the getter + const thirdPartyExtra = vi.fn(); + GLOBAL_OBJ.console.log = (...args: any[]) => { + prevLog(...args); // calls wrapper → underlying (this very function) → prevLog (wrapper) → … + thirdPartyExtra(...args); + }; + + // With the bug, this causes "Maximum call stack size exceeded" + expect(() => GLOBAL_OBJ.console.log('should not overflow')).not.toThrow(); + + expect(thirdPartyExtra).toHaveBeenCalledWith('should not overflow'); + }); + }); }); From d4d89f642a86c6ec882848d6023f394803b77cfa Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:38:09 +0200 Subject: [PATCH 05/13] fix recursion --- packages/core/src/instrument/console.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index 1ea4314e2ca8..ce6434301e80 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -56,10 +56,23 @@ function patchWithDefineProperty(level: ConsoleLevel): void { originalConsoleMethods[level] = originalMethod; let underlying: Function = originalMethod; + let isExecuting = false; const wrapper = function (...args: any[]): void { - triggerHandlers('console', { args, level }); - underlying.apply(GLOBAL_OBJ.console, args); + 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 `underlying` here would recurse, so go straight to the native method. + originalMethod.apply(GLOBAL_OBJ.console, args); + return; + } + isExecuting = true; + try { + triggerHandlers('console', { args, level }); + underlying.apply(GLOBAL_OBJ.console, args); + } finally { + isExecuting = false; + } }; markFunctionWrapped(wrapper as unknown as WrappedFunction, originalMethod as unknown as WrappedFunction); From e7709a142da1661b11cb112864cdae77894f605a Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:47:45 +0200 Subject: [PATCH 06/13] refactor --- packages/core/src/instrument/console.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index ce6434301e80..a9d469dc96d8 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -52,29 +52,28 @@ function patchWithFill(level: ConsoleLevel): void { } function patchWithDefineProperty(level: ConsoleLevel): void { - const originalMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void; - originalConsoleMethods[level] = originalMethod; + const nativeMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void; + originalConsoleMethods[level] = nativeMethod; - let underlying: Function = originalMethod; 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 (e.g. `const prev = console.log; console.log = (...a) => { prev(...a); }`). - // Calling `underlying` here would recurse, so go straight to the native method. - originalMethod.apply(GLOBAL_OBJ.console, args); + // Calling originalConsoleMethods here would recurse, so fall back to the native method. + nativeMethod.apply(GLOBAL_OBJ.console, args); return; } isExecuting = true; try { triggerHandlers('console', { args, level }); - underlying.apply(GLOBAL_OBJ.console, args); + originalConsoleMethods[level]?.apply(GLOBAL_OBJ.console, args); } finally { isExecuting = false; } }; - markFunctionWrapped(wrapper as unknown as WrappedFunction, originalMethod as unknown as WrappedFunction); + markFunctionWrapped(wrapper as unknown as WrappedFunction, nativeMethod as unknown as WrappedFunction); try { let current: any = wrapper; @@ -95,11 +94,11 @@ function patchWithDefineProperty(level: ConsoleLevel): void { newValue !== originalConsoleMethods[level] && !(newValue as WrappedFunction).__sentry_original__ ) { - underlying = newValue; + // Absorb newly "set" function as our delegate but keep our wrapper as the active method. originalConsoleMethods[level] = newValue; current = wrapper; } else { - // Accept as-is: consoleSandbox restores, other Sentry wrappers, or non-functions + // Accept as-is: consoleSandbox restoring, other Sentry wrappers, or non-functions current = newValue; } }, From 8a1fc4d1c71554ee2142d485476c2874786d647c Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:51:43 +0200 Subject: [PATCH 07/13] add test case --- packages/core/test/lib/instrument/console-lambda.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/lib/instrument/console-lambda.test.ts b/packages/core/test/lib/instrument/console-lambda.test.ts index 62b82492622c..8b3c9a9e42d3 100644 --- a/packages/core/test/lib/instrument/console-lambda.test.ts +++ b/packages/core/test/lib/instrument/console-lambda.test.ts @@ -138,6 +138,7 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', 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' })); }); }); From 249d85d5e5b9e727722f3440167d3d172cff7c61 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:59:36 +0200 Subject: [PATCH 08/13] refactor --- packages/core/src/instrument/console.ts | 9 ++-- .../lib/instrument/console-lambda.test.ts | 50 ++++++++++++++++--- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index a9d469dc96d8..6dc9cfe03df7 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -55,20 +55,21 @@ 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 { 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 originalConsoleMethods here would recurse, so fall back to the native method. + // Calling `consoleDelegate` here would recurse, so fall back to the native method. nativeMethod.apply(GLOBAL_OBJ.console, args); return; } isExecuting = true; try { triggerHandlers('console', { args, level }); - originalConsoleMethods[level]?.apply(GLOBAL_OBJ.console, args); + consoleDelegate.apply(GLOBAL_OBJ.console, args); } finally { isExecuting = false; } @@ -94,8 +95,8 @@ function patchWithDefineProperty(level: ConsoleLevel): void { newValue !== originalConsoleMethods[level] && !(newValue as WrappedFunction).__sentry_original__ ) { - // Absorb newly "set" function as our delegate but keep our wrapper as the active method. - originalConsoleMethods[level] = newValue; + // Absorb newly "set" function as the consoleDelegate but keep our wrapper as the active method. + consoleDelegate = newValue; current = wrapper; } else { // Accept as-is: consoleSandbox restoring, other Sentry wrappers, or non-functions diff --git a/packages/core/test/lib/instrument/console-lambda.test.ts b/packages/core/test/lib/instrument/console-lambda.test.ts index 8b3c9a9e42d3..91f4eb9d8599 100644 --- a/packages/core/test/lib/instrument/console-lambda.test.ts +++ b/packages/core/test/lib/instrument/console-lambda.test.ts @@ -61,13 +61,13 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', expect(second).toHaveBeenCalledWith('latest'); }); - it('updates originalConsoleMethods to point to the replacement', () => { + it('does not mutate originalConsoleMethods (kept safe for consoleSandbox)', () => { addConsoleInstrumentationHandler(vi.fn()); - const lambdaLogger = vi.fn(); - GLOBAL_OBJ.console.log = lambdaLogger; + const nativeLog = originalConsoleMethods.log; + GLOBAL_OBJ.console.log = vi.fn(); - expect(originalConsoleMethods.log).toBe(lambdaLogger); + expect(originalConsoleMethods.log).toBe(nativeLog); }); }); @@ -140,20 +140,35 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', 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', () => { - addConsoleInstrumentationHandler(vi.fn()); + const handler = vi.fn(); + addConsoleInstrumentationHandler(handler); + handler.mockClear(); // This is the extremely common pattern used by logging libraries, test frameworks, etc: // const prevLog = console.log; // console.log = (...args) => { prevLog(...args); doSomethingElse(); } - - const prevLog = GLOBAL_OBJ.console.log; // captures `wrapper` via the getter + const prevLog = GLOBAL_OBJ.console.log; const thirdPartyExtra = vi.fn(); GLOBAL_OBJ.console.log = (...args: any[]) => { - prevLog(...args); // calls wrapper → underlying (this very function) → prevLog (wrapper) → … + prevLog(...args); thirdPartyExtra(...args); }; @@ -161,6 +176,25 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', 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('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(); }); }); }); From e3e3636a4bc4c8c8ca2b989a7f50a406eb0bfc1e Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:33:45 +0200 Subject: [PATCH 09/13] exprot consoleIntegration from node-core --- .size-limit.js | 12 +- packages/core/src/instrument/console.ts | 87 ++----------- packages/node-core/src/common-exports.ts | 2 +- .../node-core/src/integrations/console.ts | 119 ++++++++++++++++++ packages/node-core/src/light/sdk.ts | 2 +- packages/node-core/src/sdk/index.ts | 2 +- .../test/integrations/console.test.ts} | 19 ++- packages/node/src/index.ts | 2 +- 8 files changed, 146 insertions(+), 99 deletions(-) create mode 100644 packages/node-core/src/integrations/console.ts rename packages/{core/test/lib/instrument/console-lambda.test.ts => node-core/test/integrations/console.test.ts} (87%) diff --git a/.size-limit.js b/.size-limit.js index 5f85cc83fac8..351c85ccca79 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -131,7 +131,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '30 KB', + limit: '28 KB', }, // React SDK (ESM) { @@ -196,7 +196,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '47 KB', + limit: '45 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', @@ -234,14 +234,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '84 KB', + limit: '83.5 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '132 KB', + limit: '130 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '213 KB', + limit: '211 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -276,7 +276,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '253 KB', + limit: '251 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index 6dc9cfe03df7..cecf1e5cad8a 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/ban-types */ import type { ConsoleLevel, HandlerDataConsole } from '../types-hoist/instrument'; -import type { WrappedFunction } from '../types-hoist/wrappedfunction'; import { CONSOLE_LEVELS, originalConsoleMethods } from '../utils/debug-logger'; -import { fill, markFunctionWrapped } from '../utils/object'; +import { fill } from '../utils/object'; import { GLOBAL_OBJ } from '../utils/worldwide'; import { addHandler, maybeInstrument, triggerHandlers } from './handlers'; @@ -29,83 +28,15 @@ function instrumentConsole(): void { return; } - if (typeof process !== 'undefined' && !!process.env.LAMBDA_TASK_ROOT) { - // The AWS Lambda runtime replaces console methods AFTER our patch, which overwrites them. - patchWithDefineProperty(level); - } else { - patchWithFill(level); - } - }); -} - -function patchWithFill(level: ConsoleLevel): void { - 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); - }; - }); -} + fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { + originalConsoleMethods[level] = originalConsoleMethod; -function patchWithDefineProperty(level: ConsoleLevel): void { - const nativeMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void; - originalConsoleMethods[level] = nativeMethod; + return function (...args: any[]): void { + triggerHandlers('console', { args, level } as HandlerDataConsole); - let consoleDelegate: Function = nativeMethod; - 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 (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; - } - isExecuting = true; - try { - triggerHandlers('console', { args, level }); - 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; - }, - // When `console[level]` is set to a new value, we want to check if it's something not done by us but by e.g. the Lambda runtime. - set(newValue) { - if ( - typeof newValue === 'function' && - // Ignore if it's set to the wrapper (e.g. by our own patch or consoleSandbox), which would cause an infinite loop. - newValue !== wrapper && - // Function is not one of our wrappers (which have __sentry_original__) and not the original (stored in originalConsoleMethods) - newValue !== originalConsoleMethods[level] && - !(newValue as WrappedFunction).__sentry_original__ - ) { - // Absorb newly "set" function as the consoleDelegate but keep our wrapper as the active method. - consoleDelegate = newValue; - current = wrapper; - } else { - // Accept as-is: consoleSandbox restoring, other Sentry wrappers, or non-functions - current = newValue; - } - }, + const log = originalConsoleMethods[level]; + log?.apply(GLOBAL_OBJ.console, args); + }; }); - } catch { - // In case defineProperty fails (e.g. in older browsers), fall back to fill-style patching - patchWithFill(level); - } + }); } 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..27e4a5c6ae26 --- /dev/null +++ b/packages/node-core/src/integrations/console.ts @@ -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 = {}) => { + 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 { + 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; + } + 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__ + ) { + 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); + }; + }); + } +} 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/core/test/lib/instrument/console-lambda.test.ts b/packages/node-core/test/integrations/console.test.ts similarity index 87% rename from packages/core/test/lib/instrument/console-lambda.test.ts rename to packages/node-core/test/integrations/console.test.ts index 91f4eb9d8599..ad1764ced6ba 100644 --- a/packages/core/test/lib/instrument/console-lambda.test.ts +++ b/packages/node-core/test/integrations/console.test.ts @@ -1,20 +1,21 @@ -// Set LAMBDA_TASK_ROOT before any imports so instrumentConsole uses patchWithDefineProperty +// 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 { addConsoleInstrumentationHandler } from '../../../src/instrument/console'; -import type { WrappedFunction } from '../../../src/types-hoist/wrappedfunction'; -import { consoleSandbox, originalConsoleMethods } from '../../../src/utils/debug-logger'; -import { markFunctionWrapped } from '../../../src/utils/object'; -import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; +import type { WrappedFunction } from '@sentry/core'; +import { addConsoleInstrumentationHandler, consoleSandbox, markFunctionWrapped, originalConsoleMethods, GLOBAL_OBJ } from '@sentry/core'; +import { consoleIntegration } from '../../src/integrations/console'; afterAll(() => { delete process.env.LAMBDA_TASK_ROOT; }); -describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', () => { +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'); @@ -162,9 +163,6 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', addConsoleInstrumentationHandler(handler); handler.mockClear(); - // This is the extremely common pattern used by logging libraries, test frameworks, etc: - // const prevLog = console.log; - // console.log = (...args) => { prevLog(...args); doSomethingElse(); } const prevLog = GLOBAL_OBJ.console.log; const thirdPartyExtra = vi.fn(); GLOBAL_OBJ.console.log = (...args: any[]) => { @@ -172,7 +170,6 @@ describe('addConsoleInstrumentationHandler in Lambda (patchWithDefineProperty)', thirdPartyExtra(...args); }; - // With the bug, this causes "Maximum call stack size exceeded" expect(() => GLOBAL_OBJ.console.log('should not overflow')).not.toThrow(); expect(thirdPartyExtra).toHaveBeenCalledWith('should not overflow'); 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, From 2821b31f9a2f405f6f54d581b7fc4d256aee6540 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:00:47 +0200 Subject: [PATCH 10/13] fix formatting --- packages/node-core/test/integrations/console.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/node-core/test/integrations/console.test.ts b/packages/node-core/test/integrations/console.test.ts index ad1764ced6ba..849f9b9fe08b 100644 --- a/packages/node-core/test/integrations/console.test.ts +++ b/packages/node-core/test/integrations/console.test.ts @@ -3,7 +3,13 @@ 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 { + addConsoleInstrumentationHandler, + consoleSandbox, + markFunctionWrapped, + originalConsoleMethods, + GLOBAL_OBJ, +} from '@sentry/core'; import { consoleIntegration } from '../../src/integrations/console'; afterAll(() => { From 6b3ee740041b603883231ed66da6d32bc1d68070 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:51:39 +0200 Subject: [PATCH 11/13] add consoleintegration type --- packages/nextjs/src/index.types.ts | 1 + 1 file changed, 1 insertion(+) 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; From 32685488fab70b28d437d78bc3b293dbc87189a2 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:25:32 +0200 Subject: [PATCH 12/13] add new tests --- .../test/integrations/console.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/node-core/test/integrations/console.test.ts b/packages/node-core/test/integrations/console.test.ts index 849f9b9fe08b..0355fe2d076b 100644 --- a/packages/node-core/test/integrations/console.test.ts +++ b/packages/node-core/test/integrations/console.test.ts @@ -12,6 +12,11 @@ import { } 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; }); @@ -183,6 +188,38 @@ describe('consoleIntegration in Lambda (patchWithDefineProperty)', () => { 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); @@ -199,5 +236,24 @@ describe('consoleIntegration in Lambda (patchWithDefineProperty)', () => { 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' }), + ); + }); }); }); From ef9eaa63f702c20db1c284bf779f4c58591f3e24 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:42:26 +0200 Subject: [PATCH 13/13] improve implementation --- .../node-core/src/integrations/console.ts | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/packages/node-core/src/integrations/console.ts b/packages/node-core/src/integrations/console.ts index 27e4a5c6ae26..d958e00bdf12 100644 --- a/packages/node-core/src/integrations/console.ts +++ b/packages/node-core/src/integrations/console.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ import type { ConsoleLevel, HandlerDataConsole, WrappedFunction } from '@sentry/core'; import { CONSOLE_LEVELS, @@ -42,61 +41,72 @@ export const consoleIntegration = defineIntegration((options: Partial { + if (level in consoleObj) { + patchWithDefineProperty(consoleObj, level); } - - patchWithDefineProperty(level); }); } -function patchWithDefineProperty(level: ConsoleLevel): void { - const nativeMethod = GLOBAL_OBJ.console[level] as (...args: unknown[]) => void; +function patchWithDefineProperty(consoleObj: Console, level: ConsoleLevel): void { + const nativeMethod = consoleObj[level] as (...args: unknown[]) => void; originalConsoleMethods[level] = nativeMethod; - let consoleDelegate: Function = 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 (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); + // 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); - consoleDelegate.apply(GLOBAL_OBJ.console, args); + 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(GLOBAL_OBJ.console, level, { + Object.defineProperty(consoleObj, level, { configurable: true, enumerable: true, get() { return current; }, set(newValue) { - if ( - typeof newValue === 'function' && - newValue !== wrapper && - newValue !== originalConsoleMethods[level] && - !(newValue as WrappedFunction).__sentry_original__ - ) { - consoleDelegate = 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; @@ -105,14 +115,12 @@ function patchWithDefineProperty(level: ConsoleLevel): void { }); } catch { // Fall back to fill-based patching if defineProperty fails - fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { + fill(consoleObj, level, function (originalConsoleMethod: () => any): Function { originalConsoleMethods[level] = originalConsoleMethod; - return function (...args: any[]): void { + return function (this: Console, ...args: any[]): void { triggerHandlers('console', { args, level } as HandlerDataConsole); - - const log = originalConsoleMethods[level]; - log?.apply(GLOBAL_OBJ.console, args); + originalConsoleMethods[level]?.apply(this, args); }; }); }