Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/js/tracing/expoRouter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) => void | Promise<void>;
// 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<T extends ExpoRouter>(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<NonNullable<ExpoRouter['prefetch']>>[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<T['prefetch']>;

// Mark as wrapped to prevent double-wrapping
(router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped = true;

return router;
}
3 changes: 3 additions & 0 deletions packages/core/src/js/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/tracing/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
49 changes: 48 additions & 1 deletion packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -121,6 +130,7 @@ export const reactNavigationIntegration = ({
enableTimeToInitialDisplayForPreloadedRoutes = false,
useDispatchedActionData = false,
useFullPathsForNavigationRoutes = false,
enablePrefetchTracking = false,
}: Partial<ReactNavigationIntegrationOptions> = {}): Integration & {
/**
* Pass the ref to the navigation container to register it to the instrumentation
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -447,6 +493,7 @@ export const reactNavigationIntegration = ({
enableTimeToInitialDisplayForPreloadedRoutes,
useDispatchedActionData,
useFullPathsForNavigationRoutes,
enablePrefetchTracking,
},
};
};
Expand Down
Loading
Loading