From 6ff50f1b8818f6f7a0ed51bcc07768c8f6a4b5a8 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 27 Nov 2025 15:11:52 +0100 Subject: [PATCH 1/4] Improve Expo Router integrations to indicate full paths instead of just component names --- .../core/src/js/tracing/reactnavigation.ts | 48 ++++++++++++++++--- samples/expo/app/_layout.tsx | 2 +- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index da4232e3fc..7179aab6d4 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -34,6 +34,30 @@ export const INTEGRATION_NAME = 'ReactNavigation'; const NAVIGATION_HISTORY_MAX_SIZE = 200; +/** + * Builds a full path from the navigation state by traversing nested navigators. + * For example, with nested navigators: "Home/Settings/Profile" + */ +function getPathFromState(state?: NavigationState): string | undefined { + if (!state) { + return undefined; + } + + const routeNames: string[] = []; + let currentState: NavigationState | undefined = state; + + while (currentState) { + const index: number = currentState.index ?? 0; + const route: NavigationRoute | undefined = currentState.routes[index]; + if (route?.name) { + routeNames.push(route.name); + } + currentState = route?.state; + } + + return routeNames.length > 0 ? routeNames.join('/') : undefined; +} + interface ReactNavigationIntegrationOptions { /** * How long the instrumentation will wait for the route to mount after a change has been initiated, @@ -319,16 +343,21 @@ export const reactNavigationIntegration = ({ const routeHasBeenSeen = recentRouteKeys.includes(route.key); - navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${route.name} mounted`); + // Get the full navigation path for nested navigators + const navigationState = navigationContainer.getState(); + const fullRoutePath = getPathFromState(navigationState); + const routeName = fullRoutePath || route.name; + + navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`); navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); navigationProcessingSpan?.end(stateChangedTimestamp); navigationProcessingSpan = undefined; if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) { - latestNavigationSpan.updateName(route.name); + latestNavigationSpan.updateName(routeName); } latestNavigationSpan.setAttributes({ - 'route.name': route.name, + 'route.name': routeName, 'route.key': route.key, // TODO: filter PII params instead of dropping them all // 'route.params': {}, @@ -347,14 +376,14 @@ export const reactNavigationIntegration = ({ addBreadcrumb({ category: 'navigation', type: 'navigation', - message: `Navigation to ${route.name}`, + message: `Navigation to ${routeName}`, data: { from: previousRoute?.name, - to: route.name, + to: routeName, }, }); - tracing?.setCurrentRoute(route.name); + tracing?.setCurrentRoute(routeName); pushRecentRouteKey(route.key); latestRoute = route; @@ -412,11 +441,18 @@ export interface NavigationRoute { key: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any params?: Record; + state?: NavigationState; +} + +interface NavigationState { + index?: number; + routes: NavigationRoute[]; } interface NavigationContainer { addListener: (type: string, listener: (event?: unknown) => void) => void; getCurrentRoute: () => NavigationRoute; + getState: () => NavigationState | undefined; } /** diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 121a5284a0..0bbb4a51eb 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -28,7 +28,7 @@ const navigationIntegration = Sentry.reactNavigationIntegration({ Sentry.init({ // Replace the example DSN below with your own DSN: - dsn: SENTRY_INTERNAL_DSN, + dsn: 'https://c96d5b6136315b7a6cef6230a38b4842@o4509786567475200.ingest.de.sentry.io/4510182962298960', debug: true, environment: 'dev', enableLogs: true, From a1467f2a85f1b9674307a386917283d165561725 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 1 Dec 2025 14:29:26 +0100 Subject: [PATCH 2/4] Tests + fixes --- .../core/src/js/tracing/reactnavigation.ts | 23 +- .../core/test/tracing/reactnavigation.test.ts | 214 +++++++++++++++++- .../core/test/tracing/reactnavigationutils.ts | 88 +++++++ 3 files changed, 320 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 7179aab6d4..1a1e179180 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -97,6 +97,13 @@ interface ReactNavigationIntegrationOptions { * @default false */ useDispatchedActionData: boolean; + + /** + * Whether to use the full paths for navigation routes. + * + * @default false + */ + useFullPathsForNavigationRoutes: boolean; } /** @@ -113,6 +120,7 @@ export const reactNavigationIntegration = ({ ignoreEmptyBackNavigationTransactions = true, enableTimeToInitialDisplayForPreloadedRoutes = false, useDispatchedActionData = false, + useFullPathsForNavigationRoutes = false, }: Partial = {}): Integration & { /** * Pass the ref to the navigation container to register it to the instrumentation @@ -344,9 +352,11 @@ export const reactNavigationIntegration = ({ const routeHasBeenSeen = recentRouteKeys.includes(route.key); // Get the full navigation path for nested navigators - const navigationState = navigationContainer.getState(); - const fullRoutePath = getPathFromState(navigationState); - const routeName = fullRoutePath || route.name; + let routeName = route.name; + if (useFullPathsForNavigationRoutes) { + const navigationState = navigationContainer.getState(); + routeName = getPathFromState(navigationState) || route.name; + } navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`); navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); @@ -386,7 +396,11 @@ export const reactNavigationIntegration = ({ tracing?.setCurrentRoute(routeName); pushRecentRouteKey(route.key); - latestRoute = route; + if (useFullPathsForNavigationRoutes) { + latestRoute = { ...route, name: routeName }; + } else { + latestRoute = route; + } // Clear the latest transaction as it has been handled. latestNavigationSpan = undefined; }; @@ -432,6 +446,7 @@ export const reactNavigationIntegration = ({ ignoreEmptyBackNavigationTransactions, enableTimeToInitialDisplayForPreloadedRoutes, useDispatchedActionData, + useFullPathsForNavigationRoutes, }, }; }; diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 8f810e3a1b..e6f564deaa 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -32,7 +32,7 @@ import { mockAppRegistryIntegration } from '../mocks/appRegistryIntegrationMock' import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { NATIVE } from '../mockWrapper'; import { getDevServer } from './../../src/js/integrations/debugsymbolicatorutils'; -import { createMockNavigationAndAttachTo } from './reactnavigationutils'; +import { createMockNavigationAndAttachTo, createMockNavigationWithNestedState } from './reactnavigationutils'; const dummyRoute = { name: 'Route', @@ -792,6 +792,218 @@ describe('ReactNavigationInstrumentation', () => { }); }); + describe('useFullPathsForNavigationRoutes option', () => { + test('transaction uses full path when useFullPathsForNavigationRoutes is true', async () => { + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useFullPathsForNavigationRoutes: true, + }); + + const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, reactNativeTracingIntegration()], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); // Flush the init transaction + + // Navigate to a nested screen: Home -> Settings -> Profile + mockNavigationWithNestedState.navigateToNestedScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Home/Settings/Profile', + [SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'profile_screen', + }), + }), + }), + }), + ); + }); + + test('transaction uses only route name when useFullPathsForNavigationRoutes is false', async () => { + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useFullPathsForNavigationRoutes: false, + }); + + const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, reactNativeTracingIntegration()], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); + + mockNavigationWithNestedState.navigateToNestedScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Profile', + [SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'profile_screen', + }), + }), + }), + }), + ); + }); + + test('transaction uses two-level path with nested tab navigator', async () => { + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useFullPathsForNavigationRoutes: true, + }); + + const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, reactNativeTracingIntegration()], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); + + mockNavigationWithNestedState.navigateToTwoLevelNested(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Tabs/Settings', + [SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'settings_screen', + }), + }), + }), + }), + ); + }); + + test('setCurrentRoute receives full path when useFullPathsForNavigationRoutes is true', async () => { + const mockSetCurrentRoute = jest.fn(); + const rnTracingIntegration = reactNativeTracingIntegration(); + rnTracingIntegration.setCurrentRoute = mockSetCurrentRoute; + + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useFullPathsForNavigationRoutes: true, + }); + + const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, rnTracingIntegration], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); + + mockSetCurrentRoute.mockClear(); + mockNavigationWithNestedState.navigateToNestedScreen(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).toHaveBeenCalledWith('Home/Settings/Profile'); + }); + + test('previous route uses full path when useFullPathsForNavigationRoutes is true', async () => { + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + useFullPathsForNavigationRoutes: true, + }); + + const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, reactNativeTracingIntegration()], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); // Flush the init transaction + + // First navigation + mockNavigationWithNestedState.navigateToNestedScreen(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + // Second navigation + mockNavigationWithNestedState.navigateToTwoLevelNested(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const actualEvent = client.event; + expect(actualEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + data: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Tabs/Settings', + [SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]: 'Home/Settings/Profile', // Full path in previous route + [SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY]: 'profile_screen', + }), + }), + }), + }), + ); + }); + }); + function setupTestClient( setupOptions: { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index aa164087f9..9254a0a936 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -103,4 +103,92 @@ export class MockNavigationContainer { getCurrentRoute(): NavigationRoute | undefined { return this.currentRoute; } + getState(): any { + return undefined; + } +} + +export class MockNavigationContainerWithState extends MockNavigationContainer { + currentState: any = undefined; + + getState(): any { + return this.currentState; + } +} + +export function createMockNavigationWithNestedState(sut: ReturnType) { + const mockedNavigationContainer = new MockNavigationContainerWithState(); + + const mockedNavigation = { + navigateToNestedScreen: () => { + // Simulate nested navigation: Home -> Settings -> Profile + mockedNavigationContainer.currentRoute = { + key: 'profile_screen', + name: 'Profile', + }; + mockedNavigationContainer.currentState = { + index: 0, + routes: [ + { + name: 'Home', + key: 'home', + state: { + index: 0, + routes: [ + { + name: 'Settings', + key: 'settings', + state: { + index: 0, + routes: [ + { + name: 'Profile', + key: 'profile_screen', + }, + ], + }, + }, + ], + }, + }, + ], + }; + mockedNavigationContainer.listeners['__unsafe_action__'](navigationAction); + mockedNavigationContainer.listeners['state']({}); + }, + navigateToTwoLevelNested: () => { + // Simulate two-level nested navigation: Tabs -> Settings + mockedNavigationContainer.currentRoute = { + key: 'settings_screen', + name: 'Settings', + }; + mockedNavigationContainer.currentState = { + index: 0, + routes: [ + { + name: 'Tabs', + key: 'tabs', + state: { + index: 1, + routes: [ + { + name: 'Home', + key: 'home', + }, + { + name: 'Settings', + key: 'settings_screen', + }, + ], + }, + }, + ], + }; + mockedNavigationContainer.listeners['__unsafe_action__'](navigationAction); + mockedNavigationContainer.listeners['state']({}); + }, + }; + + sut.registerNavigationContainer(mockRef(mockedNavigationContainer)); + return mockedNavigation; } From 6d239d624b93197250f9094e5caeda6ea4cd3e2b Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 1 Dec 2025 15:56:54 +0100 Subject: [PATCH 3/4] Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3848cc865a..deec3456aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Adds Metrics Beta ([#5402](https://github.com/getsentry/sentry-react-native/pull/5402)) +- Improves Expo Router integration to optionally include full paths to components instead of just component names ([#5414](https://github.com/getsentry/sentry-react-native/pull/5414)) ### Fixes From ddb529520a40648af5ba5a1edfcfd9c3554f8a93 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Mon, 1 Dec 2025 15:57:09 +0100 Subject: [PATCH 4/4] Layout fix --- samples/expo/app/_layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/expo/app/_layout.tsx b/samples/expo/app/_layout.tsx index 0bbb4a51eb..121a5284a0 100644 --- a/samples/expo/app/_layout.tsx +++ b/samples/expo/app/_layout.tsx @@ -28,7 +28,7 @@ const navigationIntegration = Sentry.reactNavigationIntegration({ Sentry.init({ // Replace the example DSN below with your own DSN: - dsn: 'https://c96d5b6136315b7a6cef6230a38b4842@o4509786567475200.ingest.de.sentry.io/4510182962298960', + dsn: SENTRY_INTERNAL_DSN, debug: true, environment: 'dev', enableLogs: true,