diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 7d17d62e74f8..514036462157 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -18,6 +18,7 @@ import { import { startSpan } from '@sentry/core/browser'; import type { ClientInstrumentation } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; +import { routeProvider } from './routeCache'; import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; import { resolveNavigateAbsoluteUrl, @@ -252,7 +253,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, urlPath); await startSpan( { @@ -279,7 +280,7 @@ export function createSentryClientInstrumentation( const urlPath = getPathFromRequest(info.request); const pattern = normalizeRoutePath(getPattern(info)); const routePattern = pattern || urlPath; - updateRootSpanRoute(routePattern, !!pattern); + updateRootSpanRoute(routePattern, !!pattern, urlPath); await startSpan( { @@ -365,13 +366,19 @@ 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`). + * transaction reflects the parameterized route pattern (e.g. `/users/:id`), and records the route + * against `urlPath` for the route provider. */ -function updateRootSpanRoute(routeName: string, hasPattern: boolean): void { +function updateRootSpanRoute(routeName: string, hasPattern: boolean, urlPath: string): void { if (!hasPattern) { return; } + // The instrumentation API resolves routes the hydrated router subscription never sees, so feed the + // provider from here too. Keyed on the request path rather than `location`, because route hooks + // run during the navigation, before the URL commits. + routeProvider.record(urlPath, routeName); + const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); if (!rootSpan) { diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index f8e517c500ef..c531ee123dc5 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -15,8 +15,10 @@ import { import type { DataRouter } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; import { isClientInstrumentationApiUsed } from './createClientInstrumentation'; +import { routeProvider } from './routeCache'; import { finalizeNavigationSpanFromRouterState, + getMatchedRoute, getParameterizedRoute, normalizePathname, resolveNavigateAbsoluteUrl, @@ -46,6 +48,8 @@ export function instrumentHydratedRouter(): void { if (router) { // The first time we hit the router, we try to update the pageload transaction + routeProvider.record(router.state.location.pathname, getMatchedRoute(router.state)); + const pageloadSpan = getActiveRootSpan(); if (pageloadSpan) { @@ -121,6 +125,8 @@ export function instrumentHydratedRouter(): void { // whose route info only became available after `trySubscribe`, e.g. lazy routes) with the // parameterized route. router.subscribe(newState => { + routeProvider.record(newState.location.pathname, getMatchedRoute(newState)); + const rootSpan = getActiveRootSpan(); if (!rootSpan) { diff --git a/packages/react-router/src/client/routeCache.ts b/packages/react-router/src/client/routeCache.ts new file mode 100644 index 000000000000..418d30ee8445 --- /dev/null +++ b/packages/react-router/src/client/routeCache.ts @@ -0,0 +1,6 @@ +import { createCachedRouteProvider } from '@sentry/core'; + +// The Data Router exposes its matches only through router state, and the package has no runtime +// dependency on `react-router` to call `matchRoutes` with. The provider answers from routes the +// hydrated router has already resolved instead. +export const routeProvider = createCachedRouteProvider(); diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts index ae7cc66910e8..44bc1ccb3511 100644 --- a/packages/react-router/src/client/tracingIntegration.ts +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -1,11 +1,13 @@ import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/browser'; import type { Integration } from '@sentry/core'; +import { setRouteProvider } from '@sentry/core'; import type { ClientInstrumentation } from 'react-router'; import { createSentryClientInstrumentation, type CreateSentryClientInstrumentationOptions, } from './createClientInstrumentation'; import { instrumentHydratedRouter } from './hydratedRouter'; +import { routeProvider } from './routeCache'; /** * Options for the React Router tracing integration. @@ -53,6 +55,10 @@ export function reactRouterTracingIntegration( return { ...browserTracingIntegrationInstance, name: 'ReactRouterTracingIntegration', + setup(client) { + setRouteProvider(routeProvider, client); + browserTracingIntegrationInstance.setup?.(client); + }, afterAllSetup(client) { browserTracingIntegrationInstance.afterAllSetup(client); instrumentHydratedRouter(); diff --git a/packages/react-router/src/client/utils.ts b/packages/react-router/src/client/utils.ts index c8e522b182b2..436bfdfcfa4a 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -119,8 +119,20 @@ export function normalizePathname(pathname: string): string { } export function getParameterizedRoute(routerState: RouterState): string { + return getMatchedRoute(routerState) ?? normalizePathname(routerState.location.pathname); +} + +/** + * The parameterized route the router matched, or `undefined` when nothing matched. + * + * Unlike {@link getParameterizedRoute} this does not fall back to the raw pathname, so callers that + * must not treat a URL as a route (the route provider) can tell the two apart. + */ +export function getMatchedRoute(routerState: RouterState): string | undefined { const lastMatch = routerState.matches[routerState.matches.length - 1]; - return normalizePathname(lastMatch?.route.path || routerState.location.pathname); + const path = lastMatch?.route.path; + + return path ? normalizePathname(path) : undefined; } /**