Skip to content

Commit 0f24b3d

Browse files
authored
Merge pull request #370 from backtrace-labs/aibrahim/BT-6886/unhandled-rejection-label
BT-6886: fix: label unhandled promise rejections as 'Unhandled rejection'
2 parents fdf769c + 1187436 commit 0f24b3d

7 files changed

Lines changed: 257 additions & 8 deletions

File tree

packages/browser/src/BacktraceClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class BacktraceClient<O extends BacktraceConfiguration = BacktraceConfigu
9999
new BacktraceReport(
100100
errorEvent.reason,
101101
{
102-
'error.type': 'Unhandled exception',
102+
'error.type': 'Unhandled rejection',
103103
},
104104
[],
105105
{
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { BacktraceRequestHandler } from '@backtrace/sdk-core';
2+
import { BacktraceClient } from '../../src/index.js';
3+
4+
describe('Unhandled error/rejection labeling', () => {
5+
let postedJson: string | undefined;
6+
let requestHandler: BacktraceRequestHandler;
7+
let client: BacktraceClient;
8+
9+
const defaultClientOptions = {
10+
name: 'test',
11+
version: '1.0.0',
12+
url: 'https://submit.backtrace.io/foo/bar/baz',
13+
metrics: { enable: false },
14+
breadcrumbs: { enable: false },
15+
};
16+
17+
beforeEach(() => {
18+
postedJson = undefined;
19+
requestHandler = {
20+
post: jest.fn().mockResolvedValue(Promise.resolve()),
21+
postError: jest.fn().mockImplementation((_url: string, json: string) => {
22+
postedJson = json;
23+
return Promise.resolve();
24+
}),
25+
};
26+
client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build();
27+
});
28+
29+
afterEach(() => {
30+
client.dispose();
31+
});
32+
33+
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
34+
35+
it("Should tag synthetic 'unhandledrejection' events with error.type 'Unhandled rejection'", async () => {
36+
const event = new Event('unhandledrejection') as PromiseRejectionEvent;
37+
Object.defineProperty(event, 'reason', { value: new TypeError('Failed to fetch') });
38+
Object.defineProperty(event, 'promise', { value: Promise.resolve() });
39+
window.dispatchEvent(event);
40+
41+
await flushMicrotasks();
42+
43+
expect(requestHandler.postError).toHaveBeenCalled();
44+
expect(postedJson).toBeDefined();
45+
const payload = JSON.parse(postedJson as string);
46+
expect(payload.attributes['error.type']).toBe('Unhandled rejection');
47+
expect(payload.classifiers).toContain('UnhandledPromiseRejection');
48+
});
49+
50+
it("Should tag synthetic 'error' events with error.type 'Unhandled exception'", async () => {
51+
const event = new ErrorEvent('error', {
52+
error: new Error('boom'),
53+
message: 'boom',
54+
});
55+
window.dispatchEvent(event);
56+
57+
await flushMicrotasks();
58+
59+
expect(requestHandler.postError).toHaveBeenCalled();
60+
expect(postedJson).toBeDefined();
61+
const payload = JSON.parse(postedJson as string);
62+
expect(payload.attributes['error.type']).toBe('Unhandled exception');
63+
expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection');
64+
});
65+
});

packages/node/src/BacktraceClient.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,19 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
128128
if (origin === 'uncaughtException' && !captureUnhandledExceptions) {
129129
return;
130130
}
131+
const isRejection = origin === 'unhandledRejection';
131132
await this.send(
132-
new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin }, [], {
133-
classifiers: origin === 'unhandledRejection' ? ['UnhandledPromiseRejection'] : undefined,
134-
}),
133+
new BacktraceReport(
134+
error,
135+
{
136+
'error.type': isRejection ? 'Unhandled rejection' : 'Unhandled exception',
137+
errorOrigin: origin,
138+
},
139+
[],
140+
{
141+
classifiers: isRejection ? ['UnhandledPromiseRejection'] : undefined,
142+
},
143+
),
135144
);
136145
};
137146

