Skip to content

Commit f22bb15

Browse files
authored
fix(node): Fixes and improvements to ANR detection (#9128)
1 parent aa41f97 commit f22bb15

File tree

7 files changed

+121
-22
lines changed

7 files changed

+121
-22
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const { fork } = require('child_process');
2+
const { join } = require('path');
3+
4+
const child = fork(join(__dirname, 'forked.js'), { stdio: 'inherit' });
5+
child.on('exit', () => {
6+
process.exit();
7+
});

packages/node-integration-tests/suites/anr/test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('should report ANR when event loop blocked', () => {
1212

1313
expect.assertions(testFramesDetails ? 6 : 4);
1414

15-
const testScriptPath = path.resolve(__dirname, 'scenario.js');
15+
const testScriptPath = path.resolve(__dirname, 'basic.js');
1616

1717
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
1818
const event = JSON.parse(stdout) as Event;
@@ -39,7 +39,7 @@ describe('should report ANR when event loop blocked', () => {
3939

4040
expect.assertions(6);
4141

42-
const testScriptPath = path.resolve(__dirname, 'scenario.mjs');
42+
const testScriptPath = path.resolve(__dirname, 'basic.mjs');
4343

4444
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
4545
const event = JSON.parse(stdout) as Event;
@@ -54,4 +54,29 @@ describe('should report ANR when event loop blocked', () => {
5454
done();
5555
});
5656
});
57+
58+
test('from forked process', done => {
59+
// The stack trace is different when node < 12
60+
const testFramesDetails = NODE_VERSION >= 12;
61+
62+
expect.assertions(testFramesDetails ? 6 : 4);
63+
64+
const testScriptPath = path.resolve(__dirname, 'forker.js');
65+
66+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
67+
const event = JSON.parse(stdout) as Event;
68+
69+
expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' });
70+
expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding');
71+
expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms');
72+
expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4);
73+
74+
if (testFramesDetails) {
75+
expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?');
76+
expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork');
77+
}
78+
79+
done();
80+
});
81+
});
5782
});

packages/node/src/anr/index.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Event, StackFrame } from '@sentry/types';
22
import { logger } from '@sentry/utils';
3-
import { fork } from 'child_process';
3+
import { spawn } from 'child_process';
44
import * as inspector from 'inspector';
55

66
import { addGlobalEventProcessor, captureEvent, flush } from '..';
@@ -98,28 +98,44 @@ function sendEvent(blockedMs: number, frames?: StackFrame[]): void {
9898
});
9999
}
100100

