diff --git a/CHANGELOG.md b/CHANGELOG.md index d6d8a9bcef..0708a142d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixes - crashedLastRun now returns the correct value ([#4829](https://github.com/getsentry/sentry-react-native/pull/4829)) +- Use engine-specific promise rejection tracking ([#4826](https://github.com/getsentry/sentry-react-native/pull/4826)) ## 6.14.0 diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index 4025dc13d5..df56da47cb 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -1,6 +1,14 @@ import type { EventHint, Integration, SeverityLevel } from '@sentry/core'; -import { addExceptionMechanism, captureException, getClient, getCurrentScope, logger } from '@sentry/core'; - +import { + addExceptionMechanism, + addGlobalUnhandledRejectionInstrumentationHandler, + captureException, + getClient, + getCurrentScope, + logger, +} from '@sentry/core'; + +import { isHermesEnabled, isWeb } from '../utils/environment'; import { createSyntheticError, isErrorLike } from '../utils/error'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils'; @@ -44,49 +52,83 @@ function setup(options: ReactNativeErrorHandlersOptions): void { * Setup unhandled promise rejection tracking */ function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void { - if (patchGlobalPromise) { - polyfillPromise(); - } + try { + if ( + isHermesEnabled() && + RN_GLOBAL_OBJ.HermesInternal?.enablePromiseRejectionTracker && + RN_GLOBAL_OBJ?.HermesInternal?.hasPromise?.() + ) { + logger.log('Using Hermes native promise rejection tracking'); + + RN_GLOBAL_OBJ.HermesInternal.enablePromiseRejectionTracker({ + allRejections: true, + onUnhandled: promiseRejectionTrackingOptions.onUnhandled, + onHandled: promiseRejectionTrackingOptions.onHandled, + }); - attachUnhandledRejectionHandler(); - checkPromiseAndWarn(); + logger.log('Unhandled promise rejections will be caught by Sentry.'); + } else if (isWeb()) { + logger.log('Using Browser JS promise rejection tracking for React Native Web'); + + // Use Sentry's built-in global unhandled rejection handler + addGlobalUnhandledRejectionInstrumentationHandler((error: unknown) => { + captureException(error, { + originalException: error, + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + mechanism: { handled: false, type: 'onunhandledrejection' }, + }); + }); + } else if (patchGlobalPromise) { + // For JSC and other environments, use the existing approach + polyfillPromise(); + attachUnhandledRejectionHandler(); + checkPromiseAndWarn(); + } else { + // For JSC and other environments, patching was disabled by user configuration + logger.log('Unhandled promise rejections will not be caught by Sentry.'); + } + } catch (e) { + logger.warn( + 'Failed to set up promise rejection tracking. ' + + 'Unhandled promise rejections will not be caught by Sentry.' + + 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', + ); + } } -function attachUnhandledRejectionHandler(): void { - const tracking = requireRejectionTracking(); +const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { + onUnhandled: (id, error: unknown, rejection = {}) => { + if (__DEV__) { + logger.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); + } - const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { - onUnhandled: (id, rejection = {}) => { - // eslint-disable-next-line no-console - console.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); - }, - onHandled: id => { - // eslint-disable-next-line no-console - console.warn( + // Marking the rejection as handled to avoid breaking crash rate calculations. + // See: https://github.com/getsentry/sentry-react-native/issues/4141 + captureException(error, { + data: { id }, + originalException: error, + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + mechanism: { handled: true, type: 'onunhandledrejection' }, + }); + }, + onHandled: id => { + if (__DEV__) { + logger.warn( `Promise Rejection Handled (id: ${id})\n` + 'This means you can ignore any previous messages of the form ' + `"Possible Unhandled Promise Rejection (id: ${id}):"`, ); - }, - }; + } + }, +}; + +function attachUnhandledRejectionHandler(): void { + const tracking = requireRejectionTracking(); tracking.enable({ allRejections: true, - onUnhandled: (id: string, error: unknown) => { - if (__DEV__) { - promiseRejectionTrackingOptions.onUnhandled(id, error); - } - - captureException(error, { - data: { id }, - originalException: error, - syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), - mechanism: { handled: true, type: 'onunhandledrejection' }, - }); - }, - onHandled: (id: string) => { - promiseRejectionTrackingOptions.onHandled(id); - }, + onUnhandled: promiseRejectionTrackingOptions.onUnhandled, + onHandled: promiseRejectionTrackingOptions.onHandled, }); } diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 03327bac36..04c854984a 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -3,6 +3,11 @@ import { GLOBAL_OBJ } from '@sentry/core'; import type { ErrorUtils } from 'react-native/types'; import type { ExpoGlobalObject } from './expoglobalobject'; +export interface HermesPromiseRejectionTrackingOptions { + allRejections: boolean; + onUnhandled: (id: string, error: unknown) => void; + onHandled: (id: string) => void; +} /** Internal Global object interface with common and Sentry specific properties */ export interface ReactNativeInternalGlobal extends InternalGlobal { @@ -10,6 +15,8 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __sentry_rn_v5_registered?: boolean; HermesInternal?: { getRuntimeProperties?: () => Record; + enablePromiseRejectionTracker?: (options: HermesPromiseRejectionTrackingOptions) => void; + hasPromise?: () => boolean; }; Promise: unknown; __turboModuleProxy: unknown; diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index aff3b6210f..076008fc61 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -1,16 +1,53 @@ jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils'); +jest.mock('../../src/js/utils/environment'); -import type { ExtendedError, Mechanism, SeverityLevel } from '@sentry/core'; -import { setCurrentClient } from '@sentry/core'; +import type { SeverityLevel } from '@sentry/core'; +import { addGlobalUnhandledRejectionInstrumentationHandler, captureException, setCurrentClient } from '@sentry/core'; import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; -import { requireRejectionTracking } from '../../src/js/integrations/reactnativeerrorhandlersutils'; +import { + checkPromiseAndWarn, + polyfillPromise, + requireRejectionTracking, +} from '../../src/js/integrations/reactnativeerrorhandlersutils'; +import { isHermesEnabled, isWeb } from '../../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +let errorHandlerCallback: ((error: Error, isFatal?: boolean) => Promise) | null = null; + +jest.mock('../../src/js/utils/worldwide', () => { + const actual = jest.requireActual('../../src/js/utils/worldwide'); + return { + ...actual, + RN_GLOBAL_OBJ: { + ...actual.RN_GLOBAL_OBJ, + ErrorUtils: { + setGlobalHandler: jest.fn(callback => { + errorHandlerCallback = callback; + }), + getGlobalHandler: jest.fn(() => jest.fn()), + reportError: jest.fn(), + }, + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + captureException: jest.fn(), + addGlobalUnhandledRejectionInstrumentationHandler: jest.fn(), + }; +}); + describe('ReactNativeErrorHandlers', () => { let client: TestClient; let mockDisable: jest.Mock; let mockEnable: jest.Mock; + let originalHermesInternal: any; + let mockEnablePromiseRejectionTracker: jest.Mock; beforeEach(() => { mockDisable = jest.fn(); @@ -19,35 +56,48 @@ describe('ReactNativeErrorHandlers', () => { disable: mockDisable, enable: mockEnable, }); - ErrorUtils.getGlobalHandler = () => jest.fn(); + (polyfillPromise as jest.Mock).mockImplementation(() => {}); + (checkPromiseAndWarn as jest.Mock).mockImplementation(() => {}); + + errorHandlerCallback = null; + + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + + originalHermesInternal = RN_GLOBAL_OBJ.HermesInternal; client = new TestClient(getDefaultTestClientOptions()); setCurrentClient(client); client.init(); + + mockEnablePromiseRejectionTracker = jest.fn(); + RN_GLOBAL_OBJ.HermesInternal = { + enablePromiseRejectionTracker: mockEnablePromiseRejectionTracker, + hasPromise: jest.fn(() => true), + }; + + jest.clearAllMocks(); }); afterEach(() => { jest.clearAllMocks(); + RN_GLOBAL_OBJ.HermesInternal = originalHermesInternal; }); describe('onError', () => { - let errorHandlerCallback: (error: Error, isFatal: boolean) => Promise; - - beforeEach(() => { - errorHandlerCallback = () => Promise.resolve(); - - ErrorUtils.setGlobalHandler = jest.fn(_callback => { - errorHandlerCallback = _callback as typeof errorHandlerCallback; - }); - + test('Sets up the global error handler', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); - expect(ErrorUtils.setGlobalHandler).toHaveBeenCalledWith(errorHandlerCallback); + expect(RN_GLOBAL_OBJ.ErrorUtils.setGlobalHandler).toHaveBeenCalled(); }); test('Sets handled:false on a fatal error', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), true); await client.flush(); @@ -59,6 +109,11 @@ describe('ReactNativeErrorHandlers', () => { }); test('Does not set handled:false on a non-fatal error', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), false); await client.flush(); @@ -70,6 +125,11 @@ describe('ReactNativeErrorHandlers', () => { }); test('Includes original exception in hint', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), false); await client.flush(); @@ -80,17 +140,14 @@ describe('ReactNativeErrorHandlers', () => { }); describe('onUnhandledRejection', () => { - test('unhandled rejected promise is captured with synthetical error', async () => { + test('unhandled rejected promise is captured with JSC approach', async () => { + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, 'Test Error'); - - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; - - expect(mockDisable).not.toHaveBeenCalled(); + expect(polyfillPromise).toHaveBeenCalled(); expect(mockEnable).toHaveBeenCalledWith( expect.objectContaining({ allRejections: true, @@ -98,53 +155,180 @@ describe('ReactNativeErrorHandlers', () => { onHandled: expect.any(Function), }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3); - }); - - test('error like unhandled rejected promise is captured without synthetical error', async () => { - const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, new Error('Test Error')); + const [options] = mockEnable.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; + onUnhandledHandler('test-id', 'Test Error'); - expect(mockDisable).not.toHaveBeenCalled(); - expect(mockEnable).toHaveBeenCalledWith( + expect(captureException).toHaveBeenCalledWith( + 'Test Error', expect.objectContaining({ - allRejections: true, - onUnhandled: expect.any(Function), - onHandled: expect.any(Function), + data: { id: 'test-id' }, + originalException: 'Test Error', + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect(actualSyntheticError).toBeUndefined(); }); - test('unhandled rejected sets error mechanism', async () => { + test('error like unhandled rejected promise is captured without synthetical error', async () => { + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, 'Test Error'); + const [options] = mockEnable.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; - const errorMechanism = client.event?.exception?.values[0]?.mechanism; - expect(mockDisable).not.toHaveBeenCalled(); - expect(mockEnable).toHaveBeenCalledWith( + const error = new Error('Test Error'); + onUnhandledHandler('test-id', error); + + expect(captureException).toHaveBeenCalledWith( + error, expect.objectContaining({ - allRejections: true, - onUnhandled: expect.any(Function), - onHandled: expect.any(Function), + data: { id: 'test-id' }, + originalException: error, + syntheticException: undefined, + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3); - expect(errorMechanism).toEqual({ handled: true, type: 'onunhandledrejection' } as Mechanism); + }); + + describe('Hermes engine', () => { + beforeEach(() => { + (isHermesEnabled as jest.Mock).mockReturnValue(true); + }); + + test('uses native Hermes promise rejection tracking', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledTimes(1); + expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledWith( + expect.objectContaining({ + allRejections: true, + onUnhandled: expect.any(Function), + onHandled: expect.any(Function), + }), + ); + + expect(polyfillPromise).not.toHaveBeenCalled(); + }); + + test('captures unhandled rejection with Hermes tracker', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [options] = mockEnablePromiseRejectionTracker.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; + + const testError = new Error('Hermes Test Error'); + onUnhandledHandler('hermes-test-error', testError); + + expect(captureException).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + data: { id: 'hermes-test-error' }, + originalException: testError, + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, + }), + ); + }); + }); + + describe('React Native Web', () => { + beforeEach(() => { + (isWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + }); + + test('uses addGlobalUnhandledRejectionInstrumentationHandler for React Native Web', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); + + // Verify JSC fallback was not used + expect(polyfillPromise).not.toHaveBeenCalled(); + expect(requireRejectionTracking).not.toHaveBeenCalled(); + }); + + test('captures unhandled rejection with the callback', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; + + const mockError = new Error('Web Test Error'); + callback(mockError); + + expect(captureException).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + originalException: mockError, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }), + ); + }); + + test('handles non-error rejection with synthetic error', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; + + const nonErrorObject = { message: 'Custom rejection object' }; + callback(nonErrorObject); + + expect(captureException).toHaveBeenCalledWith( + nonErrorObject, + expect.objectContaining({ + originalException: nonErrorObject, + syntheticException: expect.anything(), + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }), + ); + }); + }); + + describe('JSC and other environments', () => { + beforeEach(() => { + (isHermesEnabled as jest.Mock).mockReturnValue(false); + (isWeb as jest.Mock).mockReturnValue(false); + }); + + test('uses existing polyfill for JSC environments', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(polyfillPromise).toHaveBeenCalledTimes(1); + expect(requireRejectionTracking).toHaveBeenCalledTimes(1); + }); + + test('respects patchGlobalPromise option', () => { + const integration = reactNativeErrorHandlersIntegration({ patchGlobalPromise: false }); + integration.setupOnce(); + + expect(polyfillPromise).not.toHaveBeenCalled(); + expect(requireRejectionTracking).not.toHaveBeenCalled(); + }); }); }); });