Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

## Unreleased

### Features

- Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750))

### Fixes

- Resolve relative `SOURCEMAP_FILE` paths against the project root in the Xcode build script ([#5730](https://github.com/getsentry/sentry-react-native/pull/5730))
Expand Down
52 changes: 48 additions & 4 deletions packages/core/src/js/tracing/reactnavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,52 @@ export const INTEGRATION_NAME = 'ReactNavigation';

const NAVIGATION_HISTORY_MAX_SIZE = 200;

/**
* Extracts dynamic route parameters from a route name and its params.
* Matches Expo Router style dynamic segments like `[id]` and `[...slug]`.
*
* Only params whose keys appear as dynamic segments in the route name are returned,
* filtering out non-structural params (query params, etc.) that may contain PII.
*
* Note: dynamic segment values (e.g. the `123` in `profile/[id]`) may be user-identifiable.
* This function only extracts params — callers are responsible for checking `sendDefaultPii`
* before including the result in span attributes.
*
* Previous route params are intentionally not captured — only the current route's
* structural params are needed for trace attribution.
*/
export function extractDynamicRouteParams(
routeName: string,
params?: Record<string, unknown>,
): Record<string, string> | undefined {
if (!params) {
return undefined;
}

const dynamicKeys = new Set<string>();
const pattern = /\[(?:\.\.\.)?(\w+)\]/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(routeName)) !== null) {
if (match[1]) {
dynamicKeys.add(match[1]);
}
}

if (dynamicKeys.size === 0) {
return undefined;
}

const result: Record<string, string> = {};
for (const key of dynamicKeys) {
if (key in params) {
const value = params[key];
result[`route.params.${key}`] = Array.isArray(value) ? value.join('/') : String(value ?? '');
}
}

return Object.keys(result).length > 0 ? result : undefined;
}

