Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
### Features

- Add `enableAutoConsoleLogs` option to opt out of automatic `console.*` capture while keeping `enableLogs: true` for manual `Sentry.logger.*` calls ([#6235](https://github.com/getsentry/sentry-react-native/pull/6235))
- Instrument Expo Router `push`, `replace`, `navigate`, `back`, and `dismiss` (in addition to `prefetch`) with breadcrumbs and spans, and tag the resulting idle navigation span with the initiating `navigation.method` ([#6221](https://github.com/getsentry/sentry-react-native/pull/6221))
- Note: Expo Router span/breadcrumb attributes that may contain user identifiers (`route.href`, `route.params`, and concrete pathnames derived from string hrefs such as `/users/42`) are now gated behind `sendDefaultPii`. When `sendDefaultPii` is off (the default), prefetch spans for string hrefs use `route.name: 'unknown'` and omit `route.href`. Templated object hrefs (e.g. `{ pathname: '/users/[id]' }`) are unaffected.

### Fixes

Expand Down
11 changes: 6 additions & 5 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,13 @@ export interface ExpoRouter {
// (undocumented)
back?: () => void;
// (undocumented)
dismiss?: (count?: number) => void;
// (undocumented)
navigate?: (...args: unknown[]) => void;
// Warning: (ae-forgotten-export) The symbol "ExpoRouterHref" needs to be exported by the entry point index.d.ts
//
// (undocumented)
prefetch?: (href: string | {
pathname?: string;
params?: Record<string, unknown>;
}) => void | Promise<void>;
prefetch?: (href: ExpoRouterHref) => void | Promise<void>;
// (undocumented)
push?: (...args: unknown[]) => void;
// (undocumented)
Expand Down Expand Up @@ -856,7 +857,7 @@ export function wrapExpoRouter<T extends ExpoRouter>(router: T): T;
// src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts
// src/js/feedback/integration.ts:23:5 - (ae-forgotten-export) The symbol "FeedbackFormTheme" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnativetracing.ts:90:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnavigation.ts:219:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnavigation.ts:220:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)

Expand Down
227 changes: 192 additions & 35 deletions packages/core/src/js/tracing/expoRouter.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,118 @@
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
import { addBreadcrumb, getClient, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';

import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin';
import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin';
import { clearPendingExpoRouterNavigation, setPendingExpoRouterNavigation } from './pendingExpoRouterNavigation';

type ExpoRouterHref = string | { pathname?: string; params?: Record<string, unknown> };

/**
* Type definition for Expo Router's router object
* 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
prefetch?: (href: ExpoRouterHref) => void | Promise<void>;
push?: (...args: unknown[]) => void;
replace?: (...args: unknown[]) => void;
back?: () => void;
navigate?: (...args: unknown[]) => void;
back?: () => void;
dismiss?: (count?: number) => void;
}

type NavigationMethod = 'push' | 'replace' | 'navigate' | 'back' | 'dismiss';

interface ParsedHref {
href?: unknown;
/** A label used for span/transaction naming. May be PII when {@link concretePathname} is true. */
routeName: string;
/** Pathname extracted from the href, if any. May be PII when {@link concretePathname} is true. */
pathname?: string;
params?: Record<string, unknown>;
/**
* Whether `pathname` / `routeName` came from a concrete string href (e.g. `/users/42`)
* rather than a templated object href (e.g. `{ pathname: '/users/[id]' }`).
*
* Concrete pathnames can contain user identifiers and must be gated behind
* `sendDefaultPii`. Templated pathnames are structural and safe.
*/
concretePathname: boolean;
}

/**
* Wraps Expo Router. It currently only does one thing: extends prefetch() method
* to add automated performance monitoring.
* Wraps Expo Router methods to add automated performance monitoring and breadcrumbs.
*
* Currently wraps:
* - `prefetch` โ€” wraps the call in a `navigation.prefetch` span.
* - `push` / `replace` / `navigate` / `back` / `dismiss` โ€” adds a navigation
* breadcrumb, wraps the call in a short-lived span that mirrors prefetch's
* error/status handling, and tags the subsequent idle navigation transaction
* with the initiating `navigation.method` so the resulting span can be
* attributed back to the call site.
*
* This function instruments the `prefetch` method of an Expo Router instance
* to create performance spans that measure how long route prefetching takes.
* Safe to call repeatedly โ€” guarded by a single `__sentryWrapped` flag.
*
* @param router - The Expo Router instance from `useRouter()` hook
* @returns The same router instance with an instrumented prefetch method
* @returns The same router instance with instrumented methods
*/
export function wrapExpoRouter<T extends ExpoRouter>(router: T): T {
if (!router?.prefetch) {
if (!router) {
return router;
}

// Check if already wrapped to avoid double-wrapping
if ((router as T & { __sentryPrefetchWrapped?: boolean }).__sentryPrefetchWrapped) {
const wrappedRouter = router as T & { __sentryWrapped?: boolean };
if (wrappedRouter.__sentryWrapped) {
return router;
}

const originalPrefetch = router.prefetch.bind(router);
if (router.prefetch) {
wrapPrefetch(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;
}
if (router.push) {
router.push = wrapNavigationMethod(router, 'push', router.push.bind(router));
}
if (router.replace) {
router.replace = wrapNavigationMethod(router, 'replace', router.replace.bind(router));
}
if (router.navigate) {
router.navigate = wrapNavigationMethod(router, 'navigate', router.navigate.bind(router));
}
if (router.back) {
router.back = wrapNavigationMethod(router, 'back', router.back.bind(router)) as NonNullable<T['back']>;
}
if (router.dismiss) {
const originalDismiss = router.dismiss.bind(router) as (...args: unknown[]) => unknown;
router.dismiss = wrapNavigationMethod(router, 'dismiss', originalDismiss) as NonNullable<T['dismiss']>;
}
Comment thread
sentry-warden[bot] marked this conversation as resolved.

wrappedRouter.__sentryWrapped = true;
return router;
}

function wrapPrefetch<T extends ExpoRouter>(router: T): void {
Comment thread
sentry-warden[bot] marked this conversation as resolved.
const originalPrefetch = router.prefetch!.bind(router);

router.prefetch = ((href: ExpoRouterHref) => {
const parsed = parseHref(href);
const sendPii = isSendDefaultPiiEnabled();
// For concrete string hrefs (e.g. `/users/42`), `routeName` may carry
// user identifiers โ€” gate it behind `sendDefaultPii`. For templated
// object hrefs (e.g. `{ pathname: '/users/[id]' }`) it is structural.
const safeRouteName = parsed.concretePathname && !sendPii ? 'unknown' : parsed.routeName;

const span = startInactiveSpan({
op: 'navigation.prefetch',
name: `Prefetch ${routeName}`,
name: `Prefetch ${safeRouteName}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH,
'route.href': typeof href === 'string' ? href : JSON.stringify(href),
'route.name': routeName,
'route.name': safeRouteName,
// `route.href` may contain dynamic segment values (e.g. `/users/42`)
// or stringified `params`, so it is gated behind `sendDefaultPii`.
...(sendPii ? { 'route.href': serializeHref(href) } : undefined),
Comment thread
cursor[bot] marked this conversation as resolved.
},
});

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 => {
Expand All @@ -71,21 +125,124 @@ export function wrapExpoRouter<T extends ExpoRouter>(router: T): T {
span?.end();
throw error;
});
} else {
// Synchronous completion
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
}

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;
function wrapNavigationMethod(
router: ExpoRouter,
method: NavigationMethod,
original: (...args: unknown[]) => unknown,
): (...args: unknown[]) => unknown {
return (...args: unknown[]) => {
const parsed = parseMethodArgs(method, args);
const sendPii = isSendDefaultPiiEnabled();
// For concrete string hrefs (e.g. `/users/42`) the pathname carries the
// resolved URL โ€” gate it behind `sendDefaultPii`. Templated pathnames from
// object hrefs (e.g. `{ pathname: '/users/[id]' }`) are structural and safe.
const safePathname = parsed.concretePathname && !sendPii ? undefined : parsed.pathname;
const safeRouteName = parsed.concretePathname && !sendPii ? method : parsed.routeName;

return router;
addBreadcrumb({
category: 'navigation',
type: 'navigation',
message: `Expo Router ${method}${safePathname ? ` to ${safePathname}` : ''}`,
data: {
method,
...(safePathname ? { pathname: safePathname } : undefined),
// `href` (raw URL form) and `params` may contain user identifiers or
// other PII (e.g. `/users/42`, `{ id: '42' }`). Mirror the behavior of
// `reactnavigation.ts` and only include them when `sendDefaultPii` is on.
...(sendPii && parsed.href !== undefined ? { href: serializeHref(parsed.href) } : undefined),
...(sendPii && parsed.params ? { params: parsed.params } : undefined),
},
});

setPendingExpoRouterNavigation({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: The href, pathname, and params seem to be unused

method,
href: parsed.href,
pathname: parsed.pathname,
params: parsed.params,
});

const span = startInactiveSpan({
op: `navigation.${method}`,
name: `Navigation ${method}${safePathname ? ` to ${safePathname}` : ''}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION,
'navigation.method': method,
...(safeRouteName ? { 'route.name': safeRouteName } : undefined),
...(sendPii && parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined),
Comment thread
sentry-warden[bot] marked this conversation as resolved.
},
});

try {
const result = original.apply(router, args);
span?.setStatus({ code: SPAN_STATUS_OK });
span?.end();
return result;
} catch (error) {
// Clear the pending value so a failed navigation does not leak its
// method/href onto the next successful idle navigation span.
clearPendingExpoRouterNavigation();
span?.setStatus({ code: SPAN_STATUS_ERROR, message: String(error) });
span?.end();
Comment thread
sentry-warden[bot] marked this conversation as resolved.
throw error;
}
};
}

function parseMethodArgs(method: NavigationMethod, args: unknown[]): ParsedHref {
if (method === 'back' || method === 'dismiss') {
return { routeName: method, concretePathname: false };
}
return parseHref(args[0] as ExpoRouterHref | undefined);
}

function parseHref(href: ExpoRouterHref | undefined): ParsedHref {
if (typeof href === 'string') {
return { href, routeName: href, pathname: href, concretePathname: true };
}
if (href && typeof href === 'object') {
const pathname = typeof href.pathname === 'string' ? href.pathname : undefined;
return {
href,
routeName: pathname ?? 'unknown',
pathname,
params: href.params,
concretePathname: false,
};
}
return { routeName: 'unknown', concretePathname: false };
}

/**
* Serializes an href into a string for inclusion in spans/breadcrumbs.
*
* Wrapped in `try/catch` because `params` may contain values that `JSON.stringify`
* cannot serialize (BigInt, Symbol, circular references). A failure here must
* never prevent the underlying navigation from running.
*/
function serializeHref(href: unknown): string {
if (typeof href === 'string') {
return href;
}
try {
return JSON.stringify(href);
} catch {
return '[unserializable href]';
}
}

function isSendDefaultPiiEnabled(): boolean {
return getClient()?.getOptions()?.sendDefaultPii ?? false;
}
1 change: 1 addition & 0 deletions packages/core/src/js/tracing/origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ 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';
export const SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION = 'auto.navigation.expo_router';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_IMAGE = 'auto.resource.expo_image';
export const SPAN_ORIGIN_AUTO_RESOURCE_EXPO_ASSET = 'auto.resource.expo_asset';
39 changes: 39 additions & 0 deletions packages/core/src/js/tracing/pendingExpoRouterNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Cross-module hand-off between {@link wrapExpoRouter} and the
* {@link reactNavigationIntegration} idle navigation span.
*
* When an Expo Router method (push / replace / navigate / back / dismiss) is
* called, it stores the initiating method here. The next idle navigation span
* consumes (and clears) this value so the span can be attributed to the call
* site via the `navigation.method` attribute.
*/

export interface PendingExpoRouterNavigation {
/** The Expo Router method that initiated the navigation. */
method: 'push' | 'replace' | 'navigate' | 'back' | 'dismiss';
/** The target href (string or object), if any. */
href?: unknown;
/** Parsed pathname from the href, if any. */
pathname?: string;
/** Parsed params from the href, if any. */
params?: Record<string, unknown>;
}

let pending: PendingExpoRouterNavigation | undefined;

/** Stores the initiating Expo Router navigation call. Overwrites any previous pending value. */
export function setPendingExpoRouterNavigation(value: PendingExpoRouterNavigation): void {
pending = value;
}

/** Returns and clears the pending Expo Router navigation, if any. */
export function consumePendingExpoRouterNavigation(): PendingExpoRouterNavigation | undefined {
const value = pending;
pending = undefined;
return value;
}

/** Test helper โ€” clears the pending value without consuming it. */
export function clearPendingExpoRouterNavigation(): void {
pending = undefined;
}
12 changes: 12 additions & 0 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
markRootSpanForDiscard,
} from './onSpanEndUtils';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin';
import { consumePendingExpoRouterNavigation } from './pendingExpoRouterNavigation';
import { getReactNativeTracingIntegration } from './reactnativetracing';
import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
import {
Expand Down Expand Up @@ -350,6 +351,13 @@ export const reactNavigationIntegration = ({
const actionType = event?.data?.action?.type;
const targetRouteName = getRouteNameFromAction(event);

// Always drain the pending Expo Router value on this listener invocation โ€”
// even if we end up short-circuiting below (noop / PRELOAD / drawer /
// missing route name). If the underlying router call did not produce an
// idle nav span, the value must not leak onto the next, unrelated
// navigation. Apply it only if we actually create `latestNavigationSpan`.
const pendingExpoRouter = consumePendingExpoRouterNavigation();

if (event && !isAppRestart && !event.data?.noop) {
addBreadcrumb({
category: 'navigation.dispatch',
Expand Down Expand Up @@ -451,6 +459,10 @@ export const reactNavigationIntegration = ({
latestNavigationSpan = startGenericIdleNavigationSpan(finalSpanOptions, { ...idleSpanOptions, isAppRestart });
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION);
latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType);

if (pendingExpoRouter && latestNavigationSpan) {
latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method);
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
Expand Down
Loading
Loading