Skip to content
Open
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
88 changes: 88 additions & 0 deletions packages/core/src/js/tracing/expoRouter.ts
Original file line number Diff line number Diff line change
@@ -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<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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: should we also add the 'origin'?

name: `Prefetch ${routeName}`,
attributes: {
'route.href': typeof href === 'string' ? href : JSON.stringify(href),
'route.name': routeName,
},
});
Comment on lines +46 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The startInactiveSpan call in wrapExpoRouter is missing the origin attribute, which will cause tests to fail and result in incomplete telemetry.
Severity: CRITICAL

Suggested Fix

In packages/core/src/js/tracing/expoRouter.ts, add the origin: 'auto.navigation.react_navigation' property to the object passed to the startInactiveSpan call. This will align the implementation with test expectations and ensure consistent telemetry.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/core/src/js/tracing/expoRouter.ts#L46-L53

Potential issue: When `wrapExpoRouter` creates a prefetch span, it calls
`startInactiveSpan` without setting the `origin` attribute. However, associated tests in
`packages/core/test/tracing/expoRouter.ts` explicitly expect this attribute to be set to
`'auto.navigation.react_navigation'`. This discrepancy will cause CI tests to fail. In
production, the missing `origin` attribute leads to incomplete telemetry data, making it
harder to categorize and filter spans generated by the React Navigation integration.

Did we get this right? ๐Ÿ‘ / ๐Ÿ‘Ž to inform future reviews.

Comment on lines +46 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The wrapExpoRouter function fails to set the origin parameter when creating spans with startInactiveSpan, which is inconsistent with tests and other tracing code.
Severity: MEDIUM

Suggested Fix

Add the origin: 'auto.navigation.react_navigation' parameter to the object passed to the startInactiveSpan call within the wrapExpoRouter function to align with test expectations and ensure proper span categorization.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/core/src/js/tracing/expoRouter.ts#L46-L53

Potential issue: Spans created by the `wrapExpoRouter` function are missing the `origin`
parameter. The implementation calls `startInactiveSpan` without providing an `origin`,
even though corresponding tests explicitly assert that the `origin` should be set to
`'auto.navigation.react_navigation'`. This omission is inconsistent with other tracing
instrumentation in the codebase. The missing attribute will prevent these spans from
being correctly categorized and filtered within Sentry, impacting analysis.


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
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