From dc8966b5a15554d8d1a328376f53228abbe51cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kry=C5=A1tof=20Wold=C5=99ich?= <31292499+krystofwoldrich@users.noreply.github.com> Date: Tue, 9 Jan 2024 14:32:20 +0100 Subject: [PATCH] feat(expo): Dynamically resolve default integrations based on platform (#3465) --- CHANGELOG.md | 4 ++ src/js/integrations/default.ts | 87 ++++++++++++++++++++++++++++++ src/js/sdk.tsx | 73 ++----------------------- src/js/utils/environment.ts | 7 +++ test/profiling/integration.test.ts | 3 +- test/sdk.test.ts | 77 +++++++++++++++++++++++++- 6 files changed, 180 insertions(+), 71 deletions(-) create mode 100644 src/js/integrations/default.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f23a227f21..5538c9ee82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ This release is compatible with `expo@50.0.0-preview.6` and newer. const config = getSentryExpoConfig(config); ``` +- Resolve Default Integrations based on current platform ([#3465](https://github.com/getsentry/sentry-react-native/pull/3465)) + - Native Integrations are only added if Native Module is available + - Web Integrations only for React Native Web builds + - Includes fixes from version 5.15.2 ## 5.15.2 diff --git a/src/js/integrations/default.ts b/src/js/integrations/default.ts new file mode 100644 index 0000000000..9cb40e76d4 --- /dev/null +++ b/src/js/integrations/default.ts @@ -0,0 +1,87 @@ +import { hasTracingEnabled } from '@sentry/core'; +import { HttpClient } from '@sentry/integrations'; +import { Integrations as BrowserReactIntegrations } from '@sentry/react'; +import type { Integration } from '@sentry/types'; + +import type { ReactNativeClientOptions } from '../options'; +import { HermesProfiling } from '../profiling/integration'; +import { ReactNativeTracing } from '../tracing'; +import { notWeb } from '../utils/environment'; +import { DebugSymbolicator } from './debugsymbolicator'; +import { DeviceContext } from './devicecontext'; +import { EventOrigin } from './eventorigin'; +import { ModulesLoader } from './modulesloader'; +import { NativeLinkedErrors } from './nativelinkederrors'; +import { ReactNativeErrorHandlers } from './reactnativeerrorhandlers'; +import { ReactNativeInfo } from './reactnativeinfo'; +import { Release } from './release'; +import { createReactNativeRewriteFrames } from './rewriteframes'; +import { Screenshot } from './screenshot'; +import { SdkInfo } from './sdkinfo'; +import { ViewHierarchy } from './viewhierarchy'; + +/** + * Returns the default ReactNative integrations based on the current environment. + * + * Native integrations are only returned when native is enabled. + * + * Web integrations are only returned when running on web. + */ +export function getDefaultIntegrations(options: ReactNativeClientOptions): Integration[] { + const integrations: Integration[] = []; + + if (notWeb()) { + integrations.push( + new ReactNativeErrorHandlers({ + patchGlobalPromise: options.patchGlobalPromise, + }), + ); + integrations.push(new NativeLinkedErrors()); + } else { + integrations.push(new BrowserReactIntegrations.TryCatch()); + integrations.push(new BrowserReactIntegrations.GlobalHandlers()); + integrations.push(new BrowserReactIntegrations.LinkedErrors()); + } + + // @sentry/react default integrations + integrations.push(new BrowserReactIntegrations.InboundFilters()); + integrations.push(new BrowserReactIntegrations.FunctionToString()); + integrations.push(new BrowserReactIntegrations.Breadcrumbs()); + integrations.push(new BrowserReactIntegrations.Dedupe()); + integrations.push(new BrowserReactIntegrations.HttpContext()); + // end @sentry/react-native default integrations + + integrations.push(new Release()); + integrations.push(new EventOrigin()); + integrations.push(new SdkInfo()); + integrations.push(new ReactNativeInfo()); + + if (__DEV__ && notWeb()) { + integrations.push(new DebugSymbolicator()); + } + + integrations.push(createReactNativeRewriteFrames()); + + if (options.enableNative) { + integrations.push(new DeviceContext()); + integrations.push(new ModulesLoader()); + if (options.attachScreenshot) { + integrations.push(new Screenshot()); + } + if (options.attachViewHierarchy) { + integrations.push(new ViewHierarchy()); + } + if (options._experiments && typeof options._experiments.profilesSampleRate === 'number') { + integrations.push(new HermesProfiling()); + } + } + + if (hasTracingEnabled(options) && options.enableAutoPerformanceTracing) { + integrations.push(new ReactNativeTracing()); + } + if (options.enableCaptureFailedRequests) { + integrations.push(new HttpClient()); + } + + return integrations; +} diff --git a/src/js/sdk.tsx b/src/js/sdk.tsx index 7382e10c4e..69d3a73fe1 100644 --- a/src/js/sdk.tsx +++ b/src/js/sdk.tsx @@ -1,9 +1,7 @@ /* eslint-disable complexity */ import type { Scope } from '@sentry/core'; -import { getIntegrationsToSetup, hasTracingEnabled, Hub, initAndBind, makeMain, setExtra } from '@sentry/core'; -import { HttpClient } from '@sentry/integrations'; +import { getIntegrationsToSetup, Hub, initAndBind, makeMain, setExtra } from '@sentry/core'; import { - defaultIntegrations as reactDefaultIntegrations, defaultStackParser, getCurrentHub, makeFetchTransport, @@ -11,24 +9,9 @@ import { import type { Integration, UserFeedback } from '@sentry/types'; import { logger, stackParserFromStackParserOptions } from '@sentry/utils'; import * as React from 'react'; -import { Platform } from 'react-native'; import { ReactNativeClient } from './client'; -import { - DebugSymbolicator, - DeviceContext, - EventOrigin, - HermesProfiling, - ModulesLoader, - ReactNativeErrorHandlers, - ReactNativeInfo, - Release, - SdkInfo, -} from './integrations'; -import { NativeLinkedErrors } from './integrations/nativelinkederrors'; -import { createReactNativeRewriteFrames } from './integrations/rewriteframes'; -import { Screenshot } from './integrations/screenshot'; -import { ViewHierarchy } from './integrations/viewhierarchy'; +import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; import { ReactNativeScope } from './scope'; import { TouchEventBoundary } from './touchevents'; @@ -39,11 +22,6 @@ import { getDefaultEnvironment } from './utils/environment'; import { safeFactory, safeTracesSampler } from './utils/safe'; import { NATIVE } from './wrapper'; -const IGNORED_DEFAULT_INTEGRATIONS = [ - 'GlobalHandlers', // We will use the react-native internal handlers - 'TryCatch', // We don't need this - 'LinkedErrors', // We replace this with `NativeLinkedError` -]; const DEFAULT_OPTIONS: ReactNativeOptions = { enableNativeCrashHandling: true, enableNativeNagger: true, @@ -102,50 +80,9 @@ export function init(passedOptions: ReactNativeOptions): void { options.environment = getDefaultEnvironment(); } - const defaultIntegrations: Integration[] = passedOptions.defaultIntegrations || []; - if (passedOptions.defaultIntegrations === undefined) { - defaultIntegrations.push(new ModulesLoader()); - if (Platform.OS !== 'web') { - defaultIntegrations.push(new ReactNativeErrorHandlers({ - patchGlobalPromise: options.patchGlobalPromise, - })); - } - defaultIntegrations.push(new Release()); - defaultIntegrations.push(...[ - ...reactDefaultIntegrations.filter( - (i) => !IGNORED_DEFAULT_INTEGRATIONS.includes(i.name) - ), - ]); - - defaultIntegrations.push(new NativeLinkedErrors()); - defaultIntegrations.push(new EventOrigin()); - defaultIntegrations.push(new SdkInfo()); - defaultIntegrations.push(new ReactNativeInfo()); - - if (__DEV__) { - defaultIntegrations.push(new DebugSymbolicator()); - } - - defaultIntegrations.push(createReactNativeRewriteFrames()); - if (options.enableNative) { - defaultIntegrations.push(new DeviceContext()); - } - if (options._experiments && typeof options._experiments.profilesSampleRate === 'number') { - defaultIntegrations.push(new HermesProfiling()); - } - if (hasTracingEnabled(options) && options.enableAutoPerformanceTracing) { - defaultIntegrations.push(new ReactNativeTracing()); - } - if (options.attachScreenshot) { - defaultIntegrations.push(new Screenshot()); - } - if (options.attachViewHierarchy) { - defaultIntegrations.push(new ViewHierarchy()); - } - if (options.enableCaptureFailedRequests) { - defaultIntegrations.push(new HttpClient()); - } - } + const defaultIntegrations: false | Integration[] = passedOptions.defaultIntegrations === undefined + ? getDefaultIntegrations(options) + : passedOptions.defaultIntegrations; options.integrations = getIntegrationsToSetup({ integrations: safeFactory(passedOptions.integrations, { loggerMessage: 'The integrations threw an error' }), diff --git a/src/js/utils/environment.ts b/src/js/utils/environment.ts index 241d208f99..1167523a81 100644 --- a/src/js/utils/environment.ts +++ b/src/js/utils/environment.ts @@ -1,3 +1,5 @@ +import { Platform } from 'react-native'; + import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { ReactNativeLibraries } from './rnlibraries'; @@ -30,6 +32,11 @@ export function isExpo(): boolean { return RN_GLOBAL_OBJ.expo != null; } +/** Checks if the current platform is not web */ +export function notWeb(): boolean { + return Platform.OS !== 'web'; +} + /** Returns Hermes Version if hermes is present in the runtime */ export function getHermesVersion(): string | undefined { return ( diff --git a/test/profiling/integration.test.ts b/test/profiling/integration.test.ts index 6d85bdba13..67a19b66e9 100644 --- a/test/profiling/integration.test.ts +++ b/test/profiling/integration.test.ts @@ -10,7 +10,7 @@ import * as Sentry from '../../src/js'; import { HermesProfiling } from '../../src/js/integrations'; import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry'; import { getDebugMetadata } from '../../src/js/profiling/debugid'; -import { getDefaultEnvironment, isHermesEnabled } from '../../src/js/utils/environment'; +import { getDefaultEnvironment, isHermesEnabled, notWeb } from '../../src/js/utils/environment'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { MOCK_DSN } from '../mockDsn'; import { envelopeItemPayload, envelopeItems } from '../testutils'; @@ -24,6 +24,7 @@ describe('profiling integration', () => { }; beforeEach(() => { + (notWeb as jest.Mock).mockReturnValue(true); (isHermesEnabled as jest.Mock).mockReturnValue(true); mockWrapper.NATIVE.startProfiling.mockReturnValue(true); mockWrapper.NATIVE.stopProfiling.mockReturnValue({ diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 7065128834..a35d83fa48 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -31,7 +31,6 @@ jest.mock('@sentry/react', () => { configureScope: mockedGetCurrentHubConfigureScope, }; }), - defaultIntegrations: [{ name: 'MockedDefaultReactIntegration', setupOnce: jest.fn() }], }; }); @@ -64,6 +63,7 @@ jest.mock('../src/js/client', () => { }); jest.mock('../src/js/wrapper'); +jest.mock('../src/js/utils/environment'); jest.spyOn(logger, 'error'); @@ -75,6 +75,7 @@ import type { ReactNativeClientOptions } from '../src/js/options'; import { configureScope, flush, init, withScope } from '../src/js/sdk'; import { ReactNativeTracing, ReactNavigationInstrumentation } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; +import { getDefaultEnvironment, notWeb } from '../src/js/utils/environment'; import { firstArg, secondArg } from './testutils'; const mockedInitAndBind = initAndBind as jest.MockedFunction; @@ -83,6 +84,11 @@ const usedOptions = (): ClientOptions | undefined => { }; describe('Tests the SDK functionality', () => { + beforeEach(() => { + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => true); + (notWeb as jest.Mock).mockImplementation(() => true); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -186,6 +192,7 @@ describe('Tests the SDK functionality', () => { describe('environment', () => { it('detect development environment', () => { + (getDefaultEnvironment as jest.Mock).mockImplementation(() => 'development'); init({ enableNative: true, }); @@ -618,7 +625,73 @@ describe('Tests the SDK functionality', () => { const actualIntegrations = actualOptions.integrations; expect(actualIntegrations).toEqual( - expect.arrayContaining([expect.objectContaining({ name: 'MockedDefaultReactIntegration' })]), + expect.arrayContaining([ + expect.objectContaining({ name: 'InboundFilters' }), + expect.objectContaining({ name: 'FunctionToString' }), + expect.objectContaining({ name: 'Breadcrumbs' }), + expect.objectContaining({ name: 'Dedupe' }), + expect.objectContaining({ name: 'HttpContext' }), + ]), + ); + }); + + it('adds all platform default integrations', () => { + init({}); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Release' }), + expect.objectContaining({ name: 'EventOrigin' }), + expect.objectContaining({ name: 'SdkInfo' }), + expect.objectContaining({ name: 'ReactNativeInfo' }), + ]), + ); + }); + + it('adds web platform specific default integrations', () => { + (notWeb as jest.Mock).mockImplementation(() => false); + init({}); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'TryCatch' }), + expect.objectContaining({ name: 'GlobalHandlers' }), + expect.objectContaining({ name: 'LinkedErrors' }), + ]), + ); + }); + + it('does not add native integrations if native disabled', () => { + (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); + init({ + attachScreenshot: true, + attachViewHierarchy: true, + _experiments: { + profilesSampleRate: 0.7, + }, + }); + + const actualOptions = mockedInitAndBind.mock.calls[0][secondArg] as ReactNativeClientOptions; + const actualIntegrations = actualOptions.integrations; + + expect(actualIntegrations).toEqual( + expect.not.arrayContaining([expect.objectContaining({ name: 'DeviceContext' })]), + ); + expect(actualIntegrations).toEqual( + expect.not.arrayContaining([expect.objectContaining({ name: 'ModulesLoader' })]), + ); + expect(actualIntegrations).toEqual(expect.not.arrayContaining([expect.objectContaining({ name: 'Screenshot' })])); + expect(actualIntegrations).toEqual( + expect.not.arrayContaining([expect.objectContaining({ name: 'ViewHierarchy' })]), + ); + expect(actualIntegrations).toEqual( + expect.not.arrayContaining([expect.objectContaining({ name: 'HermesProfiling' })]), ); }); });