diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts new file mode 100644 index 000000000000..b53cda3ac968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..2a5e2910050a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index 8448829443d6..c675d003853a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -5,15 +5,9 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig( - { - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, - }, - { - // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize - workers: '100%', - }, -); +const config = getPlaywrightConfig({ + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, +}); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..fb93e77aaf8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 8069a1d1395b..a685f969eeda 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -6,7 +6,7 @@ test('Creates a pageload transaction for app router routes', async ({ page }) => const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -21,7 +21,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -32,7 +32,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' && + transactionEvent?.transaction === '/server-component/parameter/:parameters*' && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -59,7 +59,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) test('Creates a navigation transaction for `router.push()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -75,7 +75,7 @@ test('Creates a navigation transaction for `router.push()`', async ({ page }) => test('Creates a navigation transaction for `router.replace()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-replace` && + transactionEvent?.transaction === `/navigation/:param/router-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -91,7 +91,7 @@ test('Creates a navigation transaction for `router.replace()`', async ({ page }) test('Creates a navigation transaction for `router.back()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/1337/router-back` && + transactionEvent?.transaction === `/navigation/:param/router-back` && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -116,7 +116,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) => test('Creates a navigation transaction for `router.forward()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -137,7 +137,7 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page }) test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link` && + transactionEvent?.transaction === `/navigation/:param/link` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -152,7 +152,7 @@ test('Creates a navigation transaction for ``', async ({ page }) => { test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link-replace` && + transactionEvent?.transaction === `/navigation/:param/link-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -168,7 +168,7 @@ test('Creates a navigation transaction for ``', async ({ page }) test('Creates a navigation transaction for browser-back', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/browser-back` && + transactionEvent?.transaction === `/navigation/:param/browser-back` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -187,7 +187,7 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => { test('Creates a navigation transaction for browser-forward', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js index a0d2b254bc42..55a7b9361b3a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js @@ -1,11 +1,7 @@ const { withSentryConfig } = require('@sentry/nextjs'); /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - turbo: {}, // Enables Turbopack for builds - }, -}; +const nextConfig = {}; module.exports = withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 76d544bb823a..9102de60706b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.3.0-canary.40", + "next": "^15.3.5", "react": "rc", "react-dom": "rc", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts new file mode 100644 index 000000000000..0a2f1dfc0c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index a793a73a4488..425daeb3e558 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -7,6 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; +import { maybeParameterizeRoute } from './parameterization'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -34,15 +35,16 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); const origin = browserPerformanceTimeOrigin(); startBrowserTracingPageLoadSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) startTime: origin ? origin / 1000 : undefined, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }, }); } @@ -85,7 +87,9 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Instruments the Next.js app router for navigation. */ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { - const pathname = new URL(href, WINDOW.location.href).pathname; + const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname; + const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const pathname = parameterizedPathname ?? unparameterizedPathname; if (navigationRoutingMode === 'router-patch') { navigationRoutingMode = 'transition-start-hook'; @@ -96,6 +100,7 @@ export function appRouterInstrumentNavigation(client: Client): void { currentNavigationSpan.updateName(pathname); currentNavigationSpan.setAttributes({ 'navigation.type': `router.${navigationType}`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }); currentRouterPatchingNavigationSpanRef.current = undefined; } else { @@ -104,7 +109,7 @@ export function appRouterInstrumentNavigation(client: Client): void { attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': `router.${navigationType}`, }, }); @@ -112,15 +117,19 @@ export function appRouterInstrumentNavigation(client: Client): void { }; WINDOW.addEventListener('popstate', () => { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) { - currentRouterPatchingNavigationSpanRef.current.updateName(WINDOW.location.pathname); - currentRouterPatchingNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? WINDOW.location.pathname); + currentRouterPatchingNavigationSpanRef.current.setAttribute( + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + parameterizedPathname ? 'route' : 'url', + ); } else { currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': 'browser.popstate', }, }); @@ -209,9 +218,14 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe transactionAttributes['navigation.type'] = 'router.forward'; } + const parameterizedPathname = maybeParameterizeRoute(transactionName); + currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: transactionName, - attributes: transactionAttributes, + name: parameterizedPathname ?? transactionName, + attributes: { + ...transactionAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', + }, }); return target.apply(thisArg, argArray); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts new file mode 100644 index 000000000000..8ce98044a588 --- /dev/null +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -0,0 +1,170 @@ +import { GLOBAL_OBJ, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import type { RouteManifest } from '../../config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: RouteManifest | undefined; +}; + +// Some performance caches +let cachedManifest: RouteManifest | null = null; +let cachedManifestString: string | undefined = undefined; +const compiledRegexCache: Map = new Map(); +const routeResultCache: Map = new Map(); + +/** + * Calculate the specificity score for a route path. + * Lower scores indicate more specific routes. + */ +function getRouteSpecificity(routePath: string): number { + const segments = routePath.split('/').filter(Boolean); + let score = 0; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + if (paramName.endsWith('*?')) { + // Optional catch-all: [[...param]] + score += 1000; + } else if (paramName.endsWith('*')) { + // Required catch-all: [...param] + score += 100; + } else { + // Regular dynamic segment: [param] + score += 10; + } + } + // Static segments add 0 to score as they are most specific + } + + return score; +} + +/** + * Get compiled regex from cache or create and cache it. + */ +function getCompiledRegex(regexString: string): RegExp | null { + if (compiledRegexCache.has(regexString)) { + return compiledRegexCache.get(regexString) ?? null; + } + + try { + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input + const regex = new RegExp(regexString); + compiledRegexCache.set(regexString, regex); + return regex; + } catch (error) { + DEBUG_BUILD && logger.warn('Could not compile regex', { regexString, error }); + // Cache the failure to avoid repeated attempts by storing undefined + return null; + } +} + +/** + * Get and cache the route manifest from the global object. + * @returns The parsed route manifest or null if not available/invalid. + */ +function getManifest(): RouteManifest | null { + if ( + !globalWithInjectedManifest?._sentryRouteManifest || + typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' + ) { + return null; + } + + const currentManifestString = globalWithInjectedManifest._sentryRouteManifest; + + // Return cached manifest if the string hasn't changed + if (cachedManifest && cachedManifestString === currentManifestString) { + return cachedManifest; + } + + // Clear caches when manifest changes + compiledRegexCache.clear(); + routeResultCache.clear(); + + let manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + + // Shallow check if the manifest is actually what we expect it to be + try { + manifest = JSON.parse(currentManifestString); + if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) { + return null; + } + // Cache the successfully parsed manifest + cachedManifest = manifest; + cachedManifestString = currentManifestString; + return manifest; + } catch (error) { + // Something went wrong while parsing the manifest, so we'll fallback to no parameterization + DEBUG_BUILD && logger.warn('Could not extract route manifest'); + return null; + } +} + +/** + * Find matching routes from static and dynamic route collections. + * @param route - The route to match against. + * @param staticRoutes - Array of static route objects. + * @param dynamicRoutes - Array of dynamic route objects. + * @returns Array of matching route paths. + */ +function findMatchingRoutes( + route: string, + staticRoutes: RouteManifest['staticRoutes'], + dynamicRoutes: RouteManifest['dynamicRoutes'], +): string[] { + const matches: string[] = []; + + // Static path: no parameterization needed, return empty array + if (staticRoutes.some(r => r.path === route)) { + return matches; + } + + // Dynamic path: find the route pattern that matches the concrete route + for (const dynamicRoute of dynamicRoutes) { + if (dynamicRoute.regex) { + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(route)) { + matches.push(dynamicRoute.path); + } + } + } + + return matches; +} + +/** + * Parameterize a route using the route manifest. + * + * @param route - The route to parameterize. + * @returns The parameterized route or undefined if no parameterization is needed. + */ +export const maybeParameterizeRoute = (route: string): string | undefined => { + const manifest = getManifest(); + if (!manifest) { + return undefined; + } + + // Check route result cache after manifest validation + if (routeResultCache.has(route)) { + return routeResultCache.get(route); + } + + const { staticRoutes, dynamicRoutes } = manifest; + if (!Array.isArray(staticRoutes) || !Array.isArray(dynamicRoutes)) { + return undefined; + } + + const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes); + + // We can always do the `sort()` call, it will short-circuit when it has one array item + const result = matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0]; + + routeResultCache.set(route, result); + + return result; +}; diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts new file mode 100644 index 000000000000..e9f484e71827 --- /dev/null +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -0,0 +1,647 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { maybeParameterizeRoute } from '../../src/client/routing/parameterization'; +import type { RouteManifest } from '../../src/config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: string | undefined; +}; + +describe('maybeParameterizeRoute', () => { + const originalManifest = globalWithInjectedManifest._sentryRouteManifest; + + afterEach(() => { + globalWithInjectedManifest._sentryRouteManifest = originalManifest; + }); + + describe('when no manifest is available', () => { + it('should return undefined', () => { + globalWithInjectedManifest._sentryRouteManifest = undefined; + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/456/comments')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + }); + + describe('when manifest has static routes', () => { + it('should return undefined for static routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/some/nested')).toBeUndefined(); + expect(maybeParameterizeRoute('/user')).toBeUndefined(); + expect(maybeParameterizeRoute('/users')).toBeUndefined(); + }); + }); + + describe('when manifest has dynamic routes', () => { + it('should return parameterized routes for matching dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/dynamic/123')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/dynamic/abc')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john-doe')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/123/posts/456')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/john/posts/my-post')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/john-doe/settings')).toBe('/users/:id/settings'); + }); + + it('should return undefined for static routes even when dynamic routes exist', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/dynamic/static')).toBeUndefined(); + expect(maybeParameterizeRoute('/static/nested')).toBeUndefined(); + }); + + it('should handle catchall routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/catchall/123')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/abc')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/456')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/abc/789')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall')).toBe('/catchall/:path*?'); + }); + + it('should handle route groups when included', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/(auth)/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/(auth)/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/(marketing)/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard/123')).toBe('/(dashboard)/dashboard/:id'); + }); + + it('should handle route groups when stripped (default behavior)', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard/123')).toBe('/dashboard/:id'); + }); + + it('should handle routes with special characters', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + }); + + it('should return the first matching dynamic route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:slug', + regex: '^/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/about')).toBe('/:slug'); + }); + + it('should return undefined for dynamic routes without regex', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + + it('should handle invalid regex patterns gracefully', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '[invalid-regex', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + }); + + describe('when route does not match any pattern', () => { + it('should return undefined for unknown routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/unknown')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/users/123/extra')).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('')).toBeUndefined(); + }); + + it('should handle root route', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + + it('should handle complex nested dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/v1/users/:id/posts/:postId/comments/:commentId', + regex: '^/api/v1/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['id', 'postId', 'commentId'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/api/v1/users/123/posts/456/comments/789')).toBe( + '/api/v1/users/:id/posts/:postId/comments/:commentId', + ); + }); + }); + + describe('realistic Next.js App Router patterns', () => { + it.each([ + ['/', undefined], + ['/some/nested', undefined], + ['/user', undefined], + ['/users', undefined], + ['/dynamic/static', undefined], + ['/static/nested', undefined], + ['/login', undefined], + ['/signup', undefined], + ['/dashboard', undefined], + ['/settings/profile', undefined], + ['/public/about', undefined], + + ['/dynamic/123', '/dynamic/:id'], + ['/dynamic/abc', '/dynamic/:id'], + ['/users/123', '/users/:id'], + ['/users/john-doe', '/users/:id'], + ['/users/123/posts/456', '/users/:id/posts/:postId'], + ['/users/john/posts/my-post', '/users/:id/posts/:postId'], + ['/users/123/settings', '/users/:id/settings'], + ['/users/user-with-dashes/settings', '/users/:id/settings'], + ['/dashboard/123', '/dashboard/:id'], + + ['/catchall/123', '/catchall/:path*?'], + ['/catchall/abc', '/catchall/:path*?'], + ['/catchall/123/456', '/catchall/:path*?'], + ['/catchall/123/abc/789', '/catchall/:path*?'], + ['/catchall/', '/catchall/:path*?'], + ['/catchall', '/catchall/:path*?'], + + ['/unknown-route', undefined], + ['/api/unknown', undefined], + ['/posts/123', undefined], + ])('should handle route "%s" and return %s', (inputRoute, expectedRoute) => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/some/nested' }, + { path: '/user' }, + { path: '/users' }, + { path: '/dynamic/static' }, + { path: '/static/nested' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + if (expectedRoute === undefined) { + expect(maybeParameterizeRoute(inputRoute)).toBeUndefined(); + } else { + expect(maybeParameterizeRoute(inputRoute)).toBe(expectedRoute); + } + }); + }); + + describe('route specificity and precedence', () => { + it('should prefer more specific routes over catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*', + regex: '^/(.+)$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/abc')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/user-id')).toBe('/:parameter'); + + // Multiple segments should match the catch-all + expect(maybeParameterizeRoute('/123/456')).toBe('/:parameters*'); + expect(maybeParameterizeRoute('/users/123/posts')).toBe('/:parameters*'); + }); + + it('should prefer regular dynamic routes over optional catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*?', + regex: '^(?:/(.*))?$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the optional catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/test')).toBe('/:parameter'); + }); + + it('should handle multiple levels of specificity correctly', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/static' }], + dynamicRoutes: [ + { + path: '/:param', + regex: '^/([^/]+)$', + paramNames: ['param'], + }, + { + path: '/:catch*', + regex: '^/(.+)$', + paramNames: ['catch'], + }, + { + path: '/:optional*?', + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static route should take precedence (no parameterization) + expect(maybeParameterizeRoute('/static')).toBeUndefined(); + + // Single segment should match regular dynamic route + expect(maybeParameterizeRoute('/dynamic')).toBe('/:param'); + + // Multiple segments should match required catch-all over optional catch-all + expect(maybeParameterizeRoute('/path/to/resource')).toBe('/:catch*'); + }); + + it('should handle real-world Next.js app directory structure', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }], + dynamicRoutes: [ + { + path: '/blog/:slug', + regex: '^/blog/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + { + path: '/:catch*?', + regex: '^(?:/(.*))?$', + paramNames: ['catch'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static routes should not be parameterized + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/contact')).toBeUndefined(); + + // Specific dynamic routes should take precedence over catch-all + expect(maybeParameterizeRoute('/blog/my-post')).toBe('/blog/:slug'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john/posts/456')).toBe('/users/:id/posts/:postId'); + + // Unmatched multi-segment paths should match required catch-all + expect(maybeParameterizeRoute('/api/v1/data')).toBe('/:segments*'); + expect(maybeParameterizeRoute('/some/deep/nested/path')).toBe('/:segments*'); + }); + + it('should prefer routes with more static segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/users/:id', + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/:resource/:id', + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['resource', 'id'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route with static segments should win + expect(maybeParameterizeRoute('/api/users/123')).toBe('/api/users/:id'); + + // Less specific but still targeted route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/456')).toBe('/api/:resource/:id'); + + // Unmatched patterns should fall back to catch-all + expect(maybeParameterizeRoute('/some/other/path')).toBe('/:segments*'); + }); + + it('should handle complex nested catch-all scenarios', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/docs/:slug', + regex: '^/docs/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/docs/:sections*', + regex: '^/docs/(.+)$', + paramNames: ['sections'], + }, + { + path: '/files/:path*?', + regex: '^/files(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match specific route + expect(maybeParameterizeRoute('/docs/introduction')).toBe('/docs/:slug'); + + // Multiple segments should match catch-all + expect(maybeParameterizeRoute('/docs/api/reference')).toBe('/docs/:sections*'); + expect(maybeParameterizeRoute('/docs/guide/getting-started/installation')).toBe('/docs/:sections*'); + + // Optional catch-all should match both empty and filled cases + expect(maybeParameterizeRoute('/files')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/documents')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/images/avatar.png')).toBe('/files/:path*?'); + }); + + it('should correctly order routes by specificity score', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + // These routes are intentionally in non-specificity order + { + path: '/:optional*?', // Specificity: 1000 (least specific) + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + { + path: '/:catchall*', // Specificity: 100 + regex: '^/(.+)$', + paramNames: ['catchall'], + }, + { + path: '/api/:endpoint/:id', // Specificity: 20 (2 dynamic segments) + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['endpoint', 'id'], + }, + { + path: '/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Most specific route should win despite order in manifest + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/api/users/456')).toBe('/api/users/:id'); + + // More general dynamic route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/789')).toBe('/api/:endpoint/:id'); + + // Catch-all should be used when no more specific routes match + expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); + }); + }); +});