Skip to content

Commit 3eaf71e

Browse files
authored
feat(node): Add option to OnUncaughtException integration that allows mimicking native uncaught error exit behaviour (#6137)
1 parent a0564ed commit 3eaf71e

File tree

6 files changed

+229
-42
lines changed

6 files changed

+229
-42
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+
exitEvenIfOtherHandlersAreRegistered: false,
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+
exitEvenIfOtherHandlersAreRegistered: false,
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();
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 `exitEvenIfOtherHandlersAreRegistered` set to false', () => {
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-native-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-native-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

+92-42
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ 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+
* Controls if the SDK should register a handler to exit the process on uncaught errors:
16+
* - `true`: The SDK will exit the process on all uncaught errors.
17+
* - `false`: The SDK will only exit the process when there are no other `uncaughtException` handlers attached.
18+
*
19+
* Default: `true`
20+
*/
21+
exitEvenIfOtherHandlersAreRegistered: boolean;
22+
23+
/**
24+
* This is called when an uncaught error would cause the process to exit.
25+
*
26+
* @param firstError Uncaught error causing the process to exit
27+
* @param secondError Will be set if the handler was called multiple times. This can happen either because
28+
* `onFatalError` itself threw, or because an independent error happened somewhere else while `onFatalError`
29+
* was running.
30+
*/
31+
onFatalError?(firstError: Error, secondError?: Error): void;
32+
}
33+
1034
/** Global Exception handler */
1135
export class OnUncaughtException implements Integration {
1236
/**
@@ -24,24 +48,23 @@ export class OnUncaughtException implements Integration {
2448
*/
2549
public readonly handler: (error: Error) => void = this._makeErrorHandler();
2650

51+
private readonly _options: OnUncaughtExceptionOptions;
52+
2753
/**
2854
* @inheritDoc
2955
*/
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-
) {}
56+
public constructor(options: Partial<OnUncaughtExceptionOptions> = {}) {
57+
this._options = {
58+
exitEvenIfOtherHandlersAreRegistered: true,
59+
...options,
60+
};
61+
}
62+
4063
/**
4164
* @inheritDoc
4265
*/
4366
public setupOnce(): void {
44-
global.process.on('uncaughtException', this.handler.bind(this));
67+
global.process.on('uncaughtException', this.handler);
4568
}
4669

