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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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(() => {