From 94a61e1690d2b69850ee683e1deeaf74b2e68f30 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 11:21:30 +0100 Subject: [PATCH 1/6] feat(core): Capture dynamic route params as span attributes Extract dynamic route parameters ([id], [...slug]) from Expo Router style route names and include them as span attributes (route.params.*). Only structural params matching dynamic segments in the route name are captured, filtering out non-structural params that may contain PII. Closes #5422 Co-Authored-By: Claude Opus 4.6 --- .../core/src/js/tracing/reactnavigation.ts | 44 +++++- .../core/test/tracing/reactnavigation.test.ts | 125 +++++++++++++++++- .../core/test/tracing/reactnavigationutils.ts | 27 ++++ 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 0dee432c2b..f28a5c349b 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -34,6 +34,45 @@ 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. + */ +export function extractDynamicRouteParams( + routeName: string, + params?: Record, +): Record | undefined { + if (!params) { + return undefined; + } + + const dynamicKeys = new Set(); + const pattern = /\[(?:\.\.\.)?([^\]]+)\]/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" @@ -415,13 +454,10 @@ export const reactNavigationIntegration = ({ latestNavigationSpan.setAttributes({ 'route.name': routeName, 'route.key': route.key, - // TODO: filter PII params instead of dropping them all - // 'route.params': {}, + ...extractDynamicRouteParams(routeName, route.params), '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..1f705a1ecf 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,6 +1052,81 @@ describe('ReactNavigationInstrumentation', () => { }); }); + describe('dynamic route params', () => { + test('navigation span includes dynamic route params from [id] route', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush the navigation transaction + + // Navigate to a dynamic route + 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', + }), + }), + }), + }), + ); + }); + + test('navigation span includes dynamic route params from [...slug] catch-all route', async () => { + setupTestClient(); + 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', + }), + }), + }), + }), + ); + }); + + test('navigation span does not include non-dynamic params', async () => { + setupTestClient(); + jest.runOnlyPendingTimers(); // Flush the init transaction + + mockNavigation.navigateToStaticRouteWithParams(); + jest.runOnlyPendingTimers(); + + await client.flush(); + + const actualEvent = client.event; + const traceData = actualEvent?.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; 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; From 33be13b486135fb5b48b9f5a54bd16d21e13f750 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 11:26:22 +0100 Subject: [PATCH 2/6] docs: Add changelog entry for dynamic route params Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a4e11bec..180aebf8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Capture dynamic route params (`[id]`, `[...slug]`) 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)) From ec916084ebc75c1d594bb38784c25a80ececfe6b Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 11:27:13 +0100 Subject: [PATCH 3/6] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 180aebf8e5..1d0e8c214a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Features -- Capture dynamic route params (`[id]`, `[...slug]`) as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750)) +- Capture dynamic route params as span attributes for Expo Router navigations ([#5750](https://github.com/getsentry/sentry-react-native/pull/5750)) ### Fixes From 86648d52071c30f59def3d4439a0a689aff84413 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 11:36:03 +0100 Subject: [PATCH 4/6] fix(core): Use \w+ instead of [^\]]+ in route param regex to avoid ReDoS Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tracing/reactnavigation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index f28a5c349b..338e12505a 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -50,7 +50,7 @@ export function extractDynamicRouteParams( } const dynamicKeys = new Set(); - const pattern = /\[(?:\.\.\.)?([^\]]+)\]/g; + const pattern = /\[(?:\.\.\.)?(\w+)\]/g; let match: RegExpExecArray | null; while ((match = pattern.exec(routeName)) !== null) { if (match[1]) { From 35badfea8867cbc9ddfcaa9379e48082424e80e9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 13:52:46 +0100 Subject: [PATCH 5/6] fix(core): Address PR review feedback on dynamic route params - Guard against null param values with String(value ?? '') - Add JSDoc note on PII scope and why previous route params are omitted - Use it() consistently in describe blocks (was test()) - Add comment explaining the navigateToNewScreen step in test Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tracing/reactnavigation.ts | 9 ++++++++- packages/core/test/tracing/reactnavigation.test.ts | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index 338e12505a..d2e2fb9e83 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -40,6 +40,13 @@ const NAVIGATION_HISTORY_MAX_SIZE = 200; * * 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]`) are captured as-is. + * Avoid using emails or other PII as route segment identifiers in your app, as these + * values will appear in Sentry spans. + * + * Previous route params are intentionally not captured — only the current route's + * structural params are needed for trace attribution. */ export function extractDynamicRouteParams( routeName: string, @@ -66,7 +73,7 @@ export function extractDynamicRouteParams( for (const key of dynamicKeys) { if (key in params) { const value = params[key]; - result[`route.params.${key}`] = Array.isArray(value) ? value.join('/') : String(value); + result[`route.params.${key}`] = Array.isArray(value) ? value.join('/') : String(value ?? ''); } } diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 1f705a1ecf..2fd36fc9dc 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1053,14 +1053,14 @@ describe('ReactNavigationInstrumentation', () => { }); describe('dynamic route params', () => { - test('navigation span includes dynamic route params from [id] route', async () => { + it('includes dynamic route params from [id] route', async () => { setupTestClient(); 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 - // Navigate to a dynamic route mockNavigation.navigateToDynamicRoute(); jest.runOnlyPendingTimers(); @@ -1084,7 +1084,7 @@ describe('ReactNavigationInstrumentation', () => { ); }); - test('navigation span includes dynamic route params from [...slug] catch-all route', async () => { + it('includes dynamic route params from [...slug] catch-all route joined with /', async () => { setupTestClient(); jest.runOnlyPendingTimers(); // Flush the init transaction @@ -1110,7 +1110,7 @@ describe('ReactNavigationInstrumentation', () => { ); }); - test('navigation span does not include non-dynamic params', async () => { + it('does not include non-dynamic params from static routes', async () => { setupTestClient(); jest.runOnlyPendingTimers(); // Flush the init transaction From 83ea3e5d9451fae0e216983c34ab8816bfa5fbcb Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 3 Mar 2026 14:06:37 +0100 Subject: [PATCH 6/6] fix(core): Gate dynamic route params behind sendDefaultPii Route param values may be user-identifiable (user IDs, slugs, etc.). Gate their capture behind the sendDefaultPii SDK option, consistent with how other potentially-PII data is handled in the SDK. Co-Authored-By: Claude Opus 4.6 --- .../core/src/js/tracing/reactnavigation.ts | 9 +++--- .../core/test/tracing/reactnavigation.test.ts | 30 ++++++++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index d2e2fb9e83..aa9f549bb8 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -41,9 +41,9 @@ const NAVIGATION_HISTORY_MAX_SIZE = 200; * 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]`) are captured as-is. - * Avoid using emails or other PII as route segment identifiers in your app, as these - * values will appear in Sentry spans. + * 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. @@ -458,10 +458,11 @@ 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, - ...extractDynamicRouteParams(routeName, route.params), + ...(sendDefaultPii ? extractDynamicRouteParams(routeName, route.params) : undefined), 'route.has_been_seen': routeHasBeenSeen, 'previous_route.name': previousRoute?.name, 'previous_route.key': previousRoute?.key, diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 2fd36fc9dc..fc478b3f99 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -1053,8 +1053,8 @@ describe('ReactNavigationInstrumentation', () => { }); describe('dynamic route params', () => { - it('includes dynamic route params from [id] route', async () => { - setupTestClient(); + 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 @@ -1084,8 +1084,8 @@ describe('ReactNavigationInstrumentation', () => { ); }); - it('includes dynamic route params from [...slug] catch-all route joined with /', async () => { - setupTestClient(); + 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(); @@ -1110,8 +1110,22 @@ describe('ReactNavigationInstrumentation', () => { ); }); + 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(); + setupTestClient({ sendDefaultPii: true }); jest.runOnlyPendingTimers(); // Flush the init transaction mockNavigation.navigateToStaticRouteWithParams(); @@ -1119,9 +1133,7 @@ describe('ReactNavigationInstrumentation', () => { await client.flush(); - const actualEvent = client.event; - const traceData = actualEvent?.contexts?.trace?.data as Record; - + const traceData = client.event?.contexts?.trace?.data as Record; expect(traceData[SEMANTIC_ATTRIBUTE_ROUTE_NAME]).toBe('StaticScreen'); expect(traceData['route.params.utm_source']).toBeUndefined(); }); @@ -1131,6 +1143,7 @@ describe('ReactNavigationInstrumentation', () => { setupOptions: { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; useDispatchedActionData?: boolean; + sendDefaultPii?: boolean; } = {}, ) { const rNavigation = reactNavigationIntegration({ @@ -1149,6 +1162,7 @@ describe('ReactNavigationInstrumentation', () => { tracesSampleRate: 1.0, integrations: [rNavigation, rnTracing], enableAppStartTracking: false, + sendDefaultPii: setupOptions.sendDefaultPii, }); client = new TestClient(options); setCurrentClient(client);