Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 58 additions & 7 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,6 +97,13 @@ interface ReactNavigationIntegrationOptions {
* @default false
*/
useDispatchedActionData: boolean;

/**
* Whether to use the full paths for navigation routes.
*
* @default false
*/
useFullPathsForNavigationRoutes: boolean;
}

/**
Expand All @@ -89,6 +120,7 @@ export const reactNavigationIntegration = ({
ignoreEmptyBackNavigationTransactions = true,
enableTimeToInitialDisplayForPreloadedRoutes = false,
useDispatchedActionData = false,
useFullPathsForNavigationRoutes = false,
}: Partial<ReactNavigationIntegrationOptions> = {}): Integration & {
/**
* Pass the ref to the navigation container to register it to the instrumentation
Expand Down Expand Up @@ -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': {},
Expand All @@ -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;
};
Expand Down Expand Up @@ -403,6 +446,7 @@ export const reactNavigationIntegration = ({
ignoreEmptyBackNavigationTransactions,
enableTimeToInitialDisplayForPreloadedRoutes,
useDispatchedActionData,
useFullPathsForNavigationRoutes,
},
};
};
Expand All @@ -412,11 +456,18 @@ export interface NavigationRoute {
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: Record<string, any>;
state?: NavigationState;
}

interface NavigationState {
index?: number;
routes: NavigationRoute[];
}

interface NavigationContainer {
addListener: (type: string, listener: (event?: unknown) => void) => void;
getCurrentRoute: () => NavigationRoute;
getState: () => NavigationState | undefined;
}

/**
Expand Down
214 changes: 213 additions & 1 deletion packages/core/test/tracing/reactnavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading