Skip to content

Commit 729e432

Browse files
authored
feat(utils): Move common node ANR code to utils (#9191)
This PR moves common ANR code to utils so it can be used for Electron renderer ANR detection. ## `watchdogTimer` Moved to utils with the addition of an enabled flag which isn't used in node ## `createDebugPauseMessageHandler` Handles messages from the debugger protocol. - Collects script ids for later filename lookups - Collects, converts and passes stack frames to a callback when the debugger pauses - Now uses `stripSentryFramesAndReverse` to remove Sentry frames!
1 parent bb67a11 commit 729e432

File tree

4 files changed

+145
-81
lines changed

4 files changed

+145
-81
lines changed

packages/node/src/anr/debugger.ts

Lines changed: 8 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,10 @@
11
import type { StackFrame } from '@sentry/types';
2-
import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils';
2+
import { createDebugPauseMessageHandler } from '@sentry/utils';
33
import type { Debugger } from 'inspector';
44

55
import { getModuleFromFilename } from '../module';
66
import { createWebSocketClient } from './websocket';
77

8-
/**
9-
* Converts Debugger.CallFrame to Sentry StackFrame
10-
*/
11-
function callFrameToStackFrame(
12-
frame: Debugger.CallFrame,
13-
filenameFromScriptId: (id: string) => string | undefined,
14-
): StackFrame {
15-
const filename = filenameFromScriptId(frame.location.scriptId)?.replace(/^file:\/\//, '');
16-
17-
// CallFrame row/col are 0 based, whereas StackFrame are 1 based
18-
const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined;
19-
const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined;
20-
21-
return dropUndefinedKeys({
22-
filename,
23-
module: getModuleFromFilename(filename),
24-
function: frame.functionName || '?',
25-
colno,
26-
lineno,
27-
in_app: filename ? filenameIsInApp(filename) : undefined,
28-
});
29-
}
30-
318
// The only messages we care about
329
type DebugMessage =
3310
| {
@@ -45,7 +22,7 @@ type DebugMessage =
4522
async function webSocketDebugger(
4623
url: string,
4724
onMessage: (message: DebugMessage) => void,
48-
): Promise<(method: string, params?: unknown) => void> {
25+
): Promise<(method: string) => void> {
4926
let id = 0;
5027
const webSocket = await createWebSocketClient(url);
5128

@@ -54,8 +31,8 @@ async function webSocketDebugger(
5431
onMessage(message);
5532
});
5633

57-
return (method: string, params?: unknown) => {
58-
webSocket.send(JSON.stringify({ id: id++, method, params }));
34+
return (method: string) => {
35+
webSocket.send(JSON.stringify({ id: id++, method }));
5936
};
6037
}
6138

@@ -66,27 +43,10 @@ async function webSocketDebugger(
6643
* @returns A function that triggers the debugger to pause and capture a stack trace
6744
*/
6845
export async function captureStackTrace(url: string, callback: (frames: StackFrame[]) => void): Promise<() => void> {
69-
// Collect scriptId -> url map so we can look up the filenames later
70-
const scripts = new Map<string, string>();
71-
72-
const sendCommand = await webSocketDebugger(url, message => {
73-
if (message.method === 'Debugger.scriptParsed') {
74-
scripts.set(message.params.scriptId, message.params.url);
75-
} else if (message.method === 'Debugger.paused') {
76-
// copy the frames
77-
const callFrames = [...message.params.callFrames];
78-
// and resume immediately!
79-
sendCommand('Debugger.resume');
80-
sendCommand('Debugger.disable');
81-
82-
const frames = callFrames
83-
.map(frame => callFrameToStackFrame(frame, id => scripts.get(id)))
84-
// Sentry expects the frames to be in the opposite order
85-
.reverse();
86-
87-
callback(frames);
88-
}
89-
});
46+
const sendCommand: (method: string) => void = await webSocketDebugger(
47+
url,
48+
createDebugPauseMessageHandler(cmd => sendCommand(cmd), getModuleFromFilename, callback),
49+
);
9050

9151
return () => {
9252
sendCommand('Debugger.enable');

packages/node/src/anr/index.ts

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Event, StackFrame } from '@sentry/types';
2-
import { logger } from '@sentry/utils';
2+
import { logger, watchdogTimer } from '@sentry/utils';
33
import { spawn } from 'child_process';
44

55
import { addGlobalEventProcessor, captureEvent, flush } from '..';
@@ -8,36 +8,6 @@ import { captureStackTrace } from './debugger';
88
const DEFAULT_INTERVAL = 50;
99
const DEFAULT_HANG_THRESHOLD = 5000;
1010

11-
/**
12-
* A node.js watchdog timer
13-
* @param pollInterval The interval that we expect to get polled at
14-
* @param anrThreshold The threshold for when we consider ANR
15-
* @param callback The callback to call for ANR
16-
* @returns A function to call to reset the timer
17-
*/
18-
function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): () => void {
19-
let lastPoll = process.hrtime();
20-
let triggered = false;
21-
22-
setInterval(() => {
23-
const [seconds, nanoSeconds] = process.hrtime(lastPoll);
24-
const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6);
25-
26-
if (triggered === false && diffMs > pollInterval + anrThreshold) {
27-
triggered = true;
28-
callback();
29-
}
30-
31-
if (diffMs < pollInterval + anrThreshold) {
32-
triggered = false;
33-
}
34-
}, 20);
35-
36-
return () => {
37-
lastPoll = process.hrtime();
38-
};
39-
}
40-
4111
interface Options {
4212
/**
4313
* The app entry script. This is used to run the same script as the child process.
@@ -216,10 +186,10 @@ function handleChildProcess(options: Options): void {
216186
}
217187
}
218188

219-
const ping = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout);
189+
const { poll } = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout);
220190

221191
process.on('message', () => {
222-
ping();
192+
poll();
223193
});
224194
}
225195

packages/utils/src/anr.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { StackFrame } from '@sentry/types';
2+
3+
import { dropUndefinedKeys } from './object';
4+
import { filenameIsInApp, stripSentryFramesAndReverse } from './stacktrace';
5+
6+
type WatchdogReturn = {
7+
/** Resets the watchdog timer */
8+
poll: () => void;
9+
/** Enables or disables the watchdog timer */
10+
enabled: (state: boolean) => void;
11+
};
12+
13+
/**
14+
* A node.js watchdog timer
15+
* @param pollInterval The interval that we expect to get polled at
16+
* @param anrThreshold The threshold for when we consider ANR
17+
* @param callback The callback to call for ANR
18+
* @returns An object with `poll` and `enabled` functions {@link WatchdogReturn}
19+
*/
20+
export function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): WatchdogReturn {
21+
let lastPoll = process.hrtime();
22+
let triggered = false;
23+
let enabled = true;
24+
25+
setInterval(() => {
26+
const [seconds, nanoSeconds] = process.hrtime(lastPoll);
27+
const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6);
28+
29+
if (triggered === false && diffMs > pollInterval + anrThreshold) {
30+
triggered = true;
31+
if (enabled) {
32+
callback();
33+
}
34+
}
35+
36+
if (diffMs < pollInterval + anrThreshold) {
37+
triggered = false;
38+
}
39+
}, 20);
40+
41+
return {
42+
poll: () => {
43+
lastPoll = process.hrtime();
44+
},
45+
enabled: (state: boolean) => {
46+
enabled = state;
47+
},
48+
};
49+
}
50+
51+
// types copied from inspector.d.ts
52+
interface Location {
53+
scriptId: string;
54+
lineNumber: number;
55+
columnNumber?: number;
56+
}
57+
58+
interface CallFrame {
59+
functionName: string;
60+
location: Location;
61+
url: string;
62+
}
63+
64+
interface ScriptParsedEventDataType {
65+
scriptId: string;
66+
url: string;
67+
}
68+
69+
interface PausedEventDataType {
70+
callFrames: CallFrame[];
71+
reason: string;
72+
}
73+
74+
/**
75+
* Converts Debugger.CallFrame to Sentry StackFrame
76+
*/
77+
function callFrameToStackFrame(
78+
frame: CallFrame,
79+
url: string | undefined,
80+
getModuleFromFilename: (filename: string | undefined) => string | undefined,
81+
): StackFrame {
82+
const filename = url ? url.replace(/^file:\/\//, '') : undefined;
83+
84+
// CallFrame row/col are 0 based, whereas StackFrame are 1 based
85+
const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined;
86+
const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined;
87+
88+
return dropUndefinedKeys({
89+
filename,
90+
module: getModuleFromFilename(filename),
91+
function: frame.functionName || '?',
92+
colno,
93+
lineno,
94+
in_app: filename ? filenameIsInApp(filename) : undefined,
95+
});
96+
}
97+
98+
// The only messages we care about
99+
type DebugMessage =
100+
| { method: 'Debugger.scriptParsed'; params: ScriptParsedEventDataType }
101+
| { method: 'Debugger.paused'; params: PausedEventDataType };
102+
103+
/**
104+
* Creates a message handler from the v8 debugger protocol and passed stack frames to the callback when paused.
105+
*/
106+
export function createDebugPauseMessageHandler(
107+
sendCommand: (message: string) => void,
108+
getModuleFromFilename: (filename?: string) => string | undefined,
109+
pausedStackFrames: (frames: StackFrame[]) => void,
110+
): (message: DebugMessage) => void {
111+
// Collect scriptId -> url map so we can look up the filenames later
112+
const scripts = new Map<string, string>();
113+
114+
return message => {
115+
if (message.method === 'Debugger.scriptParsed') {
116+
scripts.set(message.params.scriptId, message.params.url);
117+
} else if (message.method === 'Debugger.paused') {
118+
// copy the frames
119+
const callFrames = [...message.params.callFrames];
120+
// and resume immediately
121+
sendCommand('Debugger.resume');
122+
sendCommand('Debugger.disable');
123+
124+
const stackFrames = stripSentryFramesAndReverse(
125+
callFrames.map(frame =>
126+
callFrameToStackFrame(frame, scripts.get(frame.location.scriptId), getModuleFromFilename),
127+
),
128+
);
129+
130+
pausedStackFrames(stackFrames);
131+
}
132+
};
133+
}

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ export * from './url';
3131
export * from './userIntegrations';
3232
export * from './cache';
3333
export * from './eventbuilder';
34+
export * from './anr';

0 commit comments

Comments
 (0)