Skip to content

Commit 86d8994

Browse files
timfishAbhiPrasad
andauthored
feat(node): Add eventLoopBlockIntegration (#16709)
This PR: - Adds `eventLoopBlockIntegration` which uses `@sentry-internal/node-native-stacktrace` to detect and capture blocked event loops - Adds suite of integration tests to test all the functionality - Bundling - Unlike the existing ANR integration, we can't bundle the worker code into a base64 string because there is a native module and all threads need to load the same native module - The worker is loaded via `new Worker(new URL('./thread-blocked-watchdog.js', import.meta.url))` which will be bundled correctly by latest Vite and Webpack 5 - For other bundlers you can add an extra entry point pointing at `@sentry/node-native/thread-blocked-watchdog` which should output to`thread-blocked-watchdog.js` next to your bundle. ## Usage If you instrument your application via the Node.js `--import` flag, this instrumentation will be automatically applied to all worker threads. `instrument.mjs` ```javascript import * as Sentry from '@sentry/node'; import { eventLoopBlockIntegration } from '@sentry/node-native'; Sentry.init({ dsn: '__YOUR_DSN__', // Capture stack traces when the event loop is blocked for more than 500ms integrations: [eventLoopBlockIntegration({ threshold: 500, // defaults to 1000 ms maxEventsPerHour: 5, // defaults to 1 })], }); ``` `app.mjs` ```javascript import { Worker } from 'worker_threads'; const worker = new Worker(new URL('./worker.mjs', import.meta.url)); // This main thread will be monitored for blocked event loops ``` `worker.mjs` ```javascript // This worker thread will also be monitored for blocked event loops too setTimeout(() => { longWork(); }, 2_000); ``` Start your application: ```bash node --import instrument.mjs app.mjs ``` Blocked events are captured with stack traces for all threads: <img width="892" alt="image" src="https://github.com/user-attachments/assets/4990eff1-46f9-4a1d-83c7-868493a126d5" /> --------- Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent d668714 commit 86d8994

24 files changed

+974
-218
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import { eventLoopBlockIntegration } from '@sentry/node-native';
3+
import * as path from 'path';
4+
import * as url from 'url';
5+
import { longWork } from './long-work.js';
6+
7+
global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' };
8+
9+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
10+
11+
setTimeout(() => {
12+
process.exit();
13+
}, 10000);
14+
15+
Sentry.init({
16+
dsn: process.env.SENTRY_DSN,
17+
release: '1.0',
18+
integrations: [eventLoopBlockIntegration({ appRootPath: __dirname })],
19+
});
20+
21+
setTimeout(() => {
22+
longWork();
23+
}, 1000);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import { eventLoopBlockIntegration } from '@sentry/node-native';
3+
import { longWork } from './long-work.js';
4+
5+
global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' };
6+
7+
setTimeout(() => {
8+
process.exit();
9+
}, 10000);
10+
11+
Sentry.init({
12+
dsn: process.env.SENTRY_DSN,
13+
release: '1.0',
14+
integrations: [eventLoopBlockIntegration({ maxEventsPerHour: 2 })],
15+
});
16+
17+
setTimeout(() => {
18+
longWork();
19+
}, 1000);
20+
21+
setTimeout(() => {
22+
longWork();
23+
}, 4000);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const Sentry = require('@sentry/node');
2+
const { eventLoopBlockIntegration } = require('@sentry/node-native');
3+
const { longWork } = require('./long-work.js');
4+
5+
global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' };
6+
7+
setTimeout(() => {
8+
process.exit();
9+
}, 10000);
10+
11+
Sentry.init({
12+
dsn: process.env.SENTRY_DSN,
13+
release: '1.0',
14+
integrations: [eventLoopBlockIntegration()],
15+
});
16+
17+
setTimeout(() => {
18+
longWork();
19+
}, 2000);
20+
21+
// Ensure we only send one event even with multiple blocking events
22+
setTimeout(() => {
23+
longWork();
24+
}, 5000);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as Sentry from '@sentry/node';
2+
import { eventLoopBlockIntegration } from '@sentry/node-native';
3+
import { longWork } from './long-work.js';
4+
5+
global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' };
6+
7+
setTimeout(() => {
8+
process.exit();
9+
}, 12000);
10+
11+
Sentry.init({
12+
dsn: process.env.SENTRY_DSN,
13+
release: '1.0',
14+
integrations: [eventLoopBlockIntegration()],
15+
});
16+
17+
setTimeout(() => {
18+
longWork();
19+
}, 2000);
20+
21+
// Ensure we only send one event even with multiple blocking events
22+
setTimeout(() => {
23+
longWork();
24+
}, 5000);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as Sentry from '@sentry/node';
2+
import { eventLoopBlockIntegration } from '@sentry/node-native';
3+
import * as assert from 'assert';
4+
import * as crypto from 'crypto';
5+
6+
setTimeout(() => {
7+
process.exit();
8+
}, 10000);
9+
10+
Sentry.init({
11+
dsn: process.env.SENTRY_DSN,
12+
release: '1.0',
13+
integrations: [eventLoopBlockIntegration()],
14+
});
15+
16+
function longWork() {
17+
// This loop will run almost indefinitely
18+
for (let i = 0; i < 2000000000; i++) {
19+
const salt = crypto.randomBytes(128).toString('base64');
20+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
21+
assert.ok(hash);
22+
}
23+
}
24+
25+
setTimeout(() => {
26+
longWork();
27+
}, 1000);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/node';
2+
import { eventLoopBlockIntegration } from '@sentry/node-native';
3+
4+
Sentry.init({
5+
debug: true,
6+
dsn: process.env.SENTRY_DSN,
7+
release: '1.0',
8+
integrations: [eventLoopBlockIntegration()],
9+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const crypto = require('crypto');
2+
const assert = require('assert');
3+
4+
function longWork() {
5+
for (let i = 0; i < 200; i++) {
6+
const salt = crypto.randomBytes(128).toString('base64');
7+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
8+
assert.ok(hash);
9+
}
10+
}
11+
12+
exports.longWork = longWork;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const Sentry = require('@sentry/node');
2+
const { eventLoopBlockIntegration } = require('@sentry/node-native');
3+
4+
function configureSentry() {
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
release: '1.0',
8+
debug: true,
9+
integrations: [eventLoopBlockIntegration()],
10+
});
11+
}
12+
13+
async function main() {
14+
configureSentry();
15+
await new Promise(resolve => setTimeout(resolve, 1000));
16+
process.exit(0);
17+
}
18+
19+
main();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const Sentry = require('@sentry/node');
2+
const { eventLoopBlockIntegration } = require('@sentry/node-native');
3+
4+
function configureSentry() {
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
release: '1.0',
8+
debug: true,
9+
integrations: [eventLoopBlockIntegration()],
10+
});
11+
}
12+
13+
async function main() {
14+
configureSentry();
15+
await new Promise(resolve => setTimeout(resolve, 1000));
16+
}
17+
18+
main();
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { join } from 'node:path';
2+
import type { Event } from '@sentry/core';
3+
import { afterAll, describe, expect, test } from 'vitest';
4+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
5+
6+
function EXCEPTION(thread_id = '0') {
7+
return {
8+
values: [
9+
{
10+
type: 'EventLoopBlocked',
11+
value: 'Event Loop Blocked for at least 1000 ms',
12+
mechanism: { type: 'ANR' },
13+
thread_id,
14+
stacktrace: {
15+
frames: expect.arrayContaining([
16+
expect.objectContaining({
17+
colno: expect.any(Number),
18+
lineno: expect.any(Number),
19+
filename: expect.any(String),
20+
function: '?',
21+
in_app: true,
22+
}),
23+
expect.objectContaining({
24+
colno: expect.any(Number),
25+
lineno: expect.any(Number),
26+
filename: expect.any(String),
27+
function: 'longWork',
28+
in_app: true,
29+
}),
30+
]),
31+
},
32+
},
33+
],
34+
};
35+
}
36+
37+
const ANR_EVENT = {
38+
// Ensure we have context
39+
contexts: {
40+
device: {
41+
arch: expect.any(String),
42+
},
43+
app: {
44+
app_start_time: expect.any(String),
45+
},
46+
os: {
47+
name: expect.any(String),
48+
},
49+
culture: {
50+
timezone: expect.any(String),
51+
},
52+
},
53+
threads: {
54+
values: [
55+
{
56+
id: '0',
57+
name: 'main',
58+
crashed: true,
59+
current: true,
60+
main: true,
61+
},
62+
],
63+
},
64+
// and an exception that is our ANR
65+
exception: EXCEPTION(),
66+
};
67+
68+
function ANR_EVENT_WITH_DEBUG_META(file: string): Event {
69+
return {
70+
...ANR_EVENT,
71+
debug_meta: {
72+
images: [
73+
{
74+
type: 'sourcemap',
75+
debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa',
76+
code_file: expect.stringContaining(file),
77+
},
78+
],
79+
},
80+
};
81+
}
82+
83+
describe('Thread Blocked Native', { timeout: 30_000 }, () => {
84+
afterAll(() => {
85+
cleanupChildProcesses();
86+
});
87+
88+
test('CJS', async () => {
89+
await createRunner(__dirname, 'basic.js')
90+
.withMockSentryServer()
91+
.expect({ event: ANR_EVENT_WITH_DEBUG_META('basic') })
92+
.start()
93+
.completed();
94+
});
95+
96+
test('ESM', async () => {
97+
await createRunner(__dirname, 'basic.mjs')
98+
.withMockSentryServer()
99+
.expect({ event: ANR_EVENT_WITH_DEBUG_META('basic') })
100+
.start()
101+
.completed();
102+
});
103+
104+
test('Custom appRootPath', async () => {
105+
const ANR_EVENT_WITH_SPECIFIC_DEBUG_META: Event = {
106+
...ANR_EVENT,
107+
debug_meta: {
108+
images: [
109+
{
110+
type: 'sourcemap',
111+
debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa',
112+
code_file: 'app:///app-path.mjs',
113+
},
114+
],
115+
},
116+
};
117+
118+
await createRunner(__dirname, 'app-path.mjs')
119+
.withMockSentryServer()
120+
.expect({ event: ANR_EVENT_WITH_SPECIFIC_DEBUG_META })
121+
.start()
122+
.completed();
123+
});
124+
125+
test('multiple events via maxEventsPerHour', async () => {
126+
await createRunner(__dirname, 'basic-multiple.mjs')
127+
.withMockSentryServer()
128+
.expect({ event: ANR_EVENT_WITH_DEBUG_META('basic-multiple') })
129+
.expect({ event: ANR_EVENT_WITH_DEBUG_META('basic-multiple') })
130+
.start()
131+
.completed();
132+
});
133+
134+
test('blocked indefinitely', async () => {
135+
await createRunner(__dirname, 'indefinite.mjs')
136+
.withMockSentryServer()
137+
.expect({ event: ANR_EVENT })
138+
.start()
139+
.completed();
140+
});
141+
142+
test('should exit', async () => {
143+
const runner = createRunner(__dirname, 'should-exit.js').start();
144+
145+
await new Promise(resolve => setTimeout(resolve, 5_000));
146+
147+
expect(runner.childHasExited()).toBe(true);
148+
});
149+
150+
test('should exit forced', async () => {
151+
const runner = createRunner(__dirname, 'should-exit-forced.js').start();
152+
153+
await new Promise(resolve => setTimeout(resolve, 5_000));
154+
155+
expect(runner.childHasExited()).toBe(true);
156+
});
157+
158+
test('worker thread', async () => {
159+
const instrument = join(__dirname, 'instrument.mjs');
160+
await createRunner(__dirname, 'worker-main.mjs')
161+
.withMockSentryServer()
162+
.withFlags('--import', instrument)
163+
.expect({
164+
event: event => {
165+
const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string;
166+
expect(crashedThread).toBeDefined();
167+
168+
expect(event).toMatchObject({
169+
...ANR_EVENT,
170+
exception: {
171+
...EXCEPTION(crashedThread),
172+
},
173+
threads: {
174+
values: [
175+
{
176+
id: '0',
177+
name: 'main',
178+
crashed: false,
179+
current: true,
180+
main: true,
181+
stacktrace: {
182+
frames: expect.any(Array),
183+
},
184+
},
185+
{
186+
id: crashedThread,
187+
name: `worker-${crashedThread}`,
188+
crashed: true,
189+
current: true,
190+
main: false,
191+
},
192+
],
193+
},
194+
});
195+
},
196+
})
197+
.start()
198+
.completed();
199+
});
200+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { longWork } from './long-work.js';
2+
3+
setTimeout(() => {
4+
longWork();
5+
}, 2000);

0 commit comments

Comments
 (0)