Skip to content

Commit 4d8baea

Browse files
authored
fix(console): Re-patch console in AWS Lambda runtimes (#20337)
On AWS Lambda, the Node.js runtime replaces `console.*` methods [with its own loggers](https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/9ab0fac53c768d6d8901dca9788c729d8eba94ec/src/logging/log-patch.ts#L77-L83). This means Sentry's console instrumentation gets silently overwritten, and integrations like `consoleLoggingIntegration` stop capturing console output entirely. This PR fixes that by introducing a `defineProperty`-based patching strategy for Lambda environments. Instead of simply assigning a wrapper to `console.log` (which Lambda can overwrite), we define a getter/setter on the console property. When the Lambda runtime assigns its logger, the setter intercepts it, stores the new function as the underlying delegate, and keeps Sentry's wrapper in place. The handler continues to fire, and the Lambda logger still gets called underneath (I checked that manually - the log is still shown in the CloudWatch logs). This behavior is guarded behind `process.env.LAMBDA_TASK_ROOT`, so non-Lambda environments continue to use the existing `fill()`-based patching with zero behavioral change. If `defineProperty` fails for any reason, it falls back to `fill()`. The setter also handles `consoleSandbox` correctly (recognizes when it restores the original method and allows it through), and defers to other Sentry wrappers by checking for `__sentry_original__`. Closes #18238
1 parent 738bd42 commit 4d8baea

9 files changed

Lines changed: 414 additions & 6 deletions

File tree

packages/core/src/instrument/console.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ function instrumentConsole(): void {
3232
originalConsoleMethods[level] = originalConsoleMethod;
3333

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

3837
const log = originalConsoleMethods[level];
3938
log?.apply(GLOBAL_OBJ.console, args);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { addConsoleInstrumentationHandler } from '../../../src/instrument/console';
3+
import { GLOBAL_OBJ } from '../../../src/utils/worldwide';
4+
5+
describe('addConsoleInstrumentationHandler', () => {
6+
it.each(['log', 'warn', 'error', 'debug', 'info'] as const)(
7+
'calls registered handler when console.%s is called',
8+
level => {
9+
const handler = vi.fn();
10+
addConsoleInstrumentationHandler(handler);
11+
12+
GLOBAL_OBJ.console[level]('test message');
13+
14+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test message'], level }));
15+
},
16+
);
17+
18+
it('calls through to the underlying console method without throwing', () => {
19+
addConsoleInstrumentationHandler(vi.fn());
20+
expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow();
21+
});
22+
});

packages/nextjs/src/index.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export declare function init(
2323

2424
export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
2525
export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
26+
export declare const consoleIntegration: typeof serverSdk.consoleIntegration;
2627
export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration;
2728
export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan;
2829

packages/node-core/src/common-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export { systemErrorIntegration } from './integrations/systemError';
2727
export { childProcessIntegration } from './integrations/childProcess';
2828
export { createSentryWinstonTransport } from './integrations/winston';
2929
export { pinoIntegration } from './integrations/pino';
30+
export { consoleIntegration } from './integrations/console';
3031

3132
// SDK utilities
3233
export { getSentryRelease, defaultStackParser } from './sdk/api';
@@ -117,7 +118,6 @@ export {
117118
profiler,
118119
consoleLoggingIntegration,
119120
createConsolaReporter,
120-
consoleIntegration,
121121
wrapMcpServerWithSentry,
122122
featureFlagsIntegration,
123123
spanStreamingIntegration,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import type { ConsoleLevel, HandlerDataConsole, WrappedFunction } from '@sentry/core';
3+
import {
4+
CONSOLE_LEVELS,
5+
GLOBAL_OBJ,
6+
consoleIntegration as coreConsoleIntegration,
7+
defineIntegration,
8+
fill,
9+
markFunctionWrapped,
10+
maybeInstrument,
11+
originalConsoleMethods,
12+
triggerHandlers,
13+
} from '@sentry/core';
14+
15+
interface ConsoleIntegrationOptions {
16+
levels: ConsoleLevel[];
17+
}
18+
19+
/**
20+
* Node-specific console integration that captures breadcrumbs and handles
21+
* the AWS Lambda runtime replacing console methods after our patch.
22+
*
23+
* In Lambda, console methods are patched via `Object.defineProperty` so that
24+
* external replacements (by the Lambda runtime) are absorbed as the delegate
25+
* while our wrapper stays in place. Outside Lambda, this delegates entirely
26+
* to the core `consoleIntegration` which uses the simpler `fill`-based patch.
27+
*/
28+
export const consoleIntegration = defineIntegration((options: Partial<ConsoleIntegrationOptions> = {}) => {
29+
return {
30+
name: 'Console',
31+
setup(client) {
32+
if (process.env.LAMBDA_TASK_ROOT) {
33+
maybeInstrument('console', instrumentConsoleLambda);
34+
}
35+
36+
// Delegate breadcrumb handling to the core console integration.
37+
const core = coreConsoleIntegration(options);
38+
core.setup?.(client);
39+
},
40+
};
41+
});
42+
43+
function instrumentConsoleLambda(): void {
44+
const consoleObj = GLOBAL_OBJ?.console;
45+
if (!consoleObj) {
46+
return;
47+
}
48+
49+
CONSOLE_LEVELS.forEach((level: ConsoleLevel) => {
50+
if (level in consoleObj) {
51+
patchWithDefineProperty(consoleObj, level);
52+
}
53+
});
54+
}
55+
56+
function patchWithDefineProperty(consoleObj: Console, level: ConsoleLevel): void {
57+
const nativeMethod = consoleObj[level] as (...args: unknown[]) => void;
58+
originalConsoleMethods[level] = nativeMethod;
59+
60+
let delegate: Function = nativeMethod;
61+
let savedDelegate: Function | undefined;
62+
let isExecuting = false;
63+
64+
const wrapper = function (...args: any[]): void {
65+
if (isExecuting) {
66+
// Re-entrant call: a third party captured `wrapper` via the getter and calls it from inside their replacement. We must
67+
// use `nativeMethod` (not `delegate`) to break the cycle, and we intentionally skip `triggerHandlers` to avoid duplicate
68+
// breadcrumbs. The outer invocation already triggered the handlers for this console call.
69+
nativeMethod.apply(consoleObj, args);
70+
return;
71+
}
72+
isExecuting = true;
73+
try {
74+
triggerHandlers('console', { args, level } as HandlerDataConsole);
75+
delegate.apply(consoleObj, args);
76+
} finally {
77+
isExecuting = false;
78+
}
79+
};
80+
markFunctionWrapped(wrapper as unknown as WrappedFunction, nativeMethod as unknown as WrappedFunction);
81+
82+
// consoleSandbox reads originalConsoleMethods[level] to temporarily bypass instrumentation. We replace it with a distinct reference (.bind creates a
83+
// new function identity) so the setter can tell apart "consoleSandbox bypass" from "external code restoring a native method captured before Sentry init."
84+
const sandboxBypass = nativeMethod.bind(consoleObj);
85+
originalConsoleMethods[level] = sandboxBypass;
86+
87+
try {
88+
let current: any = wrapper;
89+
90+
Object.defineProperty(consoleObj, level, {
91+
configurable: true,
92+
enumerable: true,
93+
get() {
94+
return current;
95+
},
96+
set(newValue) {
97+
if (newValue === wrapper) {
98+
// consoleSandbox restoring the wrapper: recover the saved delegate.
99+
if (savedDelegate !== undefined) {
100+
delegate = savedDelegate;
101+
savedDelegate = undefined;
102+
}
103+
current = wrapper;
104+
} else if (newValue === sandboxBypass) {
105+
// consoleSandbox entering bypass: save delegate, let getter return sandboxBypass directly so calls skip the wrapper entirely.
106+
savedDelegate = delegate;
107+
current = sandboxBypass;
108+
} else if (typeof newValue === 'function' && !(newValue as WrappedFunction).__sentry_original__) {
109+
delegate = newValue;
110+
current = wrapper;
111+
} else {
112+
current = newValue;
113+
}
114+
},
115+
});
116+
} catch {
117+
// Fall back to fill-based patching if defineProperty fails
118+
fill(consoleObj, level, function (originalConsoleMethod: () => any): Function {
119+
originalConsoleMethods[level] = originalConsoleMethod;
120+
121+
return function (this: Console, ...args: any[]): void {
122+
triggerHandlers('console', { args, level } as HandlerDataConsole);
123+
originalConsoleMethods[level]?.apply(this, args);
124+
};
125+
});
126+
}
127+
}

packages/node-core/src/light/sdk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Integration, Options } from '@sentry/core';
22
import {
33
applySdkMetadata,
4-
consoleIntegration,
54
consoleSandbox,
65
debug,
76
envToBool,
@@ -25,6 +24,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
2524
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
2625
import { processSessionIntegration } from '../integrations/processSession';
2726
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
27+
import { consoleIntegration } from '../integrations/console';
2828
import { systemErrorIntegration } from '../integrations/systemError';
2929
import { defaultStackParser, getSentryRelease } from '../sdk/api';
3030
import { makeNodeTransport } from '../transports';

packages/node-core/src/sdk/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Integration, Options } from '@sentry/core';
22
import {
33
applySdkMetadata,
4-
consoleIntegration,
54
consoleSandbox,
65
conversationIdIntegration,
76
debug,
@@ -35,6 +34,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept
3534
import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection';
3635
import { processSessionIntegration } from '../integrations/processSession';
3736
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight';
37+
import { consoleIntegration } from '../integrations/console';
3838
import { systemErrorIntegration } from '../integrations/systemError';
3939
import { makeNodeTransport } from '../transports';
4040
import type { NodeClientOptions, NodeOptions } from '../types';

0 commit comments

Comments
 (0)