Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions packages/react-router/src/client/createClientInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
{
Expand All @@ -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(
{
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/src/client/hydratedRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-router/src/client/routeCache.ts
Original file line number Diff line number Diff line change
@@ -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();
6 changes: 6 additions & 0 deletions packages/react-router/src/client/tracingIntegration.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 13 additions & 1 deletion packages/react-router/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading