From d92b0a06a5cb3d3465592f9462d8e7818e678da1 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:44:26 +0500 Subject: [PATCH 1/8] fix(node-core): Fix local variables capturing for out-of-app frames (#12588) This commit addresses an issue where local variables were not being captured for out-of-app frames, even when the `includeOutOfAppFrames` option was enabled. The `localVariablesSyncIntegration` had a race condition where it would process events before the debugger session was fully initialized. This was fixed by awaiting the session creation in `setupOnce`. The tests for this integration were failing because they were not setting up a Sentry client, which is required for the integration to be enabled. This has been corrected by adding a client to the test setup. Additionally, this commit adds tests for the `localVariablesAsyncIntegration` to ensure it correctly handles the `includeOutOfAppFrames` option. --- .../integrations/local-variables/common.ts | 6 + .../local-variables/local-variables-async.ts | 4 +- .../local-variables/local-variables-sync.ts | 172 +++++++++--------- .../local-variables/test-helpers.ts | 48 +++++ .../local-variables-async.test.ts | 73 ++++++++ .../test/integrations/local-variables.test.ts | 75 ++++++++ 6 files changed, 288 insertions(+), 90 deletions(-) create mode 100644 packages/node-core/src/integrations/local-variables/test-helpers.ts create mode 100644 packages/node-core/test/integrations/local-variables-async.test.ts create mode 100644 packages/node-core/test/integrations/local-variables.test.ts diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts index 471fa1a69864..f86988b4cbfc 100644 --- a/packages/node-core/src/integrations/local-variables/common.ts +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -99,6 +99,12 @@ export interface LocalVariablesIntegrationOptions { * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. */ maxExceptionsPerSecond?: number; + /** + * When true, local variables will be captured for all frames, including those that are not in_app. + * + * Defaults to `false`. + */ + includeOutOfAppFrames?: boolean; } export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-async.ts b/packages/node-core/src/integrations/local-variables/local-variables-async.ts index 32fff66bab4e..7bad543c2588 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-async.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-async.ts @@ -39,8 +39,8 @@ export const localVariablesAsyncIntegration = defineIntegration((( if ( // We need to have vars to add frameLocalVariables.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frame.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (frame.in_app === false && integrationOptions.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frame.function, frameLocalVariables.function) ) { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 7de91a54276e..b6c1065346c4 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -12,6 +12,7 @@ import type { Variables, } from './common'; import { createRateLimiter, functionNamesMatch } from './common'; +import { localVariablesTestHelperMethods } from './test-helpers'; /** Creates a unique hash from stack frames */ export function hashFrames(frames: StackFrame[] | undefined): string | undefined { @@ -268,8 +269,8 @@ const _localVariablesSyncIntegration = (( if ( // We need to have vars to add cachedFrameVariable.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frameVariable.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (!frameVariable.in_app && !options.includeOutOfAppFrames) || // The function names need to match !functionNamesMatch(frameVariable.function, cachedFrameVariable.function) ) { @@ -288,6 +289,8 @@ const _localVariablesSyncIntegration = (( return event; } + const testHelperMethods = localVariablesTestHelperMethods(cachedFrames); + return { name: INTEGRATION_NAME, async setupOnce() { @@ -312,96 +315,95 @@ const _localVariablesSyncIntegration = (( return; } - AsyncSession.create(sessionOverride).then( - session => { - function handlePaused( - stackParser: StackParser, - { params: { reason, data, callFrames } }: InspectorNotification, - complete: () => void, - ): void { - if (reason !== 'exception' && reason !== 'promiseRejection') { - complete(); - return; - } - - rateLimiter?.(); - - // data.description contains the original error.stack - const exceptionHash = hashFromStack(stackParser, data.description); - - if (exceptionHash == undefined) { - complete(); - return; - } - - const { add, next } = createCallbackList(frames => { - cachedFrames.set(exceptionHash, frames); - complete(); - }); + try { + const session = await AsyncSession.create(sessionOverride); + + const handlePaused = ( + stackParser: StackParser, + { params: { reason, data, callFrames } }: InspectorNotification, + complete: () => void, + ): void => { + if (reason !== 'exception' && reason !== 'promiseRejection') { + complete(); + return; + } - // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack - // For this reason we only attempt to get local variables for the first 5 frames - for (let i = 0; i < Math.min(callFrames.length, 5); i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { scopeChain, functionName, this: obj } = callFrames[i]!; + rateLimiter?.(); - const localScope = scopeChain.find(scope => scope.type === 'local'); + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data.description); - // obj.className is undefined in ESM modules - const fn = - obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + if (exceptionHash == undefined) { + complete(); + return; + } - if (localScope?.object.objectId === undefined) { - add(frames => { - frames[i] = { function: fn }; + const { add, next } = createCallbackList(frames => { + cachedFrames.set(exceptionHash, frames); + complete(); + }); + + // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack + // For this reason we only attempt to get local variables for the first 5 frames + for (let i = 0; i < Math.min(callFrames.length, 5); i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = + obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + add(frames => { + frames[i] = { function: fn }; + next(frames); + }); + } else { + const id = localScope.object.objectId; + add(frames => + session.getLocalVariables(id, vars => { + frames[i] = { function: fn, vars }; next(frames); - }); - } else { - const id = localScope.object.objectId; - add(frames => - session.getLocalVariables(id, vars => { - frames[i] = { function: fn, vars }; - next(frames); - }), - ); - } + }), + ); } - - next([]); } - const captureAll = options.captureAllExceptions !== false; + next([]); + } - session.configureAndConnect( - (ev, complete) => - handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), - captureAll, + const captureAll = options.captureAllExceptions !== false; + + session.configureAndConnect( + (ev, complete) => + handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), + captureAll, + ); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + debug.log('Local variables rate-limit lifted.'); + session.setPauseOnExceptions(true); + }, + seconds => { + debug.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session.setPauseOnExceptions(false); + }, ); + } - if (captureAll) { - const max = options.maxExceptionsPerSecond || 50; - - rateLimiter = createRateLimiter( - max, - () => { - debug.log('Local variables rate-limit lifted.'); - session.setPauseOnExceptions(true); - }, - seconds => { - debug.log( - `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, - ); - session.setPauseOnExceptions(false); - }, - ); - } - - shouldProcessEvent = true; - }, - error => { - debug.log('The `LocalVariables` integration failed to start.', error); - }, - ); + shouldProcessEvent = true; + } catch (error) { + debug.log('The `LocalVariables` integration failed to start.', error); + } }, processEvent(event: Event): Event { if (shouldProcessEvent) { @@ -410,13 +412,7 @@ const _localVariablesSyncIntegration = (( return event; }, - // These are entirely for testing - _getCachedFramesCount(): number { - return cachedFrames.size; - }, - _getFirstCachedFrame(): FrameVariables[] | undefined { - return cachedFrames.values()[0]; - }, + ...testHelperMethods, }; }) satisfies IntegrationFn; diff --git a/packages/node-core/src/integrations/local-variables/test-helpers.ts b/packages/node-core/src/integrations/local-variables/test-helpers.ts new file mode 100644 index 000000000000..42e758e72c98 --- /dev/null +++ b/packages/node-core/src/integrations/local-variables/test-helpers.ts @@ -0,0 +1,48 @@ +// // TEST-ONLY: allow tests to access the cache + +import type { LRUMap } from '@sentry/core'; +import type { FrameVariables } from './common'; + +/** + * Provides test helper methods for interacting with the local variables cache. + * These methods are intended for use in unit tests to inspect and manipulate + * the internal cache of frame variables used by the LocalVariables integration. + * + * @param cachedFrames - The LRUMap instance storing cached frame variables. + * @returns An object containing helper methods for cache inspection and mutation. + */ +export function localVariablesTestHelperMethods(cachedFrames: LRUMap): { + _getCachedFramesCount: () => number; + _getFirstCachedFrame: () => FrameVariables[] | undefined; + _setCachedFrame: (hash: string, frames: FrameVariables[]) => void; +} { + /** + * Returns the number of entries in the local variables cache. + */ + function _getCachedFramesCount(): number { + return cachedFrames.size; + } + + /** + * Returns the first set of cached frame variables, or undefined if the cache is empty. + */ + function _getFirstCachedFrame(): FrameVariables[] | undefined { + return cachedFrames.values()[0]; + } + + /** + * Sets the cached frame variables for a given stack hash. + * + * @param hash - The stack hash to associate with the cached frames. + * @param frames - The frame variables to cache. + */ + function _setCachedFrame(hash: string, frames: FrameVariables[]): void { + cachedFrames.set(hash, frames); + } + + return { + _getCachedFramesCount, + _getFirstCachedFrame, + _setCachedFrame, + }; +} diff --git a/packages/node-core/test/integrations/local-variables-async.test.ts b/packages/node-core/test/integrations/local-variables-async.test.ts new file mode 100644 index 000000000000..6d99ee862e4d --- /dev/null +++ b/packages/node-core/test/integrations/local-variables-async.test.ts @@ -0,0 +1,73 @@ +import type { Event, EventHint, StackFrame } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getCurrentScope, NodeClient } from '../../src'; +import type { FrameVariables } from '../../src/integrations/local-variables/common'; +import { LOCAL_VARIABLES_KEY } from '../../src/integrations/local-variables/common'; +import { localVariablesAsyncIntegration } from '../../src/integrations/local-variables/local-variables-async'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; + +describe('LocalVariablesAsync', () => { + beforeEach(() => { + const options = getDefaultNodeClientOptions({ + includeLocalVariables: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + const client = new NodeClient(options); + getCurrentScope().setClient(client); + }); + + it('does not add local variables to out of app frames by default', async () => { + const eventName = 'test-exclude-LocalVariables-out-of-app-frames'; + const event = getTestEvent(eventName); + const integration = localVariablesAsyncIntegration({}); + await integration.setup?.(getCurrentScope().getClient()!); + + const hint: EventHint = { + originalException: { + [LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], + }, + }; + + const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient()!) as Event; + + expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined(); + }); + + it('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { + const eventName = 'test-include-LocalVariables-out-of-app-frames'; + const event = getTestEvent(eventName); + const integration = localVariablesAsyncIntegration({ includeOutOfAppFrames: true }); + await integration.setup?.(getCurrentScope().getClient()!); + + const hint: EventHint = { + originalException: { + [LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], + }, + }; + + const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient()!) as Event; + + expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' }); + }); +}); + +function getTestEvent(fnName = 'test'): Event { + return { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + in_app: false, + function: fnName, + lineno: 1, + colno: 1, + } as StackFrame, + ], + }, + }, + ], + }, + }; +} diff --git a/packages/node-core/test/integrations/local-variables.test.ts b/packages/node-core/test/integrations/local-variables.test.ts new file mode 100644 index 000000000000..bed4bba18fef --- /dev/null +++ b/packages/node-core/test/integrations/local-variables.test.ts @@ -0,0 +1,75 @@ +import type { Event, StackFrame } from '@sentry/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getCurrentScope, NodeClient } from '../../src'; +import type { FrameVariables } from '../../src/integrations/local-variables/common'; +import type { DebugSession } from '../../src/integrations/local-variables/local-variables-sync'; +import { hashFrames, localVariablesSyncIntegration } from '../../src/integrations/local-variables/local-variables-sync'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; + +const mockSession: DebugSession = { + configureAndConnect: () => {}, + setPauseOnExceptions: () => {}, + getLocalVariables: () => {}, +}; + +describe('LocalVariables', () => { + beforeEach(() => { + const options = getDefaultNodeClientOptions({ + includeLocalVariables: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + const client = new NodeClient(options); + getCurrentScope().setClient(client); + }); + + it('does not add local variables to out of app frames by default', async () => { + const eventName = 'test-exclude-LocalVariables-out-of-app-frames'; + const event = getTestEvent(eventName); + const integration = localVariablesSyncIntegration({}, mockSession); + await integration.setupOnce?.(); + + const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); + // @ts-expect-error test helper method + integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); + + const processedEvent = integration.processEvent?.(event, {}, {} as any) as Event; + + expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined(); + }); + + it('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { + const eventName = 'test-include-LocalVariables-out-of-app-frames'; + const event = getTestEvent(eventName); + const integration = localVariablesSyncIntegration({ includeOutOfAppFrames: true }, mockSession); + await integration.setupOnce?.(); + + const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); + // @ts-expect-error test helper method + integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); + + const processedEvent = integration.processEvent?.(event, {}, {} as any) as Event; + + expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' }); + }); +}); + +function getTestEvent(fnName = 'test'): Event { + return { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + in_app: false, + function: fnName, + lineno: 1, + colno: 1, + } as StackFrame, + ], + }, + }, + ], + }, + }; +} From 7ca795c68729d8ea1664025f45048d3234358b0f Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:55:22 +0500 Subject: [PATCH 2/8] fix(local-variables): Clean up function assignment and fix syntax in local variables integration --- .../src/integrations/local-variables/local-variables-sync.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index b6c1065346c4..0cf3d82e51cb 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -352,8 +352,7 @@ const _localVariablesSyncIntegration = (( const localScope = scopeChain.find(scope => scope.type === 'local'); // obj.className is undefined in ESM modules - const fn = - obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; if (localScope?.object.objectId === undefined) { add(frames => { @@ -372,7 +371,7 @@ const _localVariablesSyncIntegration = (( } next([]); - } + }; const captureAll = options.captureAllExceptions !== false; From 53239287ffc45dfb9c8e47fab6f8f0d3f8e31754 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:00:39 +0500 Subject: [PATCH 3/8] fix(local-variables): Refine condition for skipping out-of-app frames in local variables integration --- .../src/integrations/local-variables/local-variables-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 0cf3d82e51cb..3220f0774a3e 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -270,7 +270,7 @@ const _localVariablesSyncIntegration = (( // We need to have vars to add cachedFrameVariable.vars === undefined || // Only skip out-of-app frames if includeOutOfAppFrames is not true - (!frameVariable.in_app && !options.includeOutOfAppFrames) || + (frameVariable.in_app === false && options.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frameVariable.function, cachedFrameVariable.function) ) { From 7fb4542bca6190754230a742c1d47ff236f117a1 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:46:54 +0500 Subject: [PATCH 4/8] fix(node-core): Fix race condition in local variables integration The `LocalVariables` integrations `setupOnce` method was `async`, which violates the `Integration` interface. This caused a race condition where events could be processed before the integration was fully initialized, leading to missed local variables. This commit fixes the race condition by: - Making `setupOnce` synchronous to adhere to the interface contract. - Moving the asynchronous initialization logic to a separate `setup` function. - Making `processEvent` asynchronous and awaiting the result of the `setup` function, ensuring that the integration is fully initialized before processing any events. - Updating the tests to correctly `await` the `processEvent` method. --- .../local-variables/local-variables-sync.ts | 202 +++++++++--------- .../test/integrations/local-variables.test.ts | 8 +- 2 files changed, 109 insertions(+), 101 deletions(-) diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 3220f0774a3e..50eed19888bb 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -291,120 +291,128 @@ const _localVariablesSyncIntegration = (( const testHelperMethods = localVariablesTestHelperMethods(cachedFrames); - return { - name: INTEGRATION_NAME, - async setupOnce() { - const client = getClient(); - const clientOptions = client?.getOptions(); - - if (!clientOptions?.includeLocalVariables) { - return; - } + let setupPromise: Promise | undefined; - // Only setup this integration if the Node version is >= v18 - // https://github.com/getsentry/sentry-javascript/issues/7697 - const unsupportedNodeVersion = NODE_MAJOR < 18; - - if (unsupportedNodeVersion) { - debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); - return; - } - - if (await isDebuggerEnabled()) { - debug.warn('Local variables capture has been disabled because the debugger was already enabled'); - return; - } + async function setup(): Promise { + const client = getClient(); + const clientOptions = client?.getOptions(); - try { - const session = await AsyncSession.create(sessionOverride); - - const handlePaused = ( - stackParser: StackParser, - { params: { reason, data, callFrames } }: InspectorNotification, - complete: () => void, - ): void => { - if (reason !== 'exception' && reason !== 'promiseRejection') { - complete(); - return; - } + if (!clientOptions?.includeLocalVariables) { + return; + } - rateLimiter?.(); + // Only setup this integration if the Node version is >= v18 + // https://github.com/getsentry/sentry-javascript/issues/7697 + const unsupportedNodeVersion = NODE_MAJOR < 18; - // data.description contains the original error.stack - const exceptionHash = hashFromStack(stackParser, data.description); + if (unsupportedNodeVersion) { + debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); + return; + } - if (exceptionHash == undefined) { - complete(); - return; - } + if (await isDebuggerEnabled()) { + debug.warn('Local variables capture has been disabled because the debugger was already enabled'); + return; + } - const { add, next } = createCallbackList(frames => { - cachedFrames.set(exceptionHash, frames); - complete(); - }); + try { + const session = await AsyncSession.create(sessionOverride); + + const handlePaused = ( + stackParser: StackParser, + { params: { reason, data, callFrames } }: InspectorNotification, + complete: () => void, + ): void => { + if (reason !== 'exception' && reason !== 'promiseRejection') { + complete(); + return; + } - // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack - // For this reason we only attempt to get local variables for the first 5 frames - for (let i = 0; i < Math.min(callFrames.length, 5); i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { scopeChain, functionName, this: obj } = callFrames[i]!; + rateLimiter?.(); - const localScope = scopeChain.find(scope => scope.type === 'local'); + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data.description); - // obj.className is undefined in ESM modules - const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + if (exceptionHash == undefined) { + complete(); + return; + } - if (localScope?.object.objectId === undefined) { - add(frames => { - frames[i] = { function: fn }; + const { add, next } = createCallbackList(frames => { + cachedFrames.set(exceptionHash, frames); + complete(); + }); + + // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack + // For this reason we only attempt to get local variables for the first 5 frames + for (let i = 0; i < Math.min(callFrames.length, 5); i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + add(frames => { + frames[i] = { function: fn }; + next(frames); + }); + } else { + const id = localScope.object.objectId; + add(frames => + session.getLocalVariables(id, vars => { + frames[i] = { function: fn, vars }; next(frames); - }); - } else { - const id = localScope.object.objectId; - add(frames => - session.getLocalVariables(id, vars => { - frames[i] = { function: fn, vars }; - next(frames); - }), - ); - } + }), + ); } + } - next([]); - }; - - const captureAll = options.captureAllExceptions !== false; - - session.configureAndConnect( - (ev, complete) => - handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), - captureAll, + next([]); + }; + + const captureAll = options.captureAllExceptions !== false; + + session.configureAndConnect( + (ev, complete) => + handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), + captureAll, + ); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + debug.log('Local variables rate-limit lifted.'); + session.setPauseOnExceptions(true); + }, + seconds => { + debug.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session.setPauseOnExceptions(false); + }, ); + } - if (captureAll) { - const max = options.maxExceptionsPerSecond || 50; - - rateLimiter = createRateLimiter( - max, - () => { - debug.log('Local variables rate-limit lifted.'); - session.setPauseOnExceptions(true); - }, - seconds => { - debug.log( - `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, - ); - session.setPauseOnExceptions(false); - }, - ); - } + shouldProcessEvent = true; + } catch (error) { + debug.log('The `LocalVariables` integration failed to start.', error); + } + } - shouldProcessEvent = true; - } catch (error) { - debug.log('The `LocalVariables` integration failed to start.', error); - } + return { + name: INTEGRATION_NAME, + setupOnce() { + setupPromise = setup(); }, - processEvent(event: Event): Event { + async processEvent(event: Event): Promise { + await setupPromise; + if (shouldProcessEvent) { return addLocalVariablesToEvent(event); } diff --git a/packages/node-core/test/integrations/local-variables.test.ts b/packages/node-core/test/integrations/local-variables.test.ts index bed4bba18fef..20cc785d4629 100644 --- a/packages/node-core/test/integrations/local-variables.test.ts +++ b/packages/node-core/test/integrations/local-variables.test.ts @@ -26,13 +26,13 @@ describe('LocalVariables', () => { const eventName = 'test-exclude-LocalVariables-out-of-app-frames'; const event = getTestEvent(eventName); const integration = localVariablesSyncIntegration({}, mockSession); - await integration.setupOnce?.(); + integration.setupOnce?.(); const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); // @ts-expect-error test helper method integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); - const processedEvent = integration.processEvent?.(event, {}, {} as any) as Event; + const processedEvent = (await integration.processEvent?.(event, {}, {} as any)) as Event; expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined(); }); @@ -41,13 +41,13 @@ describe('LocalVariables', () => { const eventName = 'test-include-LocalVariables-out-of-app-frames'; const event = getTestEvent(eventName); const integration = localVariablesSyncIntegration({ includeOutOfAppFrames: true }, mockSession); - await integration.setupOnce?.(); + integration.setupOnce?.(); const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); // @ts-expect-error test helper method integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); - const processedEvent = integration.processEvent?.(event, {}, {} as any) as Event; + const processedEvent = (await integration.processEvent?.(event, {}, {} as any)) as Event; expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' }); }); From 367ef5c7239186686b85381750b1530ceb927069 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 13 Sep 2025 10:42:51 +0500 Subject: [PATCH 5/8] test(local-variables): Add integration tests for local variables in out-of-app frames --- .../local-variables-out-of-app-default.js | 46 +++++++++++++++++++ .../local-variables-out-of-app.js | 42 +++++++++++++++++ .../suites/public-api/LocalVariables/test.ts | 40 ++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js new file mode 100644 index 000000000000..936f84aa375e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -0,0 +1,46 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +// const { loggingTransport } = require('@sentry-internal/node-integration-tests'); is throwing error that package not found, so using relative path +const { loggingTransport } = require('../../../src/index.ts'); + +// make sure to create the following file with the following content: +// function out_of_app_function() { +// const outOfAppVar = 'out of app value'; +// throw new Error('out-of-app error'); +// } + +// module.exports = { out_of_app_function }; + +const { out_of_app_function } = require('./node_modules/test-module/out-of-app-function.js'); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(); +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, + // either set each frame's in_app flag manually or import the `out_of_app_function` from a node_module directory + // beforeSend: (event) => { + // event.exception?.values?.[0]?.stacktrace?.frames?.forEach(frame => { + // if (frame.function === 'out_of_app_function') { + // frame.in_app = false; + // } + // }); + // return event; + // }, +}); + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + + return null; + } +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js new file mode 100644 index 000000000000..4e923391dd37 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -0,0 +1,42 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +// const { loggingTransport } = require('@sentry-internal/node-integration-tests'); is throwing error that package not found, so using relative path +const { loggingTransport } = require('../../../src/index.ts'); + +const { out_of_app_function } = require('./node_modules/test-module/out-of-app-function.js'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, + integrations: [ + Sentry.localVariablesIntegration({ + includeOutOfAppFrames: true, + }), + ], + // either set each frame's in_app flag manually or import the `out_of_app_function` from a node_module directory + // beforeSend: (event) => { + // event.exception?.values?.[0]?.stacktrace?.frames?.forEach(frame => { + // if (frame.function === 'out_of_app_function') { + // frame.in_app = false; + // } + // }); + // return event; + // }, +}); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(); +} + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + return null; + } +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 2c87d14c2b45..a19e1a803b7d 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -127,4 +127,44 @@ describe('LocalVariables integration', () => { .start() .completed(); }); + + test('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { + await createRunner(__dirname, 'local-variables-out-of-app.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toEqual({ outOfAppVar: 'out of app value' }); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); + + test('does not add local variables to out of app frames by default', async () => { + await createRunner(__dirname, 'local-variables-out-of-app-default.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toBeUndefined(); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); }); From 76893c5928b8ab26be91ed9faae37ddc7597f1b4 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 13 Sep 2025 10:45:11 +0500 Subject: [PATCH 6/8] refactor(local-variables): Remove test helper methods and related tests --- .../local-variables/local-variables-sync.ts | 11 ++- .../local-variables/test-helpers.ts | 48 ------------ .../test/integrations/local-variables.test.ts | 75 ------------------- 3 files changed, 7 insertions(+), 127 deletions(-) delete mode 100644 packages/node-core/src/integrations/local-variables/test-helpers.ts delete mode 100644 packages/node-core/test/integrations/local-variables.test.ts diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 50eed19888bb..b2af37b0c7fb 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -12,7 +12,6 @@ import type { Variables, } from './common'; import { createRateLimiter, functionNamesMatch } from './common'; -import { localVariablesTestHelperMethods } from './test-helpers'; /** Creates a unique hash from stack frames */ export function hashFrames(frames: StackFrame[] | undefined): string | undefined { @@ -289,8 +288,6 @@ const _localVariablesSyncIntegration = (( return event; } - const testHelperMethods = localVariablesTestHelperMethods(cachedFrames); - let setupPromise: Promise | undefined; async function setup(): Promise { @@ -419,7 +416,13 @@ const _localVariablesSyncIntegration = (( return event; }, - ...testHelperMethods, + // These are entirely for testing + _getCachedFramesCount(): number { + return cachedFrames.size; + }, + _getFirstCachedFrame(): FrameVariables[] | undefined { + return cachedFrames.values()[0]; + }, }; }) satisfies IntegrationFn; diff --git a/packages/node-core/src/integrations/local-variables/test-helpers.ts b/packages/node-core/src/integrations/local-variables/test-helpers.ts deleted file mode 100644 index 42e758e72c98..000000000000 --- a/packages/node-core/src/integrations/local-variables/test-helpers.ts +++ /dev/null @@ -1,48 +0,0 @@ -// // TEST-ONLY: allow tests to access the cache - -import type { LRUMap } from '@sentry/core'; -import type { FrameVariables } from './common'; - -/** - * Provides test helper methods for interacting with the local variables cache. - * These methods are intended for use in unit tests to inspect and manipulate - * the internal cache of frame variables used by the LocalVariables integration. - * - * @param cachedFrames - The LRUMap instance storing cached frame variables. - * @returns An object containing helper methods for cache inspection and mutation. - */ -export function localVariablesTestHelperMethods(cachedFrames: LRUMap): { - _getCachedFramesCount: () => number; - _getFirstCachedFrame: () => FrameVariables[] | undefined; - _setCachedFrame: (hash: string, frames: FrameVariables[]) => void; -} { - /** - * Returns the number of entries in the local variables cache. - */ - function _getCachedFramesCount(): number { - return cachedFrames.size; - } - - /** - * Returns the first set of cached frame variables, or undefined if the cache is empty. - */ - function _getFirstCachedFrame(): FrameVariables[] | undefined { - return cachedFrames.values()[0]; - } - - /** - * Sets the cached frame variables for a given stack hash. - * - * @param hash - The stack hash to associate with the cached frames. - * @param frames - The frame variables to cache. - */ - function _setCachedFrame(hash: string, frames: FrameVariables[]): void { - cachedFrames.set(hash, frames); - } - - return { - _getCachedFramesCount, - _getFirstCachedFrame, - _setCachedFrame, - }; -} diff --git a/packages/node-core/test/integrations/local-variables.test.ts b/packages/node-core/test/integrations/local-variables.test.ts deleted file mode 100644 index 20cc785d4629..000000000000 --- a/packages/node-core/test/integrations/local-variables.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Event, StackFrame } from '@sentry/core'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { getCurrentScope, NodeClient } from '../../src'; -import type { FrameVariables } from '../../src/integrations/local-variables/common'; -import type { DebugSession } from '../../src/integrations/local-variables/local-variables-sync'; -import { hashFrames, localVariablesSyncIntegration } from '../../src/integrations/local-variables/local-variables-sync'; -import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; - -const mockSession: DebugSession = { - configureAndConnect: () => {}, - setPauseOnExceptions: () => {}, - getLocalVariables: () => {}, -}; - -describe('LocalVariables', () => { - beforeEach(() => { - const options = getDefaultNodeClientOptions({ - includeLocalVariables: true, - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }); - const client = new NodeClient(options); - getCurrentScope().setClient(client); - }); - - it('does not add local variables to out of app frames by default', async () => { - const eventName = 'test-exclude-LocalVariables-out-of-app-frames'; - const event = getTestEvent(eventName); - const integration = localVariablesSyncIntegration({}, mockSession); - integration.setupOnce?.(); - - const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); - // @ts-expect-error test helper method - integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); - - const processedEvent = (await integration.processEvent?.(event, {}, {} as any)) as Event; - - expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined(); - }); - - it('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { - const eventName = 'test-include-LocalVariables-out-of-app-frames'; - const event = getTestEvent(eventName); - const integration = localVariablesSyncIntegration({ includeOutOfAppFrames: true }, mockSession); - integration.setupOnce?.(); - - const hash = hashFrames(event.exception!.values![0]!.stacktrace!.frames); - // @ts-expect-error test helper method - integration._setCachedFrame(hash!, [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables]); - - const processedEvent = (await integration.processEvent?.(event, {}, {} as any)) as Event; - - expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' }); - }); -}); - -function getTestEvent(fnName = 'test'): Event { - return { - exception: { - values: [ - { - stacktrace: { - frames: [ - { - in_app: false, - function: fnName, - lineno: 1, - colno: 1, - } as StackFrame, - ], - }, - }, - ], - }, - }; -} From 6ff90dc332f737408f954ccef917f3145c1ebc82 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 13 Sep 2025 10:55:54 +0500 Subject: [PATCH 7/8] refactor(local-variables): Remove local variables async integration tests --- .../local-variables-async.test.ts | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 packages/node-core/test/integrations/local-variables-async.test.ts diff --git a/packages/node-core/test/integrations/local-variables-async.test.ts b/packages/node-core/test/integrations/local-variables-async.test.ts deleted file mode 100644 index 6d99ee862e4d..000000000000 --- a/packages/node-core/test/integrations/local-variables-async.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Event, EventHint, StackFrame } from '@sentry/core'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { getCurrentScope, NodeClient } from '../../src'; -import type { FrameVariables } from '../../src/integrations/local-variables/common'; -import { LOCAL_VARIABLES_KEY } from '../../src/integrations/local-variables/common'; -import { localVariablesAsyncIntegration } from '../../src/integrations/local-variables/local-variables-async'; -import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; - -describe('LocalVariablesAsync', () => { - beforeEach(() => { - const options = getDefaultNodeClientOptions({ - includeLocalVariables: true, - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }); - const client = new NodeClient(options); - getCurrentScope().setClient(client); - }); - - it('does not add local variables to out of app frames by default', async () => { - const eventName = 'test-exclude-LocalVariables-out-of-app-frames'; - const event = getTestEvent(eventName); - const integration = localVariablesAsyncIntegration({}); - await integration.setup?.(getCurrentScope().getClient()!); - - const hint: EventHint = { - originalException: { - [LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], - }, - }; - - const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient()!) as Event; - - expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined(); - }); - - it('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { - const eventName = 'test-include-LocalVariables-out-of-app-frames'; - const event = getTestEvent(eventName); - const integration = localVariablesAsyncIntegration({ includeOutOfAppFrames: true }); - await integration.setup?.(getCurrentScope().getClient()!); - - const hint: EventHint = { - originalException: { - [LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], - }, - }; - - const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient()!) as Event; - - expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' }); - }); -}); - -function getTestEvent(fnName = 'test'): Event { - return { - exception: { - values: [ - { - stacktrace: { - frames: [ - { - in_app: false, - function: fnName, - lineno: 1, - colno: 1, - } as StackFrame, - ], - }, - }, - ], - }, - }; -} From d2a40cf087dc69fb39cd979c593cec8256b307f3 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:41:32 +0500 Subject: [PATCH 8/8] fix(logging-transport): Correct import path for loggingTransport to resolve package not found error --- .../LocalVariables/local-variables-out-of-app-default.js | 3 +-- .../public-api/LocalVariables/local-variables-out-of-app.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js index 936f84aa375e..1f5d7e62d3ee 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -1,8 +1,7 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); -// const { loggingTransport } = require('@sentry-internal/node-integration-tests'); is throwing error that package not found, so using relative path -const { loggingTransport } = require('../../../src/index.ts'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); // make sure to create the following file with the following content: // function out_of_app_function() { diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js index 4e923391dd37..7ee8607ed134 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -1,8 +1,7 @@ /* eslint-disable no-unused-vars */ const Sentry = require('@sentry/node'); -// const { loggingTransport } = require('@sentry-internal/node-integration-tests'); is throwing error that package not found, so using relative path -const { loggingTransport } = require('../../../src/index.ts'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); const { out_of_app_function } = require('./node_modules/test-module/out-of-app-function.js');