Skip to content

Commit 116ee68

Browse files
authored
feat(core): Add route provider API for parameterized route resolution (#23551)
Adds the route provider API to core so framework SDKs can register how to resolve a URL to a parameterized route, and wires up `bfcacheMetricsIntegration` as the first consumer since that is where the fragmentation currently produces a raw URL on a metric dimension. `bfcacheMetrics` keeps its pathname fallback, so nothing changes until a provider is registered. closes #23556
1 parent cc2bde7 commit 116ee68

9 files changed

Lines changed: 568 additions & 10 deletions

File tree

‎dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,33 @@ test('a hit carries the parameterized route that was on the scope before the fre
277277
expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id');
278278
});
279279

280+
// A registered route provider is preferred over the scope, which is what lets a framework SDK name the
281+
// segment even when nothing stamped a route on the scope. The scope holds a different name here to prove it.
282+
test('a hit carries the route resolved by a registered route provider', async ({ page }) => {
283+
const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit'));
284+
285+
await page.goto('/');
286+
await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1');
287+
288+
await page.evaluate(() => {
289+
const { Sentry } = window as unknown as { Sentry: typeof import('@sentry/browser') };
290+
Sentry.getCurrentScope().setTransactionName('/from-scope');
291+
Sentry.setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' });
292+
});
293+
294+
await page.click('#to-page-2');
295+
await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2');
296+
await page.waitForTimeout(500);
297+
298+
await page.evaluate(() => history.back());
299+
await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, {
300+
timeout: 5000,
301+
});
302+
303+
const hit = await hitPromise;
304+
expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id');
305+
});
306+
280307
// Without a routing integration the scope has no transaction name, so the segment name falls back to
281308
// `location.pathname` (page 1 is served at '/'). This matches how browserTracing names an unrouted pageload.
282309
test('a hit falls back to the raw pathname when no route is on the scope', async ({ page }) => {

‎packages/browser-utils/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ export { isBotUserAgent } from './isBotUserAgent';
2929

3030
export { getLocationHref } from './getLocationHref';
3131

32+
export {
33+
createCachedRouteProvider,
34+
createUrlRouteProvider,
35+
getRouteProvider,
36+
resolveCurrentRoute,
37+
resolveRoute,
38+
setRouteProvider,
39+
} from './routing';
40+
export type { CachedRouteProvider, RouteProvider } from './routing';
41+
3242
export { userTimingIntegration } from './performance/userTiming';
3343

3444
export { extractNetworkProtocol } from './performance/utils';
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { Client } from '@sentry/core';
2+
import { debug, getClient, LRUMap, parseStringToURLObject, safeCallback } from '@sentry/core';
3+
import { DEBUG_BUILD } from './debug-build';
4+
import { getLocationHref } from './getLocationHref';
5+
6+
/** The parts of a URL a route depends on. A `URL` satisfies it. */
7+
type RouteUrl = Pick<URL, 'pathname' | 'search' | 'hash'>;
8+
9+
/**
10+
* Resolves URLs to low-cardinality route names.
11+
*
12+
* Framework SDKs register one so that everything the SDK names after a route (span names, the scope's
13+
* transaction name, metric and span segment attributes) gets the parameterized route instead of the raw
14+
* URL, without each integration having to reach into the framework's router itself.
15+
*/
16+
export interface RouteProvider {
17+
/**
18+
* Resolves a URL path template for a specific URL, e.g. `/users/42` -> `/users/:id`.
19+
*
20+
* Must return a path template, never a route identifier. Routers that name routes independently of
21+
* their path (Vue Router's `route.name`, Ember's `posts.show`) have to return the matched path
22+
* instead: callers set `url.template` from this, and an identifier is not a template. An SDK that
23+
* wants to name its span after the identifier still can, on the span itself.
24+
*
25+
* Returns `undefined` when the URL matches no known route. Must answer for the URL it is given rather
26+
* than for wherever the router currently is, so that callers can resolve a URL they captured earlier
27+
* (a web vital reported after a soft navigation, for example).
28+
*/
29+
resolveRoute(url: RouteUrl): string | undefined;
30+
31+
/**
32+
* Resolves the route the app is currently on.
33+
*
34+
* Routers whose location lives in the address bar can delegate to `resolveRoute`, which is what
35+
* {@link createUrlRouteProvider} does. Routers that keep their own location (memory and hash routers)
36+
* have to answer from that location instead: for those, `location.href` is the unchanging shell URL
37+
* and would bucket every route together.
38+
*/
39+
resolveCurrentRoute(): string | undefined;
40+
}
41+
42+
const CLIENT_ROUTE_PROVIDERS = new WeakMap<Client, RouteProvider>();
43+
44+
/**
45+
* Registers the route provider for a client, replacing any previously registered one. `BrowserClient`
46+
* calls this with its `routeProvider` option.
47+
*
48+
* Prefer the `routeProvider` option where the provider is known at `init`: the pageload span is named
49+
* while `browserTracingIntegration` sets up, so a provider registered after `init` can only rename it
50+
* after the fact.
51+
*
52+
* A client holds one provider. An app running two routers (a framework migration, or a shell plus an
53+
* island) registers twice and the last one wins, so the first router's routes stop resolving.
54+
*/
55+
export function setRouteProvider(provider: RouteProvider, client: Client | undefined = getClient()): void {
56+
if (!client) {
57+
DEBUG_BUILD && debug.warn('Cannot set a route provider without a client.');
58+
return;
59+
}
60+
61+
if (DEBUG_BUILD && getRouteProvider(client)) {
62+
debug.warn(
63+
'A route provider is already registered for this client and will be replaced. Routes only the previous provider knows about will no longer resolve.',
64+
);
65+
}
66+
67+
CLIENT_ROUTE_PROVIDERS.set(client, provider);
68+
}
69+
70+
/**
71+
* Returns the route provider registered for a client.
72+
*/
73+
export function getRouteProvider(client: Client | undefined = getClient()): RouteProvider | undefined {
74+
return client && CLIENT_ROUTE_PROVIDERS.get(client);
75+
}
76+
77+
/**
78+
* Resolves a URL to a low-cardinality route name, e.g. `/users/42` -> `/users/:id`.
79+
*
80+
* Returns `undefined` when no route provider is registered or the URL matches no route. Callers pick
81+
* their own fallback, because the right one differs: a span name falls back to a low-cardinality
82+
* constant, the scope's transaction name to the raw path.
83+
*/
84+
export function resolveRoute(url: string | URL, client: Client | undefined = getClient()): string | undefined {
85+
const provider = getRouteProvider(client);
86+
if (!provider) {
87+
return undefined;
88+
}
89+
90+
const urlObject = typeof url === 'string' ? parseLocation(url) : url;
91+
if (!urlObject) {
92+
return undefined;
93+
}
94+
95+
return safeCallback(
96+
DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '',
97+
() => provider.resolveRoute(urlObject) || undefined,
98+
() => undefined,
99+
);
100+
}
101+
102+
/**
103+
* Resolves the route the app is currently on.
104+
*
105+
* Returns `undefined` when no route provider is registered or the current location matches no route.
106+
*/
107+
export function resolveCurrentRoute(client: Client | undefined = getClient()): string | undefined {
108+
const provider = getRouteProvider(client);
109+
110+
return safeCallback(
111+
DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '',
112+
() => provider?.resolveCurrentRoute() || undefined,
113+
() => undefined,
114+
);
115+
}
116+
117+
/**
118+
* Builds a {@link RouteProvider} for a router whose location is the browser's, which covers every
119+
* router except memory and hash routers.
120+
*/
121+
export function createUrlRouteProvider(resolveRouteFromUrl: (url: RouteUrl) => string | undefined): RouteProvider {
122+
return {
123+
resolveRoute: resolveRouteFromUrl,
124+
resolveCurrentRoute: () => {
125+
const urlObject = parseLocation(getLocationHref());
126+
127+
return urlObject && resolveRouteFromUrl(urlObject);
128+
},
129+
};
130+
}
131+
132+
/**
133+
* A {@link RouteProvider} that answers from routes it has been told about, rather than by matching.
134+
*/
135+
export interface CachedRouteProvider extends RouteProvider {
136+
/** Records the route name a router reported for a path. Ignores empty values. */
137+
record(pathname: string | undefined, routeName: string | null | undefined): void;
138+
}
139+
140+
/**
141+
* Builds a route provider for a router with no usable matcher, which can only report the route it is
142+
* on as it gets there (SvelteKit's `page.route.id`, Solid Router's current matches).
143+
*
144+
* A URL the app has not visited resolves to `undefined`, which includes the first pageload until the
145+
* router reports. Backed by an LRU so a long-lived app visiting many URLs can't grow it without end,
146+
* and so routes that keep being resolved outlive ones passed through once.
147+
*/
148+
export function createCachedRouteProvider(maxEntries: number = 50): CachedRouteProvider {
149+
const routeNames = new LRUMap<string, string>(maxEntries);
150+
151+
return {
152+
...createUrlRouteProvider(url => routeNames.get(url.pathname)),
153+
record(pathname, routeName) {
154+
if (pathname && routeName) {
155+
routeNames.set(pathname, routeName);
156+
}
157+
},
158+
};
159+
}
160+
161+
/**
162+
* Parses up front so providers never have to, and resolves relative locations (which memory routers
163+
* hand around) against the document.
164+
*/
165+
function parseLocation(url: string): RouteUrl | undefined {
166+
// Without a document `getLocationHref()` is empty, which would otherwise parse as `/`.
167+
return url ? parseStringToURLObject(url, getLocationHref() || undefined) : undefined;
168+
}

0 commit comments

Comments
 (0)