diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index ff6bbf609abe..aaf7ab42c13c 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, @@ -264,7 +265,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( { @@ -291,7 +292,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( { @@ -377,13 +378,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 70b8e195f090..f0685caf2121 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, + getRouteTemplate, normalizePathname, resolveNavigateAbsoluteUrl, resolveNavigateArg, @@ -47,6 +49,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, getRouteTemplate(router.state)); + const pageloadSpan = getActiveRootSpan(); if (pageloadSpan) { @@ -117,6 +121,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, getRouteTemplate(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 94044c096c94..743aa9fbfa62 100644 --- a/packages/react-router/src/client/utils.ts +++ b/packages/react-router/src/client/utils.ts @@ -133,7 +133,7 @@ export function normalizePathname(pathname: string): string { * relative to their parent, and index and layout routes carry none at all, so the leaf on its own * is either a fragment of the route (`edit`) or missing entirely. */ -function getRouteTemplate(routerState: RouterState): string | undefined { +export function getRouteTemplate(routerState: RouterState): string | undefined { const { matches } = routerState; if (!matches.length) {