Skip to content

Commit bad3325

Browse files
authored
feat(astro): Register astro route provider (#23792)
Registers a route provider for Astro from the `sentry-route-name` meta tag the middleware already injects into every rendered document. Registered from `init()` rather than the tracing integration, so route parameterization no longer depends on tracing being enabled. Unlike the Next.js and Remix providers 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. That guard is also what stops a navigation being named after the route it is leaving. I checked the soft-navigation behaviour against Astro 5.18 with a throwaway app rather than assuming it. `ClientRouter` swaps the tag during `astro:after-swap`, at the same moment `location` changes, and does not accumulate duplicates, so reading it per call stays correct across client-side navigations and back/forward. It is only stale *during* a navigation, before the swap, which the current-path guard already excludes. Part of #23556
1 parent 97f19ed commit bad3325

6 files changed

Lines changed: 148 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
import Layout from '../../layouts/Layout.astro';
3+
4+
export const prerender = false;
5+
---
6+
7+
<Layout title="Route provider">
8+
<button id="resolve-route">Resolve route</button>
9+
<div id="resolved-route"></div>
10+
</Layout>
11+
12+
<script>
13+
import * as Sentry from '@sentry/astro';
14+
15+
document.getElementById('resolve-route')?.addEventListener('click', () => {
16+
document.getElementById('resolved-route')!.textContent = Sentry.resolveCurrentRoute() ?? 'unresolved';
17+
});
18+
</script>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
// The route provider reads the route the middleware renders into the page, so this fails if that route
4+
// never reaches the browser.
5+
test('resolves the parameterized route through the route provider', async ({ page }) => {
6+
await page.goto('/route-provider/123');
7+
await page.locator('#resolve-route').click();
8+
9+
await expect(page.locator('#resolved-route')).toHaveText('/route-provider/[id]');
10+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { RouteProvider } from '@sentry/browser';
2+
import { WINDOW } from '@sentry/browser';
3+
4+
/**
5+
* Reads the parameterized route the Astro middleware injects into the document it rendered.
6+
*/
7+
function readRouteNameFromMeta(): string | undefined {
8+
const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined;
9+
const content = optionalDocument?.querySelector('meta[name=sentry-route-name]')?.getAttribute('content');
10+
if (!content) {
11+
return undefined;
12+
}
13+
14+
try {
15+
return decodeURIComponent(content);
16+
} catch {
17+
// The middleware encodes the route, so a value we can't decode isn't one we put there.
18+
return undefined;
19+
}
20+
}
21+
22+
/**
23+
* A route provider backed by the `sentry-route-name` meta tag the Astro middleware injects.
24+
*
25+
* Unlike a manifest-backed provider this is not a matcher: the document only ever describes the page
26+
* it rendered, so a URL other than the current one resolves to `undefined` rather than a guess.
27+
*
28+
* The tag does track client-side navigations. Astro's `ClientRouter` swaps it during
29+
* `astro:after-swap`, at the same moment `location` changes, so reading it per call stays correct
30+
* across soft navigations and back/forward. It is only stale *during* a navigation, before the swap,
31+
* which is why `resolveRoute` refuses to answer for anything but the current path.
32+
*/
33+
export function createAstroRouteProvider(): RouteProvider {
34+
const isCurrentPath = (url: { pathname: string }): boolean => url.pathname === WINDOW.location?.pathname;
35+
36+
return {
37+
resolveRoute: url => (isCurrentPath(url) ? readRouteNameFromMeta() : undefined),
38+
resolveCurrentRoute: readRouteNameFromMeta,
39+
};
40+
}

‎packages/astro/src/client/sdk.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBr
33
import type { Client, Integration } from '@sentry/core';
44
import { applySdkMetadata } from '@sentry/core';
55
import { browserTracingIntegration } from './browserTracingIntegration';
6+
import { createAstroRouteProvider } from './routeProvider';
67

78
// Tree-shakable guard to remove all code related to tracing
89
declare const __SENTRY_TRACING__: boolean;
@@ -15,6 +16,9 @@ declare const __SENTRY_TRACING__: boolean;
1516
export function init(options: BrowserOptions): Client | undefined {
1617
const opts = {
1718
defaultIntegrations: getDefaultIntegrations(options),
19+
// The middleware injects the route into the document, so route parameterization works from `init` on,
20+
// even with tracing disabled.
21+
routeProvider: createAstroRouteProvider(),
1822
...options,
1923
};
2024

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
import { createAstroRouteProvider } from '../../src/client/routeProvider';
4+
5+
let originalDocument: unknown;
6+
let originalLocation: unknown;
7+
8+
/** Mirrors what the Astro middleware injects: an encoded route on a `sentry-route-name` meta tag. */
9+
function renderPage(pathname: string, routeName: string | undefined): void {
10+
const meta = routeName
11+
? { getAttribute: (attr: string) => (attr === 'content' ? encodeURIComponent(routeName) : null) }
12+
: null;
13+
14+
(GLOBAL_OBJ as { document?: unknown }).document = {
15+
querySelector: (selector: string) => (selector === 'meta[name=sentry-route-name]' ? meta : null),
16+
};
17+
(GLOBAL_OBJ as { location?: unknown }).location = { pathname };
18+
}
19+
20+
describe('createAstroRouteProvider', () => {
21+
beforeEach(() => {
22+
originalDocument = (GLOBAL_OBJ as { document?: unknown }).document;
23+
originalLocation = (GLOBAL_OBJ as { location?: unknown }).location;
24+
});
25+
26+
afterEach(() => {
27+
(GLOBAL_OBJ as { document?: unknown }).document = originalDocument;
28+
(GLOBAL_OBJ as { location?: unknown }).location = originalLocation;
29+
});
30+
31+
it('resolves the current route from the meta tag', () => {
32+
renderPage('/users/1', '/users/[id]');
33+
34+
expect(createAstroRouteProvider().resolveCurrentRoute()).toBe('/users/[id]');
35+
});
36+
37+
it('resolves a URL that is the current page', () => {
38+
renderPage('/users/1', '/users/[id]');
39+
40+
expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/users/1'))).toBe('/users/[id]');
41+
});
42+
43+
it('refuses to answer for a URL that is not the current page', () => {
44+
renderPage('/users/1', '/users/[id]');
45+
46+
// The document only ever describes the page it rendered, so guessing here would be wrong. This is
47+
// also what keeps a navigation from being named after the route it is leaving.
48+
expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/posts/hello'))).toBeUndefined();
49+
});
50+
51+
it('follows a client-side navigation, since the meta tag is swapped with the document', () => {
52+
const provider = createAstroRouteProvider();
53+
renderPage('/users/1', '/users/[id]');
54+
expect(provider.resolveCurrentRoute()).toBe('/users/[id]');
55+
56+
renderPage('/posts/hello', '/posts/[slug]');
57+
expect(provider.resolveCurrentRoute()).toBe('/posts/[slug]');
58+
});
59+
60+
it('returns undefined when the middleware injected no route', () => {
61+
renderPage('/users/1', undefined);
62+
63+
expect(createAstroRouteProvider().resolveCurrentRoute()).toBeUndefined();
64+
});
65+
});

‎packages/astro/test/client/sdk.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ describe('Sentry client SDK', () => {
9292
});
9393
});
9494

95+
it('passes the Astro route provider unless the user passed one', () => {
96+
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' });
97+
expect(browserInit).toHaveBeenLastCalledWith(
98+
expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }),
99+
);
100+
101+
const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' };
102+
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', routeProvider });
103+
expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider }));
104+
});
105+
95106
it('returns client from init', () => {
96107
expect(init({})).not.toBeUndefined();
97108
});

0 commit comments

Comments
 (0)