@@ -170,7 +179,7 @@ export class BacktraceClient extends BacktraceCoreClient<BacktraceConfiguration>
170179
new BacktraceReport(
171180
isErrorTypeReason ? reason : (reason?.toString() ?? 'Unhandled rejection'),
172181
{
173-
'error.type': 'Unhandled exception',
182+
'error.type': 'Unhandled rejection',
174183
},
175184
[],
176185
{
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { BacktraceRequestHandler } from '@backtrace/sdk-core';
2+
import { BacktraceClient } from '../../src/index.js';
3+
import { NodeOptionReader } from '../../src/common/NodeOptionReader.js';
4+
5+
describe('Unhandled error/rejection labeling', () => {
6+
let postedJson: string | undefined;
7+
let requestHandler: BacktraceRequestHandler;
8+
let client: BacktraceClient;
9+
10+
const defaultClientOptions = {
11+
url: 'https://submit.backtrace.io/foo/bar/baz',
12+
metrics: { enable: false },
13+
breadcrumbs: { enable: false },
14+
};
15+
16+
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0));
17+
18+
const buildClient = () => {
19+
postedJson = undefined;
20+
requestHandler = {
21+
post: jest.fn().mockResolvedValue(Promise.resolve()),
22+
postError: jest.fn().mockImplementation((_url: string, json: string) => {
23+
postedJson = json;
24+
return Promise.resolve();
25+
}),
26+
};
27+
return BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build();
28+
};
29+
30+
describe('uncaughtExceptionMonitor callback', () => {
31+
beforeEach(() => {
32+
client = buildClient();
33+
});
34+
35+
afterEach(() => {
36+
client.dispose();
37+
});
38+
39+
it("Should tag origin 'unhandledRejection' as 'Unhandled rejection'", async () => {
40+
(process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit(
41+
'uncaughtExceptionMonitor',
42+
new Error('rejected'),
43+
'unhandledRejection',
44+
);
45+
await flushMicrotasks();
46+
47+
expect(requestHandler.postError).toHaveBeenCalled();
48+
const payload = JSON.parse(postedJson as string);
49+
expect(payload.attributes['error.type']).toBe('Unhandled rejection');
50+
expect(payload.classifiers).toContain('UnhandledPromiseRejection');
51+
});
52+
53+
it("Should tag origin 'uncaughtException' as 'Unhandled exception'", async () => {
54+
(process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit(
55+
'uncaughtExceptionMonitor',
56+
new Error('boom'),
57+
'uncaughtException',
58+
);
59+
await flushMicrotasks();
60+
61+
expect(requestHandler.postError).toHaveBeenCalled();
62+
const payload = JSON.parse(postedJson as string);
63+
expect(payload.attributes['error.type']).toBe('Unhandled exception');
64+
expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection');
65+
});
66+
});
67+
68+
describe("dedicated 'unhandledRejection' listener", () => {
69+
let nodeOptionReaderSpy: jest.SpyInstance;
70+
71+
beforeEach(() => {
72+
// Force the dedicated unhandledRejection listener to be registered.
73+
// See BacktraceClient.captureUnhandledErrors: the listener is skipped
74+
// when running on Node 15+ with default --unhandled-rejections behavior.
75+
nodeOptionReaderSpy = jest.spyOn(NodeOptionReader, 'read').mockImplementation((flag: string) => {
76+
if (flag === 'unhandled-rejections') return 'warn';
77+
return undefined;
78+
});
79+
client = buildClient();
80+
});
81+
82+
afterEach(() => {
83+
client.dispose();
84+
nodeOptionReaderSpy.mockRestore();
85+
});
86+
87+
it("Should tag emitted 'unhandledRejection' events as 'Unhandled rejection'", async () => {
88+
(process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit(
89+
'unhandledRejection',
90+
new Error('rejected'),
91+
Promise.resolve(),
92+
);
93+
await flushMicrotasks();
94+
95+
expect(requestHandler.postError).toHaveBeenCalled();
96+
const payload = JSON.parse(postedJson as string);
97+
expect(payload.attributes['error.type']).toBe('Unhandled rejection');
98+
expect(payload.classifiers).toContain('UnhandledPromiseRejection');
99+
});
100+
});
101+
});

packages/react-native/src/handlers/UnhandledExceptionHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler {
4646
new BacktraceReport(
4747
rejection,
4848
{
49-
'error.type': 'Unhandled exception',
49+
'error.type': 'Unhandled rejection',
5050
unhandledPromiseRejectionId: id,
5151
},
5252
[],
@@ -71,7 +71,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler {
7171
new BacktraceReport(
7272
rejection,
7373
{
74-
'error.type': 'Unhandled exception',
74+
'error.type': 'Unhandled rejection',
7575
unhandledPromiseRejectionId: id,
7676
},
7777
[],
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BacktraceReport } from '@backtrace/sdk-core';
2+
import type { BacktraceClient } from '../src/BacktraceClient';
3+
4+
jest.mock('promise/setimmediate/rejection-tracking', () => ({
5+
enable: jest.fn(),
6+
}));
7+
8+
const mockHermesInternal: {
9+
enablePromiseRejectionTracker?: jest.Mock;
10+
hasPromise?: jest.Mock;
11+
} = {};
12+
13+
jest.mock('../src/common/hermesHelper', () => ({
14+
hermes: () => (mockHermesInternal.enablePromiseRejectionTracker ? mockHermesInternal : undefined),
15+
}));
16+
17+
// eslint-disable-next-line @typescript-eslint/no-var-requires
18+
const rejectionTracking = require('promise/setimmediate/rejection-tracking');
19+
20+
import { UnhandledExceptionHandler } from '../src/handlers/UnhandledExceptionHandler';
21+
22+
describe('UnhandledExceptionHandler labeling', () => {
23+
let sendMock: jest.Mock;
24+
let client: BacktraceClient;
25+
let handler: UnhandledExceptionHandler;
26+
27+
beforeEach(() => {
28+
rejectionTracking.enable.mockClear();
29+
delete mockHermesInternal.enablePromiseRejectionTracker;
30+
delete mockHermesInternal.hasPromise;
31+
sendMock = jest.fn();
32+
client = { send: sendMock } as unknown as BacktraceClient;
33+
handler = new UnhandledExceptionHandler();
34+
});
35+
36+
it("Should tag captured unhandled promise rejections (non-Hermes) with error.type 'Unhandled rejection'", () => {
37+
handler.captureUnhandledPromiseRejections(client);
38+
39+
expect(rejectionTracking.enable).toHaveBeenCalled();
40+
const options = rejectionTracking.enable.mock.calls[0][0];
41+
options.onUnhandled(42, new Error('Failed to fetch'));
42+
43+
expect(sendMock).toHaveBeenCalled();
44+
const report = sendMock.mock.calls[0][0] as BacktraceReport;
45+
expect(report.attributes['error.type']).toBe('Unhandled rejection');
46+
expect(report.attributes['unhandledPromiseRejectionId']).toBe(42);
47+
expect(report.classifiers).toContain('UnhandledPromiseRejection');
48+
});
49+
50+
it("Should tag captured unhandled promise rejections (Hermes) with error.type 'Unhandled rejection'", () => {
51+
mockHermesInternal.hasPromise = jest.fn().mockReturnValue(true);
52+
mockHermesInternal.enablePromiseRejectionTracker = jest.fn();
53+
54+
handler.captureUnhandledPromiseRejections(client);
55+
56+
expect(mockHermesInternal.enablePromiseRejectionTracker).toHaveBeenCalled();
57+
expect(rejectionTracking.enable).not.toHaveBeenCalled();
58+
const options = mockHermesInternal.enablePromiseRejectionTracker.mock.calls[0][0];
59+
options.onUnhandled(99, new Error('Failed to fetch'));
60+
61+
expect(sendMock).toHaveBeenCalled();
62+
const report = sendMock.mock.calls[0][0] as BacktraceReport;
63+
expect(report.attributes['error.type']).toBe('Unhandled rejection');
64+
expect(report.attributes['unhandledPromiseRejectionId']).toBe(99);
65+
expect(report.classifiers).toContain('UnhandledPromiseRejection');
66+
});
67+
});
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1-
export type BacktraceErrorType = 'Message' | 'Exception' | 'Unhandled exception' | 'OOMException' | 'Hang' | 'Crash';
1+
export type BacktraceErrorType =
2+
| 'Message'
3+
| 'Exception'
4+
| 'Unhandled exception'
5+
| 'Unhandled rejection'
6+
| 'OOMException'
7+
| 'Hang'
8+
| 'Crash';

0 commit comments

Comments
 (0)