diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro new file mode 100644 index 000000000000..ff0ee0f13d59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro @@ -0,0 +1,18 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; +--- + + + +
+
+ + diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts new file mode 100644 index 000000000000..09e444a04c26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the route the middleware renders into the page, so this fails if that route +// never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + await page.locator('#resolve-route').click(); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/[id]'); +}); diff --git a/packages/astro/src/client/routeProvider.ts b/packages/astro/src/client/routeProvider.ts new file mode 100644 index 000000000000..6aeec7bdfd02 --- /dev/null +++ b/packages/astro/src/client/routeProvider.ts @@ -0,0 +1,40 @@ +import type { RouteProvider } from '@sentry/browser'; +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: { pathname: string }): 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..14effd00cacb 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBr import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } 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; @@ -15,6 +16,9 @@ declare const __SENTRY_TRACING__: boolean; export function init(options: BrowserOptions): Client | undefined { const opts = { defaultIntegrations: getDefaultIntegrations(options), + // The middleware injects the route into the document, so route parameterization works from `init` on, + // even with tracing disabled. + routeProvider: createAstroRouteProvider(), ...options, }; 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(); + }); +}); diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts index 9225af26b564..041034f9164e 100644 --- a/packages/astro/test/client/sdk.test.ts +++ b/packages/astro/test/client/sdk.test.ts @@ -92,6 +92,17 @@ describe('Sentry client SDK', () => { }); }); + it('passes the Astro route provider unless the user passed one', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + expect(browserInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', routeProvider }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); });