diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a4e11bec..1d0e8c214a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 0dee432c2b..aa9f549bb8 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -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, +): Record | undefined { + if (!params) { + return undefined; + } + + const dynamicKeys = new Set(); + 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 = {}; + 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" @@ -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', }); diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index e6f564deaa..fc478b3f99 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -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, @@ -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; @@ -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; + 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; + 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({ @@ -1026,6 +1162,7 @@ describe('ReactNavigationInstrumentation', () => { tracesSampleRate: 1.0, integrations: [rNavigation, rnTracing], enableAppStartTracking: false, + sendDefaultPii: setupOptions.sendDefaultPii, }); client = new TestClient(options); setCurrentClient(client); diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index 9254a0a936..d5cafd7de4 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -68,6 +68,33 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { + 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;