Skip to content

Commit 1a22856

Browse files
authored
feat(node): Collect Local Variables via a worker (#11586)
Our Local Variables integration is limited by the fact that it executes debugger commands from the main app thread. This means that no async work can be completed while the debugger is paused and local variables are being collected. This in turn means that all the synchronous work needs to be queued which risks stack overflows. For this reason we've limited local variable collection to the 5 frames closest to the top of the stack. When debugging from a worker thread, there are no such limitations around performing async work during debugger pause. This PR adds a worker thread which is tasked with collecting local variables when the debugger pauses. It passes these back to the main thread via IPC which are then later added to event stack frames. ## Possible Improvements It's possible to combine both ANR and Local Variables into a single worker thread.
1 parent 452bcae commit 1a22856

File tree

10 files changed

+402
-18
lines changed

10 files changed

+402
-18
lines changed

dev-packages/rollup-utils/utils.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function mergePlugins(pluginsA, pluginsB) {
2121
// here.
2222
// Additionally, the excludeReplay plugin must run before TS/Sucrase so that we can eliminate the replay code
2323
// before anything is type-checked (TS-only) and transpiled.
24-
const order = ['excludeReplay', 'typescript', 'sucrase', '...', 'terser', 'license'];
24+
const order = ['excludeReplay', 'typescript', 'sucrase', '...', 'terser', 'license', 'output-base64-worker-script'];
2525
const sortKeyA = order.includes(a.name) ? a.name : '...';
2626
const sortKeyB = order.includes(b.name) ? b.name : '...';
2727

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils';
22

3-
export function createAnrWorkerCode() {
3+
export function createWorkerCodeBuilder(entry, outDir) {
44
let base64Code;
55

6-
return {
7-
workerRollupConfig: makeBaseBundleConfig({
6+
return [
7+
makeBaseBundleConfig({
88
bundleType: 'node-worker',
9-
entrypoints: ['src/integrations/anr/worker.ts'],
9+
entrypoints: [entry],
10+
sucrase: { disableESTransforms: true },
1011
licenseTitle: '@sentry/node',
1112
outputFileBase: () => 'worker-script.js',
1213
packageSpecificConfig: {
1314
output: {
14-
dir: 'build/esm/integrations/anr',
15+
dir: outDir,
1516
sourcemap: false,
1617
},
1718
plugins: [
@@ -24,8 +25,8 @@ export function createAnrWorkerCode() {
2425
],
2526
},
2627
}),
27-
getBase64Code() {
28+
() => {
2829
return base64Code;
2930
},
30-
};
31+
];
3132
}

packages/node/rollup.npm.config.mjs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import replace from '@rollup/plugin-replace';
22
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils';
3-
import { createAnrWorkerCode } from './rollup.anr-worker.config.mjs';
3+
import { createWorkerCodeBuilder } from './rollup.anr-worker.config.mjs';
44

5-
const { workerRollupConfig, getBase64Code } = createAnrWorkerCode();
5+
const [anrWorkerConfig, getAnrBase64Code] = createWorkerCodeBuilder(
6+
'src/integrations/anr/worker.ts',
7+
'build/esm/integrations/anr',
8+
);
9+
10+
const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCodeBuilder(
11+
'src/integrations/local-variables/worker.ts',
12+
'build/esm/integrations/local-variables',
13+
);
614

715
export default [
816
...makeOtelLoaders('./build', 'otel'),
9-
// The worker needs to be built first since it's output is used in the main bundle.
10-
workerRollupConfig,
17+
// The workers needs to be built first since it's their output is copied in the main bundle.
18+
anrWorkerConfig,
19+
localVariablesWorkerConfig,
1120
...makeNPMConfigVariants(
1221
makeBaseNPMConfig({
1322
packageSpecificConfig: {
@@ -23,10 +32,11 @@ export default [
2332
plugins: [
2433
replace({
2534
delimiters: ['###', '###'],
26-
// removes some webpack warnings
35+
// removes some rollup warnings
2736
preventAssignment: true,
2837
values: {
29-
base64WorkerScript: () => getBase64Code(),
38+
AnrWorkerScript: () => getAnrBase64Code(),
39+
LocalVariablesWorkerScript: () => getLocalVariablesBase64Code(),
3040
},
3141
}),
3242
],

packages/node/src/integrations/anr/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { getCurrentScope, getGlobalScope, getIsolationScope } from '../..';
77
import { NODE_VERSION } from '../../nodeVersion';
88
import type { NodeClient } from '../../sdk/client';
99
import type { AnrIntegrationOptions, WorkerStartData } from './common';
10-
import { base64WorkerScript } from './worker-script';
10+
11+
// This string is a placeholder that gets overwritten with the worker code.
12+
export const base64WorkerScript = '###AnrWorkerScript###';
1113

1214
const DEFAULT_INTERVAL = 50;
1315
const DEFAULT_HANG_THRESHOLD = 5000;

packages/node/src/integrations/anr/worker-script.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,16 @@ export interface LocalVariablesIntegrationOptions {
117117
*/
118118
maxExceptionsPerSecond?: number;
119119
}
120+
121+
export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions {
122+
/**
123+
* Whether to enable debug logging.
124+
*/
125+
debug: boolean;
126+
/**
127+
* Base path used to calculate module name.
128+
*
129+
* Defaults to `dirname(process.argv[1])` and falls back to `process.cwd()`
130+
*/
131+
basePath?: string;
132+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import type { Integration } from '@sentry/types';
2+
import { NODE_VERSION } from '../../nodeVersion';
3+
import type { LocalVariablesIntegrationOptions } from './common';
4+
import { localVariablesAsyncIntegration } from './local-variables-async';
15
import { localVariablesSyncIntegration } from './local-variables-sync';
26

3-
export const localVariablesIntegration = localVariablesSyncIntegration;
7+
export const localVariablesIntegration = (options: LocalVariablesIntegrationOptions = {}): Integration => {
8+
return NODE_VERSION.major < 19 ? localVariablesSyncIntegration(options) : localVariablesAsyncIntegration(options);
9+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @types/node doesn't have a `node:inspector/promises` module, maybe because it's still experimental?
3+
*/
4+
declare module 'node:inspector/promises' {
5+
/**
6+
* Async Debugger session
7+
*/
8+
class Session {
9+
public constructor();
10+
11+
public connect(): void;
12+
public connectToMainThread(): void;
13+
14+
public post(method: 'Debugger.pause' | 'Debugger.resume' | 'Debugger.enable' | 'Debugger.disable'): Promise<void>;
15+
public post(
16+
method: 'Debugger.setPauseOnExceptions',
17+
params: Debugger.SetPauseOnExceptionsParameterType,
18+
): Promise<void>;
19+
public post(
20+
method: 'Runtime.getProperties',
21+
params: Runtime.GetPropertiesParameterType,
22+
): Promise<Runtime.GetPropertiesReturnType>;
23+
24+
public on(
25+
event: 'Debugger.paused',
26+
listener: (message: InspectorNotification<Debugger.PausedEventDataType>) => void,
27+
): Session;
28+
29+
public on(event: 'Debugger.resumed', listener: () => void): Session;
30+
}
31+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { defineIntegration } from '@sentry/core';
2+
import type { Event, Exception, IntegrationFn } from '@sentry/types';
3+
import { LRUMap, logger } from '@sentry/utils';
4+
import { Worker } from 'worker_threads';
5+
6+
import type { NodeClient } from '../../sdk/client';
7+
import type { FrameVariables, LocalVariablesIntegrationOptions, LocalVariablesWorkerArgs } from './common';
8+
import { functionNamesMatch, hashFrames } from './common';
9+
10+
// This string is a placeholder that gets overwritten with the worker code.
11+
export const base64WorkerScript = '###LocalVariablesWorkerScript###';
12+
13+
function log(...args: unknown[]): void {
14+
logger.log('[LocalVariables]', ...args);
15+
}
16+
17+
/**
18+
* Adds local variables to exception frames
19+
*/
20+
export const localVariablesAsyncIntegration = defineIntegration(((
21+
integrationOptions: LocalVariablesIntegrationOptions = {},
22+
) => {
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+
40+
// Filter out frames where the function name is `new Promise` since these are in the error.stack frames
41+
// but do not appear in the debugger call frames
42+
const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise');
43+
44+
for (let i = 0; i < frames.length; i++) {
45+
// Sentry frames are in reverse order
46+
const frameIndex = frames.length - i - 1;
47+
48+
// Drop out if we run out of frames to match up
49+
if (!frames[frameIndex] || !cachedFrame[i]) {
50+
break;
51+
}
52+
53+
if (
54+
// We need to have vars to add
55+
cachedFrame[i].vars === undefined ||
56+
// We're not interested in frames that are not in_app because the vars are not relevant
57+
frames[frameIndex].in_app === false ||
58+
// The function names need to match
59+
!functionNamesMatch(frames[frameIndex].function, cachedFrame[i].function)
60+
) {
61+
continue;
62+
}
63+
64+
frames[frameIndex].vars = cachedFrame[i].vars;
65+
}
66+
}
67+
68+
function addLocalVariablesToEvent(event: Event): Event {
69+
for (const exception of event.exception?.values || []) {
70+
addLocalVariablesToException(exception);
71+
}
72+
73+
return event;
74+
}
75+
76+
async function startInspector(): Promise<void> {
77+
// We load inspector dynamically because on some platforms Node is built without inspector support
78+
const inspector = await import('inspector');
79+
if (!inspector.url()) {
80+
inspector.open(0);
81+
}
82+
}
83+
84+
function startWorker(options: LocalVariablesWorkerArgs): void {
85+
const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), {
86+
workerData: options,
87+
});
88+
89+
process.on('exit', () => {
90+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
91+
worker.terminate();
92+
});
93+
94+
worker.on('message', ({ exceptionHash, frames }) => {
95+
cachedFrames.set(exceptionHash, frames);
96+
});
97+
98+
worker.once('error', (err: Error) => {
99+
log('Worker error', err);
100+
});
101+
102+
worker.once('exit', (code: number) => {
103+
log('Worker exit', code);
104+
});
105+
106+
// Ensure this thread can't block app exit
107+
worker.unref();
108+
}
109+
110+
return {
111+
name: 'LocalVariablesAsync',
112+
setup(client: NodeClient) {
113+
const clientOptions = client.getOptions();
114+
115+
if (!clientOptions.includeLocalVariables) {
116+
return;
117+
}
118+
119+
const options: LocalVariablesWorkerArgs = {
120+
...integrationOptions,
121+
debug: logger.isEnabled(),
122+
};
123+
124+
startInspector().then(
125+
() => {
126+
try {
127+
startWorker(options);
128+
} catch (e) {
129+
logger.error('Failed to start worker', e);
130+
}
131+
},
132+
e => {
133+
logger.error('Failed to start inspector', e);
134+
},
135+
);
136+
},
137+
processEvent(event: Event): Event {
138+
return addLocalVariablesToEvent(event);
139+
},
140+
};
141+
}) satisfies IntegrationFn);

0 commit comments

Comments
 (0)