Skip to content

Commit 726c868

Browse files
authored
feat(astro): Implement Request Route Parametrization for Astro 5 (#17105)
Route Parametrization for Server Requests in Astro 5. The route information is gathered during build-time. During runtime, the route information is matched to use the parametrized route information during runtime Part of #16686
1 parent 22aa741 commit 726c868

File tree

7 files changed

+158
-12
lines changed

7 files changed

+158
-12
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
import Layout from '../../layouts/Layout.astro';
3+
---
4+
5+
<Layout>
6+
<h1>User Settings</h1>
7+
</Layout>

dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,9 @@ test.describe('nested SSR routes (client, server, server request)', () => {
243243
},
244244
});
245245

246-
// Server HTTP request transaction - should be parametrized (todo: currently not parametrized)
246+
// Server HTTP request transaction
247247
expect(serverHTTPServerRequestTxn).toMatchObject({
248-
transaction: 'GET /api/user/myUsername123.json', // todo: should be parametrized to 'GET /api/user/[userId].json'
248+
transaction: 'GET /api/user/[userId].json',
249249
transaction_info: { source: 'route' },
250250
contexts: {
251251
trace: {
@@ -294,7 +294,7 @@ test.describe('nested SSR routes (client, server, server request)', () => {
294294
});
295295

296296
expect(serverPageRequestTxn).toMatchObject({
297-
transaction: 'GET /catchAll/[path]',
297+
transaction: 'GET /catchAll/[...path]',
298298
transaction_info: { source: 'route' },
299299
contexts: {
300300
trace: {
@@ -312,3 +312,55 @@ test.describe('nested SSR routes (client, server, server request)', () => {
312312
});
313313
});
314314
});
315+
316+
// Case for `user-page/[id]` vs. `user-page/settings` static routes
317+
test.describe('parametrized vs static paths', () => {
318+
test('should use static route name for static route in parametrized path', async ({ page }) => {
319+
const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => {
320+
return txnEvent?.transaction?.startsWith('/user-page/') ?? false;
321+
});
322+
323+
const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => {
324+
return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false;
325+
});
326+
327+
await page.goto('/user-page/settings');
328+
329+
const clientPageloadTxn = await clientPageloadTxnPromise;
330+
const serverPageRequestTxn = await serverPageRequestTxnPromise;
331+
332+
expect(clientPageloadTxn).toMatchObject({
333+
transaction: '/user-page/settings',
334+
transaction_info: { source: 'url' },
335+
contexts: {
336+
trace: {
337+
op: 'pageload',
338+
origin: 'auto.pageload.browser',
339+
data: {
340+
'sentry.op': 'pageload',
341+
'sentry.origin': 'auto.pageload.browser',
342+
'sentry.source': 'url',
343+
},
344+
},
345+
},
346+
});
347+
348+
expect(serverPageRequestTxn).toMatchObject({
349+
transaction: 'GET /user-page/settings',
350+
transaction_info: { source: 'route' },
351+
contexts: {
352+
trace: {
353+
op: 'http.server',
354+
origin: 'auto.http.astro',
355+
data: {
356+
'sentry.op': 'http.server',
357+
'sentry.origin': 'auto.http.astro',
358+
'sentry.source': 'route',
359+
url: expect.stringContaining('/user-page/settings'),
360+
},
361+
},
362+
},
363+
request: { url: expect.stringContaining('/user-page/settings') },
364+
});
365+
});
366+
});

dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test.describe('tracing in static routes with server islands', () => {
6363
]),
6464
);
6565

66-
expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island%2F'); // URL-encoded for 'GET /test-static/'
66+
expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island'); // URL-encoded for 'GET /server-island'
6767
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
6868

6969
const serverIslandEndpointTxn = await serverIslandEndpointTxnPromise;

dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ test.describe('tracing in static/pre-rendered routes', () => {
5353
type: 'transaction',
5454
});
5555

56-
expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static%2F'); // URL-encoded for 'GET /test-static/'
56+
expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static'
5757
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
5858

5959
await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent

packages/astro/src/integration/index.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { consoleSandbox } from '@sentry/core';
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
import { consoleSandbox, debug } from '@sentry/core';
23
import { sentryVitePlugin } from '@sentry/vite-plugin';
3-
import type { AstroConfig, AstroIntegration } from 'astro';
4+
import type { AstroConfig, AstroIntegration, RoutePart } from 'astro';
45
import * as fs from 'fs';
56
import * as path from 'path';
67
import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets';
7-
import type { SentryOptions } from './types';
8+
import type { IntegrationResolvedRoute, SentryOptions } from './types';
89

910
const PKG_NAME = '@sentry/astro';
1011

1112
export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
13+
let sentryServerInitPath: string | undefined;
14+
let didSaveRouteData = false;
15+
1216
return {
1317
name: PKG_NAME,
1418
hooks: {
@@ -134,6 +138,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
134138
injectScript('page-ssr', buildServerSnippet(options || {}));
135139
}
136140

141+
sentryServerInitPath = pathToServerInit;
142+
137143
// Prevent Sentry from being externalized for SSR.
138144
// Cloudflare like environments have Node.js APIs are available under `node:` prefix.
139145
// Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/
@@ -165,6 +171,36 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
165171
});
166172
}
167173
},
174+
175+
// @ts-expect-error - This hook is available in Astro 5+
176+
'astro:routes:resolved': ({ routes }: { routes: IntegrationResolvedRoute[] }) => {
177+
if (!sentryServerInitPath || didSaveRouteData) {
178+
return;
179+
}
180+
181+
try {
182+
const serverInitContent = readFileSync(sentryServerInitPath, 'utf8');
183+
184+
const updatedServerInitContent = `${serverInitContent}\nglobalThis["__sentryRouteInfo"] = ${JSON.stringify(
185+
routes.map(route => {
186+
return {
187+
...route,
188+
patternCaseSensitive: joinRouteSegments(route.segments), // Store parametrized routes with correct casing on `globalThis` to be able to use them on the server during runtime
189+
patternRegex: route.patternRegex.source, // using `source` to be able to serialize the regex
190+
};
191+
}),
192+
null,
193+
2,
194+
)};`;
195+
196+
writeFileSync(sentryServerInitPath, updatedServerInitContent, 'utf8');
197+
198+
didSaveRouteData = true; // Prevents writing the file multiple times during runtime
199+
debug.log('Successfully added route pattern information to Sentry config file:', sentryServerInitPath);
200+
} catch (error) {
201+
debug.warn(`Failed to write to Sentry config file at ${sentryServerInitPath}:`, error);
202+
}
203+
},
168204
},
169205
};
170206
};
@@ -271,3 +307,18 @@ export function getUpdatedSourceMapSettings(
271307

272308
return { previousUserSourceMapSetting, updatedSourceMapSetting };
273309
}
310+
311+
/**
312+
* Join Astro route segments into a case-sensitive single path string.
313+
*
314+
* Astro lowercases the parametrized route. Joining segments manually is recommended to get the correct casing of the routes.
315+
* Recommendation in comment: https://github.com/withastro/astro/issues/13885#issuecomment-2934203029
316+
* Function Reference: https://github.com/joanrieu/astro-typed-links/blob/b3dc12c6fe8d672a2bc2ae2ccc57c8071bbd09fa/package/src/integration.ts#L16
317+
*/
318+
function joinRouteSegments(segments: RoutePart[][]): string {
319+
const parthArray = segments.map(segment =>
320+
segment.map(routePart => (routePart.dynamic ? `[${routePart.content}]` : routePart.content)).join(''),
321+
);
322+
323+
return `/${parthArray.join('/')}`;
324+
}

