From 9eecc98a88cfb23524fe6b6b842695539c53dd63 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 27 May 2026 14:45:11 +0200 Subject: [PATCH 1/8] Wrap push, replace, navigate, back, dismiss in addition to prefetch --- packages/core/etc/sentry-react-native.api.md | 11 +- packages/core/src/js/tracing/expoRouter.ts | 173 +++++++++++++--- packages/core/src/js/tracing/origin.ts | 1 + .../js/tracing/pendingExpoRouterNavigation.ts | 39 ++++ .../core/src/js/tracing/reactnavigation.ts | 6 + packages/core/test/tracing/expoRouter.test.ts | 191 +++++++++++++++++- 6 files changed, 373 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/js/tracing/pendingExpoRouterNavigation.ts diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 735d0440e8..7f63a2d1e3 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -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; - }) => void | Promise; + prefetch?: (href: ExpoRouterHref) => void | Promise; // (undocumented) push?: (...args: unknown[]) => void; // (undocumented) @@ -856,7 +857,7 @@ export function wrapExpoRouter(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) diff --git a/packages/core/src/js/tracing/expoRouter.ts b/packages/core/src/js/tracing/expoRouter.ts index 8401ecdedb..dfb10f86fd 100644 --- a/packages/core/src/js/tracing/expoRouter.ts +++ b/packages/core/src/js/tracing/expoRouter.ts @@ -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 }; /** - * 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 }) => void | Promise; - // Other router methods can be added here if needed + prefetch?: (href: ExpoRouterHref) => void | Promise; 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; } /** - * 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(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>[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; + } + if (router.dismiss) { + const originalDismiss = router.dismiss.bind(router) as (...args: unknown[]) => unknown; + router.dismiss = wrapNavigationMethod(router, 'dismiss', originalDismiss) as NonNullable; + } + + wrappedRouter.__sentryWrapped = true; + return router; +} + +function wrapPrefetch(router: T): void { + const originalPrefetch = router.prefetch!.bind(router); + + router.prefetch = ((href: ExpoRouterHref) => { + const { routeName } = parseHref(href); const span = startInactiveSpan({ op: 'navigation.prefetch', @@ -58,7 +96,6 @@ export function wrapExpoRouter(router: T): T { 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 => { @@ -71,21 +108,93 @@ export function wrapExpoRouter(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; +} - // 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), + }, + }); + + 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), + ...(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); } diff --git a/packages/core/src/js/tracing/origin.ts b/packages/core/src/js/tracing/origin.ts index 3b2fd4ca32..7e2863485a 100644 --- a/packages/core/src/js/tracing/origin.ts +++ b/packages/core/src/js/tracing/origin.ts @@ -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'; diff --git a/packages/core/src/js/tracing/pendingExpoRouterNavigation.ts b/packages/core/src/js/tracing/pendingExpoRouterNavigation.ts new file mode 100644 index 0000000000..31f3368988 --- /dev/null +++ b/packages/core/src/js/tracing/pendingExpoRouterNavigation.ts @@ -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; +} + +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; +} diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 63eeba6edc..36b2e731e8 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -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 { @@ -451,6 +452,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); } diff --git a/packages/core/test/tracing/expoRouter.test.ts b/packages/core/test/tracing/expoRouter.test.ts index 5d76155e18..bd7e54d375 100644 --- a/packages/core/test/tracing/expoRouter.test.ts +++ b/packages/core/test/tracing/expoRouter.test.ts @@ -1,15 +1,24 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; import { type ExpoRouter, wrapExpoRouter } from '../../src/js/tracing'; -import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from '../../src/js/tracing/origin'; +import { + SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, +} from '../../src/js/tracing/origin'; +import { + clearPendingExpoRouterNavigation, + consumePendingExpoRouterNavigation, +} from '../../src/js/tracing/pendingExpoRouterNavigation'; const mockStartInactiveSpan = jest.fn(); +const mockAddBreadcrumb = jest.fn(); jest.mock('@sentry/core', () => { const actual = jest.requireActual('@sentry/core'); return { ...actual, startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), + addBreadcrumb: (...args: unknown[]) => mockAddBreadcrumb(...args), }; }); @@ -28,6 +37,7 @@ describe('wrapExpoRouter', () => { setAttribute: jest.fn(), }; mockStartInactiveSpan.mockReturnValue(mockSpan); + clearPendingExpoRouterNavigation(); }); it('returns the router unchanged if router is null or undefined', () => { @@ -166,20 +176,179 @@ describe('wrapExpoRouter', () => { expect(mockStartInactiveSpan).toHaveBeenCalledTimes(1); }); - it('preserves other router methods', () => { - const mockPush = jest.fn(); - const mockBack = jest.fn(); + it('still wraps prefetch when other methods are absent', () => { const mockPrefetch = jest.fn(); - const router = { - prefetch: mockPrefetch, - push: mockPush, - back: mockBack, - } as unknown as ExpoRouter; + const router = { prefetch: mockPrefetch } as ExpoRouter; const wrapped = wrapExpoRouter(router); + expect(wrapped.prefetch).not.toBe(mockPrefetch); + }); + + describe.each(['push', 'replace', 'navigate'] as const)('wraps %s', method => { + it(`creates a span and breadcrumb with string href for ${method}`, () => { + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + + const wrapped = wrapExpoRouter(router); + wrapped[method]?.('/details/123'); + + expect(original).toHaveBeenCalledWith('/details/123'); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: `navigation.${method}`, + name: `Navigation ${method} to /details/123`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': method, + 'route.href': '/details/123', + 'route.name': '/details/123', + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: `Expo Router ${method} to /details/123`, + data: { + method, + href: '/details/123', + pathname: '/details/123', + }, + }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it(`creates a span and breadcrumb with object href for ${method}`, () => { + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + const href = { pathname: '/profile', params: { id: '456' } }; + + const wrapped = wrapExpoRouter(router); + wrapped[method]?.(href); + + expect(original).toHaveBeenCalledWith(href); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: `navigation.${method}`, + name: `Navigation ${method} to /profile`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': method, + 'route.href': JSON.stringify(href), + 'route.name': '/profile', + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: `Expo Router ${method} to /profile`, + data: { + method, + href: JSON.stringify(href), + pathname: '/profile', + params: { id: '456' }, + }, + }); + }); + + it(`sets pending navigation so it can be consumed for ${method}`, () => { + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + + wrapExpoRouter(router)[method]?.({ pathname: '/profile', params: { id: '7' } }); + + const pending = consumePendingExpoRouterNavigation(); + expect(pending).toEqual({ + method, + href: { pathname: '/profile', params: { id: '7' } }, + pathname: '/profile', + params: { id: '7' }, + }); + // consumed exactly once + expect(consumePendingExpoRouterNavigation()).toBeUndefined(); + }); - expect(wrapped.push).toBe(mockPush); - expect(wrapped.back).toBe(mockBack); + it(`reports errors via SPAN_STATUS_ERROR for ${method}`, () => { + const error = new Error(`${method} failed`); + const original = jest.fn(() => { + throw error; + }); + const router = { [method]: original } as unknown as ExpoRouter; + + expect(() => wrapExpoRouter(router)[method]?.('/x')).toThrow(`${method} failed`); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ + code: SPAN_STATUS_ERROR, + message: `Error: ${method} failed`, + }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + }); + + it('wraps back() with no args', () => { + const mockBack = jest.fn(); + const router = { back: mockBack } as unknown as ExpoRouter; + + wrapExpoRouter(router).back?.(); + + expect(mockBack).toHaveBeenCalledWith(); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.back', + name: 'Navigation back', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': 'back', + 'route.name': 'back', + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: 'Expo Router back', + data: { method: 'back' }, + }); + expect(consumePendingExpoRouterNavigation()).toEqual({ method: 'back' }); + }); + + it('wraps dismiss() and forwards optional count', () => { + const mockDismiss = jest.fn(); + const router = { dismiss: mockDismiss } as unknown as ExpoRouter; + + wrapExpoRouter(router).dismiss?.(2); + + expect(mockDismiss).toHaveBeenCalledWith(2); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.dismiss', + name: 'Navigation dismiss', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': 'dismiss', + 'route.name': 'dismiss', + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: 'Expo Router dismiss', + data: { method: 'dismiss' }, + }); + }); + + it('does not double-wrap navigation methods', () => { + const mockPush = jest.fn(); + const router = { push: mockPush } as unknown as ExpoRouter; + + const wrapped1 = wrapExpoRouter(router); + const wrappedPushAfterFirst = wrapped1.push; + const wrapped2 = wrapExpoRouter(wrapped1); + + expect(wrapped2.push).toBe(wrappedPushAfterFirst); + + wrapped2.push?.('/x'); + expect(mockStartInactiveSpan).toHaveBeenCalledTimes(1); + }); + + it('returns the router unchanged if no known methods exist', () => { + const router = { somethingElse: jest.fn() } as unknown as ExpoRouter; + const wrapped = wrapExpoRouter(router); + expect(wrapped).toBe(router); }); it('binds prefetch method correctly to maintain context', () => { From f0a454ab2d8495e2ab3f5143d49c7c08632b87e5 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 28 May 2026 14:21:26 +0200 Subject: [PATCH 2/8] fix(tracing): Guard Expo Router navigation breadcrumbs and spans behind sendDefaultPii MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expo Router `push`/`replace`/`navigate`/`back`/`dismiss` (and `prefetch`) wrappers previously emitted the raw `href` and route `params` on both the navigation breadcrumb and span attributes regardless of the client options. Dynamic segments like `/users/123` and parameter values such as `{ id: '123' }` can carry user identifiers and other PII, so the existing `reactnavigation.ts` integration already gates them behind `sendDefaultPii`. The new Expo Router wrappers should follow the same contract. This change: - Reads `sendDefaultPii` from the client options and only attaches `href` (raw string or stringified object form) and `params` to the breadcrumb `data` and span attributes when it is enabled. - Keeps the non-PII fields — `method`, `pathname` (route template), and `route.name` — unconditional so navigations remain visible and groupable in Sentry without leaking user data. - Applies the same guard to the `prefetch` span's `route.href` attribute, which was newly added in this PR. - Adds dedicated `sendDefaultPii` test cases and updates the existing default-off assertions for all wrapped methods. - Adds a changelog entry for #6221. --- CHANGELOG.md | 1 + packages/core/src/js/tracing/expoRouter.ts | 20 ++++++-- packages/core/test/tracing/expoRouter.test.ts | 50 +++++++++++++++---- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dadd215ab6..aa5a57b001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) +- 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)) ### Fixes diff --git a/packages/core/src/js/tracing/expoRouter.ts b/packages/core/src/js/tracing/expoRouter.ts index dfb10f86fd..50e88c4705 100644 --- a/packages/core/src/js/tracing/expoRouter.ts +++ b/packages/core/src/js/tracing/expoRouter.ts @@ -1,4 +1,4 @@ -import { addBreadcrumb, 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_NAVIGATION, SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin'; import { setPendingExpoRouterNavigation } from './pendingExpoRouterNavigation'; @@ -88,8 +88,10 @@ function wrapPrefetch(router: T): void { name: `Prefetch ${routeName}`, attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, - 'route.href': typeof href === 'string' ? href : JSON.stringify(href), 'route.name': routeName, + // `route.href` may contain dynamic segment values (e.g. `/users/123`) + // or stringified `params`, so it is gated behind `sendDefaultPii`. + ...(isSendDefaultPiiEnabled() ? { 'route.href': serializeHref(href) } : undefined), }, }); @@ -128,6 +130,7 @@ function wrapNavigationMethod( ): (...args: unknown[]) => unknown { return (...args: unknown[]) => { const parsed = parseMethodArgs(method, args); + const sendPii = isSendDefaultPiiEnabled(); addBreadcrumb({ category: 'navigation', @@ -135,9 +138,12 @@ function wrapNavigationMethod( 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), + // `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), }, }); @@ -154,8 +160,8 @@ function wrapNavigationMethod( attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, 'navigation.method': method, - ...(parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined), ...(parsed.routeName ? { 'route.name': parsed.routeName } : undefined), + ...(sendPii && parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined), }, }); @@ -198,3 +204,7 @@ function parseHref(href: ExpoRouterHref | undefined): ParsedHref { function serializeHref(href: unknown): string { return typeof href === 'string' ? href : JSON.stringify(href); } + +function isSendDefaultPiiEnabled(): boolean { + return getClient()?.getOptions()?.sendDefaultPii ?? false; +} diff --git a/packages/core/test/tracing/expoRouter.test.ts b/packages/core/test/tracing/expoRouter.test.ts index bd7e54d375..b2ce80a18e 100644 --- a/packages/core/test/tracing/expoRouter.test.ts +++ b/packages/core/test/tracing/expoRouter.test.ts @@ -12,6 +12,7 @@ import { const mockStartInactiveSpan = jest.fn(); const mockAddBreadcrumb = jest.fn(); +let mockSendDefaultPii = false; jest.mock('@sentry/core', () => { const actual = jest.requireActual('@sentry/core'); @@ -19,6 +20,7 @@ jest.mock('@sentry/core', () => { ...actual, startInactiveSpan: (...args: unknown[]) => mockStartInactiveSpan(...args), addBreadcrumb: (...args: unknown[]) => mockAddBreadcrumb(...args), + getClient: () => ({ getOptions: () => ({ sendDefaultPii: mockSendDefaultPii }) }), }; }); @@ -37,6 +39,7 @@ describe('wrapExpoRouter', () => { setAttribute: jest.fn(), }; mockStartInactiveSpan.mockReturnValue(mockSpan); + mockSendDefaultPii = false; clearPendingExpoRouterNavigation(); }); @@ -51,7 +54,7 @@ describe('wrapExpoRouter', () => { expect(wrapped).toBe(router); }); - it('wraps prefetch method and creates a span with string href', () => { + it('wraps prefetch method and creates a span with string href (no PII attributes by default)', () => { const mockPrefetch = jest.fn(); const router = { prefetch: mockPrefetch } as ExpoRouter; @@ -63,7 +66,6 @@ describe('wrapExpoRouter', () => { name: 'Prefetch /details/123', attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, - 'route.href': '/details/123', 'route.name': '/details/123', }, }); @@ -73,7 +75,8 @@ describe('wrapExpoRouter', () => { expect(mockSpan.end).toHaveBeenCalled(); }); - it('wraps prefetch method and creates a span with object href', () => { + it('includes `route.href` on prefetch span only when sendDefaultPii is enabled', () => { + mockSendDefaultPii = true; const mockPrefetch = jest.fn(); const router = { prefetch: mockPrefetch } as ExpoRouter; const href = { pathname: '/profile', params: { id: '456' } }; @@ -86,8 +89,8 @@ describe('wrapExpoRouter', () => { name: 'Prefetch /profile', attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, - 'route.href': JSON.stringify(href), 'route.name': '/profile', + 'route.href': JSON.stringify(href), }, }); @@ -109,7 +112,6 @@ describe('wrapExpoRouter', () => { name: 'Prefetch unknown', attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, - 'route.href': JSON.stringify(href), 'route.name': 'unknown', }, }); @@ -185,7 +187,7 @@ describe('wrapExpoRouter', () => { }); describe.each(['push', 'replace', 'navigate'] as const)('wraps %s', method => { - it(`creates a span and breadcrumb with string href for ${method}`, () => { + it(`creates a PII-free span and breadcrumb with string href for ${method}`, () => { const original = jest.fn(); const router = { [method]: original } as unknown as ExpoRouter; @@ -199,7 +201,6 @@ describe('wrapExpoRouter', () => { attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, 'navigation.method': method, - 'route.href': '/details/123', 'route.name': '/details/123', }, }); @@ -209,7 +210,6 @@ describe('wrapExpoRouter', () => { message: `Expo Router ${method} to /details/123`, data: { method, - href: '/details/123', pathname: '/details/123', }, }); @@ -217,7 +217,7 @@ describe('wrapExpoRouter', () => { expect(mockSpan.end).toHaveBeenCalled(); }); - it(`creates a span and breadcrumb with object href for ${method}`, () => { + it(`creates a PII-free span and breadcrumb with object href for ${method}`, () => { const original = jest.fn(); const router = { [method]: original } as unknown as ExpoRouter; const href = { pathname: '/profile', params: { id: '456' } }; @@ -232,7 +232,6 @@ describe('wrapExpoRouter', () => { attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, 'navigation.method': method, - 'route.href': JSON.stringify(href), 'route.name': '/profile', }, }); @@ -242,8 +241,37 @@ describe('wrapExpoRouter', () => { message: `Expo Router ${method} to /profile`, data: { method, - href: JSON.stringify(href), pathname: '/profile', + }, + }); + }); + + it(`includes href and params in ${method} span/breadcrumb only when sendDefaultPii is enabled`, () => { + mockSendDefaultPii = true; + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + const href = { pathname: '/profile', params: { id: '456' } }; + + wrapExpoRouter(router)[method]?.(href); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: `navigation.${method}`, + name: `Navigation ${method} to /profile`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': method, + 'route.name': '/profile', + 'route.href': JSON.stringify(href), + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: `Expo Router ${method} to /profile`, + data: { + method, + pathname: '/profile', + href: JSON.stringify(href), params: { id: '456' }, }, }); From 394d799006e57b0b5cfdb05b4950d997f6fbf026 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 1 Jun 2026 11:13:46 +0200 Subject: [PATCH 3/8] Fixes --- packages/core/src/js/tracing/expoRouter.ts | 66 ++++++++++--- packages/core/test/tracing/expoRouter.test.ts | 98 +++++++++++++++++-- 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/packages/core/src/js/tracing/expoRouter.ts b/packages/core/src/js/tracing/expoRouter.ts index 50e88c4705..bf71115bd7 100644 --- a/packages/core/src/js/tracing/expoRouter.ts +++ b/packages/core/src/js/tracing/expoRouter.ts @@ -1,7 +1,7 @@ import { addBreadcrumb, getClient, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startInactiveSpan } from '@sentry/core'; import { SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH } from './origin'; -import { setPendingExpoRouterNavigation } from './pendingExpoRouterNavigation'; +import { clearPendingExpoRouterNavigation, setPendingExpoRouterNavigation } from './pendingExpoRouterNavigation'; type ExpoRouterHref = string | { pathname?: string; params?: Record }; @@ -21,9 +21,19 @@ 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; + /** + * 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; } /** @@ -81,17 +91,22 @@ function wrapPrefetch(router: T): void { const originalPrefetch = router.prefetch!.bind(router); router.prefetch = ((href: ExpoRouterHref) => { - const { routeName } = parseHref(href); + 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.name': routeName, - // `route.href` may contain dynamic segment values (e.g. `/users/123`) + 'route.name': safeRouteName, + // `route.href` may contain dynamic segment values (e.g. `/users/42`) // or stringified `params`, so it is gated behind `sendDefaultPii`. - ...(isSendDefaultPiiEnabled() ? { 'route.href': serializeHref(href) } : undefined), + ...(sendPii ? { 'route.href': serializeHref(href) } : undefined), }, }); @@ -131,14 +146,19 @@ function wrapNavigationMethod( 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; addBreadcrumb({ category: 'navigation', type: 'navigation', - message: `Expo Router ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`, + message: `Expo Router ${method}${safePathname ? ` to ${safePathname}` : ''}`, data: { method, - ...(parsed.pathname ? { pathname: parsed.pathname } : undefined), + ...(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. @@ -156,11 +176,11 @@ function wrapNavigationMethod( const span = startInactiveSpan({ op: `navigation.${method}`, - name: `Navigation ${method}${parsed.pathname ? ` to ${parsed.pathname}` : ''}`, + name: `Navigation ${method}${safePathname ? ` to ${safePathname}` : ''}`, attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, 'navigation.method': method, - ...(parsed.routeName ? { 'route.name': parsed.routeName } : undefined), + ...(safeRouteName ? { 'route.name': safeRouteName } : undefined), ...(sendPii && parsed.href !== undefined ? { 'route.href': serializeHref(parsed.href) } : undefined), }, }); @@ -171,6 +191,9 @@ function wrapNavigationMethod( 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(); throw error; @@ -180,14 +203,14 @@ function wrapNavigationMethod( function parseMethodArgs(method: NavigationMethod, args: unknown[]): ParsedHref { if (method === 'back' || method === 'dismiss') { - return { routeName: method }; + 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 }; + return { href, routeName: href, pathname: href, concretePathname: true }; } if (href && typeof href === 'object') { const pathname = typeof href.pathname === 'string' ? href.pathname : undefined; @@ -196,13 +219,28 @@ function parseHref(href: ExpoRouterHref | undefined): ParsedHref { routeName: pathname ?? 'unknown', pathname, params: href.params, + concretePathname: false, }; } - return { routeName: 'unknown' }; + 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 { - return typeof href === 'string' ? href : JSON.stringify(href); + if (typeof href === 'string') { + return href; + } + try { + return JSON.stringify(href); + } catch { + return '[unserializable href]'; + } } function isSendDefaultPiiEnabled(): boolean { diff --git a/packages/core/test/tracing/expoRouter.test.ts b/packages/core/test/tracing/expoRouter.test.ts index b2ce80a18e..15287f7564 100644 --- a/packages/core/test/tracing/expoRouter.test.ts +++ b/packages/core/test/tracing/expoRouter.test.ts @@ -54,19 +54,20 @@ describe('wrapExpoRouter', () => { expect(wrapped).toBe(router); }); - it('wraps prefetch method and creates a span with string href (no PII attributes by default)', () => { + it('wraps prefetch with string href and sanitizes route.name when sendDefaultPii is off', () => { const mockPrefetch = jest.fn(); const router = { prefetch: mockPrefetch } as ExpoRouter; const wrapped = wrapExpoRouter(router); wrapped.prefetch?.('/details/123'); + // Concrete string hrefs may contain user identifiers; route.name must not leak. expect(mockStartInactiveSpan).toHaveBeenCalledWith({ op: 'navigation.prefetch', - name: 'Prefetch /details/123', + name: 'Prefetch unknown', attributes: { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, - 'route.name': '/details/123', + 'route.name': 'unknown', }, }); @@ -75,6 +76,40 @@ describe('wrapExpoRouter', () => { expect(mockSpan.end).toHaveBeenCalled(); }); + it('wraps prefetch with string href and uses the literal route.name when sendDefaultPii is on', () => { + mockSendDefaultPii = true; + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + wrapExpoRouter(router).prefetch?.('/details/123'); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.prefetch', + name: 'Prefetch /details/123', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + 'route.name': '/details/123', + 'route.href': '/details/123', + }, + }); + }); + + it('wraps prefetch with templated object href and includes route.name unconditionally', () => { + const mockPrefetch = jest.fn(); + const router = { prefetch: mockPrefetch } as ExpoRouter; + + wrapExpoRouter(router).prefetch?.({ pathname: '/profile' }); + + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: 'navigation.prefetch', + name: 'Prefetch /profile', + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_PREFETCH, + 'route.name': '/profile', + }, + }); + }); + it('includes `route.href` on prefetch span only when sendDefaultPii is enabled', () => { mockSendDefaultPii = true; const mockPrefetch = jest.fn(); @@ -187,7 +222,7 @@ describe('wrapExpoRouter', () => { }); describe.each(['push', 'replace', 'navigate'] as const)('wraps %s', method => { - it(`creates a PII-free span and breadcrumb with string href for ${method}`, () => { + it(`strips concrete pathname/route.name for ${method} string hrefs when sendDefaultPii is off`, () => { const original = jest.fn(); const router = { [method]: original } as unknown as ExpoRouter; @@ -195,6 +230,33 @@ describe('wrapExpoRouter', () => { wrapped[method]?.('/details/123'); expect(original).toHaveBeenCalledWith('/details/123'); + // route.name falls back to the method label; no pathname leaks. + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ + op: `navigation.${method}`, + name: `Navigation ${method}`, + attributes: { + 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, + 'navigation.method': method, + 'route.name': method, + }, + }); + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + category: 'navigation', + type: 'navigation', + message: `Expo Router ${method}`, + data: { method }, + }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); + expect(mockSpan.end).toHaveBeenCalled(); + }); + + it(`includes concrete pathname/route.name for ${method} string hrefs when sendDefaultPii is on`, () => { + mockSendDefaultPii = true; + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + + wrapExpoRouter(router)[method]?.('/details/123'); + expect(mockStartInactiveSpan).toHaveBeenCalledWith({ op: `navigation.${method}`, name: `Navigation ${method} to /details/123`, @@ -202,6 +264,7 @@ describe('wrapExpoRouter', () => { 'sentry.origin': SPAN_ORIGIN_AUTO_EXPO_ROUTER_NAVIGATION, 'navigation.method': method, 'route.name': '/details/123', + 'route.href': '/details/123', }, }); expect(mockAddBreadcrumb).toHaveBeenCalledWith({ @@ -211,10 +274,9 @@ describe('wrapExpoRouter', () => { data: { method, pathname: '/details/123', + href: '/details/123', }, }); - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_OK }); - expect(mockSpan.end).toHaveBeenCalled(); }); it(`creates a PII-free span and breadcrumb with object href for ${method}`, () => { @@ -308,6 +370,30 @@ describe('wrapExpoRouter', () => { }); expect(mockSpan.end).toHaveBeenCalled(); }); + + it(`clears the pending navigation when ${method} throws, so the next nav is not stale-tagged`, () => { + const original = jest.fn(() => { + throw new Error('boom'); + }); + const router = { [method]: original } as unknown as ExpoRouter; + + expect(() => wrapExpoRouter(router)[method]?.('/x')).toThrow('boom'); + expect(consumePendingExpoRouterNavigation()).toBeUndefined(); + }); + + it(`does not abort ${method} when serializing an unserializable href fails`, () => { + mockSendDefaultPii = true; + const original = jest.fn(); + const router = { [method]: original } as unknown as ExpoRouter; + const circular: Record = { pathname: '/profile' }; + circular.self = circular; + const href = circular as { pathname?: string; params?: Record }; + + expect(() => wrapExpoRouter(router)[method]?.(href)).not.toThrow(); + expect(original).toHaveBeenCalledWith(href); + const startCallAttrs = mockStartInactiveSpan.mock.calls[0]![0].attributes; + expect(startCallAttrs['route.href']).toBe('[unserializable href]'); + }); }); it('wraps back() with no args', () => { From 19f8627679767ffd2ca161c48e546949db12732a Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 1 Jun 2026 11:50:48 +0200 Subject: [PATCH 4/8] Changelog fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5a57b001..b05f2c3523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### 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)) ### Fixes @@ -37,7 +38,6 @@ - Add `disableAutoUpload` option to Expo plugin to disable source map and debug symbol uploads ([#6195](https://github.com/getsentry/sentry-react-native/pull/6195)) - Expose `pauseAppHangTracking` and `resumeAppHangTracking` APIs on iOS ([#6192](https://github.com/getsentry/sentry-react-native/pull/6192)) - Better route and dynamic param extraction for Expo Router ([#6197](https://github.com/getsentry/sentry-react-native/pull/6197)) -- 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)) ### Fixes From febd51c3e15a34c3338dc8efd1915318fb60bfdc Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 2 Jun 2026 11:07:32 +0200 Subject: [PATCH 5/8] test(tracing): Cover pendingExpoRouterNavigation hand-off in reactNavigationIntegration The Expo Router wrapper sets a pending navigation value that the React Navigation idle-span listener is supposed to consume and apply to the resulting transaction as the `navigation.method` attribute. Previously only the setter side (in expoRouter.test.ts) was tested; the consumer side had no coverage, so a regression in reactnavigation.ts would have gone undetected. Add three integration-level tests against the live reactNavigationIntegration: - the next navigation transaction is tagged with `navigation.method` matching the pending Expo Router call - the pending value is consumed exactly once and does not leak into the following navigation - no `navigation.method` attribute is set when nothing is pending Surfaced by Warden review (4UA-DHD). --- .../core/test/tracing/reactnavigation.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 612720c330..502bad8560 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -14,6 +14,10 @@ import type { NavigationRoute } from '../../src/js/tracing/reactnavigation'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin'; +import { + clearPendingExpoRouterNavigation, + setPendingExpoRouterNavigation, +} from '../../src/js/tracing/pendingExpoRouterNavigation'; import { extractDynamicRouteParams, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; import { SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY, @@ -261,6 +265,59 @@ describe('ReactNavigationInstrumentation', () => { ); }); + describe('pendingExpoRouterNavigation hand-off', () => { + afterEach(() => { + clearPendingExpoRouterNavigation(); + }); + + test('tags the next navigation transaction with `navigation.method` from the pending Expo Router call', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + setPendingExpoRouterNavigation({ + method: 'push', + href: { pathname: '/profile', params: { id: '7' } }, + pathname: '/profile', + params: { id: '7' }, + }); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + await client.flush(); + + const data = client.event?.contexts?.trace?.data ?? {}; + expect(data['navigation.method']).toBe('push'); + }); + + test('consumes the pending value exactly once and does not leak into the following navigation', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + setPendingExpoRouterNavigation({ method: 'replace' }); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBe('replace'); + + // Next navigation without a pending value — must not carry over the previous method. + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBeUndefined(); + }); + + test('does not set `navigation.method` when no Expo Router call is pending', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBeUndefined(); + }); + }); + test('transaction has correct metadata after multiple navigations', async () => { setupTestClient(); jest.runOnlyPendingTimers(); // Flush the init transaction From 499ab390d486ed9382d810bd4e1bb8c55c0caa7f Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 2 Jun 2026 12:37:36 +0200 Subject: [PATCH 6/8] fix(tracing): Drain pendingExpoRouterNavigation even when the idle-span listener short-circuits `startIdleNavigationSpan` has several early-return paths (dispatched noops, PRELOAD with prefetch tracking, SET_PARAMS, drawer actions, and `useDispatchedActionData` calls without a route name in the payload). Previously `consumePendingExpoRouterNavigation()` lived after those returns, so a wrapped Expo Router call that ended up taking one of the short-circuit paths left its pending value in the module-level slot. The next, unrelated navigation then incorrectly inherited the wrong `navigation.method` attribute. Move the `consumePendingExpoRouterNavigation()` call to the very top of the listener so the value is always drained on the listener invocation triggered by the wrapper. Apply it to the span only if we actually create one. Add a regression test covering the dispatched-action-without-payload early-return path; the test fails on the previous code and passes with the fix. Surfaced by sentry-bot and cursor-bot reviews. --- .../core/src/js/tracing/reactnavigation.ts | 8 ++++++- .../core/test/tracing/reactnavigation.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 36b2e731e8..9aefde5ebf 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -351,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', @@ -453,7 +460,6 @@ export const reactNavigationIntegration = ({ 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); } diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 502bad8560..c0e1f85c26 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -316,6 +316,30 @@ describe('ReactNavigationInstrumentation', () => { expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBeUndefined(); }); + + test('drains the pending value even when the listener short-circuits (no leak onto the next nav)', async () => { + // Reproduces the bug flagged by sentry-bot/cursor-bot: with + // `useDispatchedActionData: true`, a dispatched action without a route + // name in its payload makes `startIdleNavigationSpan` early-return before + // creating a span. The pending Expo Router value must still be drained, + // otherwise the *next* unrelated navigation inherits the wrong + // `navigation.method`. + setupTestClient({ useDispatchedActionData: true }); + jest.runOnlyPendingTimers(); // Flush the init transaction + + // Simulate `router.back()` (or any wrapped method) whose underlying + // dispatch carries no route name in payload — the listener short-circuits. + setPendingExpoRouterNavigation({ method: 'back' }); + mockNavigation.emitCancelledNavigation(); + jest.runOnlyPendingTimers(); + + // Next, unrelated navigation — must NOT inherit `navigation.method: 'back'`. + mockNavigation.navigateToNewScreenWithPayload(); + jest.runOnlyPendingTimers(); + await client.flush(); + + expect(client.event?.contexts?.trace?.data?.['navigation.method']).toBeUndefined(); + }); }); test('transaction has correct metadata after multiple navigations', async () => { From 7dabf7eba19e725b6b8bb7413f9f7b6c8ef3740e Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 2 Jun 2026 13:10:33 +0200 Subject: [PATCH 7/8] fix(tracing): Use spec-compliant `auto.navigation.expo_router` origin Per the Sentry trace-origin spec (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/), the `sentry.origin` value uses the format `type.category.integration-name`, where `category` is a span operation category such as `navigation`. The new origin for wrapped Expo Router push/replace/navigate/back/dismiss calls was `auto.expo_router.navigation`, which swaps the category and integration name. Align it with the existing `auto.navigation.react_navigation` and `auto.navigation.react_native_navigation` constants so server-side filtering and downstream tools recognize these as navigation spans. The pre-existing `auto.expo_router.prefetch` origin has the same inverted ordering but is left unchanged here \u2014 it is already shipped and changing it would break consumers that filter by it. Surfaced by cursor-bot review. --- packages/core/src/js/tracing/origin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/origin.ts b/packages/core/src/js/tracing/origin.ts index 7e2863485a..4ecaca04f3 100644 --- a/packages/core/src/js/tracing/origin.ts +++ b/packages/core/src/js/tracing/origin.ts @@ -12,6 +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_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'; From 30484d28cd3834a16ef069c10ad07de6ba51ecdf Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 2 Jun 2026 13:55:19 +0200 Subject: [PATCH 8/8] docs(changelog): Note Expo Router prefetch PII-gating behavior change The PR also tightened PII handling for the pre-existing `prefetch` instrumentation: when `sendDefaultPii` is off (the default), `route.href` is dropped and concrete pathnames derived from string hrefs (e.g. `/users/42`) are replaced with `'unknown'` for `route.name`. Templated object hrefs are unaffected. Document this so users with dashboards or alerts filtering on prefetch `route.name`/`route.href` notice the change. Surfaced by cursor-bot review. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05f2c3523..ec38ed097c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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