[otel] Correctly set error code attributes for App API routes (#94979)

**View diff without whitespace**

When using adapters, throwing a JS error from a App Route Handler didn't
set the OTEL status code correctly.
It was doing this:
```js
try {
	tracer.trace(() => {
		try {
			// handle...
		} finally {
			setOtel();
		}
	});
} catch(e){
	send500();
}
```
Which I now changed to
```js
	tracer.trace(() => {
		try {
			// handle...
		} catch(e){
			send500();
		} finally {
			setOtel();
		}
	});

```
This commit is contained in:
Niklas Mischkulnig
2026-06-22 14:47:58 +02:00
committed by GitHub
parent 64429129c0
commit f66c8a6a78
7 changed files with 418 additions and 234 deletions
+231 -234
View File
@@ -289,184 +289,123 @@ export async function handler(
signalFromNodeResponse(res)
)
try {
let parentSpan: Span | undefined
const handleResponse = async (currentSpan?: Span) => {
const responseGenerator: ResponseGenerator = async ({
previousCacheEntry,
}) => {
try {
if (
!isMinimalMode &&
isOnDemandRevalidate &&
revalidateOnlyGenerated &&
!previousCacheEntry
) {
res.statusCode = 404
// on-demand revalidate always sets this header
res.setHeader('x-nextjs-cache', 'REVALIDATED')
res.end('This page could not be found')
return null
}
const response = await routeModule.handle(nextReq, context)
;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics
let pendingWaitUntil = context.renderOpts.pendingWaitUntil
// Attempt using provided waitUntil if available
// if it's not we fallback to sendResponse's handling
if (pendingWaitUntil) {
if (ctx.waitUntil) {
ctx.waitUntil(pendingWaitUntil)
pendingWaitUntil = undefined
}
}
const cacheTags = context.renderOpts.collectedTags
// If the request is for a static response, we can cache it so long
// as it's not edge.
if (isIsr) {
const blob = await response.blob()
// Copy the headers from the response.
const headers = toNodeOutgoingHttpHeaders(response.headers)
if (cacheTags) {
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
}
if (!headers['content-type'] && blob.type) {
headers['content-type'] = blob.type
}
const revalidate =
typeof context.renderOpts.collectedRevalidate === 'undefined' ||
context.renderOpts.collectedRevalidate >= INFINITE_CACHE
? false
: context.renderOpts.collectedRevalidate
const expire =
typeof context.renderOpts.collectedExpire === 'undefined' ||
context.renderOpts.collectedExpire >= INFINITE_CACHE
? // Fall back to the global `expireTime` config when the
// route has a numeric `revalidate` but didn't declare an
// explicit `expire` (e.g. via `cacheLife`). This mirrors the
// build-time fallback in `build/index.ts` so cache entries
// and the response Cache-Control header agree on the route's
// effective expire. Routes that opt out of revalidation
// (`revalidate: false`) or that are dynamic (`revalidate: 0`)
// keep `expire: undefined`.
revalidate !== false && revalidate > 0
? nextConfig.expireTime
: undefined
: context.renderOpts.collectedExpire
// Create the cache entry for the response.
const cacheEntry: ResponseCacheEntry = {
value: {
kind: CachedRouteKind.APP_ROUTE,
status: response.status,
body: Buffer.from(await blob.arrayBuffer()),
headers,
},
cacheControl: { revalidate, expire },
}
return cacheEntry
} else {
// send response without caching if not ISR
await sendResponse(
nodeNextReq,
nodeNextRes,
response,
pendingWaitUntil
)
return null
}
} catch (err) {
// if this is a background revalidate we need to report
// the request error here as it won't be bubbled
if (previousCacheEntry?.isStale) {
const silenceLog = false
await routeModule.onRequestError(
req,
err,
{
routerKind: 'App Router',
routePath: srcPage,
routeType: 'route',
revalidateReason: getRevalidateReason({
isStaticGeneration,
isOnDemandRevalidate,
}),
},
silenceLog,
routerServerContext
)
}
throw err
} finally {
// An IIFE to make early returns easier.
;(() => {
if (!currentSpan) {
return
}
currentSpan.setAttributes({
'http.status_code': res.statusCode,
'next.rsc': false,
})
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
currentSpan.setStatus({
code: SpanStatusCode.ERROR,
})
// For span status 'Error', SHOULD set 'error.type' attribute.
currentSpan.setAttribute('error.type', res.statusCode.toString())
}
const rootSpanAttributes = tracer.getRootSpanAttributes()
// We were unable to get attributes, probably OTEL is not enabled
if (!rootSpanAttributes) {
return
}
if (
rootSpanAttributes.get('next.span_type') !==
BaseServerSpan.handleRequest
) {
console.warn(
`Unexpected root span type '${rootSpanAttributes.get(
'next.span_type'
)}'. Please report this Next.js issue https://github.com/vercel/next.js`
)
return
}
const route =
rootSpanAttributes.get('next.route') || normalizedSrcPage
const name = `${method} ${route}`
currentSpan.setAttributes({
'next.route': route,
'http.route': route,
'next.span_name': name,
})
currentSpan.updateName(name)
// Propagate http.route to the parent span if one exists (e.g.
// a platform-created HTTP span in adapter deployments).
if (parentSpan && parentSpan !== currentSpan) {
parentSpan.setAttribute('http.route', route)
parentSpan.updateName(name)
}
})()
}
const responseGenerator: ResponseGenerator = async ({
previousCacheEntry,
}) => {
try {
if (
!isMinimalMode &&
isOnDemandRevalidate &&
revalidateOnlyGenerated &&
!previousCacheEntry
) {
res.statusCode = 404
// on-demand revalidate always sets this header
res.setHeader('x-nextjs-cache', 'REVALIDATED')
res.end('This page could not be found')
return null
}
const response = await routeModule.handle(nextReq, context)
;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics
let pendingWaitUntil = context.renderOpts.pendingWaitUntil
// Attempt using provided waitUntil if available
// if it's not we fallback to sendResponse's handling
if (pendingWaitUntil) {
if (ctx.waitUntil) {
ctx.waitUntil(pendingWaitUntil)
pendingWaitUntil = undefined
}
}
const cacheTags = context.renderOpts.collectedTags
// If the request is for a static response, we can cache it so long
// as it's not edge.
if (isIsr) {
const blob = await response.blob()
// Copy the headers from the response.
const headers = toNodeOutgoingHttpHeaders(response.headers)
if (cacheTags) {
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
}
if (!headers['content-type'] && blob.type) {
headers['content-type'] = blob.type
}
const revalidate =
typeof context.renderOpts.collectedRevalidate === 'undefined' ||
context.renderOpts.collectedRevalidate >= INFINITE_CACHE
? false
: context.renderOpts.collectedRevalidate
const expire =
typeof context.renderOpts.collectedExpire === 'undefined' ||
context.renderOpts.collectedExpire >= INFINITE_CACHE
? // Fall back to the global `expireTime` config when the
// route has a numeric `revalidate` but didn't declare an
// explicit `expire` (e.g. via `cacheLife`). This mirrors the
// build-time fallback in `build/index.ts` so cache entries
// and the response Cache-Control header agree on the route's
// effective expire. Routes that opt out of revalidation
// (`revalidate: false`) or that are dynamic (`revalidate: 0`)
// keep `expire: undefined`.
revalidate !== false && revalidate > 0
? nextConfig.expireTime
: undefined
: context.renderOpts.collectedExpire
// Create the cache entry for the response.
const cacheEntry: ResponseCacheEntry = {
value: {
kind: CachedRouteKind.APP_ROUTE,
status: response.status,
body: Buffer.from(await blob.arrayBuffer()),
headers,
},
cacheControl: { revalidate, expire },
}
return cacheEntry
} else {
// send response without caching if not ISR
await sendResponse(nodeNextReq, nodeNextRes, response, pendingWaitUntil)
return null
}
} catch (err) {
// if this is a background revalidate we need to report
// the request error here as it won't be bubbled
if (previousCacheEntry?.isStale) {
const silenceLog = false
await routeModule.onRequestError(
req,
err,
{
routerKind: 'App Router',
routePath: srcPage,
routeType: 'route',
revalidateReason: getRevalidateReason({
isStaticGeneration,
isOnDemandRevalidate,
}),
},
silenceLog,
routerServerContext
)
}
throw err
}
}
const handleResponse = async (
currentSpan: Span | undefined,
parentSpan: Span | undefined
) => {
try {
const cacheEntry = await routeModule.handleResponse({
req,
nextConfig,
@@ -484,7 +423,7 @@ export async function handler(
// we don't create a cacheEntry for ISR
if (!isIsr) {
return null
return
}
if (cacheEntry?.value?.kind !== CachedRouteKind.APP_ROUTE) {
@@ -542,65 +481,123 @@ export async function handler(
status: cacheEntry.value.status || 200,
})
)
return null
}
return
} catch (err) {
if (!(err instanceof NoFallbackError)) {
const silenceLog = false
await routeModule.onRequestError(
req,
err,
{
routerKind: 'App Router',
routePath: normalizedSrcPage,
routeType: 'route',
revalidateReason: getRevalidateReason({
isStaticGeneration,
isOnDemandRevalidate,
}),
},
silenceLog,
routerServerContext
)
}
// TODO: activeSpan code path is for when wrapped by
// next-server can be removed when this is no longer used
if (isWrappedByNextServer && activeSpan) {
await handleResponse(activeSpan)
} else {
parentSpan = tracer.getActiveScopeSpan()
await tracer.withPropagatedContext(
req.headers,
() =>
tracer.trace(
BaseServerSpan.handleRequest,
{
spanName: `${method} ${srcPage}`,
kind: SpanKind.SERVER,
attributes: {
'http.method': method,
'http.target': req.url,
},
// rethrow so that we can handle serving error page
// If this is during static generation, throw the error again.
if (isIsr) throw err
// Otherwise, send a 500 response.
await sendResponse(
nodeNextReq,
nodeNextRes,
new Response(null, { status: 500 })
)
return
} finally {
;(() => {
if (!currentSpan) {
return
}
let statusCode = res.statusCode
currentSpan.setAttributes({
'http.status_code': statusCode,
'next.rsc': false,
})
if (statusCode && 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
currentSpan.setStatus({
code: SpanStatusCode.ERROR,
})
// For span status 'Error', SHOULD set 'error.type' attribute.
currentSpan.setAttribute('error.type', statusCode.toString())
}
const rootSpanAttributes = tracer.getRootSpanAttributes()
// We were unable to get attributes, probably OTEL is not enabled
if (!rootSpanAttributes) {
return
}
if (
rootSpanAttributes.get('next.span_type') !==
BaseServerSpan.handleRequest
) {
console.warn(
`Unexpected root span type '${rootSpanAttributes.get(
'next.span_type'
)}'. Please report this Next.js issue https://github.com/vercel/next.js`
)
return
}
const route = rootSpanAttributes.get('next.route') || normalizedSrcPage
const name = `${method} ${route}`
currentSpan.setAttributes({
'next.route': route,
'http.route': route,
'next.span_name': name,
})
currentSpan.updateName(name)
// Propagate http.route to the parent span if one exists (e.g.
// a platform-created HTTP span in adapter deployments).
if (parentSpan && parentSpan !== currentSpan) {
parentSpan.setAttribute('http.route', route)
parentSpan.updateName(name)
}
})()
}
}
// TODO: activeSpan code path is for when wrapped by
// next-server can be removed when this is no longer used
if (isWrappedByNextServer && activeSpan) {
await handleResponse(activeSpan, undefined)
} else {
let parentSpan = tracer.getActiveScopeSpan()
await tracer.withPropagatedContext(
req.headers,
() =>
tracer.trace(
BaseServerSpan.handleRequest,
{
spanName: `${method} ${srcPage}`,
kind: SpanKind.SERVER,
attributes: {
'http.method': method,
'http.target': req.url,
},
handleResponse
),
undefined,
!isWrappedByNextServer
)
}
} catch (err) {
if (!(err instanceof NoFallbackError)) {
const silenceLog = false
await routeModule.onRequestError(
req,
err,
{
routerKind: 'App Router',
routePath: normalizedSrcPage,
routeType: 'route',
revalidateReason: getRevalidateReason({
isStaticGeneration,
isOnDemandRevalidate,
}),
},
silenceLog,
routerServerContext
)
}
// rethrow so that we can handle serving error page
// If this is during static generation, throw the error again.
if (isIsr) throw err
// Otherwise, send a 500 response.
await sendResponse(
nodeNextReq,
nodeNextRes,
new Response(null, { status: 500 })
},
(span) => handleResponse(span, parentSpan)
),
undefined,
!isWrappedByNextServer
)
return null
}
}
@@ -0,0 +1,6 @@
export async function GET() {
throw new Error('foobar')
}
export const dynamic = 'force-dynamic'
export const runtime = 'edge'
@@ -0,0 +1,5 @@
export async function GET() {
throw new Error('foobar')
}
export const dynamic = 'force-dynamic'
@@ -37,6 +37,7 @@ async function main() {
const handlers = [
[/^\/api\/app\/param\/data$/, 'app/api/app/[param]/data/route.js'],
[/^\/api\/app\/param\/error$/, 'app/api/app/[param]/error/route.js'],
[/^\/api\/app\/param\/status$/, 'app/api/app/[param]/status/route.js'],
[/^\/app\/param\/loading\/error$/, 'app/app/[param]/loading/error/page.js'],
[/^\/app\/param\/loading\/page1$/, 'app/app/[param]/loading/page1/page.js'],
@@ -48,6 +49,7 @@ async function main() {
],
// ---
[/^\/api\/pages\/param\/basic$/, 'pages/api/pages/[param]/basic.js'],
[/^\/api\/pages\/param\/error$/, 'pages/api/pages/[param]/error.js'],
[
/^\/pages\/param\/getServerSideProps$/,
'pages/pages/[param]/getServerSideProps.js',
@@ -521,6 +521,57 @@ describe.each(
])
})
it('should record status code for failing handler', async () => {
await next.fetch('/api/app/param/error', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /api/app/[param]/error',
attributes: {
'http.method': 'GET',
'http.route': '/api/app/[param]/error',
'http.status_code': 500,
'http.target': '/api/app/param/error',
'next.route': '/api/app/[param]/error',
'next.span_name': 'GET /api/app/[param]/error',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 2 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'executing api route (app) /api/app/[param]/error',
attributes: {
'next.route': '/api/app/[param]/error',
'next.span_name':
'executing api route (app) /api/app/[param]/error',
'next.span_type': 'AppRouteRouteHandlers.runHandler',
},
kind: 0,
status: { code: 2, message: 'foobar' },
},
...(useDirectEntrypointHandler
? []
: [
{
name: 'resolve page components',
attributes: {
'next.route': '/api/app/[param]/error',
'next.span_name': 'resolve page components',
'next.span_type':
'NextNodeServer.findPageComponents',
},
kind: 0,
status: { code: 0 },
},
]),
],
},
])
})
itEdge(
'should handle route handlers in app router on edge',
async () => {
@@ -549,6 +600,31 @@ describe.each(
}
)
itEdge('should handle failing handler on edge', async () => {
await next.fetch('/api/app/param/error/edge', env.fetchInit)
await expectTrace(
getCollector(),
[
{
runtime: 'edge',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'executing api route (app) /api/app/[param]/error/edge',
attributes: {
'next.route': '/api/app/[param]/error/edge',
'next.span_name':
'executing api route (app) /api/app/[param]/error/edge',
'next.span_type': 'AppRouteRouteHandlers.runHandler',
},
kind: 0,
status: { code: 2 },
},
],
true
)
})
itEdge('should trace middleware', async () => {
await next.fetch('/behind-middleware', env.fetchInit)
@@ -1304,6 +1380,95 @@ describe.each(
true
)
})
it('should handle failing api routes in pages', async () => {
await next.fetch('/api/pages/param/error', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /api/pages/[param]/error',
attributes: {
'http.method': 'GET',
'http.route': '/api/pages/[param]/error',
'http.status_code': 500,
'http.target': '/api/pages/param/error',
'next.route': '/api/pages/[param]/error',
'next.span_name': 'GET /api/pages/[param]/error',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 2 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'executing api route (pages) /api/pages/[param]/error',
attributes: {
'next.span_name':
'executing api route (pages) /api/pages/[param]/error',
'next.span_type': 'Node.runHandler',
},
kind: 0,
// TODO this difference is odd
status: { code: isNextDev ? 2 : 0 },
},
...(isNextDev
? [
{
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 },
},
]
: []),
],
},
])
})
itEdge(
'should handle failing api routes in pages on edge',
async () => {
await next.fetch('/api/pages/param/error-edge', env.fetchInit)
await expectTrace(
getCollector(),
[
{
runtime: 'edge',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'executing api route (pages) /api/pages/[param]/error-edge',
attributes: {
'next.span_name':
'executing api route (pages) /api/pages/[param]/error-edge',
'next.span_type': 'Node.runHandler',
},
kind: 0,
status: { code: 2 },
},
],
true
)
}
)
})
}
)
@@ -0,0 +1,6 @@
export const config = {
runtime: 'edge',
}
export default function handler(req, res) {
throw new Error('foobar')
}
@@ -0,0 +1,3 @@
export default function handler(req, res) {
throw new Error('foobar')
}