Repository: vercel/next.js
Pull request: bugfix: Bad `next.route` on `pages` dir OTel spans for `404`s / `500`s

## Issue

- Resolves https://github.com/vercel/next.js/issues/82102

## What it Does

Prevents the root span attribute `next.route`  from being overwritten once set for a request. This resolves two issues:

1. Fixes `404`s and `500`s in the `pagesDir` overwriting `next.route` on OpenTelemetry root span
2. Removes redundant `/route` suffixes on `appDir` API Route Handler OTel span names

## Background

The root OTel span (`BaseServer.handleRequest`) for requests in Next.js derives its `name` from its `next.route` attr, set by child spans through the `NextTracerImpl.setRootSpanAttribute` function. The problem is, when a page `404`s or `500`s, the rendering of the `404` or `500` page *also* sets `next.route`. This scrubs the root span of the original page's `next.route`, as if the user had requested the `/_not-found` or `/_error` page directly.

 For example, these two traces in `otel-desktop-viewer` are requests to the same URL, with the bottom having received an error thrown in `getServerSideProps`:

<img width="328" alt="image" src="https://github.com/user-attachments/assets/855744b6-8720-4c5e-a5e5-b15cb95e43ca" />

This is not helpful at all! Further investigation shows that root spans all have the following behavior::
- Successful responses are correctly named after their path, e.g. `GET /foo` with `status_code: 200`
- Not found responses on ALL paths are named `GET /_not-found` with `status_code: 404`
- Erroring responses on ALL paths are named `GET /_error` with `status_code: 500`

As another example, consider which `name` and `status_code` combo is more helpful:

- `name: GET /_not-found`, `status_code: 404`, `http.target: /foo?page=10`, `next.route: /_not-found`  (current)
- **`name: GET /foo`**, `status_code: 404`, `http.target: /foo?page=10`, `next.route: /foo`,  (desired)

Clearly, replacing span names with `GET /_not-found` only removes information, as the "404"-ness is already indicated by the `status_code` span attribute.

A search for `setRootSpanAttribute` confirms that this function is only used 4 times, all to set `next.route`:

https://github.com/vercel/next.js/blob/6d7266e329a684b4e254c6a265f5b3ddb6e7b332/packages/next/src/server/api-utils/index.ts?plain=1#L28

https://github.com/vercel/next.js/blob/6d7266e329a684b4e254c6a265f5b3ddb6e7b332/packages/next/src/server/route-modules/app-route/module.ts?plain=1#L743

https://github.com/vercel/next.js/blob/6d7266e329a684b4e254c6a265f5b3ddb6e7b332/packages/next/src/server/base-server.ts?plain=1#L3732

https://github.com/vercel/next.js/blob/6d7266e329a684b4e254c6a265f5b3ddb6e7b332/packages/next/src/server/app-render/app-render.tsx?plain=1#L1333

So, we need to make sure `setRootSpanAttribute` does not overwrite `next.route` if it has already been set. This PR does that, and adds tests for `/_not-found` and `/_error` page cases (which were missing from the `e2e` test suite).

## Repro

Repro: https://github.com/evankirkiles/nextjs-otel-span-name-errors

Before (using `next.js@canary`):

<img width="1773" height="1036" alt="SCR-20250727-suas" src="https://github.com/user-attachments/assets/94895bc7-8f09-4c55-ba3e-63191ac49ff7" />

After (using changes from this PR):

<img width="1728" height="997" alt="image" src="https://github.com/user-attachments/assets/3f3a1fdd-0acd-4b00-a34d-3e8e3d49e09a" />


## Fixing a bug

- [x] Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs

