Skip to content
Draft
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
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 @@
// (undocumented)
back?: () => void;
// (undocumented)
dismiss?: (count?: number) => void;
// (undocumented)

Check warning on line 276 in packages/core/etc/sentry-react-native.api.md

View check run for this annotation

@sentry/warden / warden: find-bugs

Expo Router navigation params added to breadcrumbs without `sendDefaultPii` gate

`wrapNavigationMethod` in `expoRouter.ts` unconditionally spreads `parsed.params` into the navigation breadcrumb's `data` object (and serializes the full `href` — which can include the same params — into both the breadcrumb and the `route.href` span attribute). Dynamic route / query params can carry user-identifiable values, and the rest of the SDK (see `reactnavigation.ts`) consistently gates such values behind `sendDefaultPii`. No such check exists in the new Expo Router instrumentation.
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 @@ -761,7 +762,7 @@
// 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:94: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
173 changes: 141 additions & 32 deletions packages/core/src/js/tracing/expoRouter.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,87 @@
import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core';
import { addBreadcrumb, 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 { 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;
routeName: string;
pathname?: string;
params?: Record<string, unknown>;
}

/**
* 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']>;
}

wrappedRouter.__sentryWrapped = true;
return router;
}

function wrapPrefetch<T extends ExpoRouter>(router: T): void {

Check failure on line 80 in packages/core/src/js/tracing/expoRouter.ts

View check run for this annotation

@sentry/warden / warden: code-review

Navigation `params` added to breadcrumbs and spans without `sendDefaultPii` guard

Route `params` (which can contain user IDs and other PII) are unconditionally included in breadcrumb `data` and span attributes, but the existing `reactnavigation.ts` explicitly checks `getClient()?.getOptions()?.sendDefaultPii` before including any params — this new code bypasses that guard entirely.
Comment on lines +63 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Navigation params added to breadcrumbs and spans without sendDefaultPii guard

Route params (which can contain user IDs and other PII) are unconditionally included in breadcrumb data and span attributes, but the existing reactnavigation.ts explicitly checks getClient()?.getOptions()?.sendDefaultPii before including any params — this new code bypasses that guard entirely.

Evidence
  • reactnavigation.ts:560–566 checks sendDefaultPii before including route.url or any route.params.* attributes in spans.
  • reactnavigation.ts:578–584 breadcrumb omits params entirely (from/to route names only).
  • wrapNavigationMethod (lines 63–80 of the new file) spreads parsed.params directly into the addBreadcrumb data object and sets route.href (which may encode params) as a span attribute with no sendDefaultPii check.
  • The PR checklist item "No new PII added" is unchecked.
Also found at 1 additional location
  • packages/core/src/js/tracing/expoRouter.ts:143-143

Identified by Warden code-review · 4TC-USJ

const originalPrefetch = router.prefetch!.bind(router);

router.prefetch = ((href: ExpoRouterHref) => {
const { routeName } = parseHref(href);

const span = startInactiveSpan({
op: 'navigation.prefetch',
Expand All @@ -58,7 +96,6 @@
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 +108,93 @@
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);

return router;
addBreadcrumb({
category: 'navigation',
type: 'navigation',
message: `Expo Router ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`,
data: {
method,
...(parsed.href !== undefined ? { href: serializeHref(parsed.href) } : undefined),
...(parsed.pathname ? { pathname: parsed.pathname } : undefined),
...(parsed.params ? { params: parsed.params } : undefined),
},
});

Check failure on line 143 in packages/core/src/js/tracing/expoRouter.ts

View check run for this annotation

@sentry/warden / warden: code-review

[4TC-USJ] Navigation `params` added to breadcrumbs and spans without `sendDefaultPii` guard (additional location)

Route `params` (which can contain user IDs and other PII) are unconditionally included in breadcrumb `data` and span attributes, but the existing `reactnavigation.ts` explicitly checks `getClient()?.getOptions()?.sendDefaultPii` before including any params — this new code bypasses that guard entirely.
setPendingExpoRouterNavigation({
method,
href: parsed.href,
pathname: parsed.pathname,
params: parsed.params,
});

const span = startInactiveSpan({
op: `navigation.${method}`,
name: `Navigation ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`,
attributes: {
'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION,
'navigation.method': method,
...(parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined),

Check warning on line 157 in packages/core/src/js/tracing/expoRouter.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[ZN2-RXE] Expo Router navigation params added to breadcrumbs without `sendDefaultPii` gate (additional location)

`wrapNavigationMethod` in `expoRouter.ts` unconditionally spreads `parsed.params` into the navigation breadcrumb's `data` object (and serializes the full `href` — which can include the same params — into both the breadcrumb and the `route.href` span attribute). Dynamic route / query params can carry user-identifiable values, and the rest of the SDK (see `reactnavigation.ts`) consistently gates such values behind `sendDefaultPii`. No such check exists in the new Expo Router instrumentation.
...(parsed.routeName ? { 'route.name': parsed.routeName } : undefined),
},
});

try {
const result = original.apply(router, args);
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;
}
};
}

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

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

function serializeHref(href: unknown): string {
return typeof href === 'string' ? href : JSON.stringify(href);
}
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.expo_router.navigation';
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;
}
6 changes: 6 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 @@ -433,6 +434,11 @@ 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);

const pendingExpoRouter = consumePendingExpoRouterNavigation();
if (pendingExpoRouter && latestNavigationSpan) {
latestNavigationSpan.setAttribute('navigation.method', pendingExpoRouter.method);
}
if (ignoreEmptyBackNavigationTransactions) {
ignoreEmptyBackNavigation(getClient(), latestNavigationSpan);
}
Expand Down
Loading
Loading