From 15d14cb588e770c9fe715af15bbc11fd66fc42c6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 29 Oct 2026 13:38:44 +0100 Subject: [PATCH 1/2] fix(tracing): Detect expo/fetch behind the Expo Router fetch wrapper @expo/metro-runtime before 58.0.15 wraps fetch in Expo Router apps and drops the expo.builtin marker. Fall back to the wrapper's own marker on Expo SDK 56+ unless the app opted out with EXPO_PUBLIC_USE_RN_FETCH. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ packages/core/src/js/utils/environment.ts | 42 ++++++++++- packages/core/test/utils/environment.test.ts | 77 ++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11726ae91d..eb8ceddd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### 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 + ## 8.33.0 ### Changes 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', () => { From b4290072f2e278df0f91867200d764b4cb8bebe6 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 29 Oct 2026 13:38:58 +0100 Subject: [PATCH 2/2] docs: Add PR link to changelog entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb8ceddd4c..de98817975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +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 +- 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)) ## 8.33.0