Skip to content

Commit 5e48038

Browse files
committed
feat(astro): Server Request Route Parametrization
1 parent 662da80 commit 5e48038

File tree

6 files changed

+86
-8
lines changed

6 files changed

+86
-8
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ test.describe('nested SSR routes (client, server, server request)', () => {
245245

246246
// Server HTTP request transaction - should be parametrized (todo: currently not parametrized)
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: {

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: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
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+
1215
return {
1316
name: PKG_NAME,
1417
hooks: {
@@ -134,6 +137,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
134137
injectScript('page-ssr', buildServerSnippet(options || {}));
135138
}
136139

140+
sentryServerInitPath = pathToServerInit;
141+
137142
// Prevent Sentry from being externalized for SSR.
138143
// Cloudflare like environments have Node.js APIs are available under `node:` prefix.
139144
// Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/
@@ -165,6 +170,48 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
165170
});
166171
}
167172
},
173+
174+
// @ts-expect-error - This hook is available in Astro 5
175+
'astro:routes:resolved': ({ routes }: { routes: IntegrationResolvedRoute[] }) => {
176+
if (!sentryServerInitPath) {
177+
return;
178+
}
179+
180+
/**
181+
* Astro lowercases the parametrized route. Joining segments manually is recommended to get the correct casing of the routes.
182+
* Recommendation in comment: https://github.com/withastro/astro/issues/13885#issuecomment-2934203029
183+
* Function Reference: https://github.com/joanrieu/astro-typed-links/blob/b3dc12c6fe8d672a2bc2ae2ccc57c8071bbd09fa/package/src/integration.ts#L16
184+
*/
185+
const joinSegments = (segments: RoutePart[][]): string => {
186+
const parthArray = segments.map(segment =>
187+
segment.map(routePart => (routePart.dynamic ? `[${routePart.content}]` : routePart.content)).join(''),
188+
);
189+
190+
return `/${parthArray.join('/')}`;
191+
};
192+
193+
try {
194+
const serverInitContent = readFileSync(sentryServerInitPath, 'utf8');
195+
196+
const updatedServerInitContent = `${serverInitContent}\nglobalThis["__sentryRouteInfo"] = ${JSON.stringify(
197+
routes.map(route => {
198+
return {
199+
...route,
200+
patternCaseSensitive: joinSegments(route.segments), // Store parametrized routes with correct casing on `globalThis` to be able to use them on the server during runtime
201+
patternRegex: route.patternRegex.source, // using `source` to be able to serialize the regex
202+
};
203+
}),
204+
null,
205+
2,
206+
)};`;
207+
208+
writeFileSync(sentryServerInitPath, updatedServerInitContent, 'utf8');
209+
210+
debug.log('Successfully added route pattern information to Sentry server file:', sentryServerInitPath);
211+
} catch (error) {
212+
debug.warn(`Failed to write to sentry client init file at ${sentryServerInitPath}:`, error);
213+
}
214+
},
168215
},
169216
};
170217
};

packages/astro/src/integration/types.ts

Lines changed: 21 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 { InjectedRoute, RouteData } from 'astro';
23

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

packages/astro/src/server/middleware.ts

Lines changed: 11 additions & 1 deletion
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,7 +132,13 @@ async function instrumentRequest(
128132
}
129133

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

0 commit comments

Comments
 (0)