From ffdc48035cfa18f36417e34a37111340f5067f8f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 24 Aug 2026 11:05:10 +0200 Subject: [PATCH 1/2] fix(nextjs): Resolve static routes to their own route template `findMatchingRoutes` returned nothing for a path that exactly matched a static route in the build-time manifest, so `maybeParameterizeRoute` reported it the same way as a path it could not resolve at all: no manifest, a 404, or a route excluded from the manifest. Static routes were therefore emitted with `sentry.source: 'url'` and no `url.template`, even though a static route is its own template - one with zero parameters - and is guaranteed low cardinality by the finite build-time route list. Return the route itself instead. Pageload, navigation, popstate and router-patch spans all derive their name, source and `url.template` from that return value, so static routes now carry `sentry.source: 'route'`. Under span streaming they keep their path as the span name instead of collapsing to `Pageload`, which is now reserved for paths the manifest genuinely cannot resolve. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/parameterized-routes.test.ts | 11 +-- .../tests/parameterized-routes.test.ts | 11 +-- .../tests/parameterized-routes.test.ts | 9 +- .../tests/parameterized-routes.test.ts | 9 +- .../tests/cacheComponents.spec.ts | 5 +- .../tests/pageload-tracing.test.ts | 4 +- .../tests/parameterized-routes.test.ts | 24 ++++- .../trailing-slash-parameterization.test.ts | 15 +-- .../tests/parameterized-routes.test.ts | 11 +-- .../nextjs-app-dir/tests/transactions.test.ts | 7 +- .../src/client/routing/parameterization.ts | 12 ++- .../test/client/parameterization.test.ts | 92 +++++++++---------- 12 files changed, 111 insertions(+), 99 deletions(-) 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 index 8d813e0f2774..f674e9f7279d 100644 --- 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 @@ -43,9 +43,7 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ - page, -}) => { +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -63,9 +61,10 @@ test('should create a static transaction when the `app` directory is used and th data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/static$/), 'url.path': '/parameterized/static', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -81,11 +80,9 @@ test('should create a static transaction when the `app` directory is used and th start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); - - expect(transaction.contexts?.trace?.data).not.toHaveProperty('url.template'); }); test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { 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 index b9ea5801d1cc..ecaeb9747ddd 100644 --- 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 @@ -43,9 +43,7 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ - page, -}) => { +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -63,9 +61,10 @@ test('should create a static transaction when the `app` directory is used and th data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/static$/), 'url.path': '/parameterized/static', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -81,11 +80,9 @@ test('should create a static transaction when the `app` directory is used and th start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); - - expect(transaction.contexts?.trace?.data).not.toHaveProperty('url.template'); }); test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts index 07e5f007efad..98ac069d5b3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts @@ -40,9 +40,7 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ - page, -}) => { +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-bun', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -60,7 +58,8 @@ test('should create a static transaction when the `app` directory is used and th data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -76,7 +75,7 @@ test('should create a static transaction when the `app` directory is used and th start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts index b3ba64bb55c8..7f903d044ee2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts @@ -40,9 +40,7 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ - page, -}) => { +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -60,7 +58,8 @@ test('should create a static transaction when the `app` directory is used and th data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -76,7 +75,7 @@ test('should create a static transaction when the `app` directory is used and th start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts index 457d87db3dc0..106e430c8177 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheComponents.spec.ts @@ -71,7 +71,7 @@ test('Prerendered shell does not stitch the pageload onto a stale trace', async }); const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { - return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto('/pageload-tracing'); @@ -81,8 +81,9 @@ test('Prerendered shell does not stitch the pageload onto a stale trace', async const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); expect(pageloadSpan.attributes).toMatchObject({ - ['sentry.segment.name.source']: { value: 'url', type: 'string' }, + ['sentry.segment.name.source']: { value: 'route', type: 'string' }, ['url.path']: { value: '/pageload-tracing', type: 'string' }, + ['url.template']: { value: '/pageload-tracing', type: 'string' }, }); // Under Cache Components the can be prerendered and rendered in a context detached from the // runtime server request, so a `sentry-trace` meta tag would carry a stale/unrelated trace. The diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts index 70539d3d95c5..a4a1785042d9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/pageload-tracing.test.ts @@ -7,7 +7,7 @@ test('Server and client pageload spans should share the same trace', async ({ pa }); const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { - return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/pageload-tracing`); @@ -17,7 +17,7 @@ test('Server and client pageload spans should share the same trace', async ({ pa expect(pageloadSpan.trace_id).toBeTruthy(); expect(serverSpan.trace_id).toBe(pageloadSpan.trace_id); expect(pageloadSpan.attributes).toMatchObject({ - ['sentry.segment.name.source']: { value: 'url', type: 'string' }, + ['sentry.segment.name.source']: { value: 'route', type: 'string' }, ['url.path']: { value: '/pageload-tracing', type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts index 502d7fdced2a..f7951bc3a355 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/parameterized-routes.test.ts @@ -15,22 +15,40 @@ test('should create a parameterized streamed span when the `app` directory is us expect(span.attributes['sentry.source']?.value).toBe('route'); }); -test('should create a static streamed span when the `app` directory is used and the route is not parameterized', async ({ +test('should create a streamed span named after the static route when the `app` directory is used', async ({ page, }) => { const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { - return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment; + return span.name === '/parameterized/static' && getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/parameterized/static`); const span = await spanPromise; + expect(span.name).toBe('/parameterized/static'); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + expect(span.attributes).toMatchObject({ + ['sentry.segment.name.source']: { value: 'route', type: 'string' }, + ['url.template']: { value: '/parameterized/static', type: 'string' }, + ['url.path']: { value: '/parameterized/static', type: 'string' }, + }); +}); + +test('should fall back to a low cardinality span name for routes the manifest does not know', async ({ page }) => { + const spanPromise = waitForStreamedSpan('nextjs-16-streaming', span => { + return span.name === 'Pageload' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/this-route-does-not-exist'); + + const span = await spanPromise; + expect(span.name).toBe('Pageload'); expect(span.trace_id).toMatch(/[a-f0-9]{32}/); expect(span.attributes).toMatchObject({ ['sentry.segment.name.source']: { value: 'url', type: 'string' }, - ['url.path']: { value: '/parameterized/static', type: 'string' }, + ['url.path']: { value: '/this-route-does-not-exist', type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts index cfdfe12d0c27..cb0c6e76f7e5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts @@ -20,14 +20,15 @@ test('should create a correctly named pageload transaction for a static route', data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', + 'url.template': '/static-page', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', }, }, transaction: '/static-page', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); @@ -80,14 +81,15 @@ test('should create a correctly named pageload transaction for a static nested r data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', }, }, transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); @@ -134,14 +136,15 @@ test('should create a correctly named pageload transaction for the home page', a data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', + 'url.template': '/', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', }, }, transaction: '/', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index 38772dadc092..afaf6c2a5601 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -43,9 +43,7 @@ test('should create a parameterized transaction when the `app` directory is used }); }); -test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ - page, -}) => { +test('should create a transaction named after the static route when the `app` directory is used', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' @@ -63,9 +61,10 @@ test('should create a static transaction when the `app` directory is used and th data: { 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/parameterized\/static$/), 'url.path': '/parameterized/static', + 'url.template': '/parameterized/static', }, op: 'pageload', origin: 'auto.pageload.nextjs.app_router_instrumentation', @@ -81,11 +80,9 @@ test('should create a static transaction when the `app` directory is used and th start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/parameterized/static', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', }); - - expect(transaction.contexts?.trace?.data).not.toHaveProperty('url.template'); }); test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index ec0babf7df77..8d89217eb395 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -19,7 +19,7 @@ test('Sends a pageload transaction', async ({ page }) => { expect(transactionEvent).toEqual( expect.objectContaining({ transaction: '/', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, type: 'transaction', contexts: expect.objectContaining({ react: { @@ -37,9 +37,10 @@ test('Sends a pageload transaction', async ({ page }) => { data: expect.objectContaining({ 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', + 'sentry.source': 'route', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), 'url.path': '/', + 'url.template': '/', }), }, }), @@ -51,8 +52,6 @@ test('Sends a pageload transaction', async ({ page }) => { }, }), ); - - expect(transactionEvent.contexts?.trace?.data).not.toHaveProperty('url.template'); }); test('Should send a transaction for instrumented server actions', async ({ page }) => { diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index e80720beb262..ab6835291d66 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -130,9 +130,11 @@ function findMatchingRoutes( ): string[] { const matches: string[] = []; - // Static path: no parameterization needed, return empty array + // A static route is its own template. Returning it rather than nothing lets callers tell + // "known route, zero params" apart from "we could not resolve this path at all" (no manifest, + // 404, excluded route), which is the difference between a low- and high-cardinality span name. if (staticRoutes.some(r => r.path === route)) { - return matches; + return [route]; } // Dynamic path: find the route pattern that matches the concrete route @@ -167,10 +169,10 @@ function findMatchingRoutes( } /** - * Parameterize a route using the route manifest. + * Resolve the route template for a concrete route using the route manifest. * - * @param route - The route to parameterize. - * @returns The parameterized route or undefined if no parameterization is needed. + * @param route - The route to resolve. + * @returns The route template, or undefined if the route could not be resolved from the manifest. */ export const maybeParameterizeRoute = (route: string): string | undefined => { const manifest = getManifest(); diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index dd7abff0bcd0..b3b5b0d83de6 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -25,17 +25,17 @@ describe('maybeParameterizeRoute', () => { }); describe('when manifest has static routes', () => { - it('should return undefined for static routes', () => { + it('should return the route itself 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(); + expect(maybeParameterizeRoute('/')).toBe('/'); + expect(maybeParameterizeRoute('/some/nested')).toBe('/some/nested'); + expect(maybeParameterizeRoute('/user')).toBe('/user'); + expect(maybeParameterizeRoute('/users')).toBe('/users'); }); }); @@ -78,7 +78,7 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/users/john-doe/settings')).toBe('/users/:id/settings'); }); - it('should return undefined for static routes even when dynamic routes exist', () => { + it('should return the route itself for static routes even when dynamic routes exist', () => { const manifest: RouteManifest = { staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], dynamicRoutes: [ @@ -91,9 +91,9 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - expect(maybeParameterizeRoute('/')).toBeUndefined(); - expect(maybeParameterizeRoute('/dynamic/static')).toBeUndefined(); - expect(maybeParameterizeRoute('/static/nested')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBe('/'); + expect(maybeParameterizeRoute('/dynamic/static')).toBe('/dynamic/static'); + expect(maybeParameterizeRoute('/static/nested')).toBe('/static/nested'); }); it('should handle catchall routes', () => { @@ -137,11 +137,11 @@ describe('maybeParameterizeRoute', () => { }; 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('/(auth)/login')).toBe('/(auth)/login'); + expect(maybeParameterizeRoute('/(auth)/signup')).toBe('/(auth)/signup'); + expect(maybeParameterizeRoute('/(dashboard)/dashboard')).toBe('/(dashboard)/dashboard'); + expect(maybeParameterizeRoute('/(dashboard)/settings/profile')).toBe('/(dashboard)/settings/profile'); + expect(maybeParameterizeRoute('/(marketing)/public/about')).toBe('/(marketing)/public/about'); expect(maybeParameterizeRoute('/(dashboard)/dashboard/123')).toBe('/(dashboard)/dashboard/:id'); }); @@ -165,11 +165,11 @@ describe('maybeParameterizeRoute', () => { }; 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('/login')).toBe('/login'); + expect(maybeParameterizeRoute('/signup')).toBe('/signup'); + expect(maybeParameterizeRoute('/dashboard')).toBe('/dashboard'); + expect(maybeParameterizeRoute('/settings/profile')).toBe('/settings/profile'); + expect(maybeParameterizeRoute('/public/about')).toBe('/public/about'); expect(maybeParameterizeRoute('/dashboard/123')).toBe('/dashboard/:id'); }); @@ -283,7 +283,7 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBe('/'); }); it('should handle complex nested dynamic routes', () => { @@ -307,17 +307,17 @@ describe('maybeParameterizeRoute', () => { 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], + ['/', '/'], + ['/some/nested', '/some/nested'], + ['/user', '/user'], + ['/users', '/users'], + ['/dynamic/static', '/dynamic/static'], + ['/static/nested', '/static/nested'], + ['/login', '/login'], + ['/signup', '/signup'], + ['/dashboard', '/dashboard'], + ['/settings/profile', '/settings/profile'], + ['/public/about', '/public/about'], ['/dynamic/123', '/dynamic/:id'], ['/dynamic/abc', '/dynamic/:id'], @@ -472,8 +472,8 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - // Static route should take precedence (no parameterization) - expect(maybeParameterizeRoute('/static')).toBeUndefined(); + // Static route should take precedence over the catch-alls + expect(maybeParameterizeRoute('/static')).toBe('/static'); // Single segment should match regular dynamic route expect(maybeParameterizeRoute('/dynamic')).toBe('/:param'); @@ -515,10 +515,10 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - // Static routes should not be parameterized - expect(maybeParameterizeRoute('/')).toBeUndefined(); - expect(maybeParameterizeRoute('/about')).toBeUndefined(); - expect(maybeParameterizeRoute('/contact')).toBeUndefined(); + // Static routes should resolve to themselves rather than falling through to a catch-all + expect(maybeParameterizeRoute('/')).toBe('/'); + expect(maybeParameterizeRoute('/about')).toBe('/about'); + expect(maybeParameterizeRoute('/contact')).toBe('/contact'); // Specific dynamic routes should take precedence over catch-all expect(maybeParameterizeRoute('/blog/my-post')).toBe('/blog/:slug'); @@ -827,8 +827,8 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - // Root should not be parameterized (it's a static route) - expect(maybeParameterizeRoute('/')).toBeUndefined(); + // Root is a static route, so it wins over the locale-prefixed patterns + expect(maybeParameterizeRoute('/')).toBe('/'); // Default locale (English, no prefix) - this was the bug expect(maybeParameterizeRoute('/hola')).toBe('/:locale/hola'); @@ -942,10 +942,10 @@ describe('maybeParameterizeRoute', () => { }; globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); - expect(maybeParameterizeRoute('/about/')).toBeUndefined(); - expect(maybeParameterizeRoute('/settings/profile/')).toBeUndefined(); + expect(maybeParameterizeRoute('/about/')).toBe('/about'); + expect(maybeParameterizeRoute('/settings/profile/')).toBe('/settings/profile'); // Root path should still work - expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBe('/'); }); it('should match dynamic routes when path has a trailing slash', () => { @@ -994,8 +994,8 @@ describe('maybeParameterizeRoute', () => { globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); // Static routes with trailing slash should NOT fall through to catch-all - expect(maybeParameterizeRoute('/about/')).toBeUndefined(); - expect(maybeParameterizeRoute('/contact/')).toBeUndefined(); + expect(maybeParameterizeRoute('/about/')).toBe('/about'); + expect(maybeParameterizeRoute('/contact/')).toBe('/contact'); // Dynamic routes with trailing slash should match correctly, not catch-all expect(maybeParameterizeRoute('/blog/my-post/')).toBe('/blog/:slug'); @@ -1025,13 +1025,13 @@ describe('maybeParameterizeRoute', () => { globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); // Static route with trailing slash should not match the optional catch-all - expect(maybeParameterizeRoute('/static-page/')).toBeUndefined(); + expect(maybeParameterizeRoute('/static-page/')).toBe('/static-page'); // Dynamic route with trailing slash should not match the optional catch-all expect(maybeParameterizeRoute('/parameterized/value/')).toBe('/parameterized/:param'); // Root with trailing slash is just '/' - should match static - expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBe('/'); }); it('should produce the same result for paths with and without trailing slashes', () => { From b6f7b8f7ecf1959909955fd6b6dca7d305848018 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 24 Aug 2026 11:33:17 +0200 Subject: [PATCH 2/2] deslop --- packages/nextjs/src/client/routing/parameterization.ts | 10 ++++------ packages/nextjs/test/client/parameterization.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index ab6835291d66..a45f0faab8fd 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -130,9 +130,7 @@ function findMatchingRoutes( ): string[] { const matches: string[] = []; - // A static route is its own template. Returning it rather than nothing lets callers tell - // "known route, zero params" apart from "we could not resolve this path at all" (no manifest, - // 404, excluded route), which is the difference between a low- and high-cardinality span name. + // Static path: no parameterization needed, return the route itself as already parameterized if (staticRoutes.some(r => r.path === route)) { return [route]; } @@ -169,10 +167,10 @@ function findMatchingRoutes( } /** - * Resolve the route template for a concrete route using the route manifest. + * Parameterize a route using the route manifest. * - * @param route - The route to resolve. - * @returns The route template, or undefined if the route could not be resolved from the 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(); diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index b3b5b0d83de6..18bd61bee210 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -25,7 +25,7 @@ describe('maybeParameterizeRoute', () => { }); describe('when manifest has static routes', () => { - it('should return the route itself for static routes', () => { + it('returns the route itself for static routes', () => { const manifest: RouteManifest = { staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], @@ -78,7 +78,7 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/users/john-doe/settings')).toBe('/users/:id/settings'); }); - it('should return the route itself for static routes even when dynamic routes exist', () => { + it('returns the route itself for static routes even when dynamic routes exist', () => { const manifest: RouteManifest = { staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], dynamicRoutes: [