101+
/**
102+
* Starts the node debugger and returns the inspector url.
103+
*
104+
* When inspector.url() returns undefined, it means the port is already in use so we try the next port.
105+
*/
106+
function startInspector(startPort: number = 9229): string | undefined {
107+
let inspectorUrl: string | undefined = undefined;
108+
let port = startPort;
109+
110+
while (inspectorUrl === undefined && port < startPort + 100) {
111+
inspector.open(port);
112+
inspectorUrl = inspector.url();
113+
port++;
114+
}
115+
116+
return inspectorUrl;
117+
}
118+
101119
function startChildProcess(options: Options): void {
102-
function log(message: string, err?: unknown): void {
120+
function log(message: string, ...args: unknown[]): void {
103121
if (options.debug) {
104-
if (err) {
105-
logger.log(`[ANR] ${message}`, err);
106-
} else {
107-
logger.log(`[ANR] ${message}`);
108-
}
122+
logger.log(`[ANR] ${message}`, ...args);
109123
}
110124
}
111125

112126
try {
113127
const env = { ...process.env };
128+
env.SENTRY_ANR_CHILD_PROCESS = 'true';
114129

115130
if (options.captureStackTrace) {
116-
inspector.open();
117-
env.SENTRY_INSPECT_URL = inspector.url();
131+
env.SENTRY_INSPECT_URL = startInspector();
118132
}
119133

120-
const child = fork(options.entryScript, {
134+
log(`Spawning child process with execPath:'${process.execPath}' and entryScript'${options.entryScript}'`);
135+
136+
const child = spawn(process.execPath, [options.entryScript], {
121137
env,
122-
stdio: options.debug ? 'inherit' : 'ignore',
138+
stdio: options.debug ? ['inherit', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', 'ignore', 'ipc'],
123139
});
124140
// The child process should not keep the main process alive
125141
child.unref();
@@ -133,14 +149,16 @@ function startChildProcess(options: Options): void {
133149
}
134150
}, options.pollInterval);
135151

136-
const end = (err: unknown): void => {
137-
clearInterval(timer);
138-
log('Child process ended', err);
152+
const end = (type: string): ((...args: unknown[]) => void) => {
153+
return (...args): void => {
154+
clearInterval(timer);
155+
log(`Child process ${type}`, ...args);
156+
};
139157
};
140158

141-
child.on('error', end);
142-
child.on('disconnect', end);
143-
child.on('exit', end);
159+
child.on('error', end('error'));
160+
child.on('disconnect', end('disconnect'));
161+
child.on('exit', end('exit'));
144162
} catch (e) {
145163
log('Failed to start child process', e);
146164
}
@@ -153,6 +171,8 @@ function handleChildProcess(options: Options): void {
153171
}
154172
}
155173

174+
process.title = 'sentry-anr';
175+
156176
log('Started');
157177

158178
addGlobalEventProcessor(event => {
@@ -197,6 +217,13 @@ function handleChildProcess(options: Options): void {
197217
});
198218
}
199219

220+
/**
221+
* Returns true if the current process is an ANR child process.
222+
*/
223+
export function isAnrChildProcess(): boolean {
224+
return !!process.send && !!process.env.SENTRY_ANR_CHILD_PROCESS;
225+
}
226+
200227
/**
201228
* **Note** This feature is still in beta so there may be breaking changes in future releases.
202229
*
@@ -221,17 +248,19 @@ function handleChildProcess(options: Options): void {
221248
* ```
222249
*/
223250
export function enableAnrDetection(options: Partial<Options>): Promise<void> {
224-
const isChildProcess = !!process.send;
251+
// When pm2 runs the script in cluster mode, process.argv[1] is the pm2 script and process.env.pm_exec_path is the
252+
// path to the entry script
253+
const entryScript = options.entryScript || process.env.pm_exec_path || process.argv[1];
225254

226255
const anrOptions: Options = {
227-
entryScript: options.entryScript || process.argv[1],
256+
entryScript,
228257
pollInterval: options.pollInterval || DEFAULT_INTERVAL,
229258
anrThreshold: options.anrThreshold || DEFAULT_HANG_THRESHOLD,
230259
captureStackTrace: !!options.captureStackTrace,
231260
debug: !!options.debug,
232261
};
233262

234-
if (isChildProcess) {
263+
if (isAnrChildProcess()) {
235264
handleChildProcess(anrOptions);
236265
// In the child process, the promise never resolves which stops the app code from running
237266
return new Promise<void>(() => {

packages/node/src/sdk.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
tracingContextFromHeaders,
1616
} from '@sentry/utils';
1717

18+
import { isAnrChildProcess } from './anr';
1819
import { setNodeAsyncContextStrategy } from './async';
1920
import { NodeClient } from './client';
2021
import {
@@ -110,7 +111,13 @@ export const defaultIntegrations = [
110111
*
111112
* @see {@link NodeOptions} for documentation on configuration options.
112113
*/
114+
// eslint-disable-next-line complexity
113115
export function init(options: NodeOptions = {}): void {
116+
if (isAnrChildProcess()) {
117+
options.autoSessionTracking = false;
118+
options.tracesSampleRate = 0;
119+
}
120+
114121
const carrier = getMainCarrier();
115122

116123
setNodeAsyncContextStrategy();

0 commit comments

Comments
 (0)