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';