Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},
// React SDK (ESM)
{
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
88 changes: 78 additions & 10 deletions packages/core/src/instrument/console.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -28,16 +29,83 @@ 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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});
}

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 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 });
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;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},
});
} catch {
// In case defineProperty fails (e.g. in older browsers), fall back to fill-style patching
patchWithFill(level);
}
}
200 changes: 200 additions & 0 deletions packages/core/test/lib/instrument/console-lambda.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// 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('does not mutate originalConsoleMethods (kept safe for consoleSandbox)', () => {
addConsoleInstrumentationHandler(vi.fn());

const nativeLog = originalConsoleMethods.log;
GLOBAL_OBJ.console.log = vi.fn();

expect(originalConsoleMethods.log).toBe(nativeLog);
});
});

describe('__sentry_original__ detection', () => {
it('accepts a function with __sentry_original__ without re-wrapping', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

const otherWrapper = vi.fn();
markFunctionWrapped(otherWrapper as unknown as WrappedFunction, vi.fn() as unknown as WrappedFunction);

GLOBAL_OBJ.console.log = otherWrapper;

expect(GLOBAL_OBJ.console.log).toBe(otherWrapper);
});

it('does not fire our handler when a __sentry_original__ wrapper is installed', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

const otherWrapper = vi.fn();
markFunctionWrapped(otherWrapper as unknown as WrappedFunction, vi.fn() as unknown as WrappedFunction);

GLOBAL_OBJ.console.log = otherWrapper;
handler.mockClear();

GLOBAL_OBJ.console.log('via other wrapper');

expect(handler).not.toHaveBeenCalled();
expect(otherWrapper).toHaveBeenCalledWith('via other wrapper');
});

it('re-wraps a plain function without __sentry_original__', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

GLOBAL_OBJ.console.log = vi.fn();
handler.mockClear();

GLOBAL_OBJ.console.log('plain');

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

describe('consoleSandbox interaction', () => {
it('does not fire the handler inside consoleSandbox', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);
handler.mockClear();

consoleSandbox(() => {
GLOBAL_OBJ.console.log('sandbox message');
});

expect(handler).not.toHaveBeenCalled();
});

it('resumes firing the handler after consoleSandbox returns', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

consoleSandbox(() => {
GLOBAL_OBJ.console.log('inside sandbox');
});
handler.mockClear();

GLOBAL_OBJ.console.log('after sandbox');

expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['after sandbox'], level: 'log' }));
expect(handler).not.toHaveBeenCalledWith(expect.objectContaining({ args: ['inside sandbox'], level: 'log' }));
});

it('does not fire the handler inside consoleSandbox after a Lambda-style replacement', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);

GLOBAL_OBJ.console.log = vi.fn();
handler.mockClear();

consoleSandbox(() => {
GLOBAL_OBJ.console.log('sandbox after lambda');
});

expect(handler).not.toHaveBeenCalled();
});
});

describe('third-party capture-and-call wrapping', () => {
it('does not cause infinite recursion when a third party wraps console with the capture pattern', () => {
const handler = vi.fn();
addConsoleInstrumentationHandler(handler);
handler.mockClear();

// 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[]) => {
prevLog(...args);
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');
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();
});
});
});
22 changes: 22 additions & 0 deletions packages/core/test/lib/instrument/console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { addConsoleInstrumentationHandler } from '../../../src/instrument/console';
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';

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

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

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

it('calls through to the underlying console method without throwing', () => {
addConsoleInstrumentationHandler(vi.fn());
expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow();
});
});