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*');
+ });
+ });
+});