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();
});