Changed files:
filename: packages/next/src/server/lib/trace/tracer.ts
patch: @@ -456,7 +456,7 @@ class NextTracerImpl implements NextTracer {
   public setRootSpanAttribute(key: AttributeNames, value: AttributeValue) {
     const spanId = context.active().getValue(rootSpanIdKey) as number
     const attributes = rootSpanAttributesStore.get(spanId)
-    if (attributes) {
+    if (attributes && !attributes.has(key)) {
       attributes.set(key, value)
     }
   }

filename: packages/next/src/server/route-modules/app-route/module.ts
patch: @@ -22,7 +22,6 @@ import { getImplicitTags, type ImplicitTags } from '../../lib/implicit-tags'
 import { patchFetch } from '../../lib/patch-fetch'
 import { getTracer } from '../../lib/trace/tracer'
 import { AppRouteRouteHandlersSpan } from '../../lib/trace/constants'
-import { getPathnameFromAbsolutePath } from './helpers/get-pathname-from-absolute-path'
 import * as Log from '../../../build/output/log'
 import { autoImplementMethods } from './helpers/auto-implement-methods'
 import {
@@ -765,20 +764,18 @@ export class AppRouteRouteModule extends RouteModule<
                 this.dynamic satisfies never
             }
 
-            // TODO: propagate this pathname from route matcher
-            const route = getPathnameFromAbsolutePath(this.resolvedPagePath)
-
             const tracer = getTracer()
 
             // Update the root span attribute for the route.
-            tracer.setRootSpanAttribute('next.route', route)
+            const { pathname } = this.definition
+            tracer.setRootSpanAttribute('next.route', pathname)
 
             return tracer.trace(
               AppRouteRouteHandlersSpan.runHandler,
               {
-                spanName: `executing api route (app) ${route}`,
+                spanName: `executing api route (app) ${pathname}`,
                 attributes: {
-                  'next.route': route,
+                  'next.route': pathname,
                 },
               },
               async () =>

filename: test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts
patch: @@ -394,14 +394,14 @@ describe('opentelemetry', () => {
 
             await expectTrace(getCollector(), [
               {
-                name: 'GET /api/app/[param]/data/route',
+                name: 'GET /api/app/[param]/data',
                 attributes: {
                   'http.method': 'GET',
-                  'http.route': '/api/app/[param]/data/route',
+                  'http.route': '/api/app/[param]/data',
                   'http.status_code': 200,
                   'http.target': '/api/app/param/data',
-                  'next.route': '/api/app/[param]/data/route',
-                  'next.span_name': 'GET /api/app/[param]/data/route',
+                  'next.route': '/api/app/[param]/data',
+                  'next.span_name': 'GET /api/app/[param]/data',
                   'next.span_type': 'BaseServer.handleRequest',
                 },
                 kind: 1,
@@ -410,11 +410,11 @@ describe('opentelemetry', () => {
                 parentId: env.span.rootParentId,
                 spans: [
                   {
-                    name: 'executing api route (app) /api/app/[param]/data/route',
+                    name: 'executing api route (app) /api/app/[param]/data',
                     attributes: {
-                      'next.route': '/api/app/[param]/data/route',
+                      'next.route': '/api/app/[param]/data',
                       'next.span_name':
-                        'executing api route (app) /api/app/[param]/data/route',
+                        'executing api route (app) /api/app/[param]/data',
                       'next.span_type': 'AppRouteRouteHandlers.runHandler',
                     },
                     kind: 0,
@@ -452,11 +452,11 @@ describe('opentelemetry', () => {
                 runtime: 'edge',
                 traceId: env.span.traceId,
                 parentId: env.span.rootParentId,
-                name: 'executing api route (app) /api/app/[param]/data/edge/route',
+                name: 'executing api route (app) /api/app/[param]/data/edge',
                 attributes: {
-                  'next.route': '/api/app/[param]/data/edge/route',
+                  'next.route': '/api/app/[param]/data/edge',
                   'next.span_name':
-                    'executing api route (app) /api/app/[param]/data/edge/route',
+                    'executing api route (app) /api/app/[param]/data/edge',
                   'next.span_type': 'AppRouteRouteHandlers.runHandler',
                 },
                 kind: 0,
@@ -856,6 +856,192 @@ describe('opentelemetry', () => {
             ])
           })
 
+          it('should handle getServerSideProps exceptions', async () => {
+            await next.fetch(
+              '/pages/param/getServerSidePropsError',
+              env.fetchInit
+            )
+
+            await expectTrace(getCollector(), [
+              {
+                name: 'GET /pages/[param]/getServerSidePropsError',
+                attributes: {
+                  'http.method': 'GET',
+                  'http.route': '/pages/[param]/getServerSidePropsError',
+                  'http.status_code': 500,
+                  'http.target': '/pages/param/getServerSidePropsError',
+                  'next.route': '/pages/[param]/getServerSidePropsError',
+                  'next.span_name':
+                    'GET /pages/[param]/getServerSidePropsError',
+                  'next.span_type': 'BaseServer.handleRequest',
+                  'error.type': '500',
+                },
+                kind: 1,
+                status: { code: 2 },
+                traceId: env.span.traceId,
+                parentId: env.span.rootParentId,
+                spans: [
+                  {
+                    name: 'getServerSideProps /pages/[param]/getServerSidePropsError',
+                    attributes: {
+                      'next.route': '/pages/[param]/getServerSidePropsError',
+                      'next.span_name':
+                        'getServerSideProps /pages/[param]/getServerSidePropsError',
+                      'next.span_type': 'Render.getServerSideProps',
+                      'error.type': 'Error',
+                    },
+                    kind: 0,
+                    status: {
+                      code: 2,
+                      message: 'ServerSideProps error',
+                    },
+                    events: [
+                      {
+                        name: 'exception',
+                        attributes: {
+                          'exception.type': 'Error',
+                          'exception.message': 'ServerSideProps error',
+                        },
+                      },
+                    ],
+                  },
+                  {
+                    name: 'render route (pages) /_error',
+                    attributes: {
+                      'next.route': '/_error',
+                      'next.span_name': 'render route (pages) /_error',
+                      'next.span_type': 'Render.renderDocument',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                  {
+                    name: 'resolve page components',
+                    attributes: {
+                      'next.route': '/_error',
+                      'next.span_name': 'resolve page components',
+                      'next.span_type': 'NextNodeServer.findPageComponents',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                  ...(isNextDev
+                    ? []
+                    : [
+                        {
+                          name: 'resolve page components',
+                          attributes: {
+                            'next.route': '/500',
+                            'next.span_name': 'resolve page components',
+                            'next.span_type':
+                              'NextNodeServer.findPageComponents',
+                          },
+                          kind: 0,
+                          status: { code: 0 },
+                        },
+                        {
+                          name: 'resolve page components',
+                          attributes: {
+                            'next.route': '/500',
+                            'next.span_name': 'resolve page components',
+                            'next.span_type':
+                              'NextNodeServer.findPageComponents',
+                          },
+                          kind: 0,
+                          status: { code: 0 },
+                        },
+                      ]),
+                  {
+                    name: 'resolve page components',
+                    attributes: {
+                      'next.route': '/pages/[param]/getServerSidePropsError',
+                      'next.span_name': 'resolve page components',
+                      'next.span_type': 'NextNodeServer.findPageComponents',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                ],
+              },
+            ])
+          })
+
+          it('should handle getServerSideProps returning notFound', async () => {
+            await next.fetch(
+              '/pages/param/getServerSidePropsNotFound',
+              env.fetchInit
+            )
+
+            await expectTrace(getCollector(), [
+              {
+                name: 'GET /pages/[param]/getServerSidePropsNotFound',
+                attributes: {
+                  'http.method': 'GET',
+                  'http.route': '/pages/[param]/getServerSidePropsNotFound',
+                  'http.status_code': 404,
+                  'http.target': '/pages/param/getServerSidePropsNotFound',
+                  'next.route': '/pages/[param]/getServerSidePropsNotFound',
+                  'next.span_name':
+                    'GET /pages/[param]/getServerSidePropsNotFound',
+                  'next.span_type': 'BaseServer.handleRequest',
+                },
+                kind: 1,
+                status: { code: 0 },
+                traceId: env.span.traceId,
+                parentId: env.span.rootParentId,
+                spans: [
+                  {
+                    name: 'getServerSideProps /pages/[param]/getServerSidePropsNotFound',
+                    attributes: {
+                      'next.route': '/pages/[param]/getServerSidePropsNotFound',
+                      'next.span_name':
+                        'getServerSideProps /pages/[param]/getServerSidePropsNotFound',
+                      'next.span_type': 'Render.getServerSideProps',
+                    },
+                    kind: 0,
+                    status: {
+                      code: 0,
+                    },
+                  },
+                  ...(isNextDev
+                    ? [
+                        {
+                          name: 'render route (app) /_not-found',
+                          attributes: {
+                            'next.route': '/_not-found',
+                            'next.span_name': 'render route (app) /_not-found',
+                            'next.span_type': 'AppRender.getBodyResult',
+                          },
+                          kind: 0,
+                          status: { code: 0 },
+                        },
+                      ]
+                    : []),
+                  {
+                    name: 'resolve page components',
+                    attributes: {
+                      'next.route': '/_not-found',
+                      'next.span_name': 'resolve page components',
+                      'next.span_type': 'NextNodeServer.findPageComponents',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                  {
+                    name: 'resolve page components',
+                    attributes: {
+                      'next.route': '/pages/[param]/getServerSidePropsNotFound',
+                      'next.span_name': 'resolve page components',
+                      'next.span_type': 'NextNodeServer.findPageComponents',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                ],
+              },
+            ])
+          })
+
           it('should handle api routes in pages', async () => {
             await next.fetch('/api/pages/param/basic', env.fetchInit)
 
@@ -1061,10 +1247,17 @@ async function expectTrace(collector: Collector, match: SpanMatch[]) {
         if (nameDiff !== 0) {
           return nameDiff
         }
-        return (
+        const segmentDiff =
           (a.attributes?.['next.segment'] ?? '').localeCompare(
             b.attributes?.['next.segment'] ?? ''
           ) ?? 0
+        if (segmentDiff !== 0) {
+          return segmentDiff
+        }
+        return (
+          (a.attributes?.['next.route'] ?? '').localeCompare(
+            b.attributes?.['next.route'] ?? ''
+          ) ?? 0
         )
       })
     }

filename: test/e2e/opentelemetry/instrumentation/pages/pages/[param]/getServerSidePropsError.tsx
patch: @@ -0,0 +1,7 @@
+export default function Page() {
+  return <div>Page</div>
+}
+
+export function getServerSideProps() {
+  throw new Error('ServerSideProps error')
+}

filename: test/e2e/opentelemetry/instrumentation/pages/pages/[param]/getServerSidePropsNotFound.tsx
patch: @@ -0,0 +1,7 @@
+export default function Page() {
+  return <div>Page</div>
+}
+
+export function getServerSideProps() {
+  return { notFound: true }
+}