From 3bb6c56aa13a1b97aa9a7a5b82d42a02cee3776b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 2 Feb 2026 11:58:48 +0100 Subject: [PATCH 1/8] Expo Router improvement: Prefetch route performance measurement with automatically created spans --- packages/core/src/js/tracing/index.ts | 3 ++ .../core/src/js/tracing/reactnavigation.ts | 47 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index 446d2b82f5..ce9edc8597 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -9,6 +9,9 @@ export type { ReactNativeTracingIntegration } from './reactnativetracing'; export { reactNavigationIntegration } from './reactnavigation'; export { reactNativeNavigationIntegration } from './reactnativenavigation'; +export { wrapPrefetch } from './expoPrefetch'; +export type { ExpoRouter } from './expoPrefetch'; + export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 1a1e179180..a84c157b7b 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -104,6 +104,15 @@ interface ReactNavigationIntegrationOptions { * @default false */ useFullPathsForNavigationRoutes: boolean; + + /** + * Track performance of route prefetching operations. + * Creates separate spans for PRELOAD actions to measure prefetch performance. + * This is useful for Expo Router apps that use the prefetch functionality. + * + * @default false + */ + enablePrefetchTracking: boolean; } /** @@ -121,6 +130,7 @@ export const reactNavigationIntegration = ({ enableTimeToInitialDisplayForPreloadedRoutes = false, useDispatchedActionData = false, useFullPathsForNavigationRoutes = false, + enablePrefetchTracking = false, }: Partial = {}): Integration & { /** * Pass the ref to the navigation container to register it to the instrumentation @@ -253,12 +263,46 @@ export const reactNavigationIntegration = ({ } const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined; + + // Handle PRELOAD actions separately if prefetch tracking is enabled + if (enablePrefetchTracking && navigationActionType === 'PRELOAD') { + const preloadData = event?.data.action; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const targetRoute = (preloadData as any)?.payload?.name || 'Unknown Route'; + + debug.log(`${INTEGRATION_NAME} Starting prefetch span for route: ${targetRoute}`); + + const prefetchSpan = startInactiveSpan({ + op: 'navigation.prefetch', + name: `Prefetch ${targetRoute}`, + origin: SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, + attributes: { + 'route.name': targetRoute, + }, + }); + + // Store prefetch span to end it when state changes or timeout + navigationProcessingSpan = prefetchSpan; + + // Set timeout to ensure we don't leave hanging spans + stateChangeTimeout = setTimeout(() => { + if (navigationProcessingSpan === prefetchSpan) { + debug.log(`${INTEGRATION_NAME} Prefetch span timed out for route: ${targetRoute}`); + prefetchSpan?.setStatus({ code: SPAN_STATUS_OK }); + prefetchSpan?.end(); + navigationProcessingSpan = undefined; + } + }, routeChangeTimeoutMs); + + return; + } + if ( useDispatchedActionData && navigationActionType && [ // Process common actions - 'PRELOAD', + 'PRELOAD', // Still filter PRELOAD when enablePrefetchTracking is false 'SET_PARAMS', // Drawer actions 'OPEN_DRAWER', @@ -447,6 +491,7 @@ export const reactNavigationIntegration = ({ enableTimeToInitialDisplayForPreloadedRoutes, useDispatchedActionData, useFullPathsForNavigationRoutes, + enablePrefetchTracking, }, }; }; From 3aeecaeb71c2c218700394f1764d63d6e8cf28c5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 2 Feb 2026 13:59:29 +0100 Subject: [PATCH 2/8] Performance measurement for manual expo router prefetch() call --- packages/core/src/js/index.ts | 3 +- packages/core/src/js/tracing/expoRouter.ts | 88 ++++++++ packages/core/src/js/tracing/index.ts | 4 +- .../core/src/js/tracing/reactnavigation.ts | 8 +- packages/core/test/tracing/expoRouter.ts | 198 ++++++++++++++++++ samples/expo/app/(tabs)/index.tsx | 14 ++ 6 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/js/tracing/expoRouter.ts create mode 100644 packages/core/test/tracing/expoRouter.ts diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 6d251cad10..19ba331003 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -91,9 +91,10 @@ export { getDefaultIdleNavigationSpanOptions, createTimeToFullDisplay, createTimeToInitialDisplay, + wrapExpoRouter, } from './tracing'; -export type { TimeToDisplayProps } from './tracing'; +export type { TimeToDisplayProps, ExpoRouter } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; diff --git a/packages/core/src/js/tracing/expoRouter.ts b/packages/core/src/js/tracing/expoRouter.ts new file mode 100644 index 0000000000..744bcd5f41 --- /dev/null +++ b/packages/core/src/js/tracing/expoRouter.ts @@ -0,0 +1,88 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; + +/** + * Type definition for Expo Router's router object + */ +export interface ExpoRouter { + prefetch?: (href: string | { pathname?: string; params?: Record }) => void | Promise; + // Other router methods can be added here if needed + push?: (...args: unknown[]) => void; + replace?: (...args: unknown[]) => void; + back?: () => void; + navigate?: (...args: unknown[]) => void; +} + +/** + * Wraps Expo Router. It currently only does one thing: extends prefetch() method + * to add automated performance monitoring. + * + * This function instruments the `prefetch` method of an Expo Router instance + * to create performance spans that measure how long route prefetching takes. + * + * @param router - The Expo Router instance from `useRouter()` hook + * @returns The same router instance with an instrumented prefetch method + */ +export function wrapExpoRouter(router: T): T { + if (!router?.prefetch) { + return router; + } + + // Check if already wrapped to avoid double-wrapping + if ((router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped) { + return router; + } + + const originalPrefetch = router.prefetch.bind(router); + + router.prefetch = ((href: Parameters>[0]) => { + // Extract route name from href for better span naming + let routeName = 'unknown'; + if (typeof href === 'string') { + routeName = href; + } else if (href && typeof href === 'object' && 'pathname' in href && href.pathname) { + routeName = href.pathname; + } + + const span = startInactiveSpan({ + op: 'navigation.prefetch', + name: `Prefetch ${routeName}`, + attributes: { + 'route.href': typeof href === 'string' ? href : JSON.stringify(href), + 'route.name': routeName, + }, + }); + + try { + const result = originalPrefetch(href); + + // Handle both promise and synchronous returns + if (result && typeof result === 'object' && 'then' in result && typeof result.then === 'function') { + return result + .then(res => { + span?.setStatus({ code: SPAN_STATUS_OK }); + span?.end(); + return res; + }) + .catch((error: unknown) => { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + }); + } else { + // Synchronous completion + span?.setStatus({ code: SPAN_STATUS_OK }); + span?.end(); + return result; + } + } catch (error) { + span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) }); + span?.end(); + throw error; + } + }) as NonNullable; + + // Mark as wrapped to prevent double-wrapping + (router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped = true; + + return router; +} diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index ce9edc8597..4a0e3f27d2 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -9,8 +9,8 @@ export type { ReactNativeTracingIntegration } from './reactnativetracing'; export { reactNavigationIntegration } from './reactnavigation'; export { reactNativeNavigationIntegration } from './reactnativenavigation'; -export { wrapPrefetch } from './expoPrefetch'; -export type { ExpoRouter } from './expoPrefetch'; +export { wrapExpoRouter } from './expoRouter'; +export type { ExpoRouter } from './expoRouter'; export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index a84c157b7b..0dee432c2b 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -267,15 +267,17 @@ export const reactNavigationIntegration = ({ // Handle PRELOAD actions separately if prefetch tracking is enabled if (enablePrefetchTracking && navigationActionType === 'PRELOAD') { const preloadData = event?.data.action; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const targetRoute = (preloadData as any)?.payload?.name || 'Unknown Route'; + const payload = preloadData?.payload; + const targetRoute = + payload && typeof payload === 'object' && 'name' in payload && typeof payload.name === 'string' + ? payload.name + : 'Unknown Route'; debug.log(`${INTEGRATION_NAME} Starting prefetch span for route: ${targetRoute}`); const prefetchSpan = startInactiveSpan({ op: 'navigation.prefetch', name: `Prefetch ${targetRoute}`, - origin: SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, attributes: { 'route.name': targetRoute, }, diff --git a/packages/core/test/tracing/expoRouter.ts b/packages/core/test/tracing/expoRouter.ts new file mode 100644 index 0000000000..2fe223cf34 --- /dev/null +++ b/packages/core/test/tracing/expoRouter.ts @@ -0,0 +1,198 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoRouter, wrapExpoRouter } from '../../src/js/tracing'; + +const mockStartInactiveSpan = jest.fn(); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + }; +}); + +describe('wrapExpoRouter', () => { + let mockSpan: { + setStatus: jest.Mock; + end: jest.Mock; + setAttribute: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpan = { + setStatus: jest.fn(), + end: jest.fn(), + setAttribute: jest.fn(), + }; + mockStartInactiveSpan.mockReturnValue(mockSpan); + }); + + it('returns the router unchanged if router is null or undefined', () => { + expect(wrapExpoRouter(null as unknown as ExpoRouter)).toBeNull(); + expect(wrapExpoRouter(undefined as unknown as ExpoRouter)).toBeUndefined(); + }); + + it('returns the router unchanged if prefetch method does not exist', () => { + const router = { push: jest.fn() } as unknown as ExpoRouter; + const wrapped = wrapExpoRouter(router); + expect(wrapped).toBe(router); + }); + + it('wraps prefetch method and creates a span with string href', () => { + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + wrapped.prefetch?.('/details/123'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.prefetch', + name: 'Prefetch /details/123', + origin: 'auto.navigation.react_navigation', + attributes: { + 'route.href': '/details/123', + 'route.name': '/details/123', + }, + }); + + expect(mockPrefetch).toHaveBeenCalledWith('/details/123'); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('wraps prefetch method and creates a span with object href', () => { + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + const href = { pathname: '/profile', params: { id: '456' } }; + + const wrapped = wrapExpoRouter(router); + wrapped.prefetch?.(href); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.prefetch', + name: 'Prefetch /profile', + origin: 'auto.navigation.react_navigation', + attributes: { + 'route.href': JSON.stringify(href), + 'route.name': '/profile', + }, + }); + + expect(mockPrefetch).toHaveBeenCalledWith(href); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('handles object href without pathname', () => { + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + const href = { params: { id: '789' } }; + + const wrapped = wrapExpoRouter(router); + wrapped.prefetch?.(href); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.prefetch', + name: 'Prefetch unknown', + origin: 'auto.navigation.react_navigation', + attributes: { + 'route.href': JSON.stringify(href), + 'route.name': 'unknown', + }, + }); + }); + + it('handles successful async prefetch', async () => { + const mockPrefetch = jest.fn().mockResolvedValue(undefined); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + await wrapped.prefetch?.('/async-route'); + + expect(mockPrefetch).toHaveBeenCalledWith('/async-route'); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('handles failed async prefetch', async () => { + const error = new Error('Prefetch failed'); + const mockPrefetch = jest.fn().mockRejectedValue(error); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + + await expect(wrapped.prefetch?.('/failing-route')).rejects.toThrow('Prefetch failed'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Prefetch failed', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('handles synchronous errors', () => { + const error = new Error('Sync prefetch error'); + const mockPrefetch = jest.fn().mockImplementation(() => { + throw error; + }); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + + expect(() => wrapped.prefetch?.('/error-route')).toThrow('Sync prefetch error'); + + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: 'Error: Sync prefetch error', + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it('does not double-wrap the same router', () => { + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + const wrapped1 = wrapExpoRouter(router); + const wrapped2 = wrapExpoRouter(wrapped1); + + expect(wrapped1).toBe(wrapped2); + + wrapped2.prefetch?.('/test'); + + // Should only be called once despite double wrapping + expect(mockStartInactiveSpan).toHaveBeenCalledTimes(1); + }); + + it('preserves other router methods', () => { + const mockPush = jest.fn(); + const mockBack = jest.fn(); + const mockPrefetch = jest.fn(); + const router = { + prefetch: mockPrefetch, + push: mockPush, + back: mockBack, + } as unknown as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + + expect(wrapped.push).toBe(mockPush); + expect(wrapped.back).toBe(mockBack); + }); + + it('binds prefetch method correctly to maintain context', () => { + const routerContext = { data: 'test-context' }; + const mockPrefetch = jest.fn(function (this: typeof routerContext) { + expect(this).toBe(routerContext); + }); + + const router = { + prefetch: mockPrefetch.bind(routerContext), + } as unknown as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + wrapped.prefetch?.('/test'); + + expect(mockPrefetch).toHaveBeenCalled(); + }); +}); diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 44cb99e51d..ddde1b1f7f 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -2,6 +2,7 @@ import { Button, ScrollView, StyleSheet } from 'react-native'; import * as Sentry from '@sentry/react-native'; import { reloadAppAsync, isRunningInExpoGo } from 'expo'; import * as DevClient from 'expo-dev-client'; +import { useRouter } from 'expo-router'; import { Text, View } from '@/components/Themed'; import { setScopeProperties } from '@/utils/setScopeProperties'; @@ -12,6 +13,10 @@ import { isWeb } from '../../utils/isWeb'; export default function TabOneScreen() { const { currentlyRunning } = useUpdates(); + const rawRouter = useRouter(); + // Wrap the router to monitor prefetch performance + const router = Sentry.wrapExpoRouter(rawRouter); + return ( @@ -31,6 +36,15 @@ export default function TabOneScreen() { disabled={isWeb()} /> + +