diff --git a/packages/astro/src/client/routeProvider.ts b/packages/astro/src/client/routeProvider.ts new file mode 100644 index 000000000000..ee49fd88f978 --- /dev/null +++ b/packages/astro/src/client/routeProvider.ts @@ -0,0 +1,40 @@ +import type { RouteProvider } from '@sentry/core'; +import { WINDOW } from '@sentry/browser'; + +/** + * Reads the parameterized route the Astro middleware injects into the document it rendered. + */ +function readRouteNameFromMeta(): string | undefined { + const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; + const content = optionalDocument?.querySelector('meta[name=sentry-route-name]')?.getAttribute('content'); + if (!content) { + return undefined; + } + + try { + return decodeURIComponent(content); + } catch { + // The middleware encodes the route, so a value we can't decode isn't one we put there. + return undefined; + } +} + +/** + * A route provider backed by the `sentry-route-name` meta tag the Astro middleware injects. + * + * Unlike a manifest-backed provider this is not a matcher: the document only ever describes the page + * it rendered, so a URL other than the current one resolves to `undefined` rather than a guess. + * + * The tag does track client-side navigations. Astro's `ClientRouter` swaps it during + * `astro:after-swap`, at the same moment `location` changes, so reading it per call stays correct + * across soft navigations and back/forward. It is only stale *during* a navigation, before the swap, + * which is why `resolveRoute` refuses to answer for anything but the current path. + */ +export function createAstroRouteProvider(): RouteProvider { + const isCurrentPath = (url: URL): boolean => url.pathname === WINDOW.location?.pathname; + + return { + resolveRoute: url => (isCurrentPath(url) ? readRouteNameFromMeta() : undefined), + resolveCurrentRoute: readRouteNameFromMeta, + }; +} diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 21c5770f255f..648664cf600d 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -1,8 +1,9 @@ import type { BrowserOptions } from '@sentry/browser'; import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, setRouteProvider } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { createAstroRouteProvider } from './routeProvider'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -20,7 +21,14 @@ export function init(options: BrowserOptions): Client | undefined { applySdkMetadata(opts, 'astro', ['astro', 'browser']); - return initBrowserSdk(opts); + const client = initBrowserSdk(opts); + + // Registered here rather than from the tracing integration so route parameterization does not + // depend on tracing: the middleware injects the route into the document, so anything that needs a + // route name (bfcache metrics, web vitals) can resolve one even with tracing disabled. + setRouteProvider(createAstroRouteProvider(), client); + + return client; } function getDefaultIntegrations(options: BrowserOptions): Integration[] { diff --git a/packages/astro/test/client/routeProvider.test.ts b/packages/astro/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..a81080eb37ab --- /dev/null +++ b/packages/astro/test/client/routeProvider.test.ts @@ -0,0 +1,65 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createAstroRouteProvider } from '../../src/client/routeProvider'; + +let originalDocument: unknown; +let originalLocation: unknown; + +/** Mirrors what the Astro middleware injects: an encoded route on a `sentry-route-name` meta tag. */ +function renderPage(pathname: string, routeName: string | undefined): void { + const meta = routeName + ? { getAttribute: (attr: string) => (attr === 'content' ? encodeURIComponent(routeName) : null) } + : null; + + (GLOBAL_OBJ as { document?: unknown }).document = { + querySelector: (selector: string) => (selector === 'meta[name=sentry-route-name]' ? meta : null), + }; + (GLOBAL_OBJ as { location?: unknown }).location = { pathname }; +} + +describe('createAstroRouteProvider', () => { + beforeEach(() => { + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + originalLocation = (GLOBAL_OBJ as { location?: unknown }).location; + }); + + afterEach(() => { + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + (GLOBAL_OBJ as { location?: unknown }).location = originalLocation; + }); + + it('resolves the current route from the meta tag', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBe('/users/[id]'); + }); + + it('resolves a URL that is the current page', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/users/1'))).toBe('/users/[id]'); + }); + + it('refuses to answer for a URL that is not the current page', () => { + renderPage('/users/1', '/users/[id]'); + + // The document only ever describes the page it rendered, so guessing here would be wrong. This is + // also what keeps a navigation from being named after the route it is leaving. + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/posts/hello'))).toBeUndefined(); + }); + + it('follows a client-side navigation, since the meta tag is swapped with the document', () => { + const provider = createAstroRouteProvider(); + renderPage('/users/1', '/users/[id]'); + expect(provider.resolveCurrentRoute()).toBe('/users/[id]'); + + renderPage('/posts/hello', '/posts/[slug]'); + expect(provider.resolveCurrentRoute()).toBe('/posts/[slug]'); + }); + + it('returns undefined when the middleware injected no route', () => { + renderPage('/users/1', undefined); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBeUndefined(); + }); +});