diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd0ad73b6..c8604c6fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ ## Unreleased +### Features + +- Add performance tracking for Expo Router route prefetching ([#5606](https://github.com/getsentry/sentry-react-native/pull/5606)) + - New `wrapExpoRouter` utility to instrument manual `prefetch()` calls with performance spans + - New `enablePrefetchTracking` option for `reactNavigationIntegration` to automatically track PRELOAD actions + ```tsx + // Option 1: Wrap the router for manual prefetch tracking + import { wrapExpoRouter } from '@sentry/react-native'; + import { useRouter } from 'expo-router'; + + const router = wrapExpoRouter(useRouter()); + router.prefetch('/details'); // Creates a span measuring prefetch performance + + // Option 2: Enable automatic prefetch tracking in the integration + Sentry.init({ + integrations: [ + Sentry.reactNavigationIntegration({ + enablePrefetchTracking: true, + }), + ], + }); + ``` + ### Dependencies - Bump JavaScript SDK from v10.37.0 to v10.38.0 ([#5596](https://github.com/getsentry/sentry-react-native/pull/5596)) 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..9230a40936 --- /dev/null +++ b/packages/core/src/js/tracing/expoRouter.ts @@ -0,0 +1,90 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; +import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin'; + +/** + * 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: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + '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 446d2b82f5..4a0e3f27d2 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 { wrapExpoRouter } from './expoRouter'; +export type { ExpoRouter } from './expoRouter'; + export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span'; export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types'; diff --git a/packages/core/src/js/tracing/origin.ts b/packages/core/src/js/tracing/origin.ts index 342ca237be..858dbfa2cc 100644 --- a/packages/core/src/js/tracing/origin.ts +++ b/packages/core/src/js/tracing/origin.ts @@ -10,3 +10,5 @@ export const SPAN_ORIGIN_AUTO_NAVIGATION_CUSTOM = 'auto.navigation.custom'; export const SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY = 'auto.ui.time_to_display'; export const SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY = 'manual.ui.time_to_display'; + +export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch'; diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 1a1e179180..0dee432c2b 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,48 @@ 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; + 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}`, + 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 +493,7 @@ export const reactNavigationIntegration = ({ enableTimeToInitialDisplayForPreloadedRoutes, useDispatchedActionData, useFullPathsForNavigationRoutes, + enablePrefetchTracking, }, }; }; diff --git a/packages/core/test/tracing/expoRouter.test.ts b/packages/core/test/tracing/expoRouter.test.ts new file mode 100644 index 0000000000..145cd76b54 --- /dev/null +++ b/packages/core/test/tracing/expoRouter.test.ts @@ -0,0 +1,199 @@ +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; +import { type ExpoRouter, wrapExpoRouter } from '../../src/js/tracing'; +import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from '../../src/js/tracing/origin'; + +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', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + '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', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + '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', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + '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()} /> + +