4770
/**
@@ -66,6 +89,27 @@ export class OnUncaughtException implements Integration {
6689
onFatalError = client.getOptions().onFatalError as OnFatalErrorHandler;
6790
}
6891

92+
// Attaching a listener to `uncaughtException` will prevent the node process from exiting. We generally do not
93+
// want to alter this behaviour so we check for other listeners that users may have attached themselves and adjust
94+
// exit behaviour of the SDK accordingly:
95+
// - If other listeners are attached, do not exit.
96+
// - If the only listener attached is ours, exit.
97+
const userProvidedListenersCount = global.process
98+
.listeners('uncaughtException')
99+
.reduce<number>((acc, listener) => {
100+
if (
101+
listener.name === 'domainUncaughtExceptionClear' || // as soon as we're using domains this listener is attached by node itself
102+
listener === this.handler // filter the handler we registered ourselves)
103+
) {
104+
return acc;
105+
} else {
106+
return acc + 1;
107+
}
108+
}, 0);
109+
110+
const processWouldExit = userProvidedListenersCount === 0;
111+
const shouldApplyFatalHandlingLogic = this._options.exitEvenIfOtherHandlersAreRegistered || processWouldExit;
112+
69113
if (!caughtFirstError) {
70114
const hub = getCurrentHub();
71115

@@ -82,47 +126,53 @@ export class OnUncaughtException implements Integration {
82126
originalException: error,
83127
data: { mechanism: { handled: false, type: 'onuncaughtexception' } },
84128
});
85-
if (!calledFatalError) {
129+
if (!calledFatalError && shouldApplyFatalHandlingLogic) {
86130
calledFatalError = true;
87131
onFatalError(error);
88132
}
89133
});
90134
} else {
91-
if (!calledFatalError) {
135+
if (!calledFatalError && shouldApplyFatalHandlingLogic) {
92136
calledFatalError = true;
93137
onFatalError(error);
94138
}
95139
}
96-
} else if (calledFatalError) {
97-
// we hit an error *after* calling onFatalError - pretty boned at this point, just shut it down
98-
__DEBUG_BUILD__ &&
99-
logger.warn('uncaught exception after calling fatal error shutdown callback - this is bad! forcing shutdown');
100-
logAndExitProcess(error);
101-
} else if (!caughtSecondError) {
102-
// two cases for how we can hit this branch:
103-
// - capturing of first error blew up and we just caught the exception from that
104-
// - quit trying to capture, proceed with shutdown
105-
// - a second independent error happened while waiting for first error to capture
106-
// - want to avoid causing premature shutdown before first error capture finishes
107-
// it's hard to immediately tell case 1 from case 2 without doing some fancy/questionable domain stuff
108-
// so let's instead just delay a bit before we proceed with our action here
109-
// in case 1, we just wait a bit unnecessarily but ultimately do the same thing
110-
// in case 2, the delay hopefully made us wait long enough for the capture to finish
111-
// two potential nonideal outcomes:
112-
// nonideal case 1: capturing fails fast, we sit around for a few seconds unnecessarily before proceeding correctly by calling onFatalError
113-
// nonideal case 2: case 2 happens, 1st error is captured but slowly, timeout completes before capture and we treat second error as the sendErr of (nonexistent) failure from trying to capture first error
114-
// note that after hitting this branch, we might catch more errors where (caughtSecondError && !calledFatalError)
115-
// we ignore them - they don't matter to us, we're just waiting for the second error timeout to finish
116-
caughtSecondError = true;
117-
setTimeout(() => {
118-
if (!calledFatalError) {
119-
// it was probably case 1, let's treat err as the sendErr and call onFatalError
120-
calledFatalError = true;
121-
onFatalError(firstError, error);
122-
} else {
123-
// it was probably case 2, our first error finished capturing while we waited, cool, do nothing
140+
} else {
141+
if (shouldApplyFatalHandlingLogic) {
142+
if (calledFatalError) {
143+
// we hit an error *after* calling onFatalError - pretty boned at this point, just shut it down
144+
__DEBUG_BUILD__ &&
145+
logger.warn(
146+
'uncaught exception after calling fatal error shutdown callback - this is bad! forcing shutdown',
147+
);
148+
logAndExitProcess(error);
149+
} else if (!caughtSecondError) {
150+
// two cases for how we can hit this branch:
151+
// - capturing of first error blew up and we just caught the exception from that
152+
// - quit trying to capture, proceed with shutdown
153+
// - a second independent error happened while waiting for first error to capture
154+
// - want to avoid causing premature shutdown before first error capture finishes
155+
// it's hard to immediately tell case 1 from case 2 without doing some fancy/questionable domain stuff
156+
// so let's instead just delay a bit before we proceed with our action here
157+
// in case 1, we just wait a bit unnecessarily but ultimately do the same thing
158+
// in case 2, the delay hopefully made us wait long enough for the capture to finish
159+
// two potential nonideal outcomes:
160+
// nonideal case 1: capturing fails fast, we sit around for a few seconds unnecessarily before proceeding correctly by calling onFatalError
161+
// nonideal case 2: case 2 happens, 1st error is captured but slowly, timeout completes before capture and we treat second error as the sendErr of (nonexistent) failure from trying to capture first error
162+
// note that after hitting this branch, we might catch more errors where (caughtSecondError && !calledFatalError)
163+
// we ignore them - they don't matter to us, we're just waiting for the second error timeout to finish
164+
caughtSecondError = true;
165+
setTimeout(() => {
166+
if (!calledFatalError) {
167+
// it was probably case 1, let's treat err as the sendErr and call onFatalError
168+
calledFatalError = true;
169+
onFatalError(firstError, error);
170+
} else {
171+
// it was probably case 2, our first error finished capturing while we waited, cool, do nothing
172+
}
173+
}, timeout); // capturing could take at least sendTimeout to fail, plus an arbitrary second for how long it takes to collect surrounding source etc
124174
}
125-
}, timeout); // capturing could take at least sendTimeout to fail, plus an arbitrary second for how long it takes to collect surrounding source etc
175+
}
126176
}
127177
};
128178
}

0 commit comments

Comments
 (0)