From 8b47ec760391971792bce9766e36b757cd70d1f4 Mon Sep 17 00:00:00 2001 From: "Ahmad Ibrahim (Backtrace)" Date: Wed, 13 May 2026 16:28:30 -0700 Subject: [PATCH 1/2] BT-6886, browser, node, react-native, sdk-core: fix: label unhandled promise rejections as 'Unhandled rejection' Adds 'Unhandled rejection' to BacktraceErrorType and uses it on the async error paths in browser, node, and react-native SDKs. Sync paths (window 'error' event, uncaughtException, RN ErrorBoundary, Android native) keep 'Unhandled exception'. Unhandled promise rejections and synchronous unhandled exceptions are distinct browser/node events with different crash semantics, but the JS SDKs were stamping both with error.type 'Unhandled exception'. The 'UnhandledPromiseRejection' classifier already captured the truth; the attribute now matches it. Customers can filter sync crashes from async noise without union queries. Verified end-to-end against a real Backtrace endpoint: async path submits with error.type 'Unhandled rejection', sync path still submits with 'Unhandled exception'. --- packages/browser/src/BacktraceClient.ts | 2 +- .../tests/client/unhandledErrorTests.spec.ts | 65 +++++++++++++++++++ packages/node/src/BacktraceClient.ts | 17 +++-- .../tests/client/unhandledErrorTests.spec.ts | 60 +++++++++++++++++ .../src/handlers/UnhandledExceptionHandler.ts | 4 +- .../unhandledExceptionHandlerTests.spec.ts | 42 ++++++++++++ .../src/model/report/BacktraceErrorType.ts | 9 ++- 7 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 packages/browser/tests/client/unhandledErrorTests.spec.ts create mode 100644 packages/node/tests/client/unhandledErrorTests.spec.ts create mode 100644 packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts diff --git a/packages/browser/src/BacktraceClient.ts b/packages/browser/src/BacktraceClient.ts index cb3714c4..34b0b099 100644 --- a/packages/browser/src/BacktraceClient.ts +++ b/packages/browser/src/BacktraceClient.ts @@ -99,7 +99,7 @@ export class BacktraceClient { + let postedJson: string | undefined; + let requestHandler: BacktraceRequestHandler; + let client: BacktraceClient; + + const defaultClientOptions = { + name: 'test', + version: '1.0.0', + url: 'https://submit.backtrace.io/foo/bar/baz', + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }; + + beforeEach(() => { + postedJson = undefined; + requestHandler = { + post: jest.fn().mockResolvedValue(Promise.resolve()), + postError: jest.fn().mockImplementation((_url: string, json: string) => { + postedJson = json; + return Promise.resolve(); + }), + }; + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); + }); + + afterEach(() => { + client.dispose(); + }); + + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it("Should tag synthetic 'unhandledrejection' events with error.type 'Unhandled rejection'", async () => { + const event = new Event('unhandledrejection') as PromiseRejectionEvent; + Object.defineProperty(event, 'reason', { value: new TypeError('Failed to fetch') }); + Object.defineProperty(event, 'promise', { value: Promise.resolve() }); + window.dispatchEvent(event); + + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + expect(postedJson).toBeDefined(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled rejection'); + expect(payload.classifiers).toContain('UnhandledPromiseRejection'); + }); + + it("Should tag synthetic 'error' events with error.type 'Unhandled exception'", async () => { + const event = new ErrorEvent('error', { + error: new Error('boom'), + message: 'boom', + }); + window.dispatchEvent(event); + + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + expect(postedJson).toBeDefined(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled exception'); + expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection'); + }); +}); diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index e1b223dc..41783925 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -128,10 +128,19 @@ export class BacktraceClient extends BacktraceCoreClient if (origin === 'uncaughtException' && !captureUnhandledExceptions) { return; } + const isRejection = origin === 'unhandledRejection'; await this.send( - new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin }, [], { - classifiers: origin === 'unhandledRejection' ? ['UnhandledPromiseRejection'] : undefined, - }), + new BacktraceReport( + error, + { + 'error.type': isRejection ? 'Unhandled rejection' : 'Unhandled exception', + errorOrigin: origin, + }, + [], + { + classifiers: isRejection ? ['UnhandledPromiseRejection'] : undefined, + }, + ), ); }; @@ -170,7 +179,7 @@ export class BacktraceClient extends BacktraceCoreClient new BacktraceReport( isErrorTypeReason ? reason : (reason?.toString() ?? 'Unhandled rejection'), { - 'error.type': 'Unhandled exception', + 'error.type': 'Unhandled rejection', }, [], { diff --git a/packages/node/tests/client/unhandledErrorTests.spec.ts b/packages/node/tests/client/unhandledErrorTests.spec.ts new file mode 100644 index 00000000..ba9578b1 --- /dev/null +++ b/packages/node/tests/client/unhandledErrorTests.spec.ts @@ -0,0 +1,60 @@ +import { BacktraceRequestHandler } from '@backtrace/sdk-core'; +import { BacktraceClient } from '../../src/index.js'; + +describe('Unhandled error/rejection labeling', () => { + let postedJson: string | undefined; + let requestHandler: BacktraceRequestHandler; + let client: BacktraceClient; + + const defaultClientOptions = { + url: 'https://submit.backtrace.io/foo/bar/baz', + metrics: { enable: false }, + breadcrumbs: { enable: false }, + }; + + beforeEach(() => { + postedJson = undefined; + requestHandler = { + post: jest.fn().mockResolvedValue(Promise.resolve()), + postError: jest.fn().mockImplementation((_url: string, json: string) => { + postedJson = json; + return Promise.resolve(); + }), + }; + client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); + }); + + afterEach(() => { + client.dispose(); + }); + + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it("Should tag uncaughtExceptionMonitor with origin 'unhandledRejection' as 'Unhandled rejection'", async () => { + (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( + 'uncaughtExceptionMonitor', + new Error('rejected'), + 'unhandledRejection', + ); + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled rejection'); + expect(payload.classifiers).toContain('UnhandledPromiseRejection'); + }); + + it("Should tag uncaughtExceptionMonitor with origin 'uncaughtException' as 'Unhandled exception'", async () => { + (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( + 'uncaughtExceptionMonitor', + new Error('boom'), + 'uncaughtException', + ); + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled exception'); + expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection'); + }); +}); diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index a631d457..5845379a 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -46,7 +46,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { new BacktraceReport( rejection, { - 'error.type': 'Unhandled exception', + 'error.type': 'Unhandled rejection', unhandledPromiseRejectionId: id, }, [], @@ -71,7 +71,7 @@ export class UnhandledExceptionHandler implements ExceptionHandler { new BacktraceReport( rejection, { - 'error.type': 'Unhandled exception', + 'error.type': 'Unhandled rejection', unhandledPromiseRejectionId: id, }, [], diff --git a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts new file mode 100644 index 00000000..123e010e --- /dev/null +++ b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts @@ -0,0 +1,42 @@ +import { BacktraceReport } from '@backtrace/sdk-core'; +import type { BacktraceClient } from '../src/BacktraceClient'; + +jest.mock('promise/setimmediate/rejection-tracking', () => ({ + enable: jest.fn(), +})); + +jest.mock('../src/common/hermesHelper', () => ({ + hermes: () => undefined, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const rejectionTracking = require('promise/setimmediate/rejection-tracking'); + +import { UnhandledExceptionHandler } from '../src/handlers/UnhandledExceptionHandler'; + +describe('UnhandledExceptionHandler labeling', () => { + let sendMock: jest.Mock; + let client: BacktraceClient; + let handler: UnhandledExceptionHandler; + + beforeEach(() => { + rejectionTracking.enable.mockClear(); + sendMock = jest.fn(); + client = { send: sendMock } as unknown as BacktraceClient; + handler = new UnhandledExceptionHandler(); + }); + + it("Should tag captured unhandled promise rejections with error.type 'Unhandled rejection'", () => { + handler.captureUnhandledPromiseRejections(client); + + expect(rejectionTracking.enable).toHaveBeenCalled(); + const options = rejectionTracking.enable.mock.calls[0][0]; + options.onUnhandled(42, new Error('Failed to fetch')); + + expect(sendMock).toHaveBeenCalled(); + const report = sendMock.mock.calls[0][0] as BacktraceReport; + expect(report.attributes['error.type']).toBe('Unhandled rejection'); + expect(report.attributes['unhandledPromiseRejectionId']).toBe(42); + expect(report.classifiers).toContain('UnhandledPromiseRejection'); + }); +}); diff --git a/packages/sdk-core/src/model/report/BacktraceErrorType.ts b/packages/sdk-core/src/model/report/BacktraceErrorType.ts index a9ba4e8e..cd876358 100644 --- a/packages/sdk-core/src/model/report/BacktraceErrorType.ts +++ b/packages/sdk-core/src/model/report/BacktraceErrorType.ts @@ -1 +1,8 @@ -export type BacktraceErrorType = 'Message' | 'Exception' | 'Unhandled exception' | 'OOMException' | 'Hang' | 'Crash'; +export type BacktraceErrorType = + | 'Message' + | 'Exception' + | 'Unhandled exception' + | 'Unhandled rejection' + | 'OOMException' + | 'Hang' + | 'Crash'; From 1187436dfc9b8205eed205557b023f052c7d8529 Mon Sep 17 00:00:00 2001 From: "Ahmad Ibrahim (Backtrace)" Date: Mon, 18 May 2026 13:51:02 -0700 Subject: [PATCH 2/2] BT-6886, node, react-native: test: cover direct unhandledRejection listener + Hermes path Addresses review feedback on PR #370: - node: new test exercises the dedicated process.prependListener('unhandledRejection', ...) path that runs under --unhandled-rejections=warn / Node 14. Was previously only covered via the shared uncaughtExceptionMonitor callback. - react-native: new test exercises the Hermes branch in captureUnhandledPromiseRejections by mocking hermes() to return a HermesInternal-shaped object with hasPromise and enablePromiseRejectionTracker. Was previously only covered via the non-Hermes rejectionTracking.enable path. --- .../tests/client/unhandledErrorTests.spec.ts | 103 ++++++++++++------ .../unhandledExceptionHandlerTests.spec.ts | 29 ++++- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/packages/node/tests/client/unhandledErrorTests.spec.ts b/packages/node/tests/client/unhandledErrorTests.spec.ts index ba9578b1..fe610f85 100644 --- a/packages/node/tests/client/unhandledErrorTests.spec.ts +++ b/packages/node/tests/client/unhandledErrorTests.spec.ts @@ -1,5 +1,6 @@ import { BacktraceRequestHandler } from '@backtrace/sdk-core'; import { BacktraceClient } from '../../src/index.js'; +import { NodeOptionReader } from '../../src/common/NodeOptionReader.js'; describe('Unhandled error/rejection labeling', () => { let postedJson: string | undefined; @@ -12,7 +13,9 @@ describe('Unhandled error/rejection labeling', () => { breadcrumbs: { enable: false }, }; - beforeEach(() => { + const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + + const buildClient = () => { postedJson = undefined; requestHandler = { post: jest.fn().mockResolvedValue(Promise.resolve()), @@ -21,40 +24,78 @@ describe('Unhandled error/rejection labeling', () => { return Promise.resolve(); }), }; - client = BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); - }); + return BacktraceClient.builder(defaultClientOptions).useRequestHandler(requestHandler).build(); + }; - afterEach(() => { - client.dispose(); - }); + describe('uncaughtExceptionMonitor callback', () => { + beforeEach(() => { + client = buildClient(); + }); - const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)); + afterEach(() => { + client.dispose(); + }); + + it("Should tag origin 'unhandledRejection' as 'Unhandled rejection'", async () => { + (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( + 'uncaughtExceptionMonitor', + new Error('rejected'), + 'unhandledRejection', + ); + await flushMicrotasks(); - it("Should tag uncaughtExceptionMonitor with origin 'unhandledRejection' as 'Unhandled rejection'", async () => { - (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( - 'uncaughtExceptionMonitor', - new Error('rejected'), - 'unhandledRejection', - ); - await flushMicrotasks(); - - expect(requestHandler.postError).toHaveBeenCalled(); - const payload = JSON.parse(postedJson as string); - expect(payload.attributes['error.type']).toBe('Unhandled rejection'); - expect(payload.classifiers).toContain('UnhandledPromiseRejection'); + expect(requestHandler.postError).toHaveBeenCalled(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled rejection'); + expect(payload.classifiers).toContain('UnhandledPromiseRejection'); + }); + + it("Should tag origin 'uncaughtException' as 'Unhandled exception'", async () => { + (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( + 'uncaughtExceptionMonitor', + new Error('boom'), + 'uncaughtException', + ); + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled exception'); + expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection'); + }); }); - it("Should tag uncaughtExceptionMonitor with origin 'uncaughtException' as 'Unhandled exception'", async () => { - (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( - 'uncaughtExceptionMonitor', - new Error('boom'), - 'uncaughtException', - ); - await flushMicrotasks(); - - expect(requestHandler.postError).toHaveBeenCalled(); - const payload = JSON.parse(postedJson as string); - expect(payload.attributes['error.type']).toBe('Unhandled exception'); - expect(payload.classifiers ?? []).not.toContain('UnhandledPromiseRejection'); + describe("dedicated 'unhandledRejection' listener", () => { + let nodeOptionReaderSpy: jest.SpyInstance; + + beforeEach(() => { + // Force the dedicated unhandledRejection listener to be registered. + // See BacktraceClient.captureUnhandledErrors: the listener is skipped + // when running on Node 15+ with default --unhandled-rejections behavior. + nodeOptionReaderSpy = jest.spyOn(NodeOptionReader, 'read').mockImplementation((flag: string) => { + if (flag === 'unhandled-rejections') return 'warn'; + return undefined; + }); + client = buildClient(); + }); + + afterEach(() => { + client.dispose(); + nodeOptionReaderSpy.mockRestore(); + }); + + it("Should tag emitted 'unhandledRejection' events as 'Unhandled rejection'", async () => { + (process as unknown as { emit: (e: string, ...args: unknown[]) => void }).emit( + 'unhandledRejection', + new Error('rejected'), + Promise.resolve(), + ); + await flushMicrotasks(); + + expect(requestHandler.postError).toHaveBeenCalled(); + const payload = JSON.parse(postedJson as string); + expect(payload.attributes['error.type']).toBe('Unhandled rejection'); + expect(payload.classifiers).toContain('UnhandledPromiseRejection'); + }); }); }); diff --git a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts index 123e010e..d05a14ba 100644 --- a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts @@ -5,8 +5,13 @@ jest.mock('promise/setimmediate/rejection-tracking', () => ({ enable: jest.fn(), })); +const mockHermesInternal: { + enablePromiseRejectionTracker?: jest.Mock; + hasPromise?: jest.Mock; +} = {}; + jest.mock('../src/common/hermesHelper', () => ({ - hermes: () => undefined, + hermes: () => (mockHermesInternal.enablePromiseRejectionTracker ? mockHermesInternal : undefined), })); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -21,12 +26,14 @@ describe('UnhandledExceptionHandler labeling', () => { beforeEach(() => { rejectionTracking.enable.mockClear(); + delete mockHermesInternal.enablePromiseRejectionTracker; + delete mockHermesInternal.hasPromise; sendMock = jest.fn(); client = { send: sendMock } as unknown as BacktraceClient; handler = new UnhandledExceptionHandler(); }); - it("Should tag captured unhandled promise rejections with error.type 'Unhandled rejection'", () => { + it("Should tag captured unhandled promise rejections (non-Hermes) with error.type 'Unhandled rejection'", () => { handler.captureUnhandledPromiseRejections(client); expect(rejectionTracking.enable).toHaveBeenCalled(); @@ -39,4 +46,22 @@ describe('UnhandledExceptionHandler labeling', () => { expect(report.attributes['unhandledPromiseRejectionId']).toBe(42); expect(report.classifiers).toContain('UnhandledPromiseRejection'); }); + + it("Should tag captured unhandled promise rejections (Hermes) with error.type 'Unhandled rejection'", () => { + mockHermesInternal.hasPromise = jest.fn().mockReturnValue(true); + mockHermesInternal.enablePromiseRejectionTracker = jest.fn(); + + handler.captureUnhandledPromiseRejections(client); + + expect(mockHermesInternal.enablePromiseRejectionTracker).toHaveBeenCalled(); + expect(rejectionTracking.enable).not.toHaveBeenCalled(); + const options = mockHermesInternal.enablePromiseRejectionTracker.mock.calls[0][0]; + options.onUnhandled(99, new Error('Failed to fetch')); + + expect(sendMock).toHaveBeenCalled(); + const report = sendMock.mock.calls[0][0] as BacktraceReport; + expect(report.attributes['error.type']).toBe('Unhandled rejection'); + expect(report.attributes['unhandledPromiseRejectionId']).toBe(99); + expect(report.classifiers).toContain('UnhandledPromiseRejection'); + }); });