From 840f3de9801ecf586c41a62723ce836d941df6bf Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 16 Jun 2026 10:26:45 +0200 Subject: [PATCH 1/3] [test] Unflake `metadata static routes cache` test (#94796) [flakiness metrics](https://app.datadoghq.com/ci/test/runs?query=test_level%3Atest%20%40git.repository.id%3A%22github.com%2Fvercel%2Fnext.js%22%20%40test.name%3A%22app%20dir%20-%20metadata%20static%20routes%20cache%20should%20generate%20different%20content%20after%20replace%20the%20static%20metadata%20file%22%20%40test.type%3A%22nextjs%22%20%40test.status%3A%28fail%20OR%20pass%29%20%40git.branch%3Acanary&agg_m=count&agg_m_source=base&agg_t=count&fromUser=false&index=citest&start=1780774829532&end=1781379629532&paused=false) Screenshot 2026-06-13 at 21 41 05 This test replaces the static favicon and Open Graph image and rebuilds to verify the served content changes, so it needs one production build with the original files and another with the replacements. Running both `next.start()` cycles in a single test case put two full builds under the 60s per-test budget, which is easy to exceed on slower CI runners, where the second build most often stalls while collecting build traces. We move the first build, start, and baseline measurement into `beforeAll`, where it can use the longer setup-hook budget, and leave the test body responsible only for the second build. Keeping the build out of the test body also makes it idempotent, so a retry recomputes the new hashes against the baseline that `beforeAll` captures once. --- .../metadata-static-route-cache.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts b/test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts index 70c325b6bd9d..18ea0866586a 100644 --- a/test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts +++ b/test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts @@ -13,22 +13,35 @@ describe('app dir - metadata static routes cache', () => { skipStart: true, }) - it('should generate different content after replace the static metadata file', async () => { + let faviconMd5: string + let opengraphImageMd5: string + + // Each `next.start()` performs a full production build, and a single test + // case only gets a 60s budget, which two builds can exceed on slower CI + // runners (most often while collecting build traces). The first build and + // start (with the original metadata files) therefore runs in `beforeAll`, + // which gets the longer setup-hook budget, leaving the test body responsible + // for only the second build. Keeping the build out of the test body also + // makes it idempotent, so a `jest.retryTimes` retry recomputes the new hashes + // against a stable baseline. + beforeAll(async () => { await next.start() const $ = await next.render$('/') const faviconUrl = $('link[rel="icon"]').attr('href') const faviconBody = await (await next.fetch(faviconUrl)).text() - const faviconMd5 = generateMD5(faviconBody) + faviconMd5 = generateMD5(faviconBody) const opengraphImageUrl = $('meta[property="og:image"]').attr('href') const opengraphImageBody = await ( await next.fetch(opengraphImageUrl) ).text() - const opengraphImageMd5 = generateMD5(opengraphImageBody) + opengraphImageMd5 = generateMD5(opengraphImageBody) await next.stop() + }) + it('should generate different content after replace the static metadata file', async () => { // Update favicon and opengraph image const newFaviconContent = await next.readFileBuffer('app/favicon.new.ico') await next.remove('app/favicon.ico') From 7b00d9f775f20fe90335f13825fa3494b4493446 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 16 Jun 2026 11:06:46 +0200 Subject: [PATCH 2/3] [test] Recover from a leftover build process on test retry (#94797) When a `next start` test exceeds the per-test timeout while `next build` is still running, the build process is left running and tracked by the next instance, because the build phase of `NextStartInstance.start()` clears `childProcess` only when the process exits. `jest.retryTimes` then re-runs the test body in the same process, and `start()` immediately throws `next already started`, which masks the original timeout. `NextStartInstance` now tracks whether a build or a server currently owns the child process. When `start()` finds a leftover `next build` process from an interrupted attempt, it stops that process and continues instead of throwing, so the retry can start from a clean state. The `next already started` guard is preserved for the case where a server is genuinely already running. --- test/lib/next-modes/next-start.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/lib/next-modes/next-start.ts b/test/lib/next-modes/next-start.ts index 0f04d3196d58..5024a2284759 100644 --- a/test/lib/next-modes/next-start.ts +++ b/test/lib/next-modes/next-start.ts @@ -13,6 +13,11 @@ export class NextStartInstance extends NextInstance { private _supportsImmutableAssets: boolean = false private _cliOutput: string = '' + // Tracks which phase of `start()` currently owns `childProcess`, so a retry + // can tell a leftover `next build` from an interrupted attempt apart from an + // already-running server. + private _phase: 'building' | 'serving' | undefined = undefined + private _prerenderFinishedTimeMS: number | null = null constructor(opts: NextInstanceOpts) { @@ -64,7 +69,19 @@ export class NextStartInstance extends NextInstance { options: { skipBuild?: boolean; env?: Record } = {} ) { if (this.childProcess) { - throw new Error('next already started') + if (this._phase === 'building') { + // A previous test attempt was interrupted (typically by exceeding the + // per-test timeout) while `next build` was still running, so the build + // process is still tracked here. Since `jest.retryTimes` re-runs the + // test body in the same process, stop the orphaned build and continue + // instead of failing the retry with `next already started`. + require('console').warn( + 'Found a leftover `next build` process from an interrupted test attempt; stopping it before starting again.' + ) + await this.stop() + } else { + throw new Error('next already started') + } } this._cliOutput = '' @@ -88,6 +105,7 @@ export class NextStartInstance extends NextInstance { } if (!options.skipBuild) { + this._phase = 'building' const buildArgs = this.getBuildArgs() console.log('running', shellQuote(buildArgs)) await new Promise((resolve, reject) => { @@ -155,6 +173,7 @@ export class NextStartInstance extends NextInstance { } catch {} } + this._phase = 'serving' console.log('running', shellQuote(startArgs)) await new Promise((resolve, reject) => { try { @@ -261,6 +280,7 @@ export class NextStartInstance extends NextInstance { exitCode: NodeJS.Signals | number | null cliOutput: string }>((resolve) => { + this._phase = 'building' const curOutput = this._cliOutput.length const spawnOpts = this.getSpawnOpts(options.env) const buildArgs = this.getBuildArgs(options.args) From 37e9d09966108d3e9fa57af5169379a392edba29 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:47:47 +0200 Subject: [PATCH 3/3] Properly set response-based OTEL attributes with adapters (#94603) **Ignore whitespace changes when viewing the diff** This fixes some of the failures (particularly the ones where a 500 returned from an App API route showed up as status:200 in OTEL). But the newly added tests have more failures that I skipped for now 1. Missing `setStatus` call 2. Attribute handling was too early, before `sendResponse` was called: https://github.com/vercel/next.js/blob/6f4d94ac889db3d23a77847ec4f3a9dadf9dd775/packages/next/src/build/templates/app-route.ts#L289-L296 ```js return routeModule.handle(nextReq, context).finally(() => { // <-- handle() returns the response object from the user code if (!span) return span.setAttributes({ 'http.status_code': res.statusCode, // <---------- this is using some default response object, not what handle() returned 'next.rsc': false, }) ``` --- packages/next/src/build/templates/app-page.ts | 37 +- .../next/src/build/templates/app-route.ts | 111 +-- .../next/src/build/templates/edge-ssr-app.ts | 17 +- packages/next/src/build/templates/edge-ssr.ts | 17 +- .../next/src/build/templates/pages-api.ts | 16 +- .../route-modules/pages/pages-handler.ts | 716 +++++++++--------- .../app/api/app/[param]/status/route.ts | 5 + .../custom-entrypoint-server.ts | 117 +-- .../instrumentation/opentelemetry.test.ts | 575 ++++++++------ 9 files changed, 927 insertions(+), 684 deletions(-) create mode 100644 test/e2e/opentelemetry/instrumentation/app/api/app/[param]/status/route.ts diff --git a/packages/next/src/build/templates/app-page.ts b/packages/next/src/build/templates/app-page.ts index f62b11e1e286..f70ffc44d53c 100644 --- a/packages/next/src/build/templates/app-page.ts +++ b/packages/next/src/build/templates/app-page.ts @@ -13,6 +13,7 @@ import { getRevalidateReason } from '../../server/instrumentation/utils' with { import { getTracer, SpanKind, + SpanStatusCode, type Span, } from '../../server/lib/trace/tracer' with { 'turbopack-transition': 'next-server-utility' } import type { RequestMeta } from '../../server/request-meta' @@ -191,6 +192,19 @@ function buildCompletedShellCacheKey( ) } +let srcPage = 'VAR_DEFINITION_PAGE' +// turbopack doesn't normalize `/index` in the page name +// so we need to to process dynamic routes properly +// TODO: fix turbopack providing differing value from webpack +if (process.env.TURBOPACK) { + srcPage = srcPage.replace(/\/index$/, '') || '/' +} else if (srcPage === '/index') { + // we always normalize /index specifically + srcPage = '/' +} + +const normalizedSrcPage = normalizeAppPath(srcPage) + export async function handler( req: IncomingMessage, res: ServerResponse, @@ -208,17 +222,6 @@ export async function handler( } const isMinimalMode = Boolean(getRequestMeta(req, 'minimalMode')) - let srcPage = 'VAR_DEFINITION_PAGE' - - // turbopack doesn't normalize `/index` in the page name - // so we need to to process dynamic routes properly - // TODO: fix turbopack providing differing value from webpack - if (process.env.TURBOPACK) { - srcPage = srcPage.replace(/\/index$/, '') || '/' - } else if (srcPage === '/index') { - // we always normalize /index specifically - srcPage = '/' - } const multiZoneDraftMode = process.env .__NEXT_MULTI_ZONE_DRAFT_MODE as any as boolean @@ -258,8 +261,6 @@ export async function handler( clientAssetToken, } = prepareResult - const normalizedSrcPage = normalizeAppPath(srcPage) - let { isOnDemandRevalidate } = prepareResult // We use the resolvedPathname instead of the parsedUrl.pathname because it @@ -724,6 +725,16 @@ export async function handler( '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 + 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) { diff --git a/packages/next/src/build/templates/app-route.ts b/packages/next/src/build/templates/app-route.ts index cfdaa3790612..b217eb9a326b 100644 --- a/packages/next/src/build/templates/app-route.ts +++ b/packages/next/src/build/templates/app-route.ts @@ -12,7 +12,12 @@ import { setRequestMeta, type RequestMeta, } from '../../server/request-meta' -import { getTracer, type Span, SpanKind } from '../../server/lib/trace/tracer' +import { + getTracer, + type Span, + SpanKind, + SpanStatusCode, +} from '../../server/lib/trace/tracer' import { setManifestsSingleton } from '../../server/app-render/manifests-singleton' import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths' import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node' @@ -286,51 +291,6 @@ export async function handler( try { let parentSpan: Span | undefined - const invokeRouteModule = async (span?: Span) => { - return routeModule.handle(nextReq, context).finally(() => { - if (!span) return - - span.setAttributes({ - 'http.status_code': res.statusCode, - 'next.rsc': false, - }) - - 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}` - - span.setAttributes({ - 'next.route': route, - 'http.route': route, - 'next.span_name': name, - }) - span.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 !== span) { - parentSpan.setAttribute('http.route', route) - parentSpan.updateName(name) - } - }) - } const handleResponse = async (currentSpan?: Span) => { const responseGenerator: ResponseGenerator = async ({ @@ -350,7 +310,7 @@ export async function handler( return null } - const response = await invokeRouteModule(currentSpan) + const response = await routeModule.handle(nextReq, context) ;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics let pendingWaitUntil = context.renderOpts.pendingWaitUntil @@ -447,6 +407,63 @@ export async function handler( ) } 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) + } + })() } } diff --git a/packages/next/src/build/templates/edge-ssr-app.ts b/packages/next/src/build/templates/edge-ssr-app.ts index 99a6f985549f..fa268abf0a35 100644 --- a/packages/next/src/build/templates/edge-ssr-app.ts +++ b/packages/next/src/build/templates/edge-ssr-app.ts @@ -11,7 +11,12 @@ import * as pageMod from 'VAR_USERLAND' import { setManifestsSingleton } from '../../server/app-render/manifests-singleton' import * as cacheHandlers from '../../server/use-cache/handlers' import { BaseServerSpan } from '../../server/lib/trace/constants' -import { getTracer, SpanKind, type Span } from '../../server/lib/trace/tracer' +import { + getTracer, + SpanKind, + SpanStatusCode, + type Span, +} from '../../server/lib/trace/tracer' import { WebNextRequest, WebNextResponse } from '../../server/base-http/web' import type { NextFetchEvent } from '../../server/web/spec-extension/fetch-event' import type { @@ -303,6 +308,16 @@ async function requestHandler( 'next.rsc': false, }) + if (finalStatus && finalStatus >= 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', finalStatus.toString()) + } + const rootSpanAttributes = tracer.getRootSpanAttributes() // We were unable to get attributes, probably OTEL is not enabled if (!rootSpanAttributes) { diff --git a/packages/next/src/build/templates/edge-ssr.ts b/packages/next/src/build/templates/edge-ssr.ts index 99e2d24dd5db..07b0cfa32386 100644 --- a/packages/next/src/build/templates/edge-ssr.ts +++ b/packages/next/src/build/templates/edge-ssr.ts @@ -25,7 +25,12 @@ import { WebNextRequest, WebNextResponse } from '../../server/base-http/web' import type { NextFetchEvent } from '../../server/web/spec-extension/fetch-event' import type RenderResult from '../../server/render-result' import type { RenderResultMetadata } from '../../server/render-result' -import { getTracer, SpanKind, type Span } from '../../server/lib/trace/tracer' +import { + getTracer, + SpanKind, + SpanStatusCode, + type Span, +} from '../../server/lib/trace/tracer' import { BaseServerSpan } from '../../server/lib/trace/constants' import { HTML_CONTENT_TYPE_HEADER } from '../../lib/constants' import type { RequestMeta } from '../../server/request-meta' @@ -267,6 +272,16 @@ async function requestHandler( 'next.rsc': false, }) + if (finalStatus && finalStatus >= 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', finalStatus.toString()) + } + const rootSpanAttributes = tracer.getRootSpanAttributes() // We were unable to get attributes, probably OTEL is not enabled if (!rootSpanAttributes) { diff --git a/packages/next/src/build/templates/pages-api.ts b/packages/next/src/build/templates/pages-api.ts index 8e0b2dbd0b6f..e0955a1a6c0a 100644 --- a/packages/next/src/build/templates/pages-api.ts +++ b/packages/next/src/build/templates/pages-api.ts @@ -10,7 +10,11 @@ import { hoist } from './helpers' // Import the userland code. import * as userland from 'VAR_USERLAND' -import { getTracer, SpanKind } from '../../server/lib/trace/tracer' +import { + getTracer, + SpanKind, + SpanStatusCode, +} from '../../server/lib/trace/tracer' import { BaseServerSpan } from '../../server/lib/trace/constants' import type { InstrumentationOnRequestError } from '../../server/instrumentation/types' import { @@ -120,6 +124,16 @@ export async function handler( '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 + 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) { diff --git a/packages/next/src/server/route-modules/pages/pages-handler.ts b/packages/next/src/server/route-modules/pages/pages-handler.ts index 564c46827cb9..72dd63c22f6f 100644 --- a/packages/next/src/server/route-modules/pages/pages-handler.ts +++ b/packages/next/src/server/route-modules/pages/pages-handler.ts @@ -228,338 +228,292 @@ export const getHandler = ({ routerServerContext?.isWrappedByNextServer ) - try { - const method = req.method || 'GET' - - const resolvedUrl = formatUrl({ - pathname: nextConfig.trailingSlash - ? `${encodedResolvedPathname}${!encodedResolvedPathname.endsWith('/') && parsedUrl.pathname?.endsWith('/') ? '/' : ''}` - : removeTrailingSlash(encodedResolvedPathname || '/'), - // make sure to only add query values from original URL - query: hasStaticProps ? {} : originalQuery, - }) - - let parentSpan: Span | undefined - const handleResponse = async (span?: Span) => { - const responseGenerator: ResponseGenerator = async ({ - previousCacheEntry, - }) => { - const doRender = async () => { - try { - return await routeModule - .render(req, res, { - query: - hasStaticProps && !isExperimentalCompile - ? ({ - ...params, - } as ParsedUrlQuery) - : { - ...query, - ...params, - }, + const method = req.method || 'GET' + + const resolvedUrl = formatUrl({ + pathname: nextConfig.trailingSlash + ? `${encodedResolvedPathname}${!encodedResolvedPathname.endsWith('/') && parsedUrl.pathname?.endsWith('/') ? '/' : ''}` + : removeTrailingSlash(encodedResolvedPathname || '/'), + // make sure to only add query values from original URL + query: hasStaticProps ? {} : originalQuery, + }) + + let parentSpan: Span | undefined + const handleResponse = async (span?: Span) => { + const responseGenerator: ResponseGenerator = async ({ + previousCacheEntry, + }) => { + const doRender = async () => { + try { + return await routeModule + .render(req, res, { + query: + hasStaticProps && !isExperimentalCompile + ? ({ + ...params, + } as ParsedUrlQuery) + : { + ...query, + ...params, + }, + params, + page: srcPage, + renderContext: { + isDraftMode, + isFallback: isIsrFallback, + developmentNotFoundSourcePage: getRequestMeta( + req, + 'developmentNotFoundSourcePage' + ), + }, + sharedContext: { + buildId, + customServer: + Boolean(routerServerContext?.isCustomServer) || undefined, + deploymentId, + clientAssetToken, + }, + renderOpts: { params, + routeModule, page: srcPage, - renderContext: { - isDraftMode, - isFallback: isIsrFallback, - developmentNotFoundSourcePage: getRequestMeta( - req, - 'developmentNotFoundSourcePage' - ), - }, - sharedContext: { - buildId, - customServer: - Boolean(routerServerContext?.isCustomServer) || undefined, - deploymentId, - clientAssetToken, + pageConfig: config || {}, + Component: interopDefault(userland), + ComponentMod: userland, + getStaticProps, + getStaticPaths, + getServerSideProps, + supportsDynamicResponse: !hasStaticProps, + buildManifest: isFallbackError + ? fallbackBuildManifest + : buildManifest, + nextFontManifest, + reactLoadableManifest, + + assetPrefix: nextConfig.assetPrefix, + previewProps: prerenderManifest.preview, + images: nextConfig.images as any, + nextConfigOutput: nextConfig.output, + optimizeCss: Boolean(nextConfig.experimental.optimizeCss), + nextScriptWorkers: Boolean( + nextConfig.experimental.nextScriptWorkers + ), + domainLocales: nextConfig.i18n?.domains, + crossOrigin: nextConfig.crossOrigin, + + multiZoneDraftMode, + basePath: nextConfig.basePath, + disableOptimizedLoading: + nextConfig.experimental.disableOptimizedLoading, + largePageDataBytes: + nextConfig.experimental.largePageDataBytes, + + isExperimentalCompile, + + experimental: { + clientTraceMetadata: + nextConfig.experimental.clientTraceMetadata || + ([] as any), }, - renderOpts: { - params, - routeModule, - page: srcPage, - pageConfig: config || {}, - Component: interopDefault(userland), - ComponentMod: userland, - getStaticProps, - getStaticPaths, - getServerSideProps, - supportsDynamicResponse: !hasStaticProps, - buildManifest: isFallbackError - ? fallbackBuildManifest - : buildManifest, - nextFontManifest, - reactLoadableManifest, - - assetPrefix: nextConfig.assetPrefix, - previewProps: prerenderManifest.preview, - images: nextConfig.images as any, - nextConfigOutput: nextConfig.output, - optimizeCss: Boolean(nextConfig.experimental.optimizeCss), - nextScriptWorkers: Boolean( - nextConfig.experimental.nextScriptWorkers - ), - domainLocales: nextConfig.i18n?.domains, - crossOrigin: nextConfig.crossOrigin, - - multiZoneDraftMode, - basePath: nextConfig.basePath, - disableOptimizedLoading: - nextConfig.experimental.disableOptimizedLoading, - largePageDataBytes: - nextConfig.experimental.largePageDataBytes, - - isExperimentalCompile, - - experimental: { - clientTraceMetadata: - nextConfig.experimental.clientTraceMetadata || - ([] as any), - }, - - locale, - locales, - defaultLocale, - setIsrStatus: routerServerContext?.setIsrStatus, - - isNextDataRequest: - isNextDataRequest && (hasServerProps || hasStaticProps), - - resolvedUrl, - // For getServerSideProps and getInitialProps we need to ensure we use the original URL - // and not the resolved URL to prevent a hydration mismatch on - // asPath - resolvedAsPath: - hasServerProps || hasGetInitialProps - ? formatUrl({ - // we use the original URL pathname less the _next/data prefix if - // present - pathname: isNextDataRequest - ? normalizeDataPath(originalPathname) - : originalPathname, - query: originalQuery, - }) - : resolvedUrl, - - isOnDemandRevalidate, - ErrorDebug: getRequestMeta(req, 'PagesErrorDebug'), - err: getRequestMeta(req, 'invokeError'), + locale, + locales, + defaultLocale, + setIsrStatus: routerServerContext?.setIsrStatus, + + isNextDataRequest: + isNextDataRequest && (hasServerProps || hasStaticProps), + + resolvedUrl, + // For getServerSideProps and getInitialProps we need to ensure we use the original URL + // and not the resolved URL to prevent a hydration mismatch on + // asPath + resolvedAsPath: + hasServerProps || hasGetInitialProps + ? formatUrl({ + // we use the original URL pathname less the _next/data prefix if + // present + pathname: isNextDataRequest + ? normalizeDataPath(originalPathname) + : originalPathname, + query: originalQuery, + }) + : resolvedUrl, + + isOnDemandRevalidate, + + ErrorDebug: getRequestMeta(req, 'PagesErrorDebug'), + err: getRequestMeta(req, 'invokeError'), + + // needed for experimental.optimizeCss feature + distDir: path.join( + /* turbopackIgnore: true */ + process.cwd(), + routeModule.relativeProjectDir, + routeModule.distDir + ), + }, + }) + .then((renderResult): ResponseCacheEntry => { + const { metadata } = renderResult + + let cacheControl: CacheControl | undefined = + metadata.cacheControl + + // Apply the `expireTime` fallback as soon as we have the + // render's `cacheControl`, so every downstream consumer (the + // cache stored via `incrementalCache.set`, the response + // Cache-Control header, the outgoing entry returned from this + // responseGenerator) sees a finalized `cacheControl` with a + // populated `expire`. This mirrors the build-time fallback in + // `build/index.ts` so we don't apply an expire to routes that + // opt out of revalidation entirely (`revalidate: false`) or + // that are dynamic (`revalidate: 0`). + if ( + cacheControl && + cacheControl.revalidate !== false && + cacheControl.revalidate > 0 && + cacheControl.expire === undefined + ) { + cacheControl.expire = nextConfig.expireTime + } - // needed for experimental.optimizeCss feature - distDir: path.join( - /* turbopackIgnore: true */ - process.cwd(), - routeModule.relativeProjectDir, - routeModule.distDir - ), - }, - }) - .then((renderResult): ResponseCacheEntry => { - const { metadata } = renderResult - - let cacheControl: CacheControl | undefined = - metadata.cacheControl - - // Apply the `expireTime` fallback as soon as we have the - // render's `cacheControl`, so every downstream consumer (the - // cache stored via `incrementalCache.set`, the response - // Cache-Control header, the outgoing entry returned from this - // responseGenerator) sees a finalized `cacheControl` with a - // populated `expire`. This mirrors the build-time fallback in - // `build/index.ts` so we don't apply an expire to routes that - // opt out of revalidation entirely (`revalidate: false`) or - // that are dynamic (`revalidate: 0`). - if ( - cacheControl && - cacheControl.revalidate !== false && - cacheControl.revalidate > 0 && - cacheControl.expire === undefined - ) { - cacheControl.expire = nextConfig.expireTime - } - - if ('isNotFound' in metadata && metadata.isNotFound) { - return { - value: null, - cacheControl, - } satisfies ResponseCacheEntry - } - - // Handle `isRedirect`. - if (metadata.isRedirect) { - return { - value: { - kind: CachedRouteKind.REDIRECT, - props: metadata.pageData ?? metadata.flightData, - } satisfies CachedRedirectValue, - cacheControl, - } satisfies ResponseCacheEntry - } + if ('isNotFound' in metadata && metadata.isNotFound) { + return { + value: null, + cacheControl, + } satisfies ResponseCacheEntry + } + // Handle `isRedirect`. + if (metadata.isRedirect) { return { value: { - kind: CachedRouteKind.PAGES, - html: renderResult, - pageData: renderResult.metadata.pageData, - headers: renderResult.metadata.headers, - status: renderResult.metadata.statusCode, - }, + kind: CachedRouteKind.REDIRECT, + props: metadata.pageData ?? metadata.flightData, + } satisfies CachedRedirectValue, cacheControl, - } - }) - .finally(() => { - if (!span) return - - span.setAttributes({ - 'http.status_code': res.statusCode, - 'next.rsc': false, - }) - - 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') || srcPage - const name = `${method} ${route}` - - span.setAttributes({ - 'next.route': route, - 'http.route': route, - 'next.span_name': name, - }) - span.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 !== span) { - parentSpan.setAttribute('http.route', route) - parentSpan.updateName(name) - } - }) - } catch (err: unknown) { - // 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: 'Pages Router', - routePath: srcPage, - routeType: 'render', - revalidateReason: getRevalidateReason({ - isStaticGeneration: hasStaticProps, - isOnDemandRevalidate, - }), + } satisfies ResponseCacheEntry + } + + return { + value: { + kind: CachedRouteKind.PAGES, + html: renderResult, + pageData: renderResult.metadata.pageData, + headers: renderResult.metadata.headers, + status: renderResult.metadata.statusCode, }, - silenceLog, - routerServerContext - ) - } - throw err + cacheControl, + } + }) + } catch (err: unknown) { + // 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: 'Pages Router', + routePath: srcPage, + routeType: 'render', + revalidateReason: getRevalidateReason({ + isStaticGeneration: hasStaticProps, + isOnDemandRevalidate, + }), + }, + silenceLog, + routerServerContext + ) } + throw err } + } - // if we've already generated this page we no longer - // serve the fallback - if (previousCacheEntry) { - isIsrFallback = false - } + // if we've already generated this page we no longer + // serve the fallback + if (previousCacheEntry) { + isIsrFallback = false + } - if (isIsrFallback) { - const fallbackResponse = await routeModule - .getResponseCache(req) - .get( - routeModule.isDev - ? null - : locale - ? `/${locale}${srcPage}` - : srcPage, - async ({ - previousCacheEntry: previousFallbackCacheEntry = null, - }) => { - if (!routeModule.isDev) { - return toResponseCacheEntry(previousFallbackCacheEntry) - } - return doRender() - }, - { - routeKind: RouteKind.PAGES, - isFallback: true, - isRoutePPREnabled: false, - isOnDemandRevalidate: false, - incrementalCache: await routeModule.getIncrementalCache( - req, - nextConfig, - prerenderManifest, - isMinimalMode - ), - waitUntil: ctx.waitUntil, - } - ) - if (fallbackResponse) { - // Remove the cache control from the response to prevent it from being - // used in the surrounding cache. - delete fallbackResponse.cacheControl - fallbackResponse.isMiss = true - return fallbackResponse + if (isIsrFallback) { + const fallbackResponse = await routeModule.getResponseCache(req).get( + routeModule.isDev + ? null + : locale + ? `/${locale}${srcPage}` + : srcPage, + async ({ + previousCacheEntry: previousFallbackCacheEntry = null, + }) => { + if (!routeModule.isDev) { + return toResponseCacheEntry(previousFallbackCacheEntry) + } + return doRender() + }, + { + routeKind: RouteKind.PAGES, + isFallback: true, + isRoutePPREnabled: false, + isOnDemandRevalidate: false, + incrementalCache: await routeModule.getIncrementalCache( + req, + nextConfig, + prerenderManifest, + isMinimalMode + ), + waitUntil: ctx.waitUntil, } + ) + if (fallbackResponse) { + // Remove the cache control from the response to prevent it from being + // used in the surrounding cache. + delete fallbackResponse.cacheControl + fallbackResponse.isMiss = true + return fallbackResponse } + } - 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 - } + 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 + } - if ( - isIsrFallback && - previousCacheEntry?.value?.kind === CachedRouteKind.PAGES - ) { - return { - value: { - kind: CachedRouteKind.PAGES, - html: new RenderResult(previousCacheEntry.value.html, { - contentType: HTML_CONTENT_TYPE_HEADER, - metadata: { - statusCode: previousCacheEntry.value.status, - headers: previousCacheEntry.value.headers, - }, - }), - pageData: {}, - status: previousCacheEntry.value.status, - headers: previousCacheEntry.value.headers, - } satisfies CachedPageValue, - cacheControl: { revalidate: 0, expire: undefined }, - } satisfies ResponseCacheEntry - } - return doRender() + if ( + isIsrFallback && + previousCacheEntry?.value?.kind === CachedRouteKind.PAGES + ) { + return { + value: { + kind: CachedRouteKind.PAGES, + html: new RenderResult(previousCacheEntry.value.html, { + contentType: HTML_CONTENT_TYPE_HEADER, + metadata: { + statusCode: previousCacheEntry.value.status, + headers: previousCacheEntry.value.headers, + }, + }), + pageData: {}, + status: previousCacheEntry.value.status, + headers: previousCacheEntry.value.headers, + } satisfies CachedPageValue, + cacheControl: { revalidate: 0, expire: undefined }, + } satisfies ResponseCacheEntry } + return doRender() + } + try { const result = await routeModule.handleResponse({ cacheKey, req, @@ -763,55 +717,101 @@ export const getHandler = ({ poweredByHeader: nextConfig.poweredByHeader, cacheControl: routeModule.isDev ? undefined : cacheControl, }) - } + } catch (err) { + if (!(err instanceof NoFallbackError)) { + const silenceLog = false + await routeModule.onRequestError( + req, + err, + { + routerKind: 'Pages Router', + routePath: srcPage, + routeType: 'render', + revalidateReason: getRevalidateReason({ + isStaticGeneration: hasStaticProps, + 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, - }, - }, - handleResponse - ), - undefined, - !isWrappedByNextServer - ) - } - } catch (err) { - if (!(err instanceof NoFallbackError)) { - const silenceLog = false - await routeModule.onRequestError( - req, - err, - { - routerKind: 'Pages Router', - routePath: srcPage, - routeType: 'render', - revalidateReason: getRevalidateReason({ - isStaticGeneration: hasStaticProps, - isOnDemandRevalidate, - }), - }, - silenceLog, - routerServerContext - ) + // rethrow so that we can handle serving error page + throw err + } finally { + // An IIFE to make early returns easier. + ;(() => { + if (!span) return + + span.setAttributes({ + 'http.status_code': res.statusCode, + 'next.rsc': false, + }) + + 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') || srcPage + const name = `${method} ${route}` + + span.setAttributes({ + 'next.route': route, + 'http.route': route, + 'next.span_name': name, + }) + span.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 !== span) { + parentSpan.setAttribute('http.route', route) + parentSpan.updateName(name) + } + })() } + } - // rethrow so that we can handle serving error page - throw err + // 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, + }, + }, + handleResponse + ), + undefined, + !isWrappedByNextServer + ) } } } diff --git a/test/e2e/opentelemetry/instrumentation/app/api/app/[param]/status/route.ts b/test/e2e/opentelemetry/instrumentation/app/api/app/[param]/status/route.ts new file mode 100644 index 000000000000..d051a7232ae2 --- /dev/null +++ b/test/e2e/opentelemetry/instrumentation/app/api/app/[param]/status/route.ts @@ -0,0 +1,5 @@ +export async function GET() { + return new Response(JSON.stringify({ test: 'data' }), { status: 418 }) +} + +export const dynamic = 'force-dynamic' diff --git a/test/e2e/opentelemetry/instrumentation/custom-entrypoint-server.ts b/test/e2e/opentelemetry/instrumentation/custom-entrypoint-server.ts index fb877f9b2fc0..10a8e890a210 100644 --- a/test/e2e/opentelemetry/instrumentation/custom-entrypoint-server.ts +++ b/test/e2e/opentelemetry/instrumentation/custom-entrypoint-server.ts @@ -6,7 +6,11 @@ import { trace } from '@opentelemetry/api' import { register } from './instrumentation-custom-server' -register() +const withoutParentSpan = process.argv.includes('--without-parent-span') + +if (!withoutParentSpan) { + register() +} type EntrypointHandler = ( req: IncomingMessage, @@ -16,8 +20,8 @@ type EntrypointHandler = ( } ) => Promise -function loadEntrypointHandler(pathParts: string[]): EntrypointHandler { - const entrypointPath = path.join(__dirname, '.next', 'server', ...pathParts) +function loadEntrypointHandler(handler: string): EntrypointHandler { + const entrypointPath = path.join(__dirname, '.next', 'server', handler) const mod = require(entrypointPath) as { handler?: EntrypointHandler } if (typeof mod.handler !== 'function') { throw new Error(`Entrypoint handler missing at ${entrypointPath}`) @@ -31,42 +35,48 @@ async function main() { require('next/dist/server/node-environment') - const appPageHandler = loadEntrypointHandler([ - 'app', - 'app', - '[param]', - 'rsc-fetch', - 'page.js', - ]) - const appRouteHandler = loadEntrypointHandler([ - 'app', - 'api', - 'app', - '[param]', - 'data', - 'route.js', - ]) - const pagesRouteHandler = loadEntrypointHandler([ - 'pages', - 'pages', - '[param]', - 'getServerSideProps.js', - ]) - const pagesApiRouteHandler = loadEntrypointHandler([ - 'pages', - 'api', - 'pages', - '[param]', - 'basic.js', - ]) + const handlers = [ + [/^\/api\/app\/param\/data$/, 'app/api/app/[param]/data/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'], + [/^\/app\/param\/loading\/page2$/, 'app/app/[param]/loading/page2/page.js'], + [/^\/app\/param\/rsc-fetch$/, 'app/app/[param]/rsc-fetch/page.js'], + [ + /^\/app\/param\/rsc-fetch\/error$/, + 'app/app/[param]/rsc-fetch/error/page.js', + ], + // --- + [/^\/api\/pages\/param\/basic$/, 'pages/api/pages/[param]/basic.js'], + [ + /^\/pages\/param\/getServerSideProps$/, + 'pages/pages/[param]/getServerSideProps.js', + ], + [ + /^\/pages\/param\/getServerSidePropsError$/, + 'pages/pages/[param]/getServerSidePropsError.js', + ], + [ + /^\/pages\/param\/getServerSidePropsNotFound$/, + 'pages/pages/[param]/getServerSidePropsNotFound.js', + ], + [ + /^\/pages\/param\/getStaticProps$/, + 'pages/pages/[param]/getStaticProps.js', + ], + [ + /^\/pages\/param\/getStaticProps2$/, + 'pages/pages/[param]/getStaticProps2.js', + ], + ] as const const tracer = trace.getTracer('custom-entrypoint-server', '1.0.0') const resolveHandler = (pathname: string): EntrypointHandler | undefined => { - if (pathname.startsWith('/app/')) return appPageHandler - if (pathname.startsWith('/api/app/')) return appRouteHandler - if (pathname.startsWith('/pages/')) return pagesRouteHandler - if (pathname.startsWith('/api/pages/')) return pagesApiRouteHandler + for (const [pattern, handler] of handlers) { + if (pattern.test(pathname)) return loadEntrypointHandler(handler) + } + console.error("Couldn't find resolve handler for path:", pathname) return undefined } @@ -81,20 +91,31 @@ async function main() { return } + const handle = () => handler(req, res, { waitUntil: () => {} }) + // Simulate a custom parent span around direct entrypoint invocation. - tracer.startActiveSpan(method, async (span) => { - try { - await handler(req, res, { - waitUntil: () => {}, - }) - } catch (err) { - span.recordException(err as Error) - res.statusCode = 500 - res.end('Internal Server Error') - } finally { - span.end() - } - }) + if (withoutParentSpan) { + ;(async () => { + try { + await handle() + } catch (err) { + res.statusCode = 500 + res.end('Internal Server Error') + } + })() + } else { + tracer.startActiveSpan(method, async (span) => { + try { + await handle() + } catch (err) { + span.recordException(err as Error) + res.statusCode = 500 + res.end('Internal Server Error') + } finally { + span.end() + } + }) + } }).listen(port, undefined, (err?: Error) => { if (err) throw err console.log(`- Local: http://${hostname}:${port}`) diff --git a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts index 525eda27ea6b..ee23c0da5e4d 100644 --- a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts @@ -1,5 +1,5 @@ -import { isNextDev, nextTestSetup } from 'e2e-utils' -import { check, retry } from 'next-test-utils' +import { isNextDev, isNextStart, nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers' import { SavedSpan } from './constants' @@ -11,17 +11,42 @@ const EXTERNAL = { } as const const COLLECTOR_PORT = 9001 -const isStartMode = process.env.NEXT_TEST_MODE === 'start' -describe('opentelemetry', () => { +describe.each( + [ + { name: 'default' }, + isNextStart && { + name: 'direct entrypoints', + useDirectEntrypointHandler: true, + }, + ].filter(Boolean) +)('opentelemetry - $name', ({ useDirectEntrypointHandler }) => { const { next, skipped, isNextDev } = nextTestSetup({ files: __dirname, skipDeployment: true, dependencies: require('./package.json').dependencies, - env: { - TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT), - NEXT_TELEMETRY_DISABLED: '1', - }, + ...(!useDirectEntrypointHandler + ? { + env: { + TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT), + NEXT_TELEMETRY_DISABLED: '1', + }, + } + : { + startCommand: 'pnpm start-entrypoint', + packageJson: { + scripts: { + 'start-entrypoint': + 'pnpm tsx custom-entrypoint-server.ts --without-parent-span', + }, + }, + serverReadyPattern: /- Local:/, + env: { + TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT), + NEXT_TELEMETRY_DISABLED: '1', + NODE_ENV: 'production', + }, + }), }) if (skipped) { @@ -42,6 +67,10 @@ describe('opentelemetry', () => { await collector.shutdown() }) + // Edge runtime is currently not implemented in custom-entrypoint-server.ts + const itEdge = useDirectEntrypointHandler ? it.skip : it + const itNoDirect = useDirectEntrypointHandler ? it.skip : it + for (const env of [ { name: 'root context', @@ -192,16 +221,21 @@ describe('opentelemetry', () => { }, ], }, - { - name: 'resolve page components', - attributes: { - 'next.route': '/app/[param]/rsc-fetch', - 'next.span_name': 'resolve page components', - 'next.span_type': 'NextNodeServer.findPageComponents', - }, - kind: 0, - status: { code: 0 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': '/app/[param]/rsc-fetch', + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), ], }, ]) @@ -223,7 +257,7 @@ describe('opentelemetry', () => { ]) }) - it('should handle RSC with fetch on edge', async () => { + itEdge('should handle RSC with fetch on edge', async () => { await next.fetch('/app/param/rsc-fetch/edge', env.fetchInit) await expectTrace( @@ -337,7 +371,8 @@ describe('opentelemetry', () => { ) }) - it('should handle RSC with fetch in RSC mode', async () => { + // TODO why is this failing + itNoDirect('should handle RSC with fetch in RSC mode', async () => { await next.fetch(`/app/param/rsc-fetch?${NEXT_RSC_UNION_QUERY}`, { ...env.fetchInit, headers: { @@ -399,16 +434,81 @@ describe('opentelemetry', () => { kind: 0, status: { code: 0 }, }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': '/api/app/[param]/data', + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), { - name: 'resolve page components', + name: 'start response', attributes: { - 'next.route': '/api/app/[param]/data', - 'next.span_name': 'resolve page components', - 'next.span_type': 'NextNodeServer.findPageComponents', + 'next.span_name': 'start response', + 'next.span_type': 'NextNodeServer.startResponse', }, kind: 0, status: { code: 0 }, }, + ], + }, + ]) + }) + + it('should record accurate status code for non-200 route handler responses', async () => { + await next.fetch('/api/app/param/status', env.fetchInit) + + await expectTrace(getCollector(), [ + { + name: 'GET /api/app/[param]/status', + attributes: { + 'http.method': 'GET', + 'http.route': '/api/app/[param]/status', + 'http.status_code': 418, + 'http.target': '/api/app/param/status', + 'next.route': '/api/app/[param]/status', + 'next.span_name': 'GET /api/app/[param]/status', + 'next.span_type': 'BaseServer.handleRequest', + }, + kind: 1, + status: { code: 0 }, + traceId: env.span.traceId, + parentId: env.span.rootParentId, + spans: [ + { + name: 'executing api route (app) /api/app/[param]/status', + attributes: { + 'next.route': '/api/app/[param]/status', + 'next.span_name': + 'executing api route (app) /api/app/[param]/status', + 'next.span_type': 'AppRouteRouteHandlers.runHandler', + }, + kind: 0, + status: { code: 0 }, + }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': '/api/app/[param]/status', + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), { name: 'start response', attributes: { @@ -423,32 +523,35 @@ describe('opentelemetry', () => { ]) }) - it('should handle route handlers in app router on edge', async () => { - await next.fetch('/api/app/param/data/edge', env.fetchInit) + itEdge( + 'should handle route handlers in app router on edge', + async () => { + await next.fetch('/api/app/param/data/edge', env.fetchInit) - await expectTrace( - getCollector(), - [ - { - runtime: 'edge', - traceId: env.span.traceId, - parentId: env.span.rootParentId, - name: 'executing api route (app) /api/app/[param]/data/edge', - attributes: { - 'next.route': '/api/app/[param]/data/edge', - 'next.span_name': - 'executing api route (app) /api/app/[param]/data/edge', - 'next.span_type': 'AppRouteRouteHandlers.runHandler', + await expectTrace( + getCollector(), + [ + { + runtime: 'edge', + traceId: env.span.traceId, + parentId: env.span.rootParentId, + name: 'executing api route (app) /api/app/[param]/data/edge', + attributes: { + 'next.route': '/api/app/[param]/data/edge', + 'next.span_name': + 'executing api route (app) /api/app/[param]/data/edge', + 'next.span_type': 'AppRouteRouteHandlers.runHandler', + }, + kind: 0, + status: { code: 0 }, }, - kind: 0, - status: { code: 0 }, - }, - ], - true - ) - }) + ], + true + ) + } + ) - it('should trace middleware', async () => { + itEdge('should trace middleware', async () => { await next.fetch('/behind-middleware', env.fetchInit) await expectTrace(getCollector(), [ @@ -600,16 +703,21 @@ describe('opentelemetry', () => { }, ], }, - { - 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 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + 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 }, + }, + ]), ], }, ]) @@ -723,16 +831,21 @@ describe('opentelemetry', () => { }, ], }, - { - name: 'resolve page components', - attributes: { - 'next.route': '/app/[param]/loading/error', - 'next.span_name': 'resolve page components', - 'next.span_type': 'NextNodeServer.findPageComponents', - }, - kind: 0, - status: { code: 0 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': '/app/[param]/loading/error', + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), ], }, ]) @@ -782,16 +895,21 @@ describe('opentelemetry', () => { kind: 0, status: { code: 0 }, }, - { - name: 'resolve page components', - attributes: { - 'next.route': '/pages/[param]/getServerSideProps', - 'next.span_name': 'resolve page components', - 'next.span_type': 'NextNodeServer.findPageComponents', - }, - kind: 0, - status: { code: 0 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': '/pages/[param]/getServerSideProps', + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), ], }, ]) @@ -838,22 +956,27 @@ describe('opentelemetry', () => { kind: 0, status: { code: 0 }, }, - { - name: 'resolve page components', - attributes: { - 'next.route': `/pages/[param]/getStaticProps${v}`, - 'next.span_name': 'resolve page components', - 'next.span_type': 'NextNodeServer.findPageComponents', - }, - kind: 0, - status: { code: 0 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + name: 'resolve page components', + attributes: { + 'next.route': `/pages/[param]/getStaticProps${v}`, + 'next.span_name': 'resolve page components', + 'next.span_type': + 'NextNodeServer.findPageComponents', + }, + kind: 0, + status: { code: 0 }, + }, + ]), ], }, ]) }) - it('should handle getServerSideProps on edge', async () => { + itEdge('should handle getServerSideProps on edge', async () => { await next.fetch( '/pages/param/edge/getServerSideProps', env.fetchInit @@ -910,115 +1033,131 @@ describe('opentelemetry', () => { ) }) - it('should handle getServerSideProps exceptions', async () => { - await next.fetch( - '/pages/param/getServerSidePropsError', - env.fetchInit - ) + // TODO why is this failing + itNoDirect( + '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 }, + 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', }, - ...(isNextDev - ? [] - : [ + 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: 'resolve page components', + name: 'exception', attributes: { - 'next.route': '/500', - 'next.span_name': 'resolve page components', - 'next.span_type': - 'NextNodeServer.findPageComponents', + 'exception.type': 'Error', + 'exception.message': 'ServerSideProps error', }, - 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 }, - }, - ], - }, - ]) - }) + ...(useDirectEntrypointHandler + ? [] + : [ + { + 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 || useDirectEntrypointHandler + ? [] + : [ + { + 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 }, + }, + ]), + ...(useDirectEntrypointHandler + ? [] + : [ + { + 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( @@ -1071,26 +1210,33 @@ describe('opentelemetry', () => { }, ] : []), - { - 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 }, - }, + ...(useDirectEntrypointHandler + ? [] + : [ + { + 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 }, + }, + ]), ], }, ]) @@ -1131,7 +1277,7 @@ describe('opentelemetry', () => { ]) }) - it('should handle api routes in pages on edge', async () => { + itEdge('should handle api routes in pages on edge', async () => { await next.fetch('/api/pages/param/edge', env.fetchInit) await expectTrace( @@ -1487,7 +1633,7 @@ describe('opentelemetry with custom server', () => { }) }) -if (isStartMode) { +if (isNextStart) { describe('opentelemetry with direct entrypoint handler', () => { const { next, skipped } = nextTestSetup({ files: __dirname, @@ -1631,7 +1777,7 @@ async function expectTrace( .filter(Boolean) ) - await check(async () => { + await retry(async () => { const traces = collector.getSpans() const tree: HierSavedSpan[] = [] @@ -1707,6 +1853,5 @@ async function expectTrace( }) expect(filteredTree).toMatchObject(match) - return 'success' - }, 'success') + }) }