diff --git a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js index e04783b9460a..08a8d81383a1 100644 --- a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js +++ b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.js @@ -28,8 +28,10 @@ function one(name) { ty.two(name); } -try { - one('some name'); -} catch (e) { - Sentry.captureException(e); -} +setTimeout(() => { + try { + one('some name'); + } catch (e) { + Sentry.captureException(e); + } +}, 1000); diff --git a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs index ea2c1eb2ff68..3fbf2ae69df7 100644 --- a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs +++ b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-caught.mjs @@ -30,8 +30,10 @@ function one(name) { ty.two(name); } -try { - one('some name'); -} catch (e) { - Sentry.captureException(e); -} +setTimeout(() => { + try { + one('some name'); + } catch (e) { + Sentry.captureException(e); + } +}, 1000); diff --git a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js index 11eba49dbdba..a579a9cf5ff0 100644 --- a/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js +++ b/packages/node-integration-tests/suites/public-api/LocalVariables/local-variables.js @@ -32,4 +32,6 @@ function one(name) { ty.two(name); } -one('some name'); +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js b/packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js index 03c9254efea8..e9f189647e1a 100644 --- a/packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js +++ b/packages/node-integration-tests/suites/public-api/LocalVariables/no-local-variables.js @@ -31,4 +31,6 @@ function one(name) { ty.two(name); } -one('some name'); +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 63cf685d2bc5..92c58bbe5b8d 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -6,7 +6,7 @@ export { Modules } from './modules'; export { ContextLines } from './contextlines'; export { Context } from './context'; export { RequestData } from '@sentry/core'; -export { LocalVariables } from './localvariables'; +export { LocalVariables } from './local-variables'; export { Undici } from './undici'; export { Spotlight } from './spotlight'; export { Anr } from './anr'; diff --git a/packages/node/src/integrations/local-variables/common.ts b/packages/node/src/integrations/local-variables/common.ts new file mode 100644 index 000000000000..5e5a8170d52b --- /dev/null +++ b/packages/node/src/integrations/local-variables/common.ts @@ -0,0 +1,119 @@ +import type { StackFrame, StackParser } from '@sentry/types'; +import type { Debugger } from 'inspector'; + +export type Variables = Record; + +export type RateLimitIncrement = () => void; + +/** + * Creates a rate limiter that will call the disable callback when the rate limit is reached and the enable callback + * when a timeout has occurred. + * @param maxPerSecond Maximum number of calls per second + * @param enable Callback to enable capture + * @param disable Callback to disable capture + * @returns A function to call to increment the rate limiter count + */ +export function createRateLimiter( + maxPerSecond: number, + enable: () => void, + disable: (seconds: number) => void, +): RateLimitIncrement { + let count = 0; + let retrySeconds = 5; + let disabledTimeout = 0; + + setInterval(() => { + if (disabledTimeout === 0) { + if (count > maxPerSecond) { + retrySeconds *= 2; + disable(retrySeconds); + + // Cap at one day + if (retrySeconds > 86400) { + retrySeconds = 86400; + } + disabledTimeout = retrySeconds; + } + } else { + disabledTimeout -= 1; + + if (disabledTimeout === 0) { + enable(); + } + } + + count = 0; + }, 1_000).unref(); + + return () => { + count += 1; + }; +} + +// Add types for the exception event data +export type PausedExceptionEvent = Debugger.PausedEventDataType & { + data: { + // This contains error.stack + description: string; + }; +}; + +/** Could this be an anonymous function? */ +export function isAnonymous(name: string | undefined): boolean { + return name !== undefined && (name.length === 0 || name === '?' || name === ''); +} + +/** Do the function names appear to match? */ +export function functionNamesMatch(a: string | undefined, b: string | undefined): boolean { + return a === b || (isAnonymous(a) && isAnonymous(b)); +} + +/** Creates a unique hash from stack frames */ +export function hashFrames(frames: StackFrame[] | undefined): string | undefined { + if (frames === undefined) { + return; + } + + // Only hash the 10 most recent frames (ie. the last 10) + return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, ''); +} + +/** + * We use the stack parser to create a unique hash from the exception stack trace + * This is used to lookup vars when the exception passes through the event processor + */ +export function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined { + if (stack === undefined) { + return undefined; + } + + return hashFrames(stackParser(stack, 1)); +} + +export interface FrameVariables { + function: string; + vars?: Variables; +} + +export interface Options { + /** + * Capture local variables for both caught and uncaught exceptions + * + * - When false, only uncaught exceptions will have local variables + * - When true, both caught and uncaught exceptions will have local variables. + * + * Defaults to `true`. + * + * Capturing local variables for all exceptions can be expensive since the debugger pauses for every throw to collect + * local variables. + * + * To reduce the likelihood of this feature impacting app performance or throughput, this feature is rate-limited. + * Once the rate limit is reached, local variables will only be captured for uncaught exceptions until a timeout has + * been reached. + */ + captureAllExceptions?: boolean; + /** + * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. + */ + maxExceptionsPerSecond?: number; +} diff --git a/packages/node/src/integrations/local-variables/index.ts b/packages/node/src/integrations/local-variables/index.ts new file mode 100644 index 000000000000..970eaea52719 --- /dev/null +++ b/packages/node/src/integrations/local-variables/index.ts @@ -0,0 +1,21 @@ +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import { NODE_VERSION } from '../../nodeVersion'; +import type { Options } from './common'; +import { localVariablesAsync } from './local-variables-async'; +import { localVariablesSync } from './local-variables-sync'; + +const INTEGRATION_NAME = 'LocalVariables'; + +/** + * Adds local variables to exception frames + */ +const localVariables: IntegrationFn = (options: Options = {}) => { + return NODE_VERSION.major < 19 ? localVariablesSync(options) : localVariablesAsync(options); +}; + +/** + * Adds local variables to exception frames + */ +// eslint-disable-next-line deprecation/deprecation +export const LocalVariables = convertIntegrationFnToClass(INTEGRATION_NAME, localVariables); diff --git a/packages/node/src/integrations/inspector.d.ts b/packages/node/src/integrations/local-variables/inspector.d.ts similarity index 99% rename from packages/node/src/integrations/inspector.d.ts rename to packages/node/src/integrations/local-variables/inspector.d.ts index 527006910ee9..fca628d8405d 100644 --- a/packages/node/src/integrations/inspector.d.ts +++ b/packages/node/src/integrations/local-variables/inspector.d.ts @@ -3357,3 +3357,31 @@ declare module 'node:inspector' { import inspector = require('inspector'); export = inspector; } + +/** + * @types/node doesn't have a `node:inspector/promises` module, maybe because it's still experimental? + */ +declare module 'node:inspector/promises' { + /** + * Async Debugger session + */ + class Session { + constructor(); + + connect(): void; + + post(method: 'Debugger.pause' | 'Debugger.resume' | 'Debugger.enable' | 'Debugger.disable'): Promise; + post(method: 'Debugger.setPauseOnExceptions', params: Debugger.SetPauseOnExceptionsParameterType): Promise; + post( + method: 'Runtime.getProperties', + params: Runtime.GetPropertiesParameterType, + ): Promise; + + on( + event: 'Debugger.paused', + listener: (message: InspectorNotification) => void, + ): Session; + + on(event: 'Debugger.resumed', listener: () => void): Session; + } +} diff --git a/packages/node/src/integrations/local-variables/local-variables-async.ts b/packages/node/src/integrations/local-variables/local-variables-async.ts new file mode 100644 index 000000000000..c3072c6c3f11 --- /dev/null +++ b/packages/node/src/integrations/local-variables/local-variables-async.ts @@ -0,0 +1,252 @@ +import type { Session } from 'node:inspector/promises'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, Exception, IntegrationFn, StackParser } from '@sentry/types'; +import { LRUMap, dynamicRequire, logger } from '@sentry/utils'; +import type { Debugger, InspectorNotification, Runtime } from 'inspector'; + +import type { NodeClient } from '../../client'; +import type { NodeClientOptions } from '../../types'; +import type { FrameVariables, Options, PausedExceptionEvent, RateLimitIncrement, Variables } from './common'; +import { createRateLimiter, functionNamesMatch, hashFrames, hashFromStack } from './common'; + +async function unrollArray(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .filter(v => v.name !== 'length' && !isNaN(parseInt(v.name, 10))) + .sort((a, b) => parseInt(a.name, 10) - parseInt(b.name, 10)) + .map(v => v.value?.value); +} + +async function unrollObject(session: Session, objectId: string, name: string, vars: Variables): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + + vars[name] = properties.result + .map<[string, unknown]>(v => [v.name, v.value?.value]) + .reduce((obj, [key, val]) => { + obj[key] = val; + return obj; + }, {} as Variables); +} + +function unrollOther(prop: Runtime.PropertyDescriptor, vars: Variables): void { + if (prop?.value?.value) { + vars[prop.name] = prop.value.value; + } else if (prop?.value?.description && prop?.value?.type !== 'function') { + vars[prop.name] = `<${prop.value.description}>`; + } +} + +async function getLocalVariables(session: Session, objectId: string): Promise { + const properties: Runtime.GetPropertiesReturnType = await session.post('Runtime.getProperties', { + objectId, + ownProperties: true, + }); + const variables = {}; + + for (const prop of properties.result) { + if (prop?.value?.objectId && prop?.value.className === 'Array') { + const id = prop.value.objectId; + await unrollArray(session, id, prop.name, variables); + } else if (prop?.value?.objectId && prop?.value?.className === 'Object') { + const id = prop.value.objectId; + await unrollObject(session, id, prop.name, variables); + } else if (prop?.value?.value || prop?.value?.description) { + unrollOther(prop, variables); + } + } + + return variables; +} + +const INTEGRATION_NAME = 'LocalVariablesAsync'; + +/** + * Adds local variables to exception frames + */ +export const localVariablesAsync: IntegrationFn = (options: Options = {}) => { + const cachedFrames: LRUMap = new LRUMap(20); + let rateLimiter: RateLimitIncrement | undefined; + let shouldProcessEvent = false; + + async function handlePaused( + session: Session, + stackParser: StackParser, + { reason, data, callFrames }: PausedExceptionEvent, + ): Promise { + if (reason !== 'exception' && reason !== 'promiseRejection') { + return; + } + + rateLimiter?.(); + + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data?.description); + + if (exceptionHash == undefined) { + return; + } + + const frames = []; + + for (let i = 0; i < callFrames.length; i++) { + 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) { + frames[i] = { function: fn }; + } else { + const vars = await getLocalVariables(session, localScope.object.objectId); + frames[i] = { function: fn, vars }; + } + } + + cachedFrames.set(exceptionHash, frames); + } + + async function startDebugger(session: Session, clientOptions: NodeClientOptions): Promise { + session.connect(); + + let isPaused = false; + + session.on('Debugger.resumed', () => { + isPaused = false; + }); + + session.on('Debugger.paused', (event: InspectorNotification) => { + isPaused = true; + + handlePaused(session, clientOptions.stackParser, event.params as PausedExceptionEvent).then( + () => { + // After the pause work is complete, resume execution! + return isPaused ? session.post('Debugger.resume') : Promise.resolve(); + }, + _ => { + // ignore + }, + ); + }); + + await session.post('Debugger.enable'); + + const captureAll = options.captureAllExceptions !== false; + await session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' }); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + logger.log('Local variables rate-limit lifted.'); + return session.post('Debugger.setPauseOnExceptions', { state: 'all' }); + }, + seconds => { + logger.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + return session.post('Debugger.setPauseOnExceptions', { state: 'uncaught' }); + }, + ); + } + + shouldProcessEvent = true; + } + + function addLocalVariablesToException(exception: Exception): void { + const hash = hashFrames(exception.stacktrace?.frames); + + if (hash === undefined) { + return; + } + + // Check if we have local variables for an exception that matches the hash + // remove is identical to get but also removes the entry from the cache + const cachedFrame = cachedFrames.remove(hash); + + if (cachedFrame === undefined) { + return; + } + + const frameCount = exception.stacktrace?.frames?.length || 0; + + for (let i = 0; i < frameCount; i++) { + // Sentry frames are in reverse order + const frameIndex = frameCount - i - 1; + + // Drop out if we run out of frames to match up + if (!exception.stacktrace?.frames?.[frameIndex] || !cachedFrame[i]) { + break; + } + + if ( + // We need to have vars to add + cachedFrame[i].vars === undefined || + // We're not interested in frames that are not in_app because the vars are not relevant + exception.stacktrace.frames[frameIndex].in_app === false || + // The function names need to match + !functionNamesMatch(exception.stacktrace.frames[frameIndex].function, cachedFrame[i].function) + ) { + continue; + } + + exception.stacktrace.frames[frameIndex].vars = cachedFrame[i].vars; + } + } + + function addLocalVariablesToEvent(event: Event): Event { + for (const exception of event.exception?.values || []) { + addLocalVariablesToException(exception); + } + + return event; + } + + return { + name: INTEGRATION_NAME, + setup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (!clientOptions.includeLocalVariables) { + return; + } + + try { + // TODO: Use import()... + // It would be nice to use import() here, but this built-in library is not in Node <19 so webpack will pick it + // up and report it as a missing dependency + const { Session } = dynamicRequire(module, 'node:inspector/promises'); + + startDebugger(new Session(), clientOptions).catch(e => { + logger.error('Failed to start inspector session', e); + }); + } catch (e) { + logger.error('Failed to load inspector API', e); + return; + } + }, + processEvent(event: Event): Event { + if (shouldProcessEvent) { + return addLocalVariablesToEvent(event); + } + + return event; + }, + }; +}; + +/** + * Adds local variables to exception frames + */ +// eslint-disable-next-line deprecation/deprecation +export const LocalVariablesAsync = convertIntegrationFnToClass(INTEGRATION_NAME, localVariablesAsync); diff --git a/packages/node/src/integrations/localvariables.ts b/packages/node/src/integrations/local-variables/local-variables-sync.ts similarity index 59% rename from packages/node/src/integrations/localvariables.ts rename to packages/node/src/integrations/local-variables/local-variables-sync.ts index f1a410501b4e..d2b988cca1e9 100644 --- a/packages/node/src/integrations/localvariables.ts +++ b/packages/node/src/integrations/local-variables/local-variables-sync.ts @@ -1,12 +1,14 @@ /* eslint-disable max-lines */ -import type { Event, EventProcessor, Exception, Hub, Integration, StackFrame, StackParser } from '@sentry/types'; +import { convertIntegrationFnToClass } from '@sentry/core'; +import type { Event, Exception, IntegrationFn, StackParser } from '@sentry/types'; import { LRUMap, logger } from '@sentry/utils'; import type { Debugger, InspectorNotification, Runtime, Session } from 'inspector'; -import type { NodeClient } from '../client'; +import type { NodeClient } from '../../client'; -import { NODE_VERSION } from '../nodeVersion'; +import { NODE_VERSION } from '../../nodeVersion'; +import type { FrameVariables, Options, PausedExceptionEvent, RateLimitIncrement, Variables } from './common'; +import { createRateLimiter, functionNamesMatch, hashFrames, hashFromStack } from './common'; -type Variables = Record; type OnPauseEvent = InspectorNotification; export interface DebugSession { /** Configures and connects to the debug session */ @@ -21,52 +23,6 @@ type Next = (result: T) => void; type Add = (fn: Next) => void; type CallbackWrapper = { add: Add; next: Next }; -type RateLimitIncrement = () => void; - -/** - * Creates a rate limiter - * @param maxPerSecond Maximum number of calls per second - * @param enable Callback to enable capture - * @param disable Callback to disable capture - * @returns A function to call to increment the rate limiter count - */ -export function createRateLimiter( - maxPerSecond: number, - enable: () => void, - disable: (seconds: number) => void, -): RateLimitIncrement { - let count = 0; - let retrySeconds = 5; - let disabledTimeout = 0; - - setInterval(() => { - if (disabledTimeout === 0) { - if (count > maxPerSecond) { - retrySeconds *= 2; - disable(retrySeconds); - - // Cap at one day - if (retrySeconds > 86400) { - retrySeconds = 86400; - } - disabledTimeout = retrySeconds; - } - } else { - disabledTimeout -= 1; - - if (disabledTimeout === 0) { - enable(); - } - } - - count = 0; - }, 1_000).unref(); - - return () => { - count += 1; - }; -} - /** Creates a container for callbacks to be called sequentially */ export function createCallbackList(complete: Next): CallbackWrapper { // A collection of callbacks to be executed last to first @@ -252,157 +208,20 @@ function tryNewAsyncSession(): AsyncSession | undefined { } } -// Add types for the exception event data -type PausedExceptionEvent = Debugger.PausedEventDataType & { - data: { - // This contains error.stack - description: string; - }; -}; - -/** Could this be an anonymous function? */ -function isAnonymous(name: string | undefined): boolean { - return name !== undefined && ['', '?', ''].includes(name); -} - -/** Do the function names appear to match? */ -function functionNamesMatch(a: string | undefined, b: string | undefined): boolean { - return a === b || (isAnonymous(a) && isAnonymous(b)); -} - -/** Creates a unique hash from stack frames */ -function hashFrames(frames: StackFrame[] | undefined): string | undefined { - if (frames === undefined) { - return; - } - - // Only hash the 10 most recent frames (ie. the last 10) - return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, ''); -} - -/** - * We use the stack parser to create a unique hash from the exception stack trace - * This is used to lookup vars when the exception passes through the event processor - */ -function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined { - if (stack === undefined) { - return undefined; - } - - return hashFrames(stackParser(stack, 1)); -} - -export interface FrameVariables { - function: string; - vars?: Variables; -} - -interface Options { - /** - * Capture local variables for both caught and uncaught exceptions - * - * - When false, only uncaught exceptions will have local variables - * - When true, both caught and uncaught exceptions will have local variables. - * - * Defaults to `true`. - * - * Capturing local variables for all exceptions can be expensive since the debugger pauses for every throw to collect - * local variables. - * - * To reduce the likelihood of this feature impacting app performance or throughput, this feature is rate-limited. - * Once the rate limit is reached, local variables will only be captured for uncaught exceptions until a timeout has - * been reached. - */ - captureAllExceptions?: boolean; - /** - * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. - */ - maxExceptionsPerSecond?: number; -} +const INTEGRATION_NAME = 'LocalVariablesSync'; /** * Adds local variables to exception frames - * - * Default: 50 */ -export class LocalVariables implements Integration { - public static id: string = 'LocalVariables'; - - public readonly name: string = LocalVariables.id; - - private readonly _cachedFrames: LRUMap = new LRUMap(20); - private _rateLimiter: RateLimitIncrement | undefined; - private _shouldProcessEvent = false; - - public constructor( - private readonly _options: Options = {}, - private readonly _session: DebugSession | undefined = tryNewAsyncSession(), - ) {} - - /** - * @inheritDoc - */ - public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void { - // noop - } - - /** @inheritdoc */ - public setup(client: NodeClient): void { - const clientOptions = client.getOptions(); - - if (this._session && clientOptions.includeLocalVariables) { - // Only setup this integration if the Node version is >= v18 - // https://github.com/getsentry/sentry-javascript/issues/7697 - const unsupportedNodeVersion = NODE_VERSION.major < 18; - - if (unsupportedNodeVersion) { - logger.log('The `LocalVariables` integration is only supported on Node >= v18.'); - return; - } - - const captureAll = this._options.captureAllExceptions !== false; - - this._session.configureAndConnect( - (ev, complete) => - this._handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), - captureAll, - ); - - if (captureAll) { - const max = this._options.maxExceptionsPerSecond || 50; - - this._rateLimiter = createRateLimiter( - max, - () => { - logger.log('Local variables rate-limit lifted.'); - this._session?.setPauseOnExceptions(true); - }, - seconds => { - logger.log( - `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, - ); - this._session?.setPauseOnExceptions(false); - }, - ); - } - - this._shouldProcessEvent = true; - } - } - - /** @inheritdoc */ - public processEvent(event: Event): Event { - if (this._shouldProcessEvent) { - return this._addLocalVariables(event); - } - - return event; - } - - /** - * Handle the pause event - */ - private _handlePaused( +export const localVariablesSync: IntegrationFn = ( + options: Options = {}, + session: DebugSession | undefined = tryNewAsyncSession(), +) => { + const cachedFrames: LRUMap = new LRUMap(20); + let rateLimiter: RateLimitIncrement | undefined; + let shouldProcessEvent = false; + + function handlePaused( stackParser: StackParser, { params: { reason, data, callFrames } }: InspectorNotification, complete: () => void, @@ -412,7 +231,7 @@ export class LocalVariables implements Integration { return; } - this._rateLimiter?.(); + rateLimiter?.(); // data.description contains the original error.stack const exceptionHash = hashFromStack(stackParser, data?.description); @@ -423,7 +242,7 @@ export class LocalVariables implements Integration { } const { add, next } = createCallbackList(frames => { - this._cachedFrames.set(exceptionHash, frames); + cachedFrames.set(exceptionHash, frames); complete(); }); @@ -445,7 +264,7 @@ export class LocalVariables implements Integration { } else { const id = localScope.object.objectId; add(frames => - this._session?.getLocalVariables(id, vars => { + session?.getLocalVariables(id, vars => { frames[i] = { function: fn, vars }; next(frames); }), @@ -456,21 +275,7 @@ export class LocalVariables implements Integration { next([]); } - /** - * Adds local variables event stack frames. - */ - private _addLocalVariables(event: Event): Event { - for (const exception of event?.exception?.values || []) { - this._addLocalVariablesToException(exception); - } - - return event; - } - - /** - * Adds local variables to the exception stack frames. - */ - private _addLocalVariablesToException(exception: Exception): void { + function addLocalVariablesToException(exception: Exception): void { const hash = hashFrames(exception?.stacktrace?.frames); if (hash === undefined) { @@ -479,9 +284,9 @@ export class LocalVariables implements Integration { // Check if we have local variables for an exception that matches the hash // remove is identical to get but also removes the entry from the cache - const cachedFrames = this._cachedFrames.remove(hash); + const cachedFrame = cachedFrames.remove(hash); - if (cachedFrames === undefined) { + if (cachedFrame === undefined) { return; } @@ -492,22 +297,96 @@ export class LocalVariables implements Integration { const frameIndex = frameCount - i - 1; // Drop out if we run out of frames to match up - if (!exception?.stacktrace?.frames?.[frameIndex] || !cachedFrames[i]) { + if (!exception?.stacktrace?.frames?.[frameIndex] || !cachedFrame[i]) { break; } if ( // We need to have vars to add - cachedFrames[i].vars === undefined || + cachedFrame[i].vars === undefined || // We're not interested in frames that are not in_app because the vars are not relevant exception.stacktrace.frames[frameIndex].in_app === false || // The function names need to match - !functionNamesMatch(exception.stacktrace.frames[frameIndex].function, cachedFrames[i].function) + !functionNamesMatch(exception.stacktrace.frames[frameIndex].function, cachedFrame[i].function) ) { continue; } - exception.stacktrace.frames[frameIndex].vars = cachedFrames[i].vars; + exception.stacktrace.frames[frameIndex].vars = cachedFrame[i].vars; } } -} + + function addLocalVariablesToEvent(event: Event): Event { + for (const exception of event?.exception?.values || []) { + addLocalVariablesToException(exception); + } + + return event; + } + + return { + name: INTEGRATION_NAME, + setup(client: NodeClient) { + const clientOptions = client.getOptions(); + + if (session && clientOptions.includeLocalVariables) { + // Only setup this integration if the Node version is >= v18 + // https://github.com/getsentry/sentry-javascript/issues/7697 + const unsupportedNodeVersion = NODE_VERSION.major < 18; + + if (unsupportedNodeVersion) { + logger.log('The `LocalVariables` integration is only supported on Node >= v18.'); + return; + } + + 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, + () => { + logger.log('Local variables rate-limit lifted.'); + session?.setPauseOnExceptions(true); + }, + seconds => { + logger.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session?.setPauseOnExceptions(false); + }, + ); + } + + shouldProcessEvent = true; + } + }, + processEvent(event: Event): Event { + if (shouldProcessEvent) { + return addLocalVariablesToEvent(event); + } + + return event; + }, + // These are entirely for testing + _getCachedFramesCount(): number { + return cachedFrames.size; + }, + _getFirstCachedFrame(): FrameVariables[] | undefined { + return cachedFrames.values()[0]; + }, + }; +}; + +/** + * Adds local variables to exception frames + */ +// eslint-disable-next-line deprecation/deprecation +export const LocalVariablesSync = convertIntegrationFnToClass(INTEGRATION_NAME, localVariablesSync); diff --git a/packages/node/test/integrations/localvariables.test.ts b/packages/node/test/integrations/localvariables.test.ts index b5fcc051c411..94e3ecaea20a 100644 --- a/packages/node/test/integrations/localvariables.test.ts +++ b/packages/node/test/integrations/localvariables.test.ts @@ -1,9 +1,10 @@ -import type { LRUMap } from '@sentry/utils'; import type { Debugger, InspectorNotification } from 'inspector'; import { NodeClient, defaultStackParser } from '../../src'; -import type { DebugSession, FrameVariables } from '../../src/integrations/localvariables'; -import { LocalVariables, createCallbackList, createRateLimiter } from '../../src/integrations/localvariables'; +import { createRateLimiter } from '../../src/integrations/local-variables/common'; +import type { FrameVariables } from '../../src/integrations/local-variables/common'; +import type { DebugSession } from '../../src/integrations/local-variables/local-variables-sync'; +import { LocalVariablesSync, createCallbackList } from '../../src/integrations/local-variables/local-variables-sync'; import { NODE_VERSION } from '../../src/nodeVersion'; import { getDefaultNodeClientOptions } from '../../test/helper/node-client-options'; @@ -50,7 +51,8 @@ class MockDebugSession implements DebugSession { } interface LocalVariablesPrivate { - _cachedFrames: LRUMap; + _getCachedFramesCount(): number; + _getFirstCachedFrame(): FrameVariables[] | undefined; } const exceptionEvent = { @@ -156,7 +158,7 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { '-6224981551105448869.1.2': { name: 'tim' }, '-6224981551105448869.1.6': { arr: [1, 2, 3] }, }); - const localVariables = new LocalVariables({}, session); + const localVariables = new LocalVariablesSync({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, @@ -167,15 +169,15 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { client.setupIntegrations(true); const eventProcessors = client['_eventProcessors']; - const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariablesSync'); expect(eventProcessor).toBeDefined(); await session.runPause(exceptionEvent); - expect((localVariables as unknown as LocalVariablesPrivate)._cachedFrames.size).toBe(1); + expect((localVariables as unknown as LocalVariablesPrivate)._getCachedFramesCount()).toBe(1); - const frames: FrameVariables[] = (localVariables as unknown as LocalVariablesPrivate)._cachedFrames.values()[0]; + const frames = (localVariables as unknown as LocalVariablesPrivate)._getFirstCachedFrame(); expect(frames).toBeDefined(); @@ -242,12 +244,12 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { expect(event?.exception?.values?.[0].stacktrace?.frames?.[3]?.vars).toEqual({ arr: [1, 2, 3] }); expect(event?.exception?.values?.[0].stacktrace?.frames?.[4]?.vars).toEqual({ name: 'tim' }); - expect((localVariables as unknown as LocalVariablesPrivate)._cachedFrames.size).toBe(0); + expect((localVariables as unknown as LocalVariablesPrivate)._getCachedFramesCount()).toBe(0); }); it('Only considers the first 5 frames', async () => { const session = new MockDebugSession({}); - const localVariables = new LocalVariables({}, session); + const localVariables = new LocalVariablesSync({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, @@ -259,9 +261,9 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { await session.runPause(exceptionEvent100Frames); - expect((localVariables as unknown as LocalVariablesPrivate)._cachedFrames.size).toBe(1); + expect((localVariables as unknown as LocalVariablesPrivate)._getCachedFramesCount()).toBe(1); - const frames: FrameVariables[] = (localVariables as unknown as LocalVariablesPrivate)._cachedFrames.values()[0]; + const frames = (localVariables as unknown as LocalVariablesPrivate)._getFirstCachedFrame(); expect(frames).toBeDefined(); @@ -272,7 +274,7 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { it('Should not lookup variables for non-exception reasons', async () => { const session = new MockDebugSession({}, { getLocalVariables: true }); - const localVariables = new LocalVariables({}, session); + const localVariables = new LocalVariablesSync({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, @@ -289,12 +291,12 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { await session.runPause(nonExceptionEvent); - expect((localVariables as unknown as LocalVariablesPrivate)._cachedFrames.size).toBe(0); + expect((localVariables as unknown as LocalVariablesPrivate)._getCachedFramesCount()).toBe(0); }); it('Should not initialize when disabled', async () => { const session = new MockDebugSession({}, { configureAndConnect: true }); - const localVariables = new LocalVariables({}, session); + const localVariables = new LocalVariablesSync({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, integrations: [localVariables], @@ -304,14 +306,13 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { client.setupIntegrations(true); const eventProcessors = client['_eventProcessors']; - const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariablesSync'); expect(eventProcessor).toBeDefined(); - expect(localVariables['_shouldProcessEvent']).toBe(false); }); it('Should not initialize when inspector not loaded', async () => { - const localVariables = new LocalVariables({}, undefined); + const localVariables = new LocalVariablesSync({}, undefined); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, integrations: [localVariables], @@ -321,10 +322,9 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { client.setupIntegrations(true); const eventProcessors = client['_eventProcessors']; - const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariables'); + const eventProcessor = eventProcessors.find(processor => processor.id === 'LocalVariablesSync'); expect(eventProcessor).toBeDefined(); - expect(localVariables['_shouldProcessEvent']).toBe(false); }); it('Should cache identical uncaught exception events', async () => { @@ -332,7 +332,7 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { '-6224981551105448869.1.2': { name: 'tim' }, '-6224981551105448869.1.6': { arr: [1, 2, 3] }, }); - const localVariables = new LocalVariables({}, session); + const localVariables = new LocalVariablesSync({}, session); const options = getDefaultNodeClientOptions({ stackParser: defaultStackParser, includeLocalVariables: true, @@ -348,7 +348,7 @@ describeIf(NODE_VERSION.major >= 18)('LocalVariables', () => { await session.runPause(exceptionEvent); await session.runPause(exceptionEvent); - expect((localVariables as unknown as LocalVariablesPrivate)._cachedFrames.size).toBe(1); + expect((localVariables as unknown as LocalVariablesPrivate)._getCachedFramesCount()).toBe(1); }); describe('createCallbackList', () => { diff --git a/packages/node/tsconfig.test.json b/packages/node/tsconfig.test.json index 87f6afa06b86..52333183eb70 100644 --- a/packages/node/tsconfig.test.json +++ b/packages/node/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "src/**/*.d.ts"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used