Skip to content

Commit 4c6dd80

Browse files
authored
fix(node): Remove ambiguity and race conditions when matching local variables to exceptions (#13501)
Closes #13415 This PR only modifies the async version of this integration which is used for Node > v19. I tried applying similar changes to the sync integration and I cannot get it to work without causing memory leaks. @Bruno-DaSilva has been helping me explore different ways to fix a few fundamental issues with the local variables integration. Bruno found a way to [write to the error object](#13415 (comment)) from the debugger which removes any ambiguity over which variables go with which exception. This allows us to remove the stack parsing and hashing which we were using previously to match up exceptions. Rather than write the `objectId` to the error, I have used this to write the entire local variables array directly to the error object. This completely negates the need to post the local variables from the worker thread which removes any possibility of race conditions. We then later pull the local variables directly from `hint.originalException.__SENTRY_ERROR_LOCAL_VARIABLES__`.
1 parent 7fa366f commit 4c6dd80

File tree

5 files changed

+93
-80
lines changed

5 files changed

+93
-80
lines changed

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

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { Debugger } from 'node:inspector';
2-
import type { StackFrame, StackParser } from '@sentry/types';
32

43
export type Variables = Record<string, unknown>;
54

65
export type RateLimitIncrement = () => void;
76

7+
/**
8+
* The key used to store the local variables on the error object.
9+
*/
10+
export const LOCAL_VARIABLES_KEY = '__SENTRY_ERROR_LOCAL_VARIABLES__';
11+
812
/**
913
* Creates a rate limiter that will call the disable callback when the rate limit is reached and the enable callback
1014
* when a timeout has occurred.
@@ -55,6 +59,7 @@ export type PausedExceptionEvent = Debugger.PausedEventDataType & {
5559
data: {
5660
// This contains error.stack
5761
description: string;
62+
objectId?: string;
5863
};
5964
};
6065

@@ -68,28 +73,6 @@ export function functionNamesMatch(a: string | undefined, b: string | undefined)
6873
return a === b || (isAnonymous(a) && isAnonymous(b));
6974
}
7075

71-
/** Creates a unique hash from stack frames */
72-
export function hashFrames(frames: StackFrame[] | undefined): string | undefined {
73-
if (frames === undefined) {
74-
return;
75-
}
76-
77-
// Only hash the 10 most recent frames (ie. the last 10)
78-
return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, '');
79-
}
80-
81-
/**
82-
* We use the stack parser to create a unique hash from the exception stack trace
83-
* This is used to lookup vars when the exception passes through the event processor
84-
*/
85-
export function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined {
86-
if (stack === undefined) {
87-
return undefined;
88-
}
89-
90-
return hashFrames(stackParser(stack, 1));
91-
}
92-
9376
export interface FrameVariables {
9477
function: string;
9578
vars?: Variables;

packages/node/src/integrations/local-variables/inspector.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ declare module 'node:inspector/promises' {
2020
method: 'Runtime.getProperties',
2121
params: Runtime.GetPropertiesParameterType,
2222
): Promise<Runtime.GetPropertiesReturnType>;
23+
public post(
24+
method: 'Runtime.callFunctionOn',
25+
params: Runtime.CallFunctionOnParameterType,
26+
): Promise<Runtime.CallFunctionOnReturnType>;
27+
public post(
28+
method: 'Runtime.releaseObject',
29+
params: Runtime.ReleaseObjectParameterType,
30+
): Promise<Runtime.ReleaseObjectReturnType>;
2331

2432
public on(
2533
event: 'Debugger.paused',

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

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Worker } from 'node:worker_threads';
22
import { defineIntegration } from '@sentry/core';
3-
import type { Event, Exception, IntegrationFn } from '@sentry/types';
4-
import { LRUMap, logger } from '@sentry/utils';
3+
import type { Event, EventHint, Exception, IntegrationFn } from '@sentry/types';
4+
import { logger } from '@sentry/utils';
55

66
import type { NodeClient } from '../../sdk/client';
77
import type { FrameVariables, LocalVariablesIntegrationOptions, LocalVariablesWorkerArgs } from './common';
8-
import { functionNamesMatch, hashFrames } from './common';
8+
import { LOCAL_VARIABLES_KEY } from './common';
9+
import { functionNamesMatch } from './common';
910

1011
// This string is a placeholder that gets overwritten with the worker code.
1112
export const base64WorkerScript = '###LocalVariablesWorkerScript###';
@@ -20,23 +21,7 @@ function log(...args: unknown[]): void {
2021
export const localVariablesAsyncIntegration = defineIntegration(((
2122
integrationOptions: LocalVariablesIntegrationOptions = {},
2223
) => {
23-
const cachedFrames: LRUMap<string, FrameVariables[]> = new LRUMap(20);
24-
25-
function addLocalVariablesToException(exception: Exception): void {
26-
const hash = hashFrames(exception?.stacktrace?.frames);
27-
28-
if (hash === undefined) {
29-
return;
30-
}
31-
32-
// Check if we have local variables for an exception that matches the hash
33-
// remove is identical to get but also removes the entry from the cache
34-
const cachedFrame = cachedFrames.remove(hash);
35-
36-
if (cachedFrame === undefined) {
37-
return;
38-
}
39-
24+
function addLocalVariablesToException(exception: Exception, localVariables: FrameVariables[]): void {
4025
// Filter out frames where the function name is `new Promise` since these are in the error.stack frames
4126
// but do not appear in the debugger call frames
4227
const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise');
@@ -45,32 +30,41 @@ export const localVariablesAsyncIntegration = defineIntegration(((
4530
// Sentry frames are in reverse order
4631
const frameIndex = frames.length - i - 1;
4732

48-
const cachedFrameVariable = cachedFrame[i];
49-
const frameVariable = frames[frameIndex];
33+
const frameLocalVariables = localVariables[i];
34+
const frame = frames[frameIndex];
5035

51-
if (!frameVariable || !cachedFrameVariable) {
36+
if (!frame || !frameLocalVariables) {
5237
// Drop out if we run out of frames to match up
5338
break;
5439
}
5540

5641
if (
5742
// We need to have vars to add
58-
cachedFrameVariable.vars === undefined ||
43+
frameLocalVariables.vars === undefined ||
5944
// We're not interested in frames that are not in_app because the vars are not relevant
60-
frameVariable.in_app === false ||
45+
frame.in_app === false ||
6146
// The function names need to match
62-
!functionNamesMatch(frameVariable.function, cachedFrameVariable.function)
47+
!functionNamesMatch(frame.function, frameLocalVariables.function)
6348
) {
6449
continue;
6550
}
6651

67-
frameVariable.vars = cachedFrameVariable.vars;
52+
frame.vars = frameLocalVariables.vars;
6853
}
6954
}
7055

71-
function addLocalVariablesToEvent(event: Event): Event {
72-
for (const exception of event.exception?.values || []) {
73-
addLocalVariablesToException(exception);
56+
function addLocalVariablesToEvent(event: Event, hint: EventHint): Event {
57+
if (
58+
hint.originalException &&
59+
typeof hint.originalException === 'object' &&
60+
LOCAL_VARIABLES_KEY in hint.originalException &&
61+
Array.isArray(hint.originalException[LOCAL_VARIABLES_KEY])
62+
) {
63+
for (const exception of event.exception?.values || []) {
64+
addLocalVariablesToException(exception, hint.originalException[LOCAL_VARIABLES_KEY]);
65+
}
66+
67+
hint.originalException[LOCAL_VARIABLES_KEY] = undefined;
7468
}
7569

7670
return event;
@@ -96,10 +90,6 @@ export const localVariablesAsyncIntegration = defineIntegration(((
9690
worker.terminate();
9791
});
9892

99-
worker.on('message', ({ exceptionHash, frames }) => {
100-
cachedFrames.set(exceptionHash, frames);
101-
});
102-
10393
worker.once('error', (err: Error) => {
10494
log('Worker error', err);
10595
});
@@ -139,8 +129,8 @@ export const localVariablesAsyncIntegration = defineIntegration(((
139129
},
140130
);
141131
},
142-
processEvent(event: Event): Event {
143-
return addLocalVariablesToEvent(event);
132+
processEvent(event: Event, hint: EventHint): Event {
133+
return addLocalVariablesToEvent(event, hint);
144134
},
145135
};
146136
}) satisfies IntegrationFn);

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Debugger, InspectorNotification, Runtime, Session } from 'node:inspector';
22
import { defineIntegration, getClient } from '@sentry/core';
3-
import type { Event, Exception, IntegrationFn, StackParser } from '@sentry/types';
3+
import type { Event, Exception, IntegrationFn, StackFrame, StackParser } from '@sentry/types';
44
import { LRUMap, logger } from '@sentry/utils';
55

66
import { NODE_MAJOR } from '../../nodeVersion';
@@ -12,7 +12,29 @@ import type {
1212
RateLimitIncrement,
1313
Variables,
1414
} from './common';
15-
import { createRateLimiter, functionNamesMatch, hashFrames, hashFromStack } from './common';
15+
import { createRateLimiter, functionNamesMatch } from './common';
16+
17+
/** Creates a unique hash from stack frames */
18+
export function hashFrames(frames: StackFrame[] | undefined): string | undefined {
19+
if (frames === undefined) {
20+
return;
21+
}
22+
23+
// Only hash the 10 most recent frames (ie. the last 10)
24+
return frames.slice(-10).reduce((acc, frame) => `${acc},${frame.function},${frame.lineno},${frame.colno}`, '');
25+
}
26+
27+
/**
28+
* We use the stack parser to create a unique hash from the exception stack trace
29+
* This is used to lookup vars when the exception passes through the event processor
30+
*/
31+
export function hashFromStack(stackParser: StackParser, stack: string | undefined): string | undefined {
32+
if (stack === undefined) {
33+
return undefined;
34+
}
35+
36+
return hashFrames(stackParser(stack, 1));
37+
}
1638

1739
type OnPauseEvent = InspectorNotification<Debugger.PausedEventDataType>;
1840
export interface DebugSession {

packages/node/src/integrations/local-variables/worker.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import type { Debugger, InspectorNotification, Runtime } from 'node:inspector';
22
import { Session } from 'node:inspector/promises';
3-
import { parentPort, workerData } from 'node:worker_threads';
4-
import type { StackParser } from '@sentry/types';
5-
import { createStackParser, nodeStackLineParser } from '@sentry/utils';
6-
import { createGetModuleFromFilename } from '../../utils/module';
3+
import { workerData } from 'node:worker_threads';
74
import type { LocalVariablesWorkerArgs, PausedExceptionEvent, RateLimitIncrement, Variables } from './common';
8-
import { createRateLimiter, hashFromStack } from './common';
5+
import { LOCAL_VARIABLES_KEY } from './common';
6+
import { createRateLimiter } from './common';
97

108
const options: LocalVariablesWorkerArgs = workerData;
119

12-
const stackParser = createStackParser(nodeStackLineParser(createGetModuleFromFilename(options.basePath)));
13-
1410
function log(...args: unknown[]): void {
1511
if (options.debug) {
1612
// eslint-disable-next-line no-console
@@ -88,19 +84,15 @@ let rateLimiter: RateLimitIncrement | undefined;
8884

8985
async function handlePaused(
9086
session: Session,
91-
stackParser: StackParser,
92-
{ reason, data, callFrames }: PausedExceptionEvent,
93-
): Promise<void> {
87+
{ reason, data: { objectId }, callFrames }: PausedExceptionEvent,
88+
): Promise<string | undefined> {
9489
if (reason !== 'exception' && reason !== 'promiseRejection') {
9590
return;
9691
}
9792

9893
rateLimiter?.();
9994

100-
// data.description contains the original error.stack
101-
const exceptionHash = hashFromStack(stackParser, data?.description);
102-
103-
if (exceptionHash == undefined) {
95+
if (objectId == undefined) {
10496
return;
10597
}
10698

@@ -123,7 +115,15 @@ async function handlePaused(
123115
}
124116
}
125117

126-
parentPort?.postMessage({ exceptionHash, frames });
118+
// We write the local variables to a property on the error object. These can be read by the integration as the error
119+
// event pass through the SDK event pipeline
120+
await session.post('Runtime.callFunctionOn', {
121+
functionDeclaration: `function() { this.${LOCAL_VARIABLES_KEY} = ${JSON.stringify(frames)}; }`,
122+
silent: true,
123+
objectId,
124+
});
125+
126+
return objectId;
127127
}
128128

129129
async function startDebugger(): Promise<void> {
@@ -141,13 +141,23 @@ async function startDebugger(): Promise<void> {
141141
session.on('Debugger.paused', (event: InspectorNotification<Debugger.PausedEventDataType>) => {
142142
isPaused = true;
143143

144-
handlePaused(session, stackParser, event.params as PausedExceptionEvent).then(
145-
() => {
144+
handlePaused(session, event.params as PausedExceptionEvent).then(
145+
async objectId => {
146146
// After the pause work is complete, resume execution!
147-
return isPaused ? session.post('Debugger.resume') : Promise.resolve();
147+
if (isPaused) {
148+
await session.post('Debugger.resume');
149+
}
150+
151+
if (objectId) {
152+
// The object must be released after the debugger has resumed or we get a memory leak.
153+
// For node v20, setImmediate is enough here but for v22 a longer delay is required
154+
setTimeout(async () => {
155+
await session.post('Runtime.releaseObject', { objectId });
156+
}, 1_000);
157+
}
148158
},
149159
_ => {
150-
// ignore
160+
// ignore any errors
151161
},
152162
);
153163
});

0 commit comments

Comments
 (0)