-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(node): Fix local variables capturing for out-of-app frames #17545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 1 commit
d92b0a0
7ca795c
5323928
7fb4542
662ab6d
367ef5c
76893c5
6ff90dc
c81dc55
d2a40cf
651d3f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<PausedExceptionEvent>, | ||
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<FrameVariables[]>(frames => { | ||
cachedFrames.set(exceptionHash, frames); | ||
complete(); | ||
}); | ||
try { | ||
const session = await AsyncSession.create(sessionOverride); | ||
|
||
const handlePaused = ( | ||
stackParser: StackParser, | ||
{ params: { reason, data, callFrames } }: InspectorNotification<PausedExceptionEvent>, | ||
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<FrameVariables[]>(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<PausedExceptionEvent>, complete), | ||
captureAll, | ||
const captureAll = options.captureAllExceptions !== false; | ||
|
||
session.configureAndConnect( | ||
(ev, complete) => | ||
handlePaused(clientOptions.stackParser, ev as InspectorNotification<PausedExceptionEvent>, 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; | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string, FrameVariables[]>): { | ||
_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, | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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://[email protected]/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<NodeClient>()!); | ||
|
||
const hint: EventHint = { | ||
originalException: { | ||
[LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], | ||
}, | ||
}; | ||
|
||
const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient<NodeClient>()!) 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<NodeClient>()!); | ||
|
||
const hint: EventHint = { | ||
originalException: { | ||
[LOCAL_VARIABLES_KEY]: [{ function: eventName, vars: { foo: 'bar' } } as FrameVariables], | ||
}, | ||
}; | ||
|
||
const processedEvent = integration.processEvent?.(event, hint, getCurrentScope().getClient<NodeClient>()!) 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, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
}; | ||
} |
Uh oh!
There was an error while loading. Please reload this page.