/**
* Builds a full path from the navigation state by traversing nested navigators.
* For example, with nested navigators: "Home/Settings/Profile"
Expand Down Expand Up @@ -412,16 +458,14 @@ export const reactNavigationIntegration = ({
if (spanToJSON(latestNavigationSpan).description === DEFAULT_NAVIGATION_SPAN_NAME) {
latestNavigationSpan.updateName(routeName);
}
const sendDefaultPii = getClient()?.getOptions()?.sendDefaultPii ?? false;
latestNavigationSpan.setAttributes({
'route.name': routeName,
'route.key': route.key,
// TODO: filter PII params instead of dropping them all
// 'route.params': {},
...(sendDefaultPii ? extractDynamicRouteParams(routeName, route.params) : undefined),
'route.has_been_seen': routeHasBeenSeen,
'previous_route.name': previousRoute?.name,
'previous_route.key': previousRoute?.key,
// TODO: filter PII params instead of dropping them all
// 'previous_route.params': {},
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
});
Expand Down
139 changes: 138 additions & 1 deletion packages/core/test/tracing/reactnavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js';
import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin';
import type { NavigationRoute } from '../../src/js/tracing/reactnavigation';
import { reactNavigationIntegration } from '../../src/js/tracing/reactnavigation';
import { extractDynamicRouteParams, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation';
import {
SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY,
SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME,
Expand Down Expand Up @@ -59,6 +59,54 @@ class MockNavigationContainer {
}
}

describe('extractDynamicRouteParams', () => {
it('returns undefined when params is undefined', () => {
expect(extractDynamicRouteParams('profile/[id]', undefined)).toBeUndefined();
});

it('returns undefined when route name has no dynamic segments', () => {
expect(extractDynamicRouteParams('StaticScreen', { foo: 'bar' })).toBeUndefined();
});

it('extracts single dynamic segment [id]', () => {
expect(extractDynamicRouteParams('profile/[id]', { id: '123' })).toEqual({
'route.params.id': '123',
});
});

it('extracts catch-all segment [...slug] and joins array values with /', () => {
expect(extractDynamicRouteParams('posts/[...slug]', { slug: ['tech', 'react-native'] })).toEqual({
'route.params.slug': 'tech/react-native',
});
});

it('extracts multiple dynamic segments', () => {
expect(
extractDynamicRouteParams('[org]/[project]/issues/[id]', { org: 'sentry', project: 'react-native', id: '42' }),
).toEqual({
'route.params.org': 'sentry',
'route.params.project': 'react-native',
'route.params.id': '42',
});
});

it('ignores params not matching dynamic segments', () => {
expect(extractDynamicRouteParams('profile/[id]', { id: '123', utm_source: 'email' })).toEqual({
'route.params.id': '123',
});
});

it('returns undefined when dynamic segment key is missing from params', () => {
expect(extractDynamicRouteParams('profile/[id]', { name: 'test' })).toBeUndefined();
});

it('converts non-string param values to strings', () => {
expect(extractDynamicRouteParams('items/[count]', { count: 42 })).toEqual({
'route.params.count': '42',
});
});
});

describe('ReactNavigationInstrumentation', () => {
let client: TestClient;
let mockNavigation: ReturnType<typeof createMockNavigationAndAttachTo>;
Expand Down Expand Up @@ -1004,10 +1052,98 @@ describe('ReactNavigationInstrumentation', () => {
});
});

describe('dynamic route params', () => {
it('includes dynamic route params from [id] route when sendDefaultPii is true', async () => {
setupTestClient({ sendDefaultPii: true });
jest.runOnlyPendingTimers(); // Flush the init transaction

// Navigate to a static screen first so previous_route.name is set to a known value
mockNavigation.navigateToNewScreen();
jest.runOnlyPendingTimers(); // Flush the navigation transaction

mockNavigation.navigateToDynamicRoute();
jest.runOnlyPendingTimers();

await client.flush();

const actualEvent = client.event;
expect(actualEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: 'profile/[id]',
contexts: expect.objectContaining({
trace: expect.objectContaining({
data: expect.objectContaining({
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'profile/[id]',
'route.params.id': '123',
[SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME]: 'New Screen',
}),
}),
}),
}),
);
});

it('includes dynamic route params from [...slug] catch-all route joined with / when sendDefaultPii is true', async () => {
setupTestClient({ sendDefaultPii: true });
jest.runOnlyPendingTimers(); // Flush the init transaction

mockNavigation.navigateToCatchAllRoute();
jest.runOnlyPendingTimers();

await client.flush();

const actualEvent = client.event;
expect(actualEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: 'posts/[...slug]',
contexts: expect.objectContaining({
trace: expect.objectContaining({
data: expect.objectContaining({
[SEMANTIC_ATTRIBUTE_ROUTE_NAME]: 'posts/[...slug]',
'route.params.slug': 'tech/react-native',
}),
}),
}),
}),
);
});

it('does not include dynamic route params when sendDefaultPii is false', async () => {
setupTestClient({ sendDefaultPii: false });
jest.runOnlyPendingTimers(); // Flush the init transaction

mockNavigation.navigateToDynamicRoute();
jest.runOnlyPendingTimers();

await client.flush();

const traceData = client.event?.contexts?.trace?.data as Record<string, unknown>;
expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('profile/[id]');
expect(traceData['route.params.id']).toBeUndefined();
});

it('does not include non-dynamic params from static routes', async () => {
setupTestClient({ sendDefaultPii: true });
jest.runOnlyPendingTimers(); // Flush the init transaction

mockNavigation.navigateToStaticRouteWithParams();
jest.runOnlyPendingTimers();

await client.flush();

const traceData = client.event?.contexts?.trace?.data as Record<string, unknown>;
expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('StaticScreen');
expect(traceData['route.params.utm_source']).toBeUndefined();
});
});

function setupTestClient(
setupOptions: {
beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions;
useDispatchedActionData?: boolean;
sendDefaultPii?: boolean;
} = {},
) {
const rNavigation = reactNavigationIntegration({
Expand All @@ -1026,6 +1162,7 @@ describe('ReactNavigationInstrumentation', () => {
tracesSampleRate: 1.0,
integrations: [rNavigation, rnTracing],
enableAppStartTracking: false,
sendDefaultPii: setupOptions.sendDefaultPii,
});
client = new TestClient(options);
setCurrentClient(client);
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/tracing/reactnavigationutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ export function createMockNavigationAndAttachTo(sut: ReturnType<typeof reactNavi
// this object is not used by the instrumentation
});
},
navigateToDynamicRoute: () => {
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'profile_123',
name: 'profile/[id]',
params: { id: '123' },
};
mockedNavigationContained.listeners['state']({});
},
navigateToCatchAllRoute: () => {
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'posts_slug',
name: 'posts/[...slug]',
params: { slug: ['tech', 'react-native'] },
};
mockedNavigationContained.listeners['state']({});
},
navigateToStaticRouteWithParams: () => {
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = {
key: 'static_screen',
name: 'StaticScreen',
params: { utm_source: 'email', referrer: 'homepage' },
};
mockedNavigationContained.listeners['state']({});
},
emitNavigationWithUndefinedRoute: () => {
mockedNavigationContained.listeners['__unsafe_action__'](navigationAction);
mockedNavigationContained.currentRoute = undefined as any;
Expand Down
Loading