Skip to content

Commit 2f9fb30

Browse files
authored
Improve Expo Router integration to optionally include full paths to components instead of just component names (#5414)
* Improve Expo Router integrations to indicate full paths instead of just component names * Tests + fixes * Changelog entry * Layout fix
1 parent e335046 commit 2f9fb30

File tree

4 files changed

+360
-8
lines changed

4 files changed

+360
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Features
1212

1313
- Adds Metrics Beta ([#5402](https://github.com/getsentry/sentry-react-native/pull/5402))
14+
- 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))
1415

1516
### Fixes
1617

packages/core/src/js/tracing/reactnavigation.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,30 @@ export const INTEGRATION_NAME = 'ReactNavigation';
3434

3535
const NAVIGATION_HISTORY_MAX_SIZE = 200;
3636

37+
/**
38+
* Builds a full path from the navigation state by traversing nested navigators.
39+
* For example, with nested navigators: "Home/Settings/Profile"
40+
*/
41+
function getPathFromState(state?: NavigationState): string | undefined {
42+
if (!state) {
43+
return undefined;
44+
}
45+
46+
const routeNames: string[] = [];
47+
let currentState: NavigationState | undefined = state;
48+
49+
while (currentState) {
50+
const index: number = currentState.index ?? 0;
51+
const route: NavigationRoute | undefined = currentState.routes[index];
52+
if (route?.name) {
53+
routeNames.push(route.name);
54+
}
55+
currentState = route?.state;
56+
}
57+
58+
return routeNames.length > 0 ? routeNames.join('/') : undefined;
59+
}
60+
3761
interface ReactNavigationIntegrationOptions {
3862
/**
3963
* How long the instrumentation will wait for the route to mount after a change has been initiated,
@@ -73,6 +97,13 @@ interface ReactNavigationIntegrationOptions {
7397
* @default false
7498
*/
7599
useDispatchedActionData: boolean;
100+
101+
/**
102+
* Whether to use the full paths for navigation routes.
103+
*
104+
* @default false
105+
*/
106+
useFullPathsForNavigationRoutes: boolean;
76107
}
77108

78109
/**
@@ -89,6 +120,7 @@ export const reactNavigationIntegration = ({
89120
ignoreEmptyBackNavigationTransactions = true,
90121
enableTimeToInitialDisplayForPreloadedRoutes = false,
91122
useDispatchedActionData = false,
123+
useFullPathsForNavigationRoutes = false,
92124
}: Partial<ReactNavigationIntegrationOptions> = {}): Integration & {
93125
/**
94126
* Pass the ref to the navigation container to register it to the instrumentation
@@ -319,16 +351,23 @@ export const reactNavigationIntegration = ({
319351

320352
const routeHasBeenSeen = recentRouteKeys.includes(route.key);
321353

322-
navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${route.name} mounted`);
354+
// Get the full navigation path for nested navigators
355+
let routeName = route.name;
356+
if (useFullPathsForNavigationRoutes) {
357+
const navigationState = navigationContainer.getState();
358+
routeName = getPathFromState(navigationState) || route.name;
359+
}
360+
361+
navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${routeName} mounted`);
323362
navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK });
324363
navigationProcessingSpan?.end(stateChangedTimestamp);
325364
navigationProcessingSpan = undefined;
326365

327366
if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) {
328-
latestNavigationSpan.updateName(route.name);
367+
latestNavigationSpan.updateName(routeName);
329368
}
330369
latestNavigationSpan.setAttributes({
331-
'route.name': route.name,
370+
'route.name': routeName,
332371
'route.key': route.key,
333372
// TODO: filter PII params instead of dropping them all
334373
// 'route.params': {},
@@ -347,17 +386,21 @@ export const reactNavigationIntegration = ({
347386
addBreadcrumb({
348387
category: 'navigation',
349388
type: 'navigation',
350-
message: `Navigation to ${route.name}`,
389+
message: `Navigation to ${routeName}`,
351390
data: {
352391
from: previousRoute?.name,
353-
to: route.name,
392+
to: routeName,
354393
},
355394
});
356395

357-
tracing?.setCurrentRoute(route.name);
396+
tracing?.setCurrentRoute(routeName);
358397

359398
pushRecentRouteKey(route.key);
360-
latestRoute = route;
399+
if (useFullPathsForNavigationRoutes) {
400+
latestRoute = { ...route, name: routeName };
401+
} else {
402+
latestRoute = route;
403+
}
361404
// Clear the latest transaction as it has been handled.
362405
latestNavigationSpan = undefined;
363406
};
@@ -403,6 +446,7 @@ export const reactNavigationIntegration = ({
403446
ignoreEmptyBackNavigationTransactions,
404447
enableTimeToInitialDisplayForPreloadedRoutes,
405448
useDispatchedActionData,
449+
useFullPathsForNavigationRoutes,
406450
},
407451
};
408452
};
@@ -412,11 +456,18 @@ export interface NavigationRoute {
412456
key: string;
413457
// eslint-disable-next-line @typescript-eslint/no-explicit-any
414458
params?: Record<string, any>;
459+
state?: NavigationState;
460+
}
461+
462+
interface NavigationState {
463+
index?: number;
464+
routes: NavigationRoute[];
415465
}
416466

417467
interface NavigationContainer {
418468
addListener: (type: string, listener: (event?: unknown) => void) => void;
419469
getCurrentRoute: () => NavigationRoute;
470+
getState: () => NavigationState | undefined;
420471
}
421472

422473
/**

packages/core/test/tracing/reactnavigation.test.ts

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { mockAppRegistryIntegration } from '../mocks/appRegistryIntegrationMock'
3232
import { getDefaultTestClientOptions, TestClient } from '../mocks/client';
3333
import { NATIVE } from '../mockWrapper';
3434
import { getDevServer } from './../../src/js/integrations/debugsymbolicatorutils';
35-
import { createMockNavigationAndAttachTo } from './reactnavigationutils';
35+
import { createMockNavigationAndAttachTo, createMockNavigationWithNestedState } from './reactnavigationutils';
3636

3737
const dummyRoute = {
3838
name: 'Route',
@@ -792,6 +792,218 @@ describe('ReactNavigationInstrumentation', () => {
792792
});
793793
});
794794

795+
describe('useFullPathsForNavigationRoutes option', () => {
796+
test('transaction uses full path when useFullPathsForNavigationRoutes is true', async () => {
797+
const rNavigation = reactNavigationIntegration({
798+
routeChangeTimeoutMs: 200,
799+
useFullPathsForNavigationRoutes: true,
800+
});
801+
802+
const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation);
803+
804+
const options = getDefaultTestClientOptions({
805+
enableNativeFramesTracking: false,
806+
enableStallTracking: false,
807+
tracesSampleRate: 1.0,
808+
integrations: [rNavigation, reactNativeTracingIntegration()],
809+
enableAppStartTracking: false,
810+
});
811+
812+
client = new TestClient(options);
813+
setCurrentClient(client);
814+
client.init();
815+
816+
jest.runOnlyPendingTimers(); // Flush the init transaction
817+
818+
// Navigate to a nested screen: Home -> Settings -> Profile
819+
mockNavigationWithNestedState.navigateToNestedScreen();
820+
jest.runOnlyPendingTimers();
821+
822+
await client.flush();
823+
824+
const actualEvent = client.event;
825+
expect(actualEvent).toEqual(
826+
expect.objectContaining({
827+
contexts: expect.objectContaining({
828+
trace: expect.objectContaining({
829+
data: expect.objectContaining({
830+
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Home/Settings/Profile',
831+
[SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'profile_screen',
832+
}),
833+
}),
834+
}),
835+
}),
836+
);
837+
});
838+
839+
test('transaction uses only route name when useFullPathsForNavigationRoutes is false', async () => {
840+
const rNavigation = reactNavigationIntegration({
841+
routeChangeTimeoutMs: 200,
842+
useFullPathsForNavigationRoutes: false,
843+
});
844+
845+
const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation);
846+
847+
const options = getDefaultTestClientOptions({
848+
enableNativeFramesTracking: false,
849+
enableStallTracking: false,
850+
tracesSampleRate: 1.0,
851+
integrations: [rNavigation, reactNativeTracingIntegration()],
852+
enableAppStartTracking: false,
853+
});
854+
855+
client = new TestClient(options);
856+
setCurrentClient(client);
857+
client.init();
858+
859+
jest.runOnlyPendingTimers();
860+
861+
mockNavigationWithNestedState.navigateToNestedScreen();
862+
jest.runOnlyPendingTimers();
863+
864+
await client.flush();
865+
866+
const actualEvent = client.event;
867+
expect(actualEvent).toEqual(
868+
expect.objectContaining({
869+
contexts: expect.objectContaining({
870+
trace: expect.objectContaining({
871+
data: expect.objectContaining({
872+
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Profile',
873+
[SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'profile_screen',
874+
}),
875+
}),
876+
}),
877+
}),
878+
);
879+
});
880+
881+
test('transaction uses two-level path with nested tab navigator', async () => {
882+
const rNavigation = reactNavigationIntegration({
883+
routeChangeTimeoutMs: 200,
884+
useFullPathsForNavigationRoutes: true,
885+
});
886+
887+
const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation);
888+
889+
const options = getDefaultTestClientOptions({
890+
enableNativeFramesTracking: false,
891+
enableStallTracking: false,
892+
tracesSampleRate: 1.0,
893+
integrations: [rNavigation, reactNativeTracingIntegration()],
894+
enableAppStartTracking: false,
895+
});
896+
897+
client = new TestClient(options);
898+
setCurrentClient(client);
899+
client.init();
900+
901+
jest.runOnlyPendingTimers();
902+
903+
mockNavigationWithNestedState.navigateToTwoLevelNested();
904+
jest.runOnlyPendingTimers();
905+
906+
await client.flush();
907+
908+
const actualEvent = client.event;
909+
expect(actualEvent).toEqual(
910+
expect.objectContaining({
911+
contexts: expect.objectContaining({
912+
trace: expect.objectContaining({
913+
data: expect.objectContaining({
914+
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Tabs/Settings',
915+
[SEMANTIC_ATTRIBUTE_ROUTE_KEY]: 'settings_screen',
916+
}),
917+
}),
918+
}),
919+
}),
920+
);
921+
});
922+
923+
test('setCurrentRoute receives full path when useFullPathsForNavigationRoutes is true', async () => {
924+
const mockSetCurrentRoute = jest.fn();
925+
const rnTracingIntegration = reactNativeTracingIntegration();
926+
rnTracingIntegration.setCurrentRoute = mockSetCurrentRoute;
927+
928+
const rNavigation = reactNavigationIntegration({
929+
routeChangeTimeoutMs: 200,
930+
useFullPathsForNavigationRoutes: true,
931+
});
932+
933+
const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation);
934+
935+
const options = getDefaultTestClientOptions({
936+
enableNativeFramesTracking: false,
937+
enableStallTracking: false,
938+
tracesSampleRate: 1.0,
939+
integrations: [rNavigation, rnTracingIntegration],
940+
enableAppStartTracking: false,
941+
});
942+
943+
client = new TestClient(options);
944+
setCurrentClient(client);
945+
client.init();
946+
947+
jest.runOnlyPendingTimers();
948+
949+
mockSetCurrentRoute.mockClear();
950+
mockNavigationWithNestedState.navigateToNestedScreen();
951+
jest.runOnlyPendingTimers();
952+
953+
expect(mockSetCurrentRoute).toHaveBeenCalledWith('Home/Settings/Profile');
954+
});
955+
956+
test('previous route uses full path when useFullPathsForNavigationRoutes is true', async () => {
957+
const rNavigation = reactNavigationIntegration({
958+
routeChangeTimeoutMs: 200,
959+
useFullPathsForNavigationRoutes: true,
960+
});
961+
962+
const mockNavigationWithNestedState = createMockNavigationWithNestedState(rNavigation);
963+
964+
const options = getDefaultTestClientOptions({
965+
enableNativeFramesTracking: false,
966+
enableStallTracking: false,
967+
tracesSampleRate: 1.0,
968+
integrations: [rNavigation, reactNativeTracingIntegration()],
969+
enableAppStartTracking: false,
970+
});
971+
972+
client = new TestClient(options);
973+
setCurrentClient(client);
974+
client.init();
975+
976+
jest.runOnlyPendingTimers(); // Flush the init transaction
977+
978+
// First navigation
979+
mockNavigationWithNestedState.navigateToNestedScreen();
980+
jest.runOnlyPendingTimers();
981+
982+
await client.flush();
983+
984+
// Second navigation
985+
mockNavigationWithNestedState.navigateToTwoLevelNested();
986+
jest.runOnlyPendingTimers();
987+
988+
await client.flush();
989+
990+
const actualEvent = client.event;
991+
expect(actualEvent).toEqual(
992+
expect.objectContaining({
993+
contexts: expect.objectContaining({
994+
trace: expect.objectContaining({
995+
data: expect.objectContaining({
996+
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'Tabs/Settings',
997+
[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]: 'Home/Settings/Profile', // Full path in previous route
998+
[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY]: 'profile_screen',
999+
}),
1000+
}),
1001+
}),
1002+
}),
1003+
);
1004+
});
1005+
});
1006+
7951007
function setupTestClient(
7961008
setupOptions: {
7971009
beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions;

0 commit comments

Comments
 (0)