Skip to content

Commit d92b0a0

Browse files
committed
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.
1 parent 64e486d commit d92b0a0

File tree

6 files changed

+288
-90
lines changed

6 files changed

+288
-90
lines changed

packages/node-core/src/integrations/local-variables/common.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ export interface LocalVariablesIntegrationOptions {
9999
* Maximum number of exceptions to capture local variables for per second before rate limiting is triggered.
100100
*/
101101
maxExceptionsPerSecond?: number;
102+
/**
103+
* When true, local variables will be captured for all frames, including those that are not in_app.
104+
*
105+
* Defaults to `false`.
106+
*/
107+
includeOutOfAppFrames?: boolean;
102108
}
103109

104110
export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions {

packages/node-core/src/integrations/local-variables/local-variables-async.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export const localVariablesAsyncIntegration = defineIntegration(((
3939
if (
4040
// We need to have vars to add
4141
frameLocalVariables.vars === undefined ||
42-
// We're not interested in frames that are not in_app because the vars are not relevant
43-
frame.in_app === false ||
42+
// Only skip out-of-app frames if includeOutOfAppFrames is not true
43+
(frame.in_app === false && integrationOptions.includeOutOfAppFrames !== true) ||
4444
// The function names need to match
4545
!functionNamesMatch(frame.function, frameLocalVariables.function)
4646
) {

packages/node-core/src/integrations/local-variables/local-variables-sync.ts

Lines changed: 84 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
Variables,
1313
} from './common';
1414
import { createRateLimiter, functionNamesMatch } from './common';
15+
import { localVariablesTestHelperMethods } from './test-helpers';
1516

1617
/** Creates a unique hash from stack frames */
1718
export function hashFrames(frames: StackFrame[] | undefined): string | undefined {
@@ -268,8 +269,8 @@ const _localVariablesSyncIntegration = ((
268269
if (
269270
// We need to have vars to add
270271
cachedFrameVariable.vars === undefined ||
271-
// We're not interested in frames that are not in_app because the vars are not relevant
272-
frameVariable.in_app === false ||
272+
// Only skip out-of-app frames if includeOutOfAppFrames is not true
273+
(!frameVariable.in_app && !options.includeOutOfAppFrames) ||
273274
// The function names need to match
274275
!functionNamesMatch(frameVariable.function, cachedFrameVariable.function)
275276
) {
@@ -288,6 +289,8 @@ const _localVariablesSyncIntegration = ((
288289
return event;
289290
}
290291

292+
const testHelperMethods = localVariablesTestHelperMethods(cachedFrames);
293+
291294
return {
292295
name: INTEGRATION_NAME,
293296
async setupOnce() {
@@ -312,96 +315,95 @@ const _localVariablesSyncIntegration = ((
312315
return;
313316
}
314317

315-
AsyncSession.create(sessionOverride).then(
316-
session => {
317-
function handlePaused(
318-
stackParser: StackParser,
319-
{ params: { reason, data, callFrames } }: InspectorNotification<PausedExceptionEvent>,
320-
complete: () => void,
321-
): void {
322-
if (reason !== 'exception' && reason !== 'promiseRejection') {
323-
complete();
324-
return;
325-
}
326-
327-
rateLimiter?.();
328-
329-
// data.description contains the original error.stack
330-
const exceptionHash = hashFromStack(stackParser, data.description);
331-
332-
if (exceptionHash == undefined) {
333-
complete();
334-
return;
335-
}
336-
337-
const { add, next } = createCallbackList<FrameVariables[]>(frames => {
338-
cachedFrames.set(exceptionHash, frames);
339-
complete();
340-
});
318+
try {
319+
const session = await AsyncSession.create(sessionOverride);
320+
321+
const handlePaused = (
322+
stackParser: StackParser,
323+
{ params: { reason, data, callFrames } }: InspectorNotification<PausedExceptionEvent>,
324+
complete: () => void,
325+
): void => {
326+
if (reason !== 'exception' && reason !== 'promiseRejection') {
327+
complete();
328+
return;
329+
}
341330

342-
// Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack
343-
// For this reason we only attempt to get local variables for the first 5 frames
344-
for (let i = 0; i < Math.min(callFrames.length, 5); i++) {
345-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
346-
const { scopeChain, functionName, this: obj } = callFrames[i]!;
331+
rateLimiter?.();
347332

348-
const localScope = scopeChain.find(scope => scope.type === 'local');
333+
// data.description contains the original error.stack
334+
const exceptionHash = hashFromStack(stackParser, data.description);
349335

350-
// obj.className is undefined in ESM modules
351-
const fn =
352-
obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`;
336+
if (exceptionHash == undefined) {
337+
complete();
338+
return;
339+
}
353340

354-
if (localScope?.object.objectId === undefined) {
355-
add(frames => {
356-
frames[i] = { function: fn };
341+
const { add, next } = createCallbackList<FrameVariables[]>(frames => {
342+
cachedFrames.set(exceptionHash, frames);
343+
complete();
344+
});
345+
346+
// Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack
347+
// For this reason we only attempt to get local variables for the first 5 frames
348+
for (let i = 0; i < Math.min(callFrames.length, 5); i++) {
349+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
350+
const { scopeChain, functionName, this: obj } = callFrames[i]!;
351+
352+
const localScope = scopeChain.find(scope => scope.type === 'local');
353+
354+
// obj.className is undefined in ESM modules
355+
const fn =
356+
obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`;
357+
358+
if (localScope?.object.objectId === undefined) {
359+
add(frames => {
360+
frames[i] = { function: fn };
361+
next(frames);
362+
});
363+
} else {
364+
const id = localScope.object.objectId;
365+
add(frames =>
366+
session.getLocalVariables(id, vars => {
367+
frames[i] = { function: fn, vars };
357368
next(frames);
358-
});
359-
} else {
360-
const id = localScope.object.objectId;
361-
add(frames =>
362-
session.getLocalVariables(id, vars => {
363-
frames[i] = { function: fn, vars };
364-
next(frames);
365-
}),
366-
);
367-
}
369+
}),
370+
);
368371
}
369-
370-
next([]);
371372
}
372373

373-
const captureAll = options.captureAllExceptions !== false;
374+
next([]);
375+
}
374376

375-
session.configureAndConnect(
376-
(ev, complete) =>
377-
handlePaused(clientOptions.stackParser, ev as InspectorNotification<PausedExceptionEvent>, complete),
378-
captureAll,
377+
const captureAll = options.captureAllExceptions !== false;
378+
379+
session.configureAndConnect(
380+
(ev, complete) =>
381+
handlePaused(clientOptions.stackParser, ev as InspectorNotification<PausedExceptionEvent>, complete),
382+
captureAll,
383+
);
384+
385+
if (captureAll) {
386+
const max = options.maxExceptionsPerSecond || 50;
387+
388+
rateLimiter = createRateLimiter(
389+
max,
390+
() => {
391+
debug.log('Local variables rate-limit lifted.');
392+
session.setPauseOnExceptions(true);
393+
},
394+
seconds => {
395+
debug.log(
396+
`Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`,
397+
);
398+
session.setPauseOnExceptions(false);
399+
},
379400
);
401+
}
380402

381-
if (captureAll) {
382-
const max = options.maxExceptionsPerSecond || 50;
383-
384-
rateLimiter = createRateLimiter(
385-
max,
386-
() => {
387-
debug.log('Local variables rate-limit lifted.');
388-
session.setPauseOnExceptions(true);
389-
},
390-
seconds => {
391-
debug.log(
392-
`Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`,
393-
);
394-
session.setPauseOnExceptions(false);
395-
},
396-
);
397-
}
398-
399-
shouldProcessEvent = true;
400-
},
401-
error => {
402-
debug.log('The `LocalVariables` integration failed to start.', error);
403-
},
404-
);
403+
shouldProcessEvent = true;
404+
} catch (error) {
405+
debug.log('The `LocalVariables` integration failed to start.', error);
406+
}
405407
},
406408
processEvent(event: Event): Event {
407409
if (shouldProcessEvent) {
@@ -410,13 +412,7 @@ const _localVariablesSyncIntegration = ((
410412

411413
return event;
412414
},
413-
// These are entirely for testing
414-
_getCachedFramesCount(): number {
415-
return cachedFrames.size;
416-
},
417-
_getFirstCachedFrame(): FrameVariables[] | undefined {
418-
return cachedFrames.values()[0];
419-
},
415+
...testHelperMethods,
420416
};
421417
}) satisfies IntegrationFn;
422418

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// // TEST-ONLY: allow tests to access the cache
2+
3+
import type { LRUMap } from '@sentry/core';
4+
import type { FrameVariables } from './common';
5+
6+
/**
7+
* Provides test helper methods for interacting with the local variables cache.
8+
* These methods are intended for use in unit tests to inspect and manipulate
9+
* the internal cache of frame variables used by the LocalVariables integration.
10+
*
11+
* @param cachedFrames - The LRUMap instance storing cached frame variables.
12+
* @returns An object containing helper methods for cache inspection and mutation.
13+
*/
14+
export function localVariablesTestHelperMethods(cachedFrames: LRUMap<string, FrameVariables[]>): {
15+
_getCachedFramesCount: () => number;
16+
_getFirstCachedFrame: () => FrameVariables[] | undefined;
17+
_setCachedFrame: (hash: string, frames: FrameVariables[]) => void;
18+
} {
19+
/**
20+
* Returns the number of entries in the local variables cache.
21+
*/
22+
function _getCachedFramesCount(): number {
23+
return cachedFrames.size;
24+
}
25+
26+
/**
27+
* Returns the first set of cached frame variables, or undefined if the cache is empty.
28+
*/
29+
function _getFirstCachedFrame(): FrameVariables[] | undefined {
30+
return cachedFrames.values()[0];
31+
}
32+
33+
/**
34+
* Sets the cached frame variables for a given stack hash.
35+
*
36+
* @param hash - The stack hash to associate with the cached frames.
37+
* @param frames - The frame variables to cache.
38+
*/
39+
function _setCachedFrame(hash: string, frames: FrameVariables[]): void {
40+
cachedFrames.set(hash, frames);
41+
}
42+
43+
return {
44+
_getCachedFramesCount,
45+
_getFirstCachedFrame,
46+
_setCachedFrame,
47+
};
48+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Event, EventHint, StackFrame } from '@sentry/core';
2+
import { beforeEach, describe, expect, it } from 'vitest';
3+
import { getCurrentScope, NodeClient } from '../../src';
4+
import type { FrameVariables } from '../../src/integrations/local-variables/common';
5+
import { LOCAL_VARIABLES_KEY } from '../../src/integrations/local-variables/common';
6+
import { localVariablesAsyncIntegration } from '../../src/integrations/local-variables/local-variables-async';
7+
import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions';
8+
9+
describe('LocalVariablesAsync', () => {
10+
beforeEach(() => {
11+
const options = getDefaultNodeClientOptions({
12+
includeLocalVariables: true,
13+
dsn: 'https://[email protected]/1337',
14+
});
15+
const client = new NodeClient(options);
16+
getCurrentScope().setClient(client);
17+
});
18+
19+
it('does not add local variables to out of app frames by default', async () => {
20+
const eventName = 'test-exclude-LocalVariables-out-of-app-frames';
21+
const event = getTestEvent(eventName);
22+
const integration = localVariablesAsyncIntegration({});
23+
await integration.setup?.(getCurrentScope().getClient<NodeClient>()!);
24+
25+
const hint: EventHint = {
26+
originalException: {
27+
[LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables],
28+
},
29+
};
30+
31+
const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient<NodeClient>()!) as Event;
32+
33+
expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toBeUndefined();
34+
});
35+
36+
it('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => {
37+
const eventName = 'test-include-LocalVariables-out-of-app-frames';
38+
const event = getTestEvent(eventName);
39+
const integration = localVariablesAsyncIntegration({ includeOutOfAppFrames: true });
40+
await integration.setup?.(getCurrentScope().getClient<NodeClient>()!);
41+
42+
const hint: EventHint = {
43+
originalException: {
44+
[LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables],
45+
},
46+
};
47+
48+
const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient<NodeClient>()!) as Event;
49+
50+
expect(processedEvent.exception?.values?.[0]?.stacktrace?.frames?.[0]?.vars).toEqual({ foo: 'bar' });
51+
});
52+
});
53+
54+
function getTestEvent(fnName = 'test'): Event {
55+
return {
56+
exception: {
57+
values: [
58+
{
59+
stacktrace: {
60+
frames: [
61+
{
62+
in_app: false,
63+
function: fnName,
64+
lineno: 1,
65+
colno: 1,
66+
} as StackFrame,
67+
],
68+
},
69+
},
70+
],
71+
},
72+
};
73+
}

0 commit comments

Comments
 (0)