diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0909587a..748cb87a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes +- Detect `expo/fetch` in Expo Router apps on `@expo/metro-runtime` versions that hide it, so fetch tracing and fetch breadcrumbs are enabled by default ([#6965](https://github.com/getsentry/sentry-react-native/pull/6965)) - Enable native logs on Android when `enableLogs` is set ([#6967](https://github.com/getsentry/sentry-react-native/pull/6967)) - Honor `` in iOS error screenshots ([#6974](https://github.com/getsentry/sentry-react-native/pull/6974)) diff --git a/packages/core/src/js/utils/environment.ts b/packages/core/src/js/utils/environment.ts index 0b11c9dcfa..c863c042bc 100644 --- a/packages/core/src/js/utils/environment.ts +++ b/packages/core/src/js/utils/environment.ts @@ -4,6 +4,9 @@ import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { getExpoConstants, getExpoGo } from './expomodules'; import { ReactNativeLibraries } from './rnlibraries'; +// Provided by Metro. Declared here because the SDK isn't built with Node types. +declare const process: { env: Record }; + /** Checks if the React Native Hermes engine is running */ export function isHermesEnabled(): boolean { return !!RN_GLOBAL_OBJ.HermesInternal; @@ -66,12 +69,47 @@ export function isWeb(): boolean { * fetch instrumentation instead of relying on XHR instrumentation. */ export function isExpoFetchEnabled(): boolean { + if (typeof globalThis.fetch !== 'function') { + return false; + } + const fetch = globalThis.fetch as unknown as Record; + if (fetch[Symbol.for('expo.builtin')] === true) { + return true; + } + // `@expo/metro-runtime` < 58.0.15 wraps fetch in Expo Router apps and drops the `expo.builtin` marker. + // Behind the wrapper, `expo/fetch` is active on Expo SDK 56+ unless the app opted out. return ( - typeof globalThis.fetch === 'function' && - (globalThis.fetch as unknown as Record)[Symbol.for('expo.builtin')] === true + fetch[Symbol.for('expo.polyfillFetchWithWindowLocation')] === true && + (getExpoSdkMajorVersion() ?? 0) >= 56 && + !isReactNativeFetchForced() ); } +function getExpoSdkMajorVersion(): number | undefined { + // The native module exposes the embedded app config as `manifest` (a JSON string on Android). + // `expoConfig` is only built by the `expo-constants` JS module. + const manifest: unknown = getExpoConstants()?.manifest; + let sdkVersion: unknown; + try { + const appConfig = typeof manifest === 'string' ? JSON.parse(manifest) : manifest; + sdkVersion = (appConfig as { sdkVersion?: unknown } | null | undefined)?.sdkVersion; + } catch { + return undefined; + } + const major = typeof sdkVersion === 'string' ? parseInt(sdkVersion, 10) : NaN; + return isNaN(major) ? undefined : major; +} + +function isReactNativeFetchForced(): boolean { + try { + // Read exactly like `expo`'s runtime does, so both resolve the same value after babel-preset-expo. + const useRnFetch = process.env.EXPO_PUBLIC_USE_RN_FETCH; + return useRnFetch === '1' || useRnFetch === 'true'; + } catch { + return false; + } +} + /** * Checks if React Native reports `resource` performance entries for its network requests. * Reported by default since React Native 0.83 (0.81 and 0.82 have it behind a feature flag). diff --git a/packages/core/test/utils/environment.test.ts b/packages/core/test/utils/environment.test.ts index 1ee47ef75b..711ba5038d 100644 --- a/packages/core/test/utils/environment.test.ts +++ b/packages/core/test/utils/environment.test.ts @@ -1,5 +1,6 @@ import { isExpoFetchEnabled, isResourceTimingSupported } from '../../src/js/utils/environment'; import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; describe('isExpoFetchEnabled', () => { const originalFetch = globalThis.fetch; @@ -43,6 +44,82 @@ describe('isExpoFetchEnabled', () => { globalThis.fetch = expoFetch; expect(isExpoFetchEnabled()).toBe(false); }); + + describe('with the Expo Router fetch wrapper', () => { + const originalExpo = RN_GLOBAL_OBJ.expo; + + const mockRouterFetchWrapper = (): void => { + const wrapper = jest.fn() as unknown as typeof fetch; + Object.defineProperty(wrapper, Symbol.for('expo.polyfillFetchWithWindowLocation'), { value: true }); + globalThis.fetch = wrapper; + }; + + const mockExpoManifest = (manifest: unknown): void => { + RN_GLOBAL_OBJ.expo = { + modules: { ExponentConstants: { manifest } }, + } as typeof RN_GLOBAL_OBJ.expo; + }; + + const mockExpoSdkVersion = (sdkVersion: string | undefined): void => { + mockExpoManifest({ sdkVersion }); + }; + + afterEach(() => { + RN_GLOBAL_OBJ.expo = originalExpo; + delete process.env.EXPO_PUBLIC_USE_RN_FETCH; + }); + + it.each(['56.0.0', '57.0.0', '58.0.0'])('returns true on Expo SDK %s', sdkVersion => { + mockRouterFetchWrapper(); + mockExpoSdkVersion(sdkVersion); + + expect(isExpoFetchEnabled()).toBe(true); + }); + + it('returns false on Expo SDK 55, which does not install expo/fetch', () => { + mockRouterFetchWrapper(); + mockExpoSdkVersion('55.0.0'); + + expect(isExpoFetchEnabled()).toBe(false); + }); + + it('reads the Expo SDK version from a JSON string manifest (Android)', () => { + mockRouterFetchWrapper(); + mockExpoManifest(JSON.stringify({ sdkVersion: '57.0.0' })); + + expect(isExpoFetchEnabled()).toBe(true); + }); + + it('returns false when the manifest is not valid JSON', () => { + mockRouterFetchWrapper(); + mockExpoManifest('{'); + + expect(isExpoFetchEnabled()).toBe(false); + }); + + it('returns false when the Expo SDK version is unknown', () => { + mockRouterFetchWrapper(); + mockExpoSdkVersion(undefined); + + expect(isExpoFetchEnabled()).toBe(false); + }); + + it.each(['1', 'true'])('returns false when EXPO_PUBLIC_USE_RN_FETCH is %s', value => { + mockRouterFetchWrapper(); + mockExpoSdkVersion('57.0.0'); + process.env.EXPO_PUBLIC_USE_RN_FETCH = value; + + expect(isExpoFetchEnabled()).toBe(false); + }); + + it('returns true when EXPO_PUBLIC_USE_RN_FETCH is not an opt-in value', () => { + mockRouterFetchWrapper(); + mockExpoSdkVersion('57.0.0'); + process.env.EXPO_PUBLIC_USE_RN_FETCH = '0'; + + expect(isExpoFetchEnabled()).toBe(true); + }); + }); }); describe('isResourceTimingSupported', () => {