Repository: vercel/next.js
Pull request: fix: OTel root span should indicate error status on exceptions

For cases when the root span's status returned 500, the span status returned `OK`.
This results in inaccurate telemetry, as users may expect the span to indicate an error when a 500 occurs.

Failed CI: https://github.com/vercel/next.js/actions/runs/16633984270/job/47070403977?pr=82212#step:34:427

Also, the OTel documentation states:

> For HTTP status codes in the 5xx range, as well as any other code the client failed to interpret, span status SHOULD be set to Error.

x-ref: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status

Therefore, set the root span to `Error` when the status is in the 5xx range.

In addition, as the document states:

> When instrumentation detects such errors it SHOULD set span status to Error and SHOULD set the error.type attribute.

Added `error.type` attributes to places where sets `Error` span status.

Fixes NEXT-4668

Changed files:
filename: packages/next/src/server/app-render/create-error-handler.tsx
patch: @@ -91,6 +91,7 @@ export function createFlightReactServerErrorHandler(
     const span = getTracer().getActiveScopeSpan()
     if (span) {
       span.recordException(err)
+      span.setAttribute('error.type', err.name)
       span.setStatus({
         code: SpanStatusCode.ERROR,
         message: err.message,
@@ -164,6 +165,7 @@ export function createHTMLReactServerErrorHandler(
       const span = getTracer().getActiveScopeSpan()
       if (span) {
         span.recordException(err)
+        span.setAttribute('error.type', err.name)
         span.setStatus({
           code: SpanStatusCode.ERROR,
           message: err.message,
@@ -244,6 +246,7 @@ export function createHTMLErrorHandler(
       const span = getTracer().getActiveScopeSpan()
       if (span) {
         span.recordException(err)
+        span.setAttribute('error.type', err.name)
         span.setStatus({
           code: SpanStatusCode.ERROR,
           message: err.message,

filename: packages/next/src/server/base-server.ts
patch: @@ -96,7 +96,12 @@ import { AppRouteRouteMatcherProvider } from './route-matcher-providers/app-rout
 import { PagesAPIRouteMatcherProvider } from './route-matcher-providers/pages-api-route-matcher-provider'
 import { PagesRouteMatcherProvider } from './route-matcher-providers/pages-route-matcher-provider'
 import { ServerManifestLoader } from './route-matcher-providers/helpers/manifest-loaders/server-manifest-loader'
-import { getTracer, isBubbledError, SpanKind } from './lib/trace/tracer'
+import {
+  getTracer,
+  isBubbledError,
+  SpanKind,
+  SpanStatusCode,
+} from './lib/trace/tracer'
 import { BaseServerSpan } from './lib/trace/constants'
 import { I18NProvider } from './lib/i18n-provider'
 import { sendResponse } from './send-response'
@@ -896,6 +901,16 @@ export default abstract class Server<
               'next.rsc': isRSCRequest,
             })
 
+            if (res.statusCode && res.statusCode >= 500) {
+              // For 5xx status codes: SHOULD be set to 'Error' span status.
+              // x-ref: https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
+              span.setStatus({
+                code: SpanStatusCode.ERROR,
+              })
+              // For span status 'Error', SHOULD set 'error.type' attribute.
+              span.setAttribute('error.type', res.statusCode.toString())
+            }
+
             const rootSpanAttributes = tracer.getRootSpanAttributes()
             // We were unable to get attributes, probably OTEL is not enabled
             if (!rootSpanAttributes) return

filename: packages/next/src/server/lib/trace/tracer.ts
patch: @@ -56,6 +56,7 @@ const closeSpanWithError = (span: Span, error?: Error) => {
   } else {
     if (error) {
       span.recordException(error)
+      span.setAttribute('error.type', error.name)
     }
     span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message })
   }

filename: test/e2e/opentelemetry/instrumentation/app/app/[param]/rsc-fetch/error/page.tsx
patch: @@ -0,0 +1,13 @@
+export default async function Page({
+  searchParams,
+}: {
+  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
+}) {
+  const { status } = await searchParams
+
+  if (status === 'error') {
+    throw new Error('Error from Server Component')
+  }
+
+  return <p>Page</p>
+}

filename: test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts
patch: @@ -529,6 +529,136 @@ describe('opentelemetry', () => {
               },
             ])
           })
+
+          it('should handle error in RSC', async () => {
+            await next.fetch(
+              '/app/param/rsc-fetch/error?status=error',
+              env.fetchInit
+            )
+
+            await expectTrace(getCollector(), [
+              {
+                name: 'GET /app/[param]/rsc-fetch/error',
+                attributes: {
+                  'http.method': 'GET',
+                  'http.route': '/app/[param]/rsc-fetch/error',
+                  'http.status_code': 500,
+                  'http.target': '/app/param/rsc-fetch/error?status=error',
+                  'next.route': '/app/[param]/rsc-fetch/error',
+                  'next.rsc': false,
+                  'next.span_name': 'GET /app/[param]/rsc-fetch/error',
+                  'next.span_type': 'BaseServer.handleRequest',
+                  'error.type': '500',
+                },
+                kind: 1,
+                status: { code: 2 },
+                traceId: env.span.traceId,
+                parentId: env.span.rootParentId,
+                spans: [
+                  {
+                    name: 'render route (app) /app/[param]/rsc-fetch/error',
+                    attributes: {
+                      'next.route': '/app/[param]/rsc-fetch/error',
+                      'next.span_name':
+                        'render route (app) /app/[param]/rsc-fetch/error',
+                      'next.span_type': 'AppRender.getBodyResult',
+                    },
+                    kind: 0,
+                    status: { code: 2 },
+                    spans: [
+                      {
+                        name: 'build component tree',
+                        attributes: {
+                          'next.span_name': 'build component tree',
+                          'next.span_type':
+                            'NextNodeServer.createComponentTree',
+                        },
+                        kind: 0,
+                        status: { code: 0 },
+                        spans: [
+                          {
+                            name: 'resolve segment modules',
+                            attributes: {
+                              'next.segment': '__PAGE__',
+                              'next.span_name': 'resolve segment modules',
+                              'next.span_type':
+                                'NextNodeServer.getLayoutOrPageModule',
+                            },
+                            kind: 0,
+                            status: { code: 0 },
+                          },
+                          {
+                            name: 'resolve segment modules',
+                            attributes: {
+                              'next.segment': '[param]',
+                              'next.span_name': 'resolve segment modules',
+                              'next.span_type':
+                                'NextNodeServer.getLayoutOrPageModule',
+                            },
+                            kind: 0,
+                            status: { code: 0 },
+                          },
+                        ],
+                      },
+                      {
+                        name: 'generateMetadata /app/[param]/layout',
+                        attributes: {
+                          'next.page': '/app/[param]/layout',
+                          'next.span_name':
+                            'generateMetadata /app/[param]/layout',
+                          'next.span_type': 'ResolveMetadata.generateMetadata',
+                        },
+                        kind: 0,
+                        status: { code: 0 },
+                      },
+                      {
+                        name: 'generateMetadata /app/[param]/layout',
+                        attributes: {
+                          'next.page': '/app/[param]/layout',
+                          'next.span_name':
+                            'generateMetadata /app/[param]/layout',
+                          'next.span_type': 'ResolveMetadata.generateMetadata',
+                        },
+                        kind: 0,
+                        status: { code: 0 },
+                      },
+                      {
+                        attributes: {
+                          'next.clientComponentLoadCount': isNextDev ? 9 : 8,
+                          'next.span_type':
+                            'NextNodeServer.clientComponentLoading',
+                        },
+                        kind: 0,
+                        name: 'NextNodeServer.clientComponentLoading',
+                        status: {
+                          code: 0,
+                        },
+                      },
+                      {
+                        name: 'start response',
+                        attributes: {
+                          'next.span_name': 'start response',
+                          'next.span_type': 'NextNodeServer.startResponse',
+                        },
+                        kind: 0,
+                        status: { code: 0 },
+                      },
+                    ],
+                  },
+                  {
+                    name: 'resolve page components',
+                    attributes: {
+                      'next.route': '/app/[param]/rsc-fetch/error',
+                      'next.span_name': 'resolve page components',
+                      'next.span_type': 'NextNodeServer.findPageComponents',
+                    },
+                    kind: 0,
+                    status: { code: 0 },
+                  },
+                ],
+              },
+            ])
+          })
         })
 
         describe('pages', () => {