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
10 changes: 10 additions & 0 deletions packages/browser/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ export {
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
} from '@sentry/core/browser';

export {
createCachedRouteProvider,
createUrlRouteProvider,
resolveCurrentRoute,
getRouteProvider,
resolveRoute,
setRouteProvider,
} from '@sentry/core/browser';
export type { CachedRouteProvider, RouteProvider } from '@sentry/core/browser';

export { WINDOW } from './helpers';
export { BrowserClient } from './client';
export { makeFetchTransport } from './transports/fetch';
Expand Down
20 changes: 13 additions & 7 deletions packages/browser/src/integrations/bfcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import type { IntegrationFn, SpanAttributes } from '@sentry/core/browser';
import { debug, defineIntegration, getCurrentScope, metrics } from '@sentry/core/browser';
import { debug, defineIntegration, getCurrentScope, metrics, resolveCurrentRoute } from '@sentry/core/browser';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../helpers';

Expand Down Expand Up @@ -127,14 +127,20 @@ function _captureBFCacheReason({ reason, frame }: CollectedReason, routeName?: s
}

/**
* The segment name for a bfcache navigation, read from the scope rather than any span.
* The segment name for a bfcache navigation.
*
* A hit restore is silent to tracing (no pageload span), but the frozen scope still holds the last
* transaction name a downstream SDK (Vue/React/etc.) set before the freeze, so we reuse that. On a miss the
* page reloads with a fresh scope, so this is the new pageload name. Falls back to the raw pathname when unset.
* A registered route provider is preferred because it is parameterized, which matters more here than
* elsewhere: this ends up as a metric dimension, where a raw URL is unbounded cardinality.
*
* Without one we fall back to the scope. A hit restore is silent to tracing (no pageload span), but the
* frozen scope still holds the last transaction name a downstream SDK (Vue/React/etc.) set before the
* freeze, so we reuse that. On a miss the page reloads with a fresh scope, so this is the new pageload
* name. Falls back to the raw pathname when unset.
*
* Exported for tests only.
*/
function _getSegmentName(): string | undefined {
return getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname;
export function _getSegmentName(): string | undefined {
return resolveCurrentRoute() || getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname;
}

/**
Expand Down
50 changes: 47 additions & 3 deletions packages/browser/test/integrations/bfcache.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { debug } from '@sentry/core/browser';
import { _collectNotRestoredReasons, _resolveMaxReasons } from '../../src/integrations/bfcache';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { debug, getCurrentScope, setCurrentClient, setRouteProvider } from '@sentry/core/browser';
import { BrowserClient } from '../../src/client';
import { _collectNotRestoredReasons, _getSegmentName, _resolveMaxReasons } from '../../src/integrations/bfcache';
import { WINDOW } from '../../src/helpers';
import { getDefaultBrowserClientOptions } from '../helper/browser-client-options';

describe('bfcacheIntegration', () => {
describe('_getSegmentName', () => {
beforeEach(() => {
getCurrentScope().setTransactionName(undefined);
const client = new BrowserClient(getDefaultBrowserClientOptions());
setCurrentClient(client);
client.init();
});

afterEach(() => {
delete (WINDOW as { location?: unknown }).location;
getCurrentScope().setTransactionName(undefined);
getCurrentScope().setClient(undefined);
});

it('prefers the parameterized route from a registered provider', () => {
getCurrentScope().setTransactionName('/users/42');
setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' });

expect(_getSegmentName()).toBe('/users/:id');
});

it('falls back to the scope when no provider is registered', () => {
getCurrentScope().setTransactionName('/users/:id');

expect(_getSegmentName()).toBe('/users/:id');
});

it('falls back to the scope when the provider matches no route', () => {
getCurrentScope().setTransactionName('/users/:id');
setRouteProvider({ resolveRoute: () => undefined, resolveCurrentRoute: () => undefined });

expect(_getSegmentName()).toBe('/users/:id');
});

it('falls back to the raw pathname when nothing else knows the route', () => {
(WINDOW as { location?: unknown }).location = { pathname: '/users/42' };

expect(_getSegmentName()).toBe('/users/42');
});
});

describe('_resolveMaxReasons', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/browser-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export {

export { startIdleSpan } from './tracing/idleSpan';

export {
createCachedRouteProvider,
createUrlRouteProvider,
resolveCurrentRoute,
getRouteProvider,
resolveRoute,
setRouteProvider,
} from './routing';
export type { CachedRouteProvider, RouteProvider } from './routing';

export { spanStreamingIntegration } from './integrations/browserSpanStreaming';

export {
Expand Down
171 changes: 171 additions & 0 deletions packages/core/src/routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { Client } from './client';
import { getClient } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import { getLocationHref } from './utils/browser';
import { LRUMap } from './utils/lru';
import { debug } from './utils/debug-logger';

/**
* Resolves URLs to low-cardinality route names.
*
* Framework SDKs register one so that everything the SDK names after a route (span names, the scope's
* transaction name, metric and span segment attributes) gets the parameterized route instead of the raw
* URL, without each integration having to reach into the framework's router itself.
*
* A provider only answers "which route is this", never what the caller does with the answer.
*/
export interface RouteProvider {
/**
* Resolves a route name for a specific URL, e.g. `/users/42` -> `/users/:id`.
*
* Returns `undefined` when the URL matches no known route. Must answer for the URL it is given rather
* than for wherever the router currently is, so that callers can resolve a URL they captured earlier
* (a web vital reported after a soft navigation, for example).
*/
resolveRoute(url: URL): string | undefined;

/**
* Resolves the route the app is currently on.
*
* Routers whose location lives in the address bar can delegate to `resolveRoute`, which is what
* {@link createUrlRouteProvider} does. Routers that keep their own location (memory and hash routers)
* have to answer from that location instead: for those, `location.href` is the unchanging shell URL
* and would bucket every route together.
*/
resolveCurrentRoute(): string | undefined;
}

const CLIENT_ROUTE_PROVIDERS = new WeakMap<Client, RouteProvider>();

/**
* Registers the route provider for a client, replacing any previously registered one.
*
* Register during an integration's `setup` rather than `afterAllSetup`: the pageload span is named
* while `browserTracingIntegration` sets up, so a provider registered later can only rename it after
* the fact.
*
* A client holds one provider. An app running two routers (a framework migration, or a shell plus an
* island) registers twice and the last one wins, so the first router's routes stop resolving.
*/
export function setRouteProvider(provider: RouteProvider, client: Client | undefined = getClient()): void {
if (!client) {
DEBUG_BUILD && debug.warn('Cannot set a route provider without a client.');
return;
}

if (DEBUG_BUILD && CLIENT_ROUTE_PROVIDERS.has(client)) {
debug.warn(
'A route provider is already registered for this client and will be replaced. Routes only the previous provider knows about will no longer resolve.',
);
}

CLIENT_ROUTE_PROVIDERS.set(client, provider);
}

/**
* Returns the route provider registered for a client, if any.
*/
export function getRouteProvider(client: Client | undefined = getClient()): RouteProvider | undefined {
return client && CLIENT_ROUTE_PROVIDERS.get(client);
}

/**
* Resolves a URL to a low-cardinality route name, e.g. `/users/42` -> `/users/:id`.
*
* Returns `undefined` when no route provider is registered or the URL matches no route. Callers pick
* their own fallback, because the right one differs: a span name falls back to a low-cardinality
* constant, the scope's transaction name to the raw path.
*/
export function resolveRoute(url: string | URL, client: Client | undefined = getClient()): string | undefined {
const provider = getRouteProvider(client);
if (!provider) {
return undefined;
}

const urlObject = typeof url === 'string' ? toURLObject(url) : url;
if (!urlObject) {
return undefined;
}

return callProvider(() => provider.resolveRoute(urlObject));
}

/**
* Resolves the route the app is currently on.
*
* Returns `undefined` when no route provider is registered or the current location matches no route.
*/
export function resolveCurrentRoute(client: Client | undefined = getClient()): string | undefined {
const provider = getRouteProvider(client);

return provider && callProvider(() => provider.resolveCurrentRoute());
}

/**
* Builds a {@link RouteProvider} for a router whose location is the browser's, which covers every
* router except memory and hash routers.
*/
export function createUrlRouteProvider(resolveRouteFromUrl: (url: URL) => string | undefined): RouteProvider {
return {
resolveRoute: resolveRouteFromUrl,
resolveCurrentRoute: () => {
const urlObject = toURLObject(getLocationHref());

return urlObject && resolveRouteFromUrl(urlObject);
},
};
}

/**
* A {@link RouteProvider} that answers from routes it has been told about, rather than by matching.
*/
export interface CachedRouteProvider extends RouteProvider {
/** Records the route name a router reported for a path. Ignores empty values. */
record(pathname: string | undefined, routeName: string | null | undefined): void;
}

/**
* Builds a route provider for a router with no usable matcher, which can only report the route it is
* on as it gets there (SvelteKit's `page.route.id`, Solid Router's current matches).
*
* A URL the app has not visited resolves to `undefined`, which includes the first pageload until the
* router reports. Backed by an LRU so a long-lived app visiting many URLs can't grow it without end,
* and so routes that keep being resolved outlive ones passed through once.
*/
export function createCachedRouteProvider(maxEntries: number = 50): CachedRouteProvider {
const routeNames = new LRUMap<string, string>(maxEntries);

return {
...createUrlRouteProvider(url => routeNames.get(url.pathname)),
record(pathname, routeName) {
if (pathname && routeName) {
routeNames.set(pathname, routeName);
}
},
};
}

/**
* Normalizes to a real `URL` so providers never have to parse, and relative locations (which memory
* routers hand around) resolve against the document.
*/
function toURLObject(url: string): URL | undefined {
try {
return new URL(url, getLocationHref() || undefined);
} catch {
return undefined;
}
}

/**
* Route providers are framework code we don't control, so a throw must not take down whatever the SDK
* was naming.
*/
function callProvider(resolve: () => string | undefined): string | undefined {
try {
return resolve() || undefined;
} catch (error) {
DEBUG_BUILD && debug.warn('Route provider threw while resolving a route:', error);
return undefined;
}
}
Loading
Loading