diff --git a/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro new file mode 100644 index 000000000000..5a46ac891a24 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +--- + + +

User Settings

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts index 2699e827718e..07e0467382da 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts @@ -30,11 +30,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', span_id: expect.stringMatching(/[a-f0-9]{16}/), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -55,9 +55,7 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/test-ssr', - transaction_info: { - source: 'url', - }, + transaction_info: { source: 'route' }, type: 'transaction', }); @@ -113,9 +111,7 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: 'GET /test-ssr', - transaction_info: { - source: 'route', - }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); @@ -194,18 +190,21 @@ test.describe('nested SSR routes (client, server, server request)', () => { span => span.op === 'http.client' && span.description?.includes('/api/user/'), ); + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2Fuser-page%2F%5BuserId%5D'); + // Client pageload transaction - actual URL with pageload operation expect(clientPageloadTxn).toMatchObject({ - transaction: '/user-page/myUsername123', // todo: parametrize - transaction_info: { source: 'url' }, + transaction: '/user-page/[userId]', + transaction_info: { source: 'route' }, contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', data: { 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }, }, }, @@ -275,20 +274,23 @@ test.describe('nested SSR routes (client, server, server request)', () => { await page.goto('/catchAll/hell0/whatever-do'); + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2FcatchAll%2F%5Bpath%5D'); + const clientPageloadTxn = await clientPageloadTxnPromise; const serverPageRequestTxn = await serverPageRequestTxnPromise; expect(clientPageloadTxn).toMatchObject({ - transaction: '/catchAll/hell0/whatever-do', // todo: parametrize - transaction_info: { source: 'url' }, + transaction: '/catchAll/[path]', + transaction_info: { source: 'route' }, contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', data: { 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }, }, }, @@ -313,3 +315,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-4', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-4', 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: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + 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-4/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts index c04bbb568f2e..30bcbee1a026 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts @@ -35,11 +35,11 @@ test.describe('tracing in static/pre-rendered routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -48,12 +48,12 @@ test.describe('tracing in static/pre-rendered routes', () => { platform: 'javascript', transaction: '/test-static', transaction_info: { - source: 'url', + source: 'route', }, 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/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 8267adcb4ea9..b7dda807c65c 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 @@ -30,11 +30,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', span_id: expect.stringMatching(/[a-f0-9]{16}/), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -56,7 +56,7 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { timestamp: expect.any(Number), transaction: '/test-ssr', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); @@ -193,18 +193,21 @@ test.describe('nested SSR routes (client, server, server request)', () => { span => span.op === 'http.client' && span.description?.includes('/api/user/'), ); + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2Fuser-page%2F%5BuserId%5D'); + // Client pageload transaction - actual URL with pageload operation expect(clientPageloadTxn).toMatchObject({ - transaction: '/user-page/myUsername123', // todo: parametrize to '/user-page/[userId]' - transaction_info: { source: 'url' }, + transaction: '/user-page/[userId]', + transaction_info: { source: 'route' }, contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', data: { 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }, }, }, @@ -274,20 +277,23 @@ test.describe('nested SSR routes (client, server, server request)', () => { await page.goto('/catchAll/hell0/whatever-do'); + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2FcatchAll%2F%5B...path%5D'); + const clientPageloadTxn = await clientPageloadTxnPromise; const serverPageRequestTxn = await serverPageRequestTxnPromise; expect(clientPageloadTxn).toMatchObject({ - transaction: '/catchAll/hell0/whatever-do', // todo: parametrize to '/catchAll/[...path]' - transaction_info: { source: 'url' }, + transaction: '/catchAll/[...path]', + transaction_info: { source: 'route' }, contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', data: { 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }, }, }, @@ -331,15 +337,15 @@ test.describe('parametrized vs static paths', () => { expect(clientPageloadTxn).toMatchObject({ transaction: '/user-page/settings', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, contexts: { trace: { op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', data: { 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }, }, }, 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 dda88afe4714..202051e7a57e 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 @@ -32,11 +32,11 @@ test.describe('tracing in static routes with server islands', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -45,7 +45,7 @@ test.describe('tracing in static routes with server islands', () => { platform: 'javascript', transaction: '/server-island', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); 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 90b26fb645e9..7593d6823d9b 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 @@ -35,11 +35,11 @@ test.describe('tracing in static/pre-rendered routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -48,7 +48,7 @@ test.describe('tracing in static/pre-rendered routes', () => { platform: 'javascript', transaction: '/test-static', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); diff --git a/packages/astro/src/client/browserTracingIntegration.ts b/packages/astro/src/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..7f8576671635 --- /dev/null +++ b/packages/astro/src/client/browserTracingIntegration.ts @@ -0,0 +1,52 @@ +import { browserTracingIntegration as originalBrowserTracingIntegration, WINDOW } from '@sentry/browser'; +import type { Integration, TransactionSource } from '@sentry/core'; +import { debug, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Returns the value of a meta-tag + */ +function getMetaContent(metaName: string): string | undefined { + const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; + const metaTag = optionalDocument?.querySelector(`meta[name=${metaName}]`); + return metaTag?.getAttribute('content') || undefined; +} + +/** + * A custom browser tracing integrations for Astro. + */ +export function browserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + const integration = originalBrowserTracingIntegration(options); + + return { + ...integration, + setup(client) { + // Original integration setup call + integration.setup?.(client); + + client.on('afterStartPageLoadSpan', pageLoadSpan => { + const routeNameFromMetaTags = getMetaContent('sentry-route-name'); + + if (routeNameFromMetaTags) { + let decodedRouteName; + try { + decodedRouteName = decodeURIComponent(routeNameFromMetaTags); + } catch { + // We ignore errors here, e.g. if the value cannot be URL decoded. + return; + } + + DEBUG_BUILD && debug.log(`[Tracing] Using route name from Sentry HTML meta-tag: ${decodedRouteName}`); + + pageLoadSpan.updateName(decodedRouteName); + pageLoadSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' as TransactionSource, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.astro', + }); + } + }); + }, + }; +} diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index f04725d1ef1e..21c5770f255f 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -1,11 +1,8 @@ import type { BrowserOptions } from '@sentry/browser'; -import { - browserTracingIntegration, - getDefaultIntegrations as getBrowserDefaultIntegrations, - init as initBrowserSdk, -} from '@sentry/browser'; +import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { browserTracingIntegration } from './browserTracingIntegration'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; diff --git a/packages/astro/src/debug-build.ts b/packages/astro/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/astro/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/astro/src/index.client.ts b/packages/astro/src/index.client.ts index 2b85c05c3af1..55a6c8d3915b 100644 --- a/packages/astro/src/index.client.ts +++ b/packages/astro/src/index.client.ts @@ -1,3 +1,6 @@ export * from '@sentry/browser'; +// Override the browserTracingIntegration with the custom Astro version +export { browserTracingIntegration } from './client/browserTracingIntegration'; + export { init } from './client/sdk'; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index ec9b8cd2f82f..9f04d5427fcf 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -213,7 +213,7 @@ async function instrumentRequest( try { for await (const chunk of bodyReporter()) { const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); - const modifiedHtml = addMetaTagToHead(html); + const modifiedHtml = addMetaTagToHead(html, parametrizedRoute); controller.enqueue(new TextEncoder().encode(modifiedHtml)); } } catch (e) { @@ -253,11 +253,13 @@ async function instrumentRequest( * This function optimistically assumes that the HTML coming in chunks will not be split * within the tag. If this still happens, we simply won't replace anything. */ -function addMetaTagToHead(htmlChunk: string): string { +function addMetaTagToHead(htmlChunk: string, parametrizedRoute?: string): string { if (typeof htmlChunk !== 'string') { return htmlChunk; } - const metaTags = getTraceMetaTags(); + const metaTags = parametrizedRoute + ? `${getTraceMetaTags()}\n\n` + : getTraceMetaTags(); if (!metaTags) { return htmlChunk; @@ -317,26 +319,30 @@ export function interpolateRouteFromUrlAndParams( return acc.replace(key, `[${valuesToMultiSegmentParams[key]}]`); }, decodedUrlPathname); - return urlWithReplacedMultiSegmentParams - .split('/') - .map(segment => { - if (!segment) { - return ''; - } + return ( + urlWithReplacedMultiSegmentParams + .split('/') + .map(segment => { + if (!segment) { + return ''; + } - if (valuesToParams[segment]) { - return replaceWithParamName(segment); - } + if (valuesToParams[segment]) { + return replaceWithParamName(segment); + } - // astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/ - const segmentParts = segment.split('-'); - if (segmentParts.length > 1) { - return segmentParts.map(part => replaceWithParamName(part)).join('-'); - } + // astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/ + const segmentParts = segment.split('-'); + if (segmentParts.length > 1) { + return segmentParts.map(part => replaceWithParamName(part)).join('-'); + } - return segment; - }) - .join('/'); + return segment; + }) + .join('/') + // Remove trailing slash (only if it's not the only segment) + .replace(/^(.+?)\/$/, '$1') + ); } function tryDecodeUrl(url: string): string | undefined { diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index e8c16fa570d3..6430a5f47eb7 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -482,4 +482,11 @@ describe('interpolateRouteFromUrlAndParams', () => { const expectedRoute = '/usernames/user'; expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); }); + + it('removes trailing slashes from the route', () => { + const rawUrl = '/users/123/'; + const params = { id: '123' }; + const expectedRoute = '/users/[id]'; + expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); + }); });