Skip to content

Commit 42a723e

Browse files
authored
Expo Router integration improvement: Prefetch route performance measurement with automatically created spans (#5606)
* Expo Router improvement: Prefetch route performance measurement with automatically created spans * Performance measurement for manual expo router prefetch() call * Changelog entry with the usage example * Fix * `sentry.origin` added, tests adjusted * File renamed * Test fixes * Lint fix
1 parent 61fa569 commit 42a723e

File tree

8 files changed

+381
-2
lines changed

8 files changed

+381
-2
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,29 @@
88
99
## Unreleased
1010

11+
### Features
12+
13+
- Add performance tracking for Expo Router route prefetching ([#5606](https://github.com/getsentry/sentry-react-native/pull/5606))
14+
- New `wrapExpoRouter` utility to instrument manual `prefetch()` calls with performance spans
15+
- New `enablePrefetchTracking` option for `reactNavigationIntegration` to automatically track PRELOAD actions
16+
```tsx
17+
// Option 1: Wrap the router for manual prefetch tracking
18+
import { wrapExpoRouter } from '@sentry/react-native';
19+
import { useRouter } from 'expo-router';
20+
21+
const router = wrapExpoRouter(useRouter());
22+
router.prefetch('/details'); // Creates a span measuring prefetch performance
23+
24+
// Option 2: Enable automatic prefetch tracking in the integration
25+
Sentry.init({
26+
integrations: [
27+
Sentry.reactNavigationIntegration({
28+
enablePrefetchTracking: true,
29+
}),
30+
],
31+
});
32+
```
33+
1134
### Dependencies
1235

1336
- Bump JavaScript SDK from v10.37.0 to v10.38.0 ([#5596](https://github.com/getsentry/sentry-react-native/pull/5596))

packages/core/src/js/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ export {
9191
getDefaultIdleNavigationSpanOptions,
9292
createTimeToFullDisplay,
9393
createTimeToInitialDisplay,
94+
wrapExpoRouter,
9495
} from './tracing';
9596

96-
export type { TimeToDisplayProps } from './tracing';
97+
export type { TimeToDisplayProps, ExpoRouter } from './tracing';
9798

9899
export { Mask, Unmask } from './replay/CustomMask';
99100

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
2+
import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin';
3+
4+
/**
5+
* Type definition for Expo Router's router object
6+
*/
7+
export interface ExpoRouter {
8+
prefetch?: (href: string | { pathname?: string; params?: Record<string, unknown> }) => void | Promise<void>;
9+
// Other router methods can be added here if needed
10+
push?: (...args: unknown[]) => void;
11+
replace?: (...args: unknown[]) => void;
12+
back?: () => void;
13+
navigate?: (...args: unknown[]) => void;
14+
}
15+
16+
/**
17+
* Wraps Expo Router. It currently only does one thing: extends prefetch() method
18+
* to add automated performance monitoring.
19+
*
20+
* This function instruments the `prefetch` method of an Expo Router instance
21+
* to create performance spans that measure how long route prefetching takes.
22+
*
23+
* @param router - The Expo Router instance from `useRouter()` hook
24+
* @returns The same router instance with an instrumented prefetch method
25+
*/
26+
export function wrapExpoRouter<T extends ExpoRouter>(router: T): T {
27+
if (!router?.prefetch) {
28+
return router;
29+
}
30+
31+
// Check if already wrapped to avoid double-wrapping
32+
if ((router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped) {
33+
return router;
34+
}
35+
36+
const originalPrefetch = router.prefetch.bind(router);
37+
38+
router.prefetch = ((href: Parameters<NonNullable<ExpoRouter['prefetch']>>[0]) => {
39+
// Extract route name from href for better span naming
40+
let routeName = 'unknown';
41+
if (typeof href === 'string') {
42+
routeName = href;
43+
} else if (href && typeof href === 'object' && 'pathname' in href && href.pathname) {
44+
routeName = href.pathname;
45+
}
46+
47+
const span = startInactiveSpan({
48+
op: 'navigation.prefetch',
49+
name: `Prefetch ${routeName}`,
50+
attributes: {
51+
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH,
52+
'route.href': typeof href === 'string' ? href : JSON.stringify(href),
53+
'route.name': routeName,
54+
},
55+
});
56+
57+
try {
58+
const result = originalPrefetch(href);
59+
60+
// Handle both promise and synchronous returns
61+
if (result && typeof result === 'object' && 'then' in result && typeof result.then === 'function') {
62+
return result
63+
.then(res => {
64+
span?.setStatus({ code: SPAN_STATUS_OK });
65+
span?.end();
66+
return res;
67+
})
68+
.catch((error: unknown) => {
69+
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
70+
span?.end();
71+
throw error;
72+
});
73+
} else {
74+
// Synchronous completion
75+
span?.setStatus({ code: SPAN_STATUS_OK });
76+
span?.end();
77+
return result;
78+
}
79+
} catch (error) {
80+
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
81+
span?.end();
82+
throw error;
83+
}
84+
}) as NonNullable<T['prefetch']>;
85+
86+
// Mark as wrapped to prevent double-wrapping
87+
(router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped = true;
88+
89+
return router;
90+
}

packages/core/src/js/tracing/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export type { ReactNativeTracingIntegration } from './reactnativetracing';
99
export { reactNavigationIntegration } from './reactnavigation';
1010
export { reactNativeNavigationIntegration } from './reactnativenavigation';
1111

12+
export { wrapExpoRouter } from './expoRouter';
13+
export type { ExpoRouter } from './expoRouter';
14+
1215
export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions } from './span';
1316

1417
export type { ReactNavigationCurrentRoute, ReactNavigationRoute } from './types';

packages/core/src/js/tracing/origin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ export const SPAN_ORIGIN_AUTO_NAVIGATION_CUSTOM = 'auto.navigation.custom';
1010

1111
export const SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY = 'auto.ui.time_to_display';
1212
export const SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY = 'manual.ui.time_to_display';
13+
14+
export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH = 'auto.expo_router.prefetch';

packages/core/src/js/tracing/reactnavigation.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ interface ReactNavigationIntegrationOptions {
104104
* @default false
105105
*/
106106
useFullPathsForNavigationRoutes: boolean;
107+
108+
/**
109+
* Track performance of route prefetching operations.
110+
* Creates separate spans for PRELOAD actions to measure prefetch performance.
111+
* This is useful for Expo Router apps that use the prefetch functionality.
112+
*
113+
* @default false
114+
*/
115+
enablePrefetchTracking: boolean;
107116
}
108117

109118
/**
@@ -121,6 +130,7 @@ export const reactNavigationIntegration = ({
121130
enableTimeToInitialDisplayForPreloadedRoutes = false,
122131
useDispatchedActionData = false,
123132
useFullPathsForNavigationRoutes = false,
133+
enablePrefetchTracking = false,
124134
}: Partial<ReactNavigationIntegrationOptions> = {}): Integration & {
125135
/**
126136
* Pass the ref to the navigation container to register it to the instrumentation
@@ -253,12 +263,48 @@ export const reactNavigationIntegration = ({
253263
}
254264

255265
const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined;
266+
267+
// Handle PRELOAD actions separately if prefetch tracking is enabled
268+
if (enablePrefetchTracking && navigationActionType === 'PRELOAD') {
269+
const preloadData = event?.data.action;
270+
const payload = preloadData?.payload;
271+
const targetRoute =
272+
payload && typeof payload === 'object' && 'name' in payload && typeof payload.name === 'string'
273+
? payload.name
274+
: 'Unknown Route';
275+
276+
debug.log(`${INTEGRATION_NAME} Starting prefetch span for route: ${targetRoute}`);
277+
278+
const prefetchSpan = startInactiveSpan({
279+
op: 'navigation.prefetch',
280+
name: `Prefetch ${targetRoute}`,
281+
attributes: {
282+
'route.name': targetRoute,
283+
},
284+
});
285+
286+
// Store prefetch span to end it when state changes or timeout
287+
navigationProcessingSpan = prefetchSpan;
288+
289+
// Set timeout to ensure we don't leave hanging spans
290+
stateChangeTimeout = setTimeout(() => {
291+
if (navigationProcessingSpan === prefetchSpan) {
292+
debug.log(`${INTEGRATION_NAME} Prefetch span timed out for route: ${targetRoute}`);
293+
prefetchSpan?.setStatus({ code: SPAN_STATUS_OK });
294+
prefetchSpan?.end();
295+
navigationProcessingSpan = undefined;
296+
}
297+
}, routeChangeTimeoutMs);
298+
299+
return;
300+
}
301+
256302
if (
257303
useDispatchedActionData &&
258304
navigationActionType &&
259305
[
260306
// Process common actions
261-
'PRELOAD',
307+
'PRELOAD', // Still filter PRELOAD when enablePrefetchTracking is false
262308
'SET_PARAMS',
263309
// Drawer actions
264310
'OPEN_DRAWER',
@@ -447,6 +493,7 @@ export const reactNavigationIntegration = ({
447493
enableTimeToInitialDisplayForPreloadedRoutes,
448494
useDispatchedActionData,
449495
useFullPathsForNavigationRoutes,
496+
enablePrefetchTracking,
450497
},
451498
};
452499
};

0 commit comments

Comments
 (0)