Skip to content

Commit 5a8d4aa

Browse files
timfishAbhiPrasad
andauthored
feat(node): App Not Responding with stack traces (#9079)
This PR adds ANR detection for Node via a forked child process. The child process runs the same entry point as the main app. To ensure that the main app code does not run in the child process, we use a promise that only resolves in the main process. When the `captureStackTrace` option is enabled, debugging is enabled in the main app process and the child process uses WebSockets to capture stack traces via the v8 inspector API. Overhead is expected to be minimal. With no ANR detected, the only overhead in the main app process is polling the child process over IPC by default every 50ms. The ANR child process consumes around 50-60 MB or RAM and simply keeps track of the polling. Once ANR has been detected, the main process will get paused briefly in the debugger to capture a stack trace frames. At this point, the event loop has been blocked for seconds so the debugging overhead can be considered negligible. Once an ANR event has been reported, the child process exits to prevent further duplicate events. ```ts import { init, enableANRDetection } from '@sentry/node'; init({ dsn: "__DSN__" }); // ESM supports top-level await await enableANRDetection({ captureStackTrace: true }); runApp(); // for CJS you'll need then enableANRDetection({ captureStackTrace: true }).then(() => { runApp(); }) ``` Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent 50a36bf commit 5a8d4aa

File tree

10 files changed

+847
-21
lines changed

10 files changed

+847
-21
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const crypto = require('crypto');
2+
3+
const Sentry = require('@sentry/node');
4+
5+
// close both processes after 5 seconds
6+
setTimeout(() => {
7+
process.exit();
8+
}, 5000);
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
release: '1.0',
13+
beforeSend: event => {
14+
// eslint-disable-next-line no-console
15+
console.log(JSON.stringify(event));
16+
},
17+
});
18+
19+
Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }).then(() => {
20+
function longWork() {
21+
for (let i = 0; i < 100; i++) {
22+
const salt = crypto.randomBytes(128).toString('base64');
23+
// eslint-disable-next-line no-unused-vars
24+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
25+
}
26+
}
27+
28+
setTimeout(() => {
29+
longWork();
30+
}, 1000);
31+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as crypto from 'crypto';
2+
3+
import * as Sentry from '@sentry/node';
4+
5+
// close both processes after 5 seconds
6+
setTimeout(() => {
7+
process.exit();
8+
}, 5000);
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
release: '1.0',
13+
beforeSend: event => {
14+
// eslint-disable-next-line no-console
15+
console.log(JSON.stringify(event));
16+
},
17+
});
18+
19+
await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true });
20+
21+
function longWork() {
22+
for (let i = 0; i < 100; i++) {
23+
const salt = crypto.randomBytes(128).toString('base64');
24+
// eslint-disable-next-line no-unused-vars
25+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
26+
}
27+
}
28+
29+
setTimeout(() => {
30+
longWork();
31+
}, 1000);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Event } from '@sentry/node';
2+
import { parseSemver } from '@sentry/utils';
3+
import * as childProcess from 'child_process';
4+
import * as path from 'path';
5+
6+
const NODE_VERSION = parseSemver(process.versions.node).major || 0;
7+
8+
describe('should report ANR when event loop blocked', () => {
9+
test('CJS', done => {
10+
// The stack trace is different when node < 12
11+
const testFramesDetails = NODE_VERSION >= 12;
12+
13+
expect.assertions(testFramesDetails ? 6 : 4);
14+
15+
const testScriptPath = path.resolve(__dirname, 'scenario.js');
16+
17+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
18+
const event = JSON.parse(stdout) as Event;
19+
20+
expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' });
21+
expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding');
22+
expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms');
23+
expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4);
24+
25+
if (testFramesDetails) {
26+
expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?');
27+
expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork');
28+
}
29+
30+
done();
31+
});
32+
});
33+
34+
test('ESM', done => {
35+
if (NODE_VERSION < 14) {
36+
done();
37+
return;
38+
}
39+
40+
expect.assertions(6);
41+
42+
const testScriptPath = path.resolve(__dirname, 'scenario.mjs');
43+
44+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
45+
const event = JSON.parse(stdout) as Event;
46+
47+
expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' });
48+
expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding');
49+
expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms');
50+
expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4);
51+
expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?');
52+
expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork');
53+
54+
done();
55+
});
56+
});
57+
});

packages/node/src/anr/debugger.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { StackFrame } from '@sentry/types';
2+
import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils';
3+
import type { Debugger } from 'inspector';
4+
5+
import { getModuleFromFilename } from '../module';
6+
import { createWebSocketClient } from './websocket';
7+
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+
31+
// The only messages we care about
32+
type DebugMessage =
33+
| {
34+
method: 'Debugger.scriptParsed';
35+
params: Debugger.ScriptParsedEventDataType;
36+
}
37+
| { method: 'Debugger.paused'; params: Debugger.PausedEventDataType };
38+
39+
/**
40+
* Wraps a websocket connection with the basic logic of the Node debugger protocol.
41+
* @param url The URL to connect to
42+
* @param onMessage A callback that will be called with each return message from the debugger
43+
* @returns A function that can be used to send commands to the debugger
44+
*/
45+
async function webSocketDebugger(
46+
url: string,
47+
onMessage: (message: DebugMessage) => void,
48+
): Promise<(method: string, params?: unknown) => void> {
49+
let id = 0;
50+
const webSocket = await createWebSocketClient(url);
51+
52+
webSocket.on('message', (data: Buffer) => {
53+
const message = JSON.parse(data.toString()) as DebugMessage;
54+
onMessage(message);
55+
});
56+
57+
return (method: string, params?: unknown) => {
58+
webSocket.send(JSON.stringify({ id: id++, method, params }));
59+
};
60+
}
61+
62+
/**
63+
* Captures stack traces from the Node debugger.
64+
* @param url The URL to connect to
65+
* @param callback A callback that will be called with the stack frames
66+
* @returns A function that triggers the debugger to pause and capture a stack trace
67+
*/
68+
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+
});
90+
91+
return () => {
92+
sendCommand('Debugger.enable');
93+
sendCommand('Debugger.pause');
94+
};
95+
}

0 commit comments

Comments
 (0)