Skip to content

Commit aa41f97

Browse files
authored
feat(node): Rate limit local variables for caught exceptions and enable captureAllExceptions by default (#9102)
1 parent 5c2546f commit aa41f97

File tree

2 files changed

+163
-6
lines changed

2 files changed

+163
-6
lines changed

packages/node/src/integrations/localvariables.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import type { Event, EventProcessor, Exception, Hub, Integration, StackFrame, StackParser } from '@sentry/types';
23
import { logger } from '@sentry/utils';
34
import type { Debugger, InspectorNotification, Runtime, Session } from 'inspector';
@@ -11,6 +12,8 @@ type OnPauseEvent = InspectorNotification<Debugger.PausedEventDataType>;
1112
export interface DebugSession {
1213
/** Configures and connects to the debug session */
1314
configureAndConnect(onPause: (message: OnPauseEvent, complete: () => void) => void, captureAll: boolean): void;
15+
/** Updates which kind of exceptions to capture */
16+
setPauseOnExceptions(captureAll: boolean): void;
1417
/** Gets local variables for an objectId */
1518
getLocalVariables(objectId: string, callback: (vars: Variables) => void): void;
1619
}
@@ -19,6 +22,52 @@ type Next<T> = (result: T) => void;
1922
type Add<T> = (fn: Next<T>) => void;
2023
type CallbackWrapper<T> = { add: Add<T>; next: Next<T> };
2124

25+
type RateLimitIncrement = () => void;
26+
27+
/**
28+
* Creates a rate limiter
29+
* @param maxPerSecond Maximum number of calls per second
30+
* @param enable Callback to enable capture
31+
* @param disable Callback to disable capture
32+
* @returns A function to call to increment the rate limiter count
33+
*/
34+
export function createRateLimiter(
35+
maxPerSecond: number,
36+
enable: () => void,
37+
disable: (seconds: number) => void,
38+
): RateLimitIncrement {
39+
let count = 0;
40+
let retrySeconds = 5;
41+
let disabledTimeout = 0;
42+
43+
setInterval(() => {
44+
if (disabledTimeout === 0) {
45+
if (count > maxPerSecond) {
46+
retrySeconds *= 2;
47+
disable(retrySeconds);
48+
49+
// Cap at one day
50+
if (retrySeconds > 86400) {
51+
retrySeconds = 86400;
52+
}
53+
disabledTimeout = retrySeconds;
54+
}
55+
} else {
56+
disabledTimeout -= 1;
57+
58+
if (disabledTimeout === 0) {
59+
enable();
60+
}
61+
}
62+
63+
count = 0;
64+
}, 1_000).unref();
65+
66+
return () => {
67+
count += 1;
68+
};
69+
}
70+
2271
/** Creates a container for callbacks to be called sequentially */
2372
export function createCallbackList<T>(complete: Next<T>): CallbackWrapper<T> {
2473
// A collection of callbacks to be executed last to first
@@ -103,6 +152,10 @@ class AsyncSession implements DebugSession {
103152
this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' });
104153
}
105154

155+
public setPauseOnExceptions(captureAll: boolean): void {
156+
this._session.post('Debugger.setPauseOnExceptions', { state: captureAll ? 'all' : 'uncaught' });
157+
}
158+
106159
/** @inheritdoc */
107160
public getLocalVariables(objectId: string, complete: (vars: Variables) => void): void {
108161
this._getProperties(objectId, props => {
@@ -245,26 +298,41 @@ export interface FrameVariables {
245298
vars?: Variables;
246299
}
247300

248-
/** There are no options yet. This allows them to be added later without breaking changes */
249-
// eslint-disable-next-line @typescript-eslint/no-empty-interface
250301
interface Options {
251302
/**
252-
* Capture local variables for both handled and unhandled exceptions
303+
* Capture local variables for both caught and uncaught exceptions
304+
*
305+
* - When false, only uncaught exceptions will have local variables
306+
* - When true, both caught and uncaught exceptions will have local variables.
307+
*
308+
* Defaults to `true`.
253309
*
254-
* Default: false - Only captures local variables for uncaught exceptions
310+
* Capturing local variables for all exceptions can be expensive since the debugger pauses for every throw to collect
311+
* local variables.
312+
*
313+
* To reduce the likelihood of this feature impacting app performance or throughput, this feature is rate-limited.
314+
* Once the rate limit is reached, local variables will only be captured for uncaught exceptions until a timeout has
315+
* been reached.
255316
*/
256317
captureAllExceptions?: boolean;
318+
/**
319+
* Maximum number of exceptions to capture local variables for per second before rate limiting is triggered.
320+
*/
321+
maxExceptionsPerSecond?: number;
257322
}
258323

259324
/**
260325
* Adds local variables to exception frames
326+
*
327+
* Default: 50
261328
*/
262329
export class LocalVariables implements Integration {
263330
public static id: string = 'LocalVariables';
264331

265332
public readonly name: string = LocalVariables.id;
266333

267334
private readonly _cachedFrames: LRUMap<string, FrameVariables[]> = new LRUMap(20);
335+
private _rateLimiter: RateLimitIncrement | undefined;
268336

269337
public constructor(
270338
private readonly _options: Options = {},
@@ -293,12 +361,32 @@ export class LocalVariables implements Integration {
293361
return;
294362
}
295363

364+
const captureAll = this._options.captureAllExceptions !== false;
365+
296366
this._session.configureAndConnect(
297367
(ev, complete) =>
298368
this._handlePaused(clientOptions.stackParser, ev as InspectorNotification<PausedExceptionEvent>, complete),
299-
!!this._options.captureAllExceptions,
369+
captureAll,
300370
);
301371

372+
if (captureAll) {
373+
const max = this._options.maxExceptionsPerSecond || 50;
374+
375+
this._rateLimiter = createRateLimiter(
376+
max,
377+
() => {
378+
logger.log('Local variables rate-limit lifted.');
379+
this._session?.setPauseOnExceptions(true);
380+
},
381+
seconds => {
382+
logger.log(
383+
`Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`,
384+
);
385+
this._session?.setPauseOnExceptions(false);
386+
},
387+
);
388+
}
389+
302390
addGlobalEventProcessor(async event => this._addLocalVariables(event));
303391
}
304392
}
@@ -316,6 +404,8 @@ export class LocalVariables implements Integration {
316404
return;
317405
}
318406

407+
this._rateLimiter?.();
408+
319409
// data.description contains the original error.stack
320410
const exceptionHash = hashFromStack(stackParser, data?.description);
321411

packages/node/test/integrations/localvariables.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import type { LRUMap } from 'lru_map';
44

55
import { defaultStackParser } from '../../src';
66
import type { DebugSession, FrameVariables } from '../../src/integrations/localvariables';
7-
import { createCallbackList, LocalVariables } from '../../src/integrations/localvariables';
7+
import { createCallbackList, createRateLimiter, LocalVariables } from '../../src/integrations/localvariables';
88
import { NODE_VERSION } from '../../src/nodeVersion';
99
import { getDefaultNodeClientOptions } from '../../test/helper/node-client-options';
1010

11+
jest.setTimeout(20_000);
12+
1113
const describeIf = (condition: boolean) => (condition ? describe : describe.skip);
1214

1315
interface ThrowOn {
@@ -31,6 +33,8 @@ class MockDebugSession implements DebugSession {
3133
this._onPause = onPause;
3234
}
3335

36+
public setPauseOnExceptions(_: boolean): void {}
37+
3438
public getLocalVariables(objectId: string, callback: (vars: Record<string, unknown>) => void): void {
3539
if (this._throwOn?.getLocalVariables) {
3640
throw new Error('getLocalVariables should not be called');
@@ -431,4 +435,67 @@ describeIf((NODE_VERSION.major || 0) >= 18)('LocalVariables', () => {
431435
next(10);
432436
});
433437
});
438+
439+
describe('rateLimiter', () => {
440+
it('calls disable if exceeded', done => {
441+
const increment = createRateLimiter(
442+
5,
443+
() => {},
444+
() => {
445+
done();
446+
},
447+
);
448+
449+
for (let i = 0; i < 7; i++) {
450+
increment();
451+
}
452+
});
453+
454+
it('does not call disable if not exceeded', done => {
455+
const increment = createRateLimiter(
456+
5,
457+
() => {
458+
throw new Error('Should not be called');
459+
},
460+
() => {
461+
throw new Error('Should not be called');
462+
},
463+
);
464+
465+
let count = 0;
466+
467+
const timer = setInterval(() => {
468+
for (let i = 0; i < 4; i++) {
469+
increment();
470+
}
471+
472+
count += 1;
473+
474+
if (count >= 5) {
475+
clearInterval(timer);
476+
done();
477+
}
478+
}, 1_000);
479+
});
480+
481+
it('re-enables after timeout', done => {
482+
let called = false;
483+
484+
const increment = createRateLimiter(
485+
5,
486+
() => {
487+
expect(called).toEqual(true);
488+
done();
489+
},
490+
() => {
491+
expect(called).toEqual(false);
492+
called = true;
493+
},
494+
);
495+
496+
for (let i = 0; i < 10; i++) {
497+
increment();
498+
}
499+
});
500+
});
434501
});

0 commit comments

Comments
 (0)