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 diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index da4232e3fc..1a1e179180 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, @@ -73,6 +97,13 @@ interface ReactNavigationIntegrationOptions { * @default false */ useDispatchedActionData: boolean; + + /** + * Whether to use the full paths for navigation routes. + * + * @default false + */ + useFullPathsForNavigationRoutes: boolean; } /** @@ -89,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 @@ -319,16 +351,23 @@ 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 + 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 }); 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,17 +386,21 @@ 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; + if (useFullPathsForNavigationRoutes) { + latestRoute = { ...route, name: routeName }; + } else { + latestRoute = route; + } // Clear the latest transaction as it has been handled. latestNavigationSpan = undefined; }; @@ -403,6 +446,7 @@ export const reactNavigationIntegration = ({ ignoreEmptyBackNavigationTransactions, enableTimeToInitialDisplayForPreloadedRoutes, useDispatchedActionData, + useFullPathsForNavigationRoutes, }, }; }; @@ -412,11 +456,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/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; }