From f597246e051644a0afe6c86ba188617be30aee1f Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Fri, 17 Jul 2026 17:04:24 +0200 Subject: [PATCH] feat(react-router): Set navigation.route.id from the matched route id Sets the navigation.route.id attribute (from @sentry/conventions) on pageload and navigation spans, sourced from the leaf matched route's id (route.id). This is the framework-assigned, file-based route identifier (e.g. routes/blog.$slug), which is distinct from the parameterized path used for url.template, and which Relay prefers for span description inference. The id is set at every url.template set-site across both instrumentation modes: the data-router patching path (hydratedRouter + numeric-navigation finalize in utils) and the native instrumentation API path (createClientInstrumentation, threaded through updateRootSpanRoute). A getRouteId helper centralizes reading the id from RouterState. The attribute is omitted when no id is present. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../performance/navigation.client.test.ts | 1 + .../tests/performance/pageload.client.test.ts | 1 + .../performance/navigation.client.test.ts | 1 + .../tests/performance/pageload.client.test.ts | 1 + .../src/client/createClientInstrumentation.ts | 14 +++++++++----- .../react-router/src/client/hydratedRouter.ts | 12 ++++++++++-- packages/react-router/src/client/utils.ts | 17 +++++++++++++++-- .../client/createClientInstrumentation.test.ts | 6 +++++- .../test/client/hydratedRouter.test.ts | 18 ++++++++++++++++++ .../react-router/test/client/utils.test.ts | 16 ++++++++++++++++ 10 files changed, 77 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts index 347662a9ef9d..e97aa36d1218 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts @@ -121,6 +121,7 @@ test.describe('client - hybrid navigation (instrumentation API span + legacy par data: { 'sentry.source': 'route', 'url.template': '/performance/with/:param', + 'navigation.route.id': 'routes/performance/dynamic-param', 'url.path': '/performance/with/sentry', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts index b7d19ec74f80..c4e93134023d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts @@ -50,6 +50,7 @@ test.describe('client - instrumentation API pageload', () => { data: { 'sentry.source': 'route', 'url.template': '/performance/with/:param', + 'navigation.route.id': 'routes/performance/dynamic-param', 'url.path': '/performance/with/some-param', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/some-param$/), }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index 6555db3766c3..fa13ef7064d8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -89,6 +89,7 @@ test.describe('client - navigation performance', () => { data: { 'sentry.source': 'route', 'url.template': '/performance/with/:param', + 'navigation.route.id': 'routes/performance/dynamic-param', 'url.path': '/performance/with/object-nav', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), }, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index f996989ccbf5..2251ac6309f1 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -79,6 +79,7 @@ test.describe('client - pageload performance', () => { 'sentry.op': 'pageload', 'sentry.source': 'route', 'url.template': '/performance/with/:param', + 'navigation.route.id': 'routes/performance/dynamic-param', 'url.path': '/performance/with/sentry', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), }, diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 9ad9421609a4..43669318d4a1 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -24,7 +24,7 @@ import { finalizeNavigationSpanFromHydratedRouter, updateNavigationSpanUrlFromLocation, } from './utils'; -import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { NAVIGATION_ROUTE_ID, URL_TEMPLATE } from '@sentry/conventions/attributes'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; @@ -249,7 +249,7 @@ export function createSentryClientInstrumentation( const routePattern = pattern || urlPath; // Parameterize the active navigation root span. (Route hooks don't fire on initial // pageload, so this only affects navigations.) - updateRootSpanRoute(routePattern, !!pattern); + updateRootSpanRoute(routePattern, !!pattern, routeId); await startSpan( { @@ -275,7 +275,7 @@ export function createSentryClientInstrumentation( const urlPath = getPathFromRequest(info.request); const pattern = normalizeRoutePath(getPattern(info)); const routePattern = pattern || urlPath; - updateRootSpanRoute(routePattern, !!pattern); + updateRootSpanRoute(routePattern, !!pattern, routeId); await startSpan( { @@ -360,7 +360,7 @@ export function createSentryClientInstrumentation( * Updates the active navigation/pageload root span name with the parameterized route, so the * transaction reflects the parameterized route pattern (e.g. `/users/:id`). */ -function updateRootSpanRoute(routeName: string, hasPattern: boolean): void { +function updateRootSpanRoute(routeName: string, hasPattern: boolean, routeId?: string): void { if (!hasPattern) { return; } @@ -377,7 +377,11 @@ function updateRootSpanRoute(routeName: string, hasPattern: boolean): void { } updateSpanName(rootSpan, routeName); - rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeName }); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: routeName, + ...(routeId && { [NAVIGATION_ROUTE_ID]: routeId }), + }); } /** diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index d0fbb7b04f29..ab0483508ccc 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -18,11 +18,12 @@ import { isClientInstrumentationApiUsed } from './createClientInstrumentation'; import { finalizeNavigationSpanFromRouterState, getParameterizedRoute, + getRouteId, normalizePathname, resolveNavigateAbsoluteUrl, resolveNavigateArg, } from './utils'; -import { URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { NAVIGATION_ROUTE_ID, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __reactRouterDataRouter?: DataRouter; @@ -56,11 +57,13 @@ export function instrumentHydratedRouter(): void { // this event is for the currently active pageload normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) ) { + const pageloadRouteId = getRouteId(router.state); pageloadSpan.updateName(parameterizePageloadRoute); pageloadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react_router', [URL_TEMPLATE]: parameterizePageloadRoute, + ...(pageloadRouteId && { [NAVIGATION_ROUTE_ID]: pageloadRouteId }), }); } } @@ -149,8 +152,13 @@ export function instrumentHydratedRouter(): void { (destinationPathname === normalizePathname(rootSpanName) || (spanPathname && destinationPathname === normalizePathname(spanPathname))) ) { + const routeId = getRouteId(newState); rootSpan.updateName(parameterizedRoute); - rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: parameterizedRoute }); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: parameterizedRoute, + ...(routeId && { [NAVIGATION_ROUTE_ID]: routeId }), + }); } }); return true; diff --git a/packages/react-router/src/client/utils.ts b/packages/react-router/src/client/utils.ts index 0c670f454c3d..c75e17eb4a19 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -1,7 +1,7 @@ import { getAbsoluteUrl } from '@sentry/browser'; import type { Span } from '@sentry/core'; import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { NAVIGATION_ROUTE_ID, URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { DataRouter, RouterState } from 'react-router'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; @@ -122,6 +122,14 @@ export function getParameterizedRoute(routerState: RouterState): string { return normalizePathname(lastMatch?.route.path || routerState.location.pathname); } +/** + * Returns the framework-assigned id of the leaf matched route (e.g. `routes/blog.$slug`), which + * is distinct from the parameterized path pattern used for `url.template`. + */ +export function getRouteId(routerState: RouterState): string | undefined { + return routerState.matches[routerState.matches.length - 1]?.route.id; +} + /** * Updates a navigation span's URL attributes and parameterizes its name from the router state. * Used after numeric navigations (`navigate(-1)` / `navigate(1)`) where route hooks may not @@ -144,8 +152,13 @@ export function finalizeNavigationSpanFromRouterState(span: Span, routerState: R normalizePathname(routerState.location.pathname) === normalizePathname(pathname) ) { const parameterizedRoute = getParameterizedRoute(routerState); + const routeId = getRouteId(routerState); span.updateName(parameterizedRoute); - span.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: parameterizedRoute }); + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: parameterizedRoute, + ...(routeId && { [NAVIGATION_ROUTE_ID]: routeId }), + }); } } diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts index d5c44f4cf6ca..b9dceb215a21 100644 --- a/packages/react-router/test/client/createClientInstrumentation.test.ts +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -880,7 +880,11 @@ describe('navigation root parameterization', () => { }); expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, '/users/:id'); - expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ 'sentry.source': 'route', 'url.template': '/users/:id' }); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'sentry.source': 'route', + 'url.template': '/users/:id', + 'navigation.route.id': 'r', + }); }); it('does not rename the root span when the route has no pattern', async () => { diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index f425b7e80c21..25a35b45fbc2 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -114,6 +114,24 @@ describe('instrumentHydratedRouter', () => { }); }); + it('sets navigation.route.id from the leaf matched route id on state change', () => { + instrumentHydratedRouter(); + const callback = mockRouter.subscribe.mock.calls[0][0]; + const newState = { + location: { pathname: '/foo/bar' }, + matches: [{ route: { path: '/foo/:id', id: 'routes/foo.$id' } }], + navigation: { state: 'idle' }, + }; + mockRouter.navigate('/foo/bar'); + (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan); + callback(newState); + expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'url.template': '/foo/:id', + 'navigation.route.id': 'routes/foo.$id', + }); + }); + it('does not overwrite pageload origin when the pageload is still active', () => { // Regression test for #20784: a static-route pageload (where pathname == rootSpanName) was // being tagged with `origin: auto.navigation.react_router` because the subscribe callback diff --git a/packages/react-router/test/client/utils.test.ts b/packages/react-router/test/client/utils.test.ts index 858f1a595e59..e64fb05eaa9a 100644 --- a/packages/react-router/test/client/utils.test.ts +++ b/packages/react-router/test/client/utils.test.ts @@ -160,6 +160,22 @@ describe('finalizeNavigationSpanFromRouterState', () => { }); }); + it('sets navigation.route.id from the leaf matched route id', () => { + const span = { updateName: vi.fn(), setAttributes: vi.fn() } as any; + + finalizeNavigationSpanFromRouterState(span, { + location: { pathname: '/performance/' }, + matches: [{ route: { path: '', id: 'routes/performance/index' } }], + navigation: { state: 'idle' }, + } as any); + + expect(span.setAttributes).toHaveBeenLastCalledWith({ + 'sentry.source': 'route', + 'url.template': '/performance', + 'navigation.route.id': 'routes/performance/index', + }); + }); + it('sets url attributes but skips parameterization when router state is stale', () => { const span = { updateName: vi.fn(), setAttributes: vi.fn() } as any;