Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/core/src/instrument/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ function instrumentConsole(): void {
originalConsoleMethods[level] = originalConsoleMethod;

return function (...args: any[]): void {
const handlerData: HandlerDataConsole = { args, level };
triggerHandlers('console', handlerData);
triggerHandlers('console', { args, level } as HandlerDataConsole);

const log = originalConsoleMethods[level];
log?.apply(GLOBAL_OBJ.console, args);
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/lib/instrument/console.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { addConsoleInstrumentationHandler } from '../../../src/instrument/console';
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';

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

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

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

it('calls through to the underlying console method without throwing', () => {
addConsoleInstrumentationHandler(vi.fn());
expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow();
});
});
2 changes: 1 addition & 1 deletion packages/node-core/src/common-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { systemErrorIntegration } from './integrations/systemError';
export { childProcessIntegration } from './integrations/childProcess';
export { createSentryWinstonTransport } from './integrations/winston';
export { pinoIntegration } from './integrations/pino';
export { consoleIntegration } from './integrations/console';

// SDK utilities
export { getSentryRelease, defaultStackParser } from './sdk/api';
Expand Down Expand Up @@ -117,7 +118,6 @@ export {
profiler,
consoleLoggingIntegration,
createConsolaReporter,
consoleIntegration,
wrapMcpServerWithSentry,
featureFlagsIntegration,
spanStreamingIntegration,
Expand Down
119 changes: 119 additions & 0 deletions packages/node-core/src/integrations/console.ts
Original file line number Diff line number Diff line change
@@ -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<ConsoleIntegrationOptions> = {}) => {
return {
name: 'Console',
setup(client) {
if (process.env.LAMBDA_TASK_ROOT) {
maybeInstrument('console', instrumentConsoleLambda);
}

// Delegate breadcrumb handling to the core console integration.
const core = coreConsoleIntegration(options);
core.setup?.(client);
},
};
});

function instrumentConsoleLambda(): void {
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);
};
});
}
}
2 changes: 1 addition & 1 deletion packages/node-core/src/light/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import {
applySdkMetadata,
consoleIntegration,
consoleSandbox,
debug,
envToBool,
Expand All @@ -25,6 +24,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
import { processSessionIntegration } from '../integrations/processSession';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
import { consoleIntegration } from '../integrations/console';
import { systemErrorIntegration } from '../integrations/systemError';
import { defaultStackParser, getSentryRelease } from '../sdk/api';
import { makeNodeTransport } from '../transports';
Expand Down
2 changes: 1 addition & 1 deletion packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Integration, Options } from '@sentry/core';
import {
applySdkMetadata,
consoleIntegration,
consoleSandbox,
conversationIdIntegration,
debug,
Expand Down Expand Up @@ -35,6 +34,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
import { processSessionIntegration } from '../integrations/processSession';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
import { consoleIntegration } from '../integrations/console';
import { systemErrorIntegration } from '../integrations/systemError';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
Expand Down
Loading
Loading