diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro
new file mode 100644
index 000000000000..8260e632c07b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro
@@ -0,0 +1,7 @@
+---
+import Layout from '../../layouts/Layout.astro';
+---
+
+
+ User Settings
+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts
index 9315d3d3ea84..8267adcb4ea9 100644
--- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts
+++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts
@@ -243,9 +243,9 @@ test.describe('nested SSR routes (client, server, server request)', () => {
},
});
- // Server HTTP request transaction - should be parametrized (todo: currently not parametrized)
+ // Server HTTP request transaction
expect(serverHTTPServerRequestTxn).toMatchObject({
- transaction: 'GET /api/user/myUsername123.json', // todo: should be parametrized to 'GET /api/user/[userId].json'
+ transaction: 'GET /api/user/[userId].json',
transaction_info: { source: 'route' },
contexts: {
trace: {
@@ -294,7 +294,7 @@ test.describe('nested SSR routes (client, server, server request)', () => {
});
expect(serverPageRequestTxn).toMatchObject({
- transaction: 'GET /catchAll/[path]',
+ transaction: 'GET /catchAll/[...path]',
transaction_info: { source: 'route' },
contexts: {
trace: {
@@ -312,3 +312,55 @@ test.describe('nested SSR routes (client, server, server request)', () => {
});
});
});
+
+// Case for `user-page/[id]` vs. `user-page/settings` static routes
+test.describe('parametrized vs static paths', () => {
+ test('should use static route name for static route in parametrized path', async ({ page }) => {
+ const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => {
+ return txnEvent?.transaction?.startsWith('/user-page/') ?? false;
+ });
+
+ const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => {
+ return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false;
+ });
+
+ await page.goto('/user-page/settings');
+
+ const clientPageloadTxn = await clientPageloadTxnPromise;
+ const serverPageRequestTxn = await serverPageRequestTxnPromise;
+
+ expect(clientPageloadTxn).toMatchObject({
+ transaction: '/user-page/settings',
+ transaction_info: { source: 'url' },
+ contexts: {
+ trace: {
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ data: {
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.browser',
+ 'sentry.source': 'url',
+ },
+ },
+ },
+ });
+
+ expect(serverPageRequestTxn).toMatchObject({
+ transaction: 'GET /user-page/settings',
+ transaction_info: { source: 'route' },
+ contexts: {
+ trace: {
+ op: 'http.server',
+ origin: 'auto.http.astro',
+ data: {
+ 'sentry.op': 'http.server',
+ 'sentry.origin': 'auto.http.astro',
+ 'sentry.source': 'route',
+ url: expect.stringContaining('/user-page/settings'),
+ },
+ },
+ },
+ request: { url: expect.stringContaining('/user-page/settings') },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts
index fc396999d76e..dda88afe4714 100644
--- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts
+++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts
@@ -63,7 +63,7 @@ test.describe('tracing in static routes with server islands', () => {
]),
);
- expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island%2F'); // URL-encoded for 'GET /test-static/'
+ expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island'); // URL-encoded for 'GET /server-island'
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
const serverIslandEndpointTxn = await serverIslandEndpointTxnPromise;
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts
index 9db35c72a47d..90b26fb645e9 100644
--- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts
+++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts
@@ -53,7 +53,7 @@ test.describe('tracing in static/pre-rendered routes', () => {
type: 'transaction',
});
- expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static%2F'); // URL-encoded for 'GET /test-static/'
+ expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static'
expect(baggageMetaTagContent).toContain('sentry-sampled=true');
await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent
diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts
index 28092cad84be..69564fcc6535 100644
--- a/packages/astro/src/integration/index.ts
+++ b/packages/astro/src/integration/index.ts
@@ -1,14 +1,18 @@
-import { consoleSandbox } from '@sentry/core';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { consoleSandbox, debug } from '@sentry/core';
import { sentryVitePlugin } from '@sentry/vite-plugin';
-import type { AstroConfig, AstroIntegration } from 'astro';
+import type { AstroConfig, AstroIntegration, RoutePart } from 'astro';
import * as fs from 'fs';
import * as path from 'path';
import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets';
-import type { SentryOptions } from './types';
+import type { IntegrationResolvedRoute, SentryOptions } from './types';
const PKG_NAME = '@sentry/astro';
export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
+ let sentryServerInitPath: string | undefined;
+ let didSaveRouteData = false;
+
return {
name: PKG_NAME,
hooks: {
@@ -134,6 +138,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
injectScript('page-ssr', buildServerSnippet(options || {}));
}
+ sentryServerInitPath = pathToServerInit;
+
// Prevent Sentry from being externalized for SSR.
// Cloudflare like environments have Node.js APIs are available under `node:` prefix.
// Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/
@@ -165,6 +171,36 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
});
}
},
+
+ // @ts-expect-error - This hook is available in Astro 5+
+ 'astro:routes:resolved': ({ routes }: { routes: IntegrationResolvedRoute[] }) => {
+ if (!sentryServerInitPath || didSaveRouteData) {
+ return;
+ }
+
+ try {
+ const serverInitContent = readFileSync(sentryServerInitPath, 'utf8');
+
+ const updatedServerInitContent = `${serverInitContent}\nglobalThis["__sentryRouteInfo"] = ${JSON.stringify(
+ routes.map(route => {
+ return {
+ ...route,
+ patternCaseSensitive: joinRouteSegments(route.segments), // Store parametrized routes with correct casing on `globalThis` to be able to use them on the server during runtime
+ patternRegex: route.patternRegex.source, // using `source` to be able to serialize the regex
+ };
+ }),
+ null,
+ 2,
+ )};`;
+
+ writeFileSync(sentryServerInitPath, updatedServerInitContent, 'utf8');
+
+ didSaveRouteData = true; // Prevents writing the file multiple times during runtime
+ debug.log('Successfully added route pattern information to Sentry config file:', sentryServerInitPath);
+ } catch (error) {
+ debug.warn(`Failed to write to Sentry config file at ${sentryServerInitPath}:`, error);
+ }
+ },
},
};
};
@@ -271,3 +307,18 @@ export function getUpdatedSourceMapSettings(
return { previousUserSourceMapSetting, updatedSourceMapSetting };
}
+
+/**
+ * Join Astro route segments into a case-sensitive single path string.
+ *
+ * Astro lowercases the parametrized route. Joining segments manually is recommended to get the correct casing of the routes.
+ * Recommendation in comment: https://github.com/withastro/astro/issues/13885#issuecomment-2934203029
+ * Function Reference: https://github.com/joanrieu/astro-typed-links/blob/b3dc12c6fe8d672a2bc2ae2ccc57c8071bbd09fa/package/src/integration.ts#L16
+ */
+function joinRouteSegments(segments: RoutePart[][]): string {
+ const parthArray = segments.map(segment =>
+ segment.map(routePart => (routePart.dynamic ? `[${routePart.content}]` : routePart.content)).join(''),
+ );
+
+ return `/${parthArray.join('/')}`;
+}
diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts
index 08a8635889fe..aed2b7e1d193 100644
--- a/packages/astro/src/integration/types.ts
+++ b/packages/astro/src/integration/types.ts
@@ -1,4 +1,5 @@
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
+import type { RouteData } from 'astro';
type SdkInitPaths = {
/**
@@ -224,3 +225,27 @@ export type SentryOptions = SdkInitPaths &
debug?: boolean;
// eslint-disable-next-line deprecation/deprecation
} & DeprecatedRuntimeOptions;
+
+/**
+ * Routes inside 'astro:routes:resolved' hook (Astro v5+)
+ *
+ * Inline type for official `IntegrationResolvedRoute`.
+ * The type includes more properties, but we only need some of them.
+ *
+ * @see https://github.com/withastro/astro/blob/04e60119afee668264a2ff6665c19a32150f4c91/packages/astro/src/types/public/integrations.ts#L287
+ */
+export type IntegrationResolvedRoute = {
+ isPrerendered: RouteData['prerender'];
+ pattern: RouteData['route'];
+ patternRegex: RouteData['pattern'];
+ segments: RouteData['segments'];
+};
+
+/**
+ * Internal type for Astro routes, as we store an additional `patternCaseSensitive` property alongside the
+ * lowercased parametrized `pattern` of each Astro route.
+ */
+export type ResolvedRouteWithCasedPattern = IntegrationResolvedRoute & {
+ patternRegex: string; // RegEx gets stringified
+ patternCaseSensitive: string;
+};
diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts
index fb2f2e572fa4..ec9b8cd2f82f 100644
--- a/packages/astro/src/server/middleware.ts
+++ b/packages/astro/src/server/middleware.ts
@@ -23,6 +23,7 @@ import {
withIsolationScope,
} from '@sentry/node';
import type { APIContext, MiddlewareResponseHandler } from 'astro';
+import type { ResolvedRouteWithCasedPattern } from '../integration/types';
type MiddlewareOptions = {
/**
@@ -95,6 +96,9 @@ async function instrumentRequest(
addNonEnumerableProperty(locals, '__sentry_wrapped__', true);
}
+ const storedBuildTimeRoutes = (globalThis as unknown as { __sentryRouteInfo?: ResolvedRouteWithCasedPattern[] })
+ ?.__sentryRouteInfo;
+
const isDynamicPageRequest = checkIsDynamicPageRequest(ctx);
const request = ctx.request;
@@ -128,8 +132,15 @@ async function instrumentRequest(
}
try {
- const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
- const source = interpolatedRoute ? 'route' : 'url';
+ // `routePattern` is available after Astro 5
+ const contextWithRoutePattern = ctx as Parameters[0] & { routePattern?: string };
+ const rawRoutePattern = contextWithRoutePattern.routePattern;
+ const foundRoute = storedBuildTimeRoutes?.find(route => route.pattern === rawRoutePattern);
+
+ const parametrizedRoute =
+ foundRoute?.patternCaseSensitive || interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params);
+
+ const source = parametrizedRoute ? 'route' : 'url';
// storing res in a variable instead of directly returning is necessary to
// invoke the catch block if next() throws
@@ -148,12 +159,12 @@ async function instrumentRequest(
attributes['http.fragment'] = ctx.url.hash;
}
- isolationScope?.setTransactionName(`${method} ${interpolatedRoute || ctx.url.pathname}`);
+ isolationScope?.setTransactionName(`${method} ${parametrizedRoute || ctx.url.pathname}`);
const res = await startSpan(
{
attributes,
- name: `${method} ${interpolatedRoute || ctx.url.pathname}`,
+ name: `${method} ${parametrizedRoute || ctx.url.pathname}`,
op: 'http.server',
},
async span => {