packages/astro/src/integration/types.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
2+
import type { RouteData } from 'astro';
23

34
type SdkInitPaths = {
45
/**
@@ -224,3 +225,27 @@ export type SentryOptions = SdkInitPaths &
224225
debug?: boolean;
225226
// eslint-disable-next-line deprecation/deprecation
226227
} & DeprecatedRuntimeOptions;
228+
229+
/**
230+
* Routes inside 'astro:routes:resolved' hook (Astro v5+)
231+
*
232+
* Inline type for official `IntegrationResolvedRoute`.
233+
* The type includes more properties, but we only need some of them.
234+
*
235+
* @see https://github.com/withastro/astro/blob/04e60119afee668264a2ff6665c19a32150f4c91/packages/astro/src/types/public/integrations.ts#L287
236+
*/
237+
export type IntegrationResolvedRoute = {
238+
isPrerendered: RouteData['prerender'];
239+
pattern: RouteData['route'];
240+
patternRegex: RouteData['pattern'];
241+
segments: RouteData['segments'];
242+
};
243+
244+
/**
245+
* Internal type for Astro routes, as we store an additional `patternCaseSensitive` property alongside the
246+
* lowercased parametrized `pattern` of each Astro route.
247+
*/
248+
export type ResolvedRouteWithCasedPattern = IntegrationResolvedRoute & {
249+
patternRegex: string; // RegEx gets stringified
250+
patternCaseSensitive: string;
251+
};

packages/astro/src/server/middleware.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
withIsolationScope,
2424
} from '@sentry/node';
2525
import type { APIContext, MiddlewareResponseHandler } from 'astro';
26+
import type { ResolvedRouteWithCasedPattern } from '../integration/types';
2627

2728
type MiddlewareOptions = {
2829
/**
@@ -95,6 +96,9 @@ async function instrumentRequest(
9596
addNonEnumerableProperty(locals, '__sentry_wrapped__', true);
9697
}
9798

99+
const storedBuildTimeRoutes = (globalThis as unknown as { __sentryRouteInfo?: ResolvedRouteWithCasedPattern[] })
100+
?.__sentryRouteInfo;
101+
98102
const isDynamicPageRequest = checkIsDynamicPageRequest(ctx);
99103

100104
const request = ctx.request;
@@ -128,8 +132,15 @@ async function instrumentRequest(
128132
}
129133

130134
try {
131-
const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
132-
const source = interpolatedRoute ? 'route' : 'url';
135+
// `routePattern` is available after Astro 5
136+
const contextWithRoutePattern = ctx as Parameters<MiddlewareResponseHandler>[0] & { routePattern?: string };
137+
const rawRoutePattern = contextWithRoutePattern.routePattern;
138+
const foundRoute = storedBuildTimeRoutes?.find(route => route.pattern === rawRoutePattern);
139+
140+
const parametrizedRoute =
141+
foundRoute?.patternCaseSensitive || interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
142+
143+
const source = parametrizedRoute ? 'route' : 'url';
133144
// storing res in a variable instead of directly returning is necessary to
134145
// invoke the catch block if next() throws
135146

@@ -148,12 +159,12 @@ async function instrumentRequest(
148159
attributes['http.fragment'] = ctx.url.hash;
149160
}
150161

151-
isolationScope?.setTransactionName(`${method} ${interpolatedRoute || ctx.url.pathname}`);
162+
isolationScope?.setTransactionName(`${method} ${parametrizedRoute || ctx.url.pathname}`);
152163

153164
const res = await startSpan(
154165
{
155166
attributes,
156-
name: `${method} ${interpolatedRoute || ctx.url.pathname}`,
167+
name: `${method} ${parametrizedRoute || ctx.url.pathname}`,
157168
op: 'http.server',
158169
},
159170
async span => {

0 commit comments

Comments
 (0)