Skip to content

Commit 8922b5b

Browse files
author
Luca Forstner
committed
feat(node): Add option to OnUncaughtException integration that allows mimicing native exit behaviour
1 parent a0564ed commit 8922b5b

File tree

6 files changed

+192
-14
lines changed

6 files changed

+192
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const Sentry = require('@sentry/node');
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
});
6+
7+
process.on('uncaughtException', () => {
8+
// do nothing - this will prevent the Error below from closing this process before the timeout resolves
9+
});
10+
11+
setTimeout(() => {
12+
process.stdout.write("I'm alive!");
13+
process.exit(0);
14+
}, 500);
15+
16+
throw new Error();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const Sentry = require('@sentry/node');
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
integrations: integrations => {
6+
return integrations.map(integration => {
7+
if (integration.name === 'OnUncaughtException') {
8+
return new Sentry.Integrations.OnUncaughtException({
9+
mimicNativeBehaviour: true,
10+
});
11+
} else {
12+
return integration;
13+
}
14+
});
15+
},
16+
});
17+
18+
process.on('uncaughtException', () => {
19+
// do nothing - this will prevent the Error below from closing this process before the timeout resolves
20+
});
21+
22+
setTimeout(() => {
23+
process.stdout.write("I'm alive!");
24+
process.exit(0);
25+
}, 500);
26+
27+
throw new Error();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const Sentry = require('@sentry/node');
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
integrations: integrations => {
6+
return integrations.map(integration => {
7+
if (integration.name === 'OnUncaughtException') {
8+
return new Sentry.Integrations.OnUncaughtException({
9+
mimicNativeBehaviour: true,
10+
});
11+
} else {
12+
return integration;
13+
}
14+
});
15+
},
16+
});
17+
18+
setTimeout(() => {
19+
// This should not be called because the script throws before this resolves
20+
process.stdout.write("I'm alive!");
21+
process.exit(0);
22+
}, 500);
23+
24+
throw new Error();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const Sentry = require('@sentry/node');
2+
3+
Sentry.init({
4+
dsn: 'https://[email protected]/1337',
5+
});
6+
7+
setTimeout(() => {
8+
// This should not be called because the script throws before this resolves
9+
process.stdout.write("I'm alive!");
10+
process.exit(0);
11+
}, 500);
12+
13+
throw new Error();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as childProcess from 'child_process';
2+
import * as path from 'path';
3+
4+
describe('OnUncaughtException integration', () => {
5+
test('should close process on uncaught error with no additional listeners registered', done => {
6+
expect.assertions(3);
7+
8+
const testScriptPath = path.resolve(__dirname, 'no-additional-listener-test-script.js');
9+
10+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => {
11+
expect(err).not.toBeNull();
12+
expect(err?.code).toBe(1);
13+
expect(stdout).not.toBe("I'm alive!");
14+
done();
15+
});
16+
});
17+
18+
test('should close process on uncaught error when additional listeners are registered', done => {
19+
expect.assertions(3);
20+
21+
const testScriptPath = path.resolve(__dirname, 'additional-listener-test-script.js');
22+
23+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => {
24+
expect(err).not.toBeNull();
25+
expect(err?.code).toBe(1);
26+
expect(stdout).not.toBe("I'm alive!");
27+
done();
28+
});
29+
});
30+
31+
describe('with `mimicNativeBehaviour` set to true', () => {
32+
test('should close process on uncaught error with no additional listeners registered', done => {
33+
expect.assertions(3);
34+
35+
const testScriptPath = path.resolve(__dirname, 'mimic-behaviour-no-additional-listener-test-script.js');
36+
37+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => {
38+
expect(err).not.toBeNull();
39+
expect(err?.code).toBe(1);
40+
expect(stdout).not.toBe("I'm alive!");
41+
done();
42+
});
43+
});
44+
45+
test('should not close process on uncaught error when additional listeners are registered', done => {
46+
expect.assertions(2);
47+
48+
const testScriptPath = path.resolve(__dirname, 'mimic-behaviour-additional-listener-test-script.js');
49+
50+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (err, stdout) => {
51+
expect(err).toBeNull();
52+
expect(stdout).toBe("I'm alive!");
53+
done();
54+
});
55+
});
56+
});
57+
});

packages/node/src/integrations/onuncaughtexception.ts

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ import { logAndExitProcess } from './utils/errorhandling';
77

88
type OnFatalErrorHandler = (firstError: Error, secondError?: Error) => void;
99

10+
interface OnUncaughtExceptionOptions {
11+
// TODO(v8): Evaluate whether we should switch the default behaviour here.
12+
// Also, we can evaluate using https://nodejs.org/api/process.html#event-uncaughtexceptionmonitor per default, and
13+
// falling back to current behaviour when that's not available.
14+
/**
15+
* Whether the SDK should mimic native behaviour when a global error occurs. Default: `false`
16+
* - `false`: The SDK will exit the process on all uncaught errors.
17+
* - `true`: The SDK will only exit the process when there are no other 'uncaughtException' handlers attached.
18+
*/
19+
mimicNativeBehaviour: boolean;
20+
21+
/**
22+
* This is called when an uncaught error would cause the process to exit.
23+
*
24+
* @param firstError Uncaught error causing the process to exit
25+
* @param secondError Will be set if the handler was called multiple times. This can happen either because
26+
* `onFatalError` itself threw, or because an independent error happened somewhere else while `onFatalError`
27+
* was running.
28+
*/
29+
onFatalError?(firstError: Error, secondError?: Error): void;
30+
}
31+
1032
/** Global Exception handler */
1133
export class OnUncaughtException implements Integration {
1234
/**
@@ -24,19 +46,18 @@ export class OnUncaughtException implements Integration {
2446
*/
2547
public readonly handler: (error: Error) => void = this._makeErrorHandler();
2648

49+
private readonly _options: OnUncaughtExceptionOptions;
50+
2751
/**
2852
* @inheritDoc
2953
*/
30-
public constructor(
31-
private readonly _options: {
32-
/**
33-
* Default onFatalError handler
34-
* @param firstError Error that has been thrown
35-
* @param secondError If this was called multiple times this will be set
36-
*/
37-
onFatalError?(firstError: Error, secondError?: Error): void;
38-
} = {},
39-
) {}
54+
public constructor(options: Partial<OnUncaughtExceptionOptions> = {}) {
55+
this._options = {
56+
mimicNativeBehaviour: false,
57+
...options,
58+
};
59+
}
60+
4061
/**
4162
* @inheritDoc
4263
*/
@@ -58,6 +79,26 @@ export class OnUncaughtException implements Integration {
5879
let onFatalError: OnFatalErrorHandler = logAndExitProcess;
5980
const client = getCurrentHub().getClient<NodeClient>();
6081

82+
// Attaching a listener to `uncaughtException` will prevent the node process from exiting. We generally do not
83+
// want to alter this behaviour so we check for other listeners that users may have attached themselves and adjust
84+
// exit behaviour of the SDK accordingly:
85+
// - If other listeners are attached, do not exit.
86+
// - If the only listener attached is ours, exit.
87+
const userProvidedListenersCount = global.process
88+
.listeners('uncaughtException')
89+
.reduce<number>((acc, listener) => {
90+
if (
91+
listener.name === 'domainUncaughtExceptionClear' || // as soon as we're using domains this listener is attached by node itself
92+
listener === this.handler // filter the handler we registered ourselves)
93+
) {
94+
return acc;
95+
} else {
96+
return acc + 1;
97+
}
98+
}, 0);
99+
100+
const shouldExitProcess: boolean = !this._options.mimicNativeBehaviour || userProvidedListenersCount === 0;
101+
61102
if (this._options.onFatalError) {
62103
// eslint-disable-next-line @typescript-eslint/unbound-method
63104
onFatalError = this._options.onFatalError;
@@ -82,23 +123,23 @@ export class OnUncaughtException implements Integration {
82123
originalException: error,
83124
data: { mechanism: { handled: false, type: 'onuncaughtexception' } },
84125
});
85-
if (!calledFatalError) {
126+
if (!calledFatalError && shouldExitProcess) {
86127
calledFatalError = true;
87128
onFatalError(error);
88129
}
89130
});
90131
} else {
91-
if (!calledFatalError) {
132+
if (!calledFatalError && shouldExitProcess) {
92133
calledFatalError = true;
93134
onFatalError(error);
94135
}
95136
}
96-
} else if (calledFatalError) {
137+
} else if (calledFatalError && shouldExitProcess) {
97138
// we hit an error *after* calling onFatalError - pretty boned at this point, just shut it down
98139
__DEBUG_BUILD__ &&
99140
logger.warn('uncaught exception after calling fatal error shutdown callback - this is bad! forcing shutdown');
100141
logAndExitProcess(error);
101-
} else if (!caughtSecondError) {
142+
} else if (!caughtSecondError && shouldExitProcess) {
102143
// two cases for how we can hit this branch:
103144
// - capturing of first error blew up and we just caught the exception from that
104145
// - quit trying to capture, proceed with shutdown

0 commit comments

Comments
 (0)