Skip to content

Commit a3cf458

Browse files
authored
feat(node): Capture exceptions from worker_threads (#15105)
Updates the `childProcessIntegration` to capture `worker_threads` errors (including their stack traces) rather than capturing a breadcrumb. This features is enabled by default and can be disabled by setting the `captureWorkerErrors` option to `false`: ```ts Sentry.init({ dsn: 'https://[email protected]/1337', integrations: [Sentry.childProcessIntegration({ captureWorkerErrors: false })], }); ``` When `captureWorkerErrors: false`, a breadcrumb will be captured instead. This PR also adds more integration tests for the `childProcessIntegration`.
1 parent c307597 commit a3cf458

File tree

9 files changed

+171
-12
lines changed

9 files changed

+171
-12
lines changed

dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const __dirname = new URL('.', import.meta.url).pathname;
99
Sentry.init({
1010
dsn: 'https://[email protected]/1337',
1111
release: '1.0',
12+
integrations: [Sentry.childProcessIntegration({ captureWorkerErrors: false })],
1213
transport: loggingTransport,
1314
});
1415

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
setTimeout(() => {
2+
throw new Error('Test error');
3+
}, 1000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
setTimeout(() => {
2+
throw new Error('Test error');
3+
}, 1000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const Sentry = require('@sentry/node');
2+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
3+
const path = require('path');
4+
const { fork } = require('child_process');
5+
6+
Sentry.init({
7+
debug: true,
8+
dsn: 'https://[email protected]/1337',
9+
release: '1.0',
10+
transport: loggingTransport,
11+
});
12+
13+
// eslint-disable-next-line no-unused-vars
14+
const _child = fork(path.join(__dirname, 'child.mjs'));
15+
16+
setTimeout(() => {
17+
throw new Error('Exiting main process');
18+
}, 3000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { fork } from 'child_process';
2+
import * as path from 'path';
3+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
4+
import * as Sentry from '@sentry/node';
5+
6+
const __dirname = new URL('.', import.meta.url).pathname;
7+
8+
Sentry.init({
9+
debug: true,
10+
dsn: 'https://[email protected]/1337',
11+
release: '1.0',
12+
transport: loggingTransport,
13+
});
14+
15+
const _child = fork(path.join(__dirname, 'child.mjs'));
16+
17+
setTimeout(() => {
18+
throw new Error('Exiting main process');
19+
}, 3000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Event } from '@sentry/core';
2+
import { conditionalTest } from '../../utils';
3+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
4+
5+
const WORKER_EVENT: Event = {
6+
exception: {
7+
values: [
8+
{
9+
type: 'Error',
10+
value: 'Test error',
11+
mechanism: {
12+
type: 'instrument',
13+
handled: false,
14+
data: {
15+
threadId: expect.any(String),
16+
},
17+
},
18+
},
19+
],
20+
},
21+
};
22+
23+
const CHILD_EVENT: Event = {
24+
exception: {
25+
values: [
26+
{
27+
type: 'Error',
28+
value: 'Exiting main process',
29+
},
30+
],
31+
},
32+
breadcrumbs: [
33+
{
34+
category: 'child_process',
35+
message: "Child process exited with code '1'",
36+
level: 'warning',
37+
},
38+
],
39+
};
40+
41+
describe('should capture child process events', () => {
42+
afterAll(() => {
43+
cleanupChildProcesses();
44+
});
45+
46+
conditionalTest({ min: 20 })('worker', () => {
47+
test('ESM', done => {
48+
createRunner(__dirname, 'worker.mjs').expect({ event: WORKER_EVENT }).start(done);
49+
});
50+
51+
test('CJS', done => {
52+
createRunner(__dirname, 'worker.js').expect({ event: WORKER_EVENT }).start(done);
53+
});
54+
});
55+
56+
conditionalTest({ min: 20 })('fork', () => {
57+
test('ESM', done => {
58+
createRunner(__dirname, 'fork.mjs').expect({ event: CHILD_EVENT }).start(done);
59+
});
60+
61+
test('CJS', done => {
62+
createRunner(__dirname, 'fork.js').expect({ event: CHILD_EVENT }).start(done);
63+
});
64+
});
65+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const Sentry = require('@sentry/node');
2+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
3+
const path = require('path');
4+
const { Worker } = require('worker_threads');
5+
6+
Sentry.init({
7+
debug: true,
8+
dsn: 'https://[email protected]/1337',
9+
release: '1.0',
10+
transport: loggingTransport,
11+
});
12+
13+
// eslint-disable-next-line no-unused-vars
14+
const _worker = new Worker(path.join(__dirname, 'child.js'));
15+
16+
setTimeout(() => {
17+
process.exit();
18+
}, 3000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as path from 'path';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
import * as Sentry from '@sentry/node';
4+
import { Worker } from 'worker_threads';
5+
6+
const __dirname = new URL('.', import.meta.url).pathname;
7+
8+
Sentry.init({
9+
debug: true,
10+
dsn: 'https://[email protected]/1337',
11+
release: '1.0',
12+
transport: loggingTransport,
13+
});
14+
15+
const _worker = new Worker(path.join(__dirname, 'child.mjs'));
16+
17+
setTimeout(() => {
18+
process.exit();
19+
}, 3000);

packages/node/src/integrations/childProcess.ts

+25-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChildProcess } from 'node:child_process';
22
import * as diagnosticsChannel from 'node:diagnostics_channel';
33
import type { Worker } from 'node:worker_threads';
4-
import { addBreadcrumb, defineIntegration } from '@sentry/core';
4+
import { addBreadcrumb, captureException, defineIntegration } from '@sentry/core';
55

66
interface Options {
77
/**
@@ -10,17 +10,24 @@ interface Options {
1010
* @default false
1111
*/
1212
includeChildProcessArgs?: boolean;
13+
14+
/**
15+
* Whether to capture errors from worker threads.
16+
*
17+
* @default true
18+
*/
19+
captureWorkerErrors?: boolean;
1320
}
1421

1522
const INTEGRATION_NAME = 'ChildProcess';
1623

1724
/**
18-
* Capture breadcrumbs for child processes and worker threads.
25+
* Capture breadcrumbs and events for child processes and worker threads.
1926
*/
2027
export const childProcessIntegration = defineIntegration((options: Options = {}) => {
2128
return {
2229
name: INTEGRATION_NAME,
23-
setup(_client) {
30+
setup() {
2431
diagnosticsChannel.channel('child_process').subscribe((event: unknown) => {
2532
if (event && typeof event === 'object' && 'process' in event) {
2633
captureChildProcessEvents(event.process as ChildProcess, options);
@@ -29,7 +36,7 @@ export const childProcessIntegration = defineIntegration((options: Options = {})
2936

3037
diagnosticsChannel.channel('worker_threads').subscribe((event: unknown) => {
3138
if (event && typeof event === 'object' && 'worker' in event) {
32-
captureWorkerThreadEvents(event.worker as Worker);
39+
captureWorkerThreadEvents(event.worker as Worker, options);
3340
}
3441
});
3542
},
@@ -62,7 +69,7 @@ function captureChildProcessEvents(child: ChildProcess, options: Options): void
6269
addBreadcrumb({
6370
category: 'child_process',
6471
message: `Child process exited with code '${code}'`,
65-
level: 'warning',
72+
level: code === 0 ? 'info' : 'warning',
6673
data,
6774
});
6875
}
@@ -82,19 +89,25 @@ function captureChildProcessEvents(child: ChildProcess, options: Options): void
8289
});
8390
}
8491

85-
function captureWorkerThreadEvents(worker: Worker): void {
92+
function captureWorkerThreadEvents(worker: Worker, options: Options): void {
8693
let threadId: number | undefined;
8794

8895
worker
8996
.on('online', () => {
9097
threadId = worker.threadId;
9198
})
9299
.on('error', error => {
93-
addBreadcrumb({
94-
category: 'worker_thread',
95-
message: `Worker thread errored with '${error.message}'`,
96-
level: 'error',
97-
data: { threadId },
98-
});
100+
if (options.captureWorkerErrors !== false) {
101+
captureException(error, {
102+
mechanism: { type: 'instrument', handled: false, data: { threadId: String(threadId) } },
103+
});
104+
} else {
105+
addBreadcrumb({
106+
category: 'worker_thread',
107+
message: `Worker thread errored with '${error.message}'`,
108+
level: 'error',
109+
data: { threadId },
110+
});
111+
}
99112
});
100113
}

0 commit comments

Comments
 (0)