diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5a45ada2..4a042a0ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,17 @@ # Changelog +## Unreleased + +### Features + +- Send react native js engine, turbo module, fabric flags and component stack in Event contexts ([#2552](https://github.com/getsentry/sentry-react-native/pull/2552)) + ## 5.0.0-alpha.6 - Latest changes from 4.6.1 ### Features + - Add initial support for the RN New Architecture, backwards compatible RNSentry Turbo Module ([#2522](https://github.com/getsentry/sentry-react-native/pull/2522)) ### Breaking changes diff --git a/src/js/integrations/debugsymbolicator.ts b/src/js/integrations/debugsymbolicator.ts index 038ae6311b..4994af792e 100644 --- a/src/js/integrations/debugsymbolicator.ts +++ b/src/js/integrations/debugsymbolicator.ts @@ -24,7 +24,7 @@ interface ReactNativeFrame { /** * React Native Error */ -type ReactNativeError = Error & { +export type ReactNativeError = Error & { framesToPop?: number; jsEngine?: string; preventSymbolication?: boolean; @@ -66,13 +66,6 @@ export class DebugSymbolicator implements Integration { stack = parseErrorStack(reactError.stack); } - // Ideally this should go into contexts but android sdk doesn't support it - event.extra = { - ...event.extra, - componentStack: reactError.componentStack, - jsEngine: reactError.jsEngine, - }; - await self._symbolicate(event, stack); event.platform = 'node'; // Setting platform node makes sure we do not show source maps errors diff --git a/src/js/integrations/index.ts b/src/js/integrations/index.ts index 484b807b1f..1c5bbed445 100644 --- a/src/js/integrations/index.ts +++ b/src/js/integrations/index.ts @@ -4,3 +4,4 @@ export { ReactNativeErrorHandlers } from './reactnativeerrorhandlers'; export { Release } from './release'; export { EventOrigin } from './eventorigin'; export { SdkInfo } from './sdkinfo'; +export { ReactNativeInfo } from './reactnativeinfo'; diff --git a/src/js/integrations/reactnativeinfo.ts b/src/js/integrations/reactnativeinfo.ts new file mode 100644 index 0000000000..5339905b75 --- /dev/null +++ b/src/js/integrations/reactnativeinfo.ts @@ -0,0 +1,64 @@ +import { Context, Event, EventHint, EventProcessor, Integration } from '@sentry/types'; + +import { isFabricEnabled, isHermesEnabled, isTurboModuleEnabled } from '../utils/environment'; +import { ReactNativeError } from './debugsymbolicator'; + +export interface ReactNativeContext extends Context { + js_engine?: string; + turbo_module: boolean; + fabric: boolean; + component_stack?: string; +} + +/** Loads React Native context at runtime */ +export class ReactNativeInfo implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'ReactNativeInfo'; + + /** + * @inheritDoc + */ + public name: string = ReactNativeInfo.id; + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { + addGlobalEventProcessor(async (event: Event, hint?: EventHint) => { + const reactNativeError = hint?.originalException + ? hint?.originalException as ReactNativeError + : undefined; + + const reactNativeContext: ReactNativeContext = { + turbo_module: isTurboModuleEnabled(), + fabric: isFabricEnabled(), + }; + + if (isHermesEnabled()) { + reactNativeContext.js_engine = 'hermes'; + } else if (reactNativeError?.jsEngine) { + reactNativeContext.js_engine = reactNativeError.jsEngine; + } + + if (reactNativeContext.js_engine === 'hermes') { + event.tags = { + hermes: 'true', + ...event.tags, + }; + } + + if (reactNativeError?.componentStack) { + reactNativeContext.component_stack = reactNativeError.componentStack; + } + + event.contexts = { + react_native_context: reactNativeContext, + ...event.contexts, + }; + + return event; + }); + } +} diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index f44158975a..e6f3d9d26f 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -6,7 +6,7 @@ import { getCurrentHub, } from '@sentry/react'; import { Integration, Scope, StackFrame, UserFeedback } from '@sentry/types'; -import { getGlobalObject, logger, stackParserFromStackParserOptions } from '@sentry/utils'; +import { logger, stackParserFromStackParserOptions } from '@sentry/utils'; import * as React from 'react'; import { ReactNativeClient } from './client'; @@ -15,6 +15,7 @@ import { DeviceContext, EventOrigin, ReactNativeErrorHandlers, + ReactNativeInfo, Release, SdkInfo, } from './integrations'; @@ -86,6 +87,7 @@ export function init(passedOptions: ReactNativeOptions): void { defaultIntegrations.push(new EventOrigin()); defaultIntegrations.push(new SdkInfo()); + defaultIntegrations.push(new ReactNativeInfo()); if (__DEV__) { defaultIntegrations.push(new DebugSymbolicator()); @@ -129,11 +131,6 @@ export function init(passedOptions: ReactNativeOptions): void { defaultIntegrations, }); initAndBind(ReactNativeClient, options); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any - if (getGlobalObject().HermesInternal) { - getCurrentHub().setTag('hermes', 'true'); - } } /** diff --git a/src/js/utils/environment.ts b/src/js/utils/environment.ts new file mode 100644 index 0000000000..57c0be2737 --- /dev/null +++ b/src/js/utils/environment.ts @@ -0,0 +1,27 @@ +import { getGlobalObject } from '@sentry/utils'; + +interface ReactNativeGlobal { + HermesInternal: unknown; + __turboModuleProxy: unknown; + nativeFabricUIManager: unknown; +} + +/** Safely gets global object with React Native specific properties */ +function getRNGlobalObject(): T & ReactNativeGlobal & ReturnType { + return getGlobalObject(); +} + +/** Checks if the React Native Hermes engine is running */ +export function isHermesEnabled(): boolean { + return !!getRNGlobalObject().HermesInternal; +} + +/** Checks if the React Native TurboModules are enabled */ +export function isTurboModuleEnabled(): boolean { + return getRNGlobalObject().__turboModuleProxy != null; +} + +/** Checks if the React Native Fabric renderer is running */ +export function isFabricEnabled(): boolean { + return getRNGlobalObject().nativeFabricUIManager != null; +} diff --git a/src/js/wrapper.ts b/src/js/wrapper.ts index 1fc09ab283..79cb7c207b 100644 --- a/src/js/wrapper.ts +++ b/src/js/wrapper.ts @@ -21,16 +21,10 @@ import { Spec, } from './NativeRNSentry'; import { ReactNativeOptions } from './options'; +import { isTurboModuleEnabled } from './utils/environment' import { utf8ToBytes } from './vendor'; -declare global { - // eslint-disable-next-line no-var - var __turboModuleProxy: unknown; -} - -const isTurboModuleEnabled = globalThis.__turboModuleProxy != null; - -const RNSentry: Spec | undefined = isTurboModuleEnabled +const RNSentry: Spec | undefined = isTurboModuleEnabled() ? TurboModuleRegistry.getEnforcing('RNSentry') : NativeModules.RNSentry; diff --git a/test/integrations/reactnativeinfo.test.ts b/test/integrations/reactnativeinfo.test.ts new file mode 100644 index 0000000000..d8de1df2a3 --- /dev/null +++ b/test/integrations/reactnativeinfo.test.ts @@ -0,0 +1,154 @@ +import { Event, EventHint } from '@sentry/types'; + +import { ReactNativeError } from '../../src/js/integrations/debugsymbolicator'; +import { ReactNativeContext, ReactNativeInfo } from '../../src/js/integrations/reactnativeinfo'; + +let mockedIsHermesEnabled: jest.Mock; +let mockedIsTurboModuleEnabled: jest.Mock; +let mockedIsFabricEnabled: jest.Mock; + +jest.mock('../../src/js/utils/environment', () => ({ + isHermesEnabled: () => mockedIsHermesEnabled(), + isTurboModuleEnabled: () => mockedIsTurboModuleEnabled(), + isFabricEnabled: () => mockedIsFabricEnabled(), +})); + +describe('React Native Info', () => { + beforeEach(() => { + mockedIsHermesEnabled = jest.fn().mockReturnValue(false); + mockedIsTurboModuleEnabled = jest.fn().mockReturnValue(false); + mockedIsFabricEnabled = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('does not pollute event with undefined fields', async () => { + const mockEvent: Event = { + message: 'test' + }; + const mockedHint: EventHint = {}; + const actualEvent = await executeIntegrationFor(mockEvent, mockedHint); + + expectMocksToBeCalledOnce(); + expect(actualEvent).toEqual({ + message: 'test', + contexts: { + react_native_context: { + turbo_module: false, + fabric: false, + }, + }, + }); + }); + + it('adds hermes tag and js_engine to context if hermes enabled', async () => { + mockedIsHermesEnabled = jest.fn().mockReturnValue(true); + const actualEvent = await executeIntegrationFor({}, {}); + + expectMocksToBeCalledOnce(); + expect(actualEvent?.tags?.hermes).toEqual('true'); + expect( + (actualEvent?.contexts?.react_native_context as ReactNativeContext | undefined)?.js_engine, + ).toEqual('hermes'); + }); + + it('does not override existing hermes tag', async () => { + mockedIsHermesEnabled = jest.fn().mockReturnValue(true); + const mockedEvent: Event = { + tags: { + hermes: 'test_hermes_tag', + }, + }; + const actualEvent = await executeIntegrationFor(mockedEvent, {}); + + expectMocksToBeCalledOnce(); + expect(actualEvent?.tags?.hermes).toEqual('test_hermes_tag'); + }); + + it('adds engine from rn error', async () => { + const mockedHint: EventHint = { + originalException: { + jsEngine: 'test_engine', + }, + }; + const actualEvent = await executeIntegrationFor({}, mockedHint); + + expectMocksToBeCalledOnce(); + expect(actualEvent?.tags?.hermes).toEqual(undefined); + expect( + (actualEvent?.contexts?.react_native_context as ReactNativeContext | undefined)?.js_engine, + ).toEqual('test_engine'); + }); + + it('adds component stack', async () => { + const mockedHint: EventHint = { + originalException: { + componentStack: 'test_stack', + }, + }; + const actualEvent = await executeIntegrationFor({}, mockedHint); + + expectMocksToBeCalledOnce(); + expect( + (actualEvent?.contexts?.react_native_context as ReactNativeContext | undefined)?.component_stack, + ).toEqual('test_stack'); + }); + + it('marks turbo modules enabled', async () => { + mockedIsTurboModuleEnabled = jest.fn().mockReturnValue(true); + const actualEvent = await executeIntegrationFor({}, {}); + + expectMocksToBeCalledOnce(); + expect( + (actualEvent?.contexts?.react_native_context as ReactNativeContext | undefined)?.turbo_module, + ).toEqual(true); + }); + + it('marks fabric enabled', async () => { + mockedIsFabricEnabled = jest.fn().mockReturnValue(true); + const actualEvent = await executeIntegrationFor({}, {}); + + expectMocksToBeCalledOnce(); + expect( + (actualEvent?.contexts?.react_native_context as ReactNativeContext | undefined)?.fabric, + ).toEqual(true); + }); + + it('does not override existing react_native_context', async () => { + const mockedEvent: Event = { + contexts: { + react_native_context: { + test: 'context', + }, + }, + }; + const actualEvent = await executeIntegrationFor(mockedEvent, {}); + + expectMocksToBeCalledOnce(); + expect(actualEvent?.contexts?.react_native_context).toEqual({ + test: 'context', + }); + }); +}); + +function expectMocksToBeCalledOnce() { + expect(mockedIsHermesEnabled).toBeCalledTimes(1); + expect(mockedIsTurboModuleEnabled).toBeCalledTimes(1); + expect(mockedIsFabricEnabled).toBeCalledTimes(1); +} + +function executeIntegrationFor(mockedEvent: Event, mockedHint: EventHint): Promise { + const integration = new ReactNativeInfo(); + return new Promise((resolve, reject) => { + integration.setupOnce(async (eventProcessor) => { + try { + const processedEvent = await eventProcessor(mockedEvent, mockedHint); + resolve(processedEvent); + } catch (e) { + reject(e); + } + }); + }); +}