From 17e8da0f7fcfe05e50c521491bee1630944c491d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 21 May 2026 14:33:07 +0200 Subject: [PATCH 1/2] Correct route and dynamic param extraction for Expo Router (#6157) --- .../src/js/tracing/expoRouterIntegration.ts | 48 ++++++ .../core/src/js/tracing/reactnavigation.ts | 59 +++++++- .../tracing/expoRouterIntegration.test.ts | 84 ++++++++++ .../core/test/tracing/reactnavigation.test.ts | 143 ++++++++++++++++++ 4 files changed, 330 insertions(+), 4 deletions(-) diff --git a/packages/core/src/js/tracing/expoRouterIntegration.ts b/packages/core/src/js/tracing/expoRouterIntegration.ts index af1f90befc..4f777ef14a 100644 --- a/packages/core/src/js/tracing/expoRouterIntegration.ts +++ b/packages/core/src/js/tracing/expoRouterIntegration.ts @@ -2,6 +2,8 @@ import type { Client, Integration } from '@sentry/core'; import { debug } from '@sentry/core'; +import type { RouteOverride } from './reactnavigation'; + import { getReactNavigationIntegration, reactNavigationIntegration } from './reactnavigation'; export const INTEGRATION_NAME = 'ExpoRouter'; @@ -13,8 +15,17 @@ interface ExpoRouterNavigationRef { current: unknown | null; } +interface ExpoRouterUrlObject { + unstable_globalHref?: string; + pathname?: string; + pathnameWithParams?: string; + params?: Record; + segments?: string[]; +} + interface ExpoRouterStore { navigationRef?: ExpoRouterNavigationRef; + getRouteInfo?: () => ExpoRouterUrlObject; } type ExpoRouterIntegrationOptions = Parameters[0]; @@ -57,6 +68,8 @@ export const expoRouterIntegration = (options: ExpoRouterIntegrationOptions = {} const navigationRef = store.navigationRef; + reactNavigation._setRouteOverrideProvider?.(() => buildExpoRouterRouteOverride(store)); + if (navigationRef.current) { reactNavigation.registerNavigationContainer(navigationRef); return; @@ -106,3 +119,38 @@ function tryGetExpoRouterStore(): ExpoRouterStore | null { return null; } } + +/** + * Builds a templated pathname from Expo Router's `segments` + * + * Examples: + * ['(tabs)', 'profile', '[id]'] -> '/profile/[id]' + * ['posts', '[...slug]'] -> '/posts/[...slug]' + * [] -> '/' + */ +export function buildExpoRouterTemplatedPath(segments: string[] | undefined): string { + if (!segments || segments.length === 0) { + return '/'; + } + const filtered = segments.filter(s => !(s.startsWith('(') && s.endsWith(')'))); + return filtered.length === 0 ? '/' : `/${filtered.join('/')}`; +} + +function buildExpoRouterRouteOverride(store: ExpoRouterStore): RouteOverride | undefined { + let info: ExpoRouterUrlObject | undefined; + try { + info = store.getRouteInfo?.(); + } catch { + return undefined; + } + if (!info) { + return undefined; + } + + const templatedPath = buildExpoRouterTemplatedPath(info.segments); + return { + templatedPath, + concreteUrl: info.pathnameWithParams ?? info.pathname, + params: info.params, + }; +} diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 652936cccc..d732edb5c6 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -87,6 +87,25 @@ export function extractDynamicRouteParams( return Object.keys(result).length > 0 ? result : undefined; } +/** + * Optional route override provided by another integration (e.g. Expo Router). + * + * When supplied, the route name and related attributes are derived from this + * override instead of React Navigation's `getCurrentRoute().name`, so we can + * report meaningful templated paths (e.g. `/profile/[id]`) instead of file + * names like `index` or `(tabs)`. + */ +export interface RouteOverride { + // templated pathname such as `/profile/[id]`. Used as the span name and `route.path` attribute + templatedPath: string; + // concrete URL with resolved values, e.g. `/profile/123?foo=bar` + concreteUrl?: string; + // merged route params (path + query) + params?: Record; +} + +export type RouteOverrideProvider = () => RouteOverride | undefined; + /** * Builds a full path from the navigation state by traversing nested navigators. * For example, with nested navigators: "Home/Settings/Profile" @@ -193,9 +212,15 @@ export const reactNavigationIntegration = ({ * @param navigationContainerRef Ref to a `NavigationContainer` */ registerNavigationContainer: (navigationContainerRef: unknown) => void; + /** + * Internal API: allow another integration (for example, Expo Router integration) to + * supply the canonical route info on every state change. Use `undefined` to clear. + */ + _setRouteOverrideProvider: (provider: RouteOverrideProvider | undefined) => void; options: ReactNavigationIntegrationOptions; } => { let navigationContainer: NavigationContainer | undefined; + let routeOverrideProvider: RouteOverrideProvider | undefined; let tracing: ReactNativeTracingIntegration | undefined; let idleSpanOptions: Parameters[1] = defaultIdleOptions; @@ -474,9 +499,28 @@ export const reactNavigationIntegration = ({ const routeHasBeenSeen = recentRouteKeys.includes(route.key); - // Get the full navigation path for nested navigators + // Resolve route name. Order of preference: + // 1. Route override provider (e.g. Expo Router templated path) + // 2. Full path joined from React Navigation state + // 3. React Navigation's leaf route name let routeName = route.name; - if (useFullPathsForNavigationRoutes) { + let routePath: string | undefined; + let routeUrl: string | undefined; + let routeParams: Record | undefined = route.params; + + let override: RouteOverride | undefined; + try { + override = routeOverrideProvider?.(); + } catch (e) { + debug.warn(`${INTEGRATION_NAME} Route override provider threw, falling back to React Navigation route.`, e); + } + + if (override?.templatedPath) { + routeName = override.templatedPath; + routePath = override.templatedPath; + routeUrl = override.concreteUrl; + routeParams = override.params ?? route.params; + } else if (useFullPathsForNavigationRoutes) { const navigationState = navigationContainer.getState(); routeName = getPathFromState(navigationState) || route.name; } @@ -493,7 +537,9 @@ export const reactNavigationIntegration = ({ latestNavigationSpan.setAttributes({ 'route.name': routeName, 'route.key': route.key, - ...(sendDefaultPii ? extractDynamicRouteParams(routeName, route.params) : undefined), + ...(routePath ? { 'route.path': routePath } : undefined), + ...(sendDefaultPii && routeUrl ? { 'route.url': routeUrl } : undefined), + ...(sendDefaultPii ? extractDynamicRouteParams(routeName, routeParams) : undefined), 'route.has_been_seen': routeHasBeenSeen, 'previous_route.name': previousRoute?.name, 'previous_route.key': previousRoute?.key, @@ -517,7 +563,7 @@ export const reactNavigationIntegration = ({ tracing?.setCurrentRoute(routeName); pushRecentRouteKey(route.key); - if (useFullPathsForNavigationRoutes) { + if (override?.templatedPath || useFullPathsForNavigationRoutes) { latestRoute = { ...route, name: routeName }; } else { latestRoute = route; @@ -557,10 +603,15 @@ export const reactNavigationIntegration = ({ } }; + const _setRouteOverrideProvider = (provider: RouteOverrideProvider | undefined): void => { + routeOverrideProvider = provider; + }; + return { name: INTEGRATION_NAME, afterAllSetup, registerNavigationContainer, + _setRouteOverrideProvider, options: { routeChangeTimeoutMs, enableTimeToInitialDisplay, diff --git a/packages/core/test/tracing/expoRouterIntegration.test.ts b/packages/core/test/tracing/expoRouterIntegration.test.ts index d83548912d..13d1d7cc3a 100644 --- a/packages/core/test/tracing/expoRouterIntegration.test.ts +++ b/packages/core/test/tracing/expoRouterIntegration.test.ts @@ -199,6 +199,90 @@ describe('expoRouterIntegration', () => { }); }); + describe('route override provider', () => { + it('registers a route override provider on reactNavigation derived from store.getRouteInfo', () => { + const container = createMockNavigationContainer(); + const getRouteInfo = jest.fn(() => ({ + segments: ['(tabs)', 'profile', '[id]'], + params: { id: '123', utm_source: 'email' }, + pathnameWithParams: '/profile/123?utm_source=email', + pathname: '/profile/123', + })); + jest.doMock( + EXPO_ROUTER_STORE_MODULE, + () => ({ + store: { navigationRef: { current: container }, getRouteInfo }, + }), + { virtual: true }, + ); + + const { expoRouterIntegration: integration } = require('../../src/js/tracing/expoRouterIntegration'); + const { client, addIntegration } = createMockClient(); + + const integ = integration(); + integ.afterAllSetup?.(client); + + const reactNavigation = addIntegration.mock.calls[0][0]; + expect(typeof reactNavigation._setRouteOverrideProvider).toBe('function'); + + // The integration should have installed a provider on reactNavigation. We can + // grab it by spying on the setter. + const setProvider = reactNavigation._setRouteOverrideProvider as jest.Mock | ((p: unknown) => void); + // Since we don't have a spy, install one and re-run setup to capture the provider. + jest.resetModules(); + jest.doMock( + EXPO_ROUTER_STORE_MODULE, + () => ({ + store: { navigationRef: { current: container }, getRouteInfo }, + }), + { virtual: true }, + ); + const captured: { provider?: () => unknown } = {}; + const { reactNavigationIntegration: actualRn } = require('../../src/js/tracing/reactnavigation'); + const realRn = actualRn(); + jest.spyOn(realRn, '_setRouteOverrideProvider').mockImplementation((p: unknown) => { + captured.provider = p as () => unknown; + }); + const { client: client2, addIntegration: add2, getIntegrationByName: getByName2 } = createMockClient(); + getByName2.mockImplementation((name: string) => + name === REACT_NAVIGATION_INTEGRATION_NAME ? realRn : undefined, + ); + const { expoRouterIntegration: integ2 } = require('../../src/js/tracing/expoRouterIntegration'); + integ2().afterAllSetup?.(client2); + expect(add2).not.toHaveBeenCalled(); + + const result = captured.provider?.() as { + templatedPath: string; + concreteUrl?: string; + params?: Record; + }; + expect(result).toEqual({ + templatedPath: '/profile/[id]', + concreteUrl: '/profile/123?utm_source=email', + params: { id: '123', utm_source: 'email' }, + }); + expect(getRouteInfo).toHaveBeenCalled(); + // unused — silence ts-noemit warnings for captured `setProvider` + void setProvider; + }); + }); + + describe('buildExpoRouterTemplatedPath', () => { + it('strips group segments and joins with /', () => { + const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration'); + expect(buildExpoRouterTemplatedPath(['(tabs)', 'profile', '[id]'])).toBe('/profile/[id]'); + expect(buildExpoRouterTemplatedPath(['posts', '[...slug]'])).toBe('/posts/[...slug]'); + expect(buildExpoRouterTemplatedPath(['(auth)', '(group)', 'login'])).toBe('/login'); + }); + + it('returns / for empty or all-group segments (index routes)', () => { + const { buildExpoRouterTemplatedPath } = require('../../src/js/tracing/expoRouterIntegration'); + expect(buildExpoRouterTemplatedPath([])).toBe('/'); + expect(buildExpoRouterTemplatedPath(undefined)).toBe('/'); + expect(buildExpoRouterTemplatedPath(['(tabs)'])).toBe('/'); + }); + }); + describe('cleanup', () => { it('clears the polling timer when the client closes', () => { const navigationRef: { current: unknown } = { current: null }; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index aef0614df7..0bf59db43b 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1498,6 +1498,149 @@ describe('ReactNavigationInstrumentation', () => { }); }); + describe('route override provider (Expo Router integration hook)', () => { + function setupWithOverride(setupOptions: { sendDefaultPii?: boolean } = {}) { + const rNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200 }); + mockNavigation = createMockNavigationAndAttachTo(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, reactNativeTracingIntegration()], + enableAppStartTracking: false, + sendDefaultPii: setupOptions.sendDefaultPii, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + return rNavigation; + } + + it('uses templated path as route.name and sets route.path; omits url and params without PII', async () => { + const rNavigation = setupWithOverride({ sendDefaultPii: false }); + jest.runOnlyPendingTimers(); + + rNavigation._setRouteOverrideProvider(() => ({ + templatedPath: '/profile/[id]', + concreteUrl: '/profile/123?utm_source=email', + params: { id: '123', utm_source: 'email' }, + })); + + mockNavigation.navigateToDynamicRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(client.event?.transaction).toBe('/profile/[id]'); + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/profile/[id]'); + expect(traceData['route.path']).toBe('/profile/[id]'); + expect(traceData['route.url']).toBeUndefined(); + expect(traceData['route.params.id']).toBeUndefined(); + expect(traceData['route.params.utm_source']).toBeUndefined(); + }); + + it('exposes route.url and only path params (not query) under sendDefaultPii', async () => { + const rNavigation = setupWithOverride({ sendDefaultPii: true }); + jest.runOnlyPendingTimers(); + + rNavigation._setRouteOverrideProvider(() => ({ + templatedPath: '/profile/[id]', + concreteUrl: '/profile/123?utm_source=email', + params: { id: '123', utm_source: 'email' }, + })); + + mockNavigation.navigateToDynamicRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/profile/[id]'); + expect(traceData['route.path']).toBe('/profile/[id]'); + expect(traceData['route.url']).toBe('/profile/123?utm_source=email'); + expect(traceData['route.params.id']).toBe('123'); + expect(traceData['route.params.utm_source']).toBeUndefined(); + }); + + it('extracts catch-all [...slug] params from the templated path', async () => { + const rNavigation = setupWithOverride({ sendDefaultPii: true }); + jest.runOnlyPendingTimers(); + + rNavigation._setRouteOverrideProvider(() => ({ + templatedPath: '/posts/[...slug]', + concreteUrl: '/posts/tech/react-native', + params: { slug: ['tech', 'react-native'] }, + })); + + mockNavigation.navigateToCatchAllRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/posts/[...slug]'); + expect(traceData['route.path']).toBe('/posts/[...slug]'); + expect(traceData['route.params.slug']).toBe('tech/react-native'); + }); + + it('falls back to React Navigation route name when provider returns undefined', async () => { + const rNavigation = setupWithOverride(); + jest.runOnlyPendingTimers(); + + rNavigation._setRouteOverrideProvider(() => undefined); + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('New Screen'); + expect(traceData['route.path']).toBeUndefined(); + expect(traceData['route.url']).toBeUndefined(); + }); + + it('does not throw and falls back when provider throws', async () => { + const rNavigation = setupWithOverride(); + jest.runOnlyPendingTimers(); + + rNavigation._setRouteOverrideProvider(() => { + throw new Error('boom'); + }); + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('New Screen'); + expect(traceData['route.path']).toBeUndefined(); + }); + + it('uses overridden name for previous_route.name on subsequent navigations', async () => { + const rNavigation = setupWithOverride(); + jest.runOnlyPendingTimers(); + + const sequence = [ + { templatedPath: '/profile/[id]', params: { id: '1' } }, + { templatedPath: '/posts/[...slug]', params: { slug: ['a', 'b'] } }, + ]; + let i = 0; + rNavigation._setRouteOverrideProvider(() => sequence[Math.min(i++, sequence.length - 1)]); + + mockNavigation.navigateToDynamicRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + mockNavigation.navigateToCatchAllRoute(); + jest.runOnlyPendingTimers(); + await client.flush(); + + const traceData = client.event?.contexts?.trace?.data as Record; + expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('/posts/[...slug]'); + expect(traceData[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]).toBe('/profile/[id]'); + }); + }); + function setupTestClient( setupOptions: { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; From 7c1452993ef8d132bfd5e68bbff3bdf8f863f3af Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 21 May 2026 14:46:51 +0200 Subject: [PATCH 2/2] Changelog changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b688ba123..e7bdde4486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - 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)) ## 8.12.0