Files
Niklas MischkulnigandGitHub c2b5142640 otel: Use correct parent span for Node.js middleware (#95306)
Node.js Middleware was inheriting the wrong parent span (and behaved
differently compared to Edge runtime Middleware)

`Middleware.execute` and `BaseServer.handleRequest` should be sibling
spans (depending on context, both are root spans). Previously
`BaseServer.handleRequest` had a child span `Middleware.execute`

Depends on https://github.com/vercel/next.js/pull/95357 to make the
direct entrypoint test pass
2026-07-06 14:53:45 +02:00

2106 lines
77 KiB
TypeScript

import { FileRef, 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 path from 'path'
import { SavedSpan } from './constants'
import { type Collector, connectCollector } from './collector'
const EXTERNAL = {
traceId: 'ee75cd9e534ff5e9ed78b4a0c706f0f2',
spanId: '0f6a325411bdc432',
} as const
const COLLECTOR_PORT = 9001
function setup({ useDirectEntrypointHandler, useNodeMiddleware }) {
let collector: Collector
function getCollector(): Collector {
return collector
}
beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})
afterEach(async () => {
await collector.shutdown()
})
let next = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
...(!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',
},
}),
overrideFiles: useNodeMiddleware
? {
'middleware.ts': new FileRef(
path.join(__dirname, 'middleware-node.ts')
),
}
: undefined,
})
return { next, getCollector }
}
describe.each(
[
{ name: 'default' },
isNextStart && {
name: 'direct entrypoints',
useDirectEntrypointHandler: true,
},
].filter(Boolean)
)('opentelemetry - $name', ({ useDirectEntrypointHandler }) => {
const {
next: { next, skipped, isNextDev },
getCollector,
} = setup({
useDirectEntrypointHandler,
useNodeMiddleware: false,
})
if (skipped) {
return
}
// Edge runtime is currently not implemented in custom-entrypoint-server.ts
const itEdge = useDirectEntrypointHandler ? it.skip : it
for (const env of [
{
name: 'root context',
fetchInit: undefined,
span: {
traceId: '[trace-id]',
rootParentId: undefined,
},
},
{
name: 'incoming context propagation',
fetchInit: {
headers: {
traceparent: `00-${EXTERNAL.traceId}-${EXTERNAL.spanId}-01`,
},
},
span: {
traceId: EXTERNAL.traceId,
rootParentId: EXTERNAL.spanId,
},
},
]) {
;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)(
env.name,
() => {
describe('app router', () => {
it('should handle RSC with fetch', async () => {
await next.fetch('/app/param/rsc-fetch', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /app/[param]/rsc-fetch',
attributes: {
'http.method': 'GET',
'http.route': '/app/[param]/rsc-fetch',
'http.status_code': 200,
'http.target': '/app/param/rsc-fetch',
'next.route': '/app/[param]/rsc-fetch',
'next.rsc': false,
'next.span_name': 'GET /app/[param]/rsc-fetch',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'render route (app) /app/[param]/rsc-fetch',
attributes: {
'next.route': '/app/[param]/rsc-fetch',
'next.span_name':
'render route (app) /app/[param]/rsc-fetch',
'next.span_type': 'AppRender.getBodyResult',
},
kind: 0,
status: { code: 0 },
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: 'fetch GET https://example.vercel.sh/',
attributes: {
'http.method': 'GET',
'http.url': 'https://example.vercel.sh/',
'net.peer.name': 'example.vercel.sh',
'next.span_name':
'fetch GET https://example.vercel.sh/',
'next.span_type': 'AppRender.fetch',
},
kind: 2,
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]/rsc-fetch/page',
attributes: {
'next.page': '/app/[param]/rsc-fetch/page',
'next.span_name':
'generateMetadata /app/[param]/rsc-fetch/page',
'next.span_type': 'ResolveMetadata.generateMetadata',
},
kind: 0,
status: { code: 0 },
},
{
attributes: {
'next.clientComponentLoadCount': isNextDev ? 8 : 7,
'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 },
},
],
},
...(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 },
},
]),
],
},
])
})
it('should propagate custom context without span', async () => {
await next.fetch('/app/param/rsc-fetch', {
...env.fetchInit,
headers: { ...env.fetchInit?.headers, 'x-custom': 'custom1' },
})
await expectTrace(getCollector(), [
{
name: 'GET /app/[param]/rsc-fetch',
attributes: {
custom: 'custom1',
},
},
])
})
itEdge('should handle RSC with fetch on edge', async () => {
await next.fetch('/app/param/rsc-fetch/edge', env.fetchInit)
await expectTrace(
getCollector(),
[
{
traceId: env.span.traceId,
parentId: env.span.rootParentId,
runtime: 'edge',
name: 'GET /app/[param]/rsc-fetch/edge',
kind: 1,
attributes: {
'next.span_name': 'GET /app/[param]/rsc-fetch/edge',
'next.span_type': 'BaseServer.handleRequest',
'http.method': 'GET',
'http.target': '/app/param/rsc-fetch/edge?param=param',
'http.status_code': 200,
'next.route': '/app/[param]/rsc-fetch/edge',
'http.route': '/app/[param]/rsc-fetch/edge',
},
status: { code: 0 },
spans: [
{
name: 'render route (app) /app/[param]/rsc-fetch/edge',
kind: 0,
attributes: {
'next.span_name':
'render route (app) /app/[param]/rsc-fetch/edge',
'next.span_type': 'AppRender.getBodyResult',
'next.route': '/app/[param]/rsc-fetch/edge',
},
status: { code: 0 },
spans: [
{
name: 'build component tree',
kind: 0,
attributes: {
'next.span_name': 'build component tree',
'next.span_type':
'NextNodeServer.createComponentTree',
},
status: { code: 0 },
spans: [
{
name: 'resolve segment modules',
kind: 0,
attributes: {
'next.span_name': 'resolve segment modules',
'next.span_type':
'NextNodeServer.getLayoutOrPageModule',
'next.segment': '__PAGE__',
},
status: { code: 0 },
},
{
name: 'resolve segment modules',
kind: 0,
attributes: {
'next.span_name': 'resolve segment modules',
'next.span_type':
'NextNodeServer.getLayoutOrPageModule',
'next.segment': '[param]',
},
status: { code: 0 },
},
],
},
{
name: 'fetch GET https://example.vercel.sh/',
kind: 2,
attributes: {
'next.span_name':
'fetch GET https://example.vercel.sh/',
'next.span_type': 'AppRender.fetch',
'http.url': 'https://example.vercel.sh/',
'http.method': 'GET',
'net.peer.name': 'example.vercel.sh',
},
status: { code: 0 },
},
{
name: 'generateMetadata /app/[param]/layout',
kind: 0,
attributes: {
'next.span_name':
'generateMetadata /app/[param]/layout',
'next.span_type':
'ResolveMetadata.generateMetadata',
'next.page': '/app/[param]/layout',
},
status: { code: 0 },
},
{
name: 'generateMetadata /app/[param]/rsc-fetch/edge/page',
kind: 0,
attributes: {
'next.span_name':
'generateMetadata /app/[param]/rsc-fetch/edge/page',
'next.span_type':
'ResolveMetadata.generateMetadata',
'next.page': '/app/[param]/rsc-fetch/edge/page',
},
status: { code: 0 },
},
],
},
],
},
],
true
)
})
it('should handle RSC with fetch in RSC mode', async () => {
await next.fetch(`/app/param/rsc-fetch?${NEXT_RSC_UNION_QUERY}`, {
...env.fetchInit,
headers: {
...env.fetchInit?.headers,
Rsc: '1',
},
})
await expectTrace(getCollector(), [
{
runtime: 'nodejs',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'RSC GET /app/[param]/rsc-fetch',
attributes: {
'http.method': 'GET',
'http.route': '/app/[param]/rsc-fetch',
'http.status_code': 200,
'http.target': `/app/param/rsc-fetch?${NEXT_RSC_UNION_QUERY}`,
'next.route': '/app/[param]/rsc-fetch',
'next.rsc': true,
'next.span_name': 'RSC GET /app/[param]/rsc-fetch',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
},
])
})
it('should handle route handlers in app router', async () => {
await next.fetch('/api/app/param/data', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /api/app/[param]/data',
attributes: {
'http.method': 'GET',
'http.route': '/api/app/[param]/data',
'http.status_code': 200,
'http.target': '/api/app/param/data',
'next.route': '/api/app/[param]/data',
'next.span_name': 'GET /api/app/[param]/data',
'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]/data',
attributes: {
'next.route': '/api/app/[param]/data',
'next.span_name':
'executing api route (app) /api/app/[param]/data',
'next.span_type': 'AppRouteRouteHandlers.runHandler',
},
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: 'start response',
attributes: {
'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: {
'next.span_name': 'start response',
'next.span_type': 'NextNodeServer.startResponse',
},
kind: 0,
status: { code: 0 },
},
],
},
])
})
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 () => {
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',
},
kind: 0,
status: { code: 0 },
},
],
true
)
}
)
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
)
})
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 ? 12 : 10,
'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 },
},
],
},
...(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 },
},
]),
],
},
])
})
it('should handle error inside Suspense boundary', async () => {
await next.fetch('/app/param/loading/error', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /app/[param]/loading/error',
attributes: {
'http.method': 'GET',
'http.route': '/app/[param]/loading/error',
// The response starts streaming before the error,
// so HTTP status is 200 (unlike synchronous errors which get 500)
'http.status_code': 200,
'http.target': '/app/param/loading/error',
'next.route': '/app/[param]/loading/error',
'next.rsc': false,
'next.span_name': 'GET /app/[param]/loading/error',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'render route (app) /app/[param]/loading/error',
attributes: {
'next.route': '/app/[param]/loading/error',
'next.span_name':
'render route (app) /app/[param]/loading/error',
'next.span_type': 'AppRender.getBodyResult',
'error.type': 'Error',
},
kind: 0,
// The render span should have error status because an error
// was thrown inside a Suspense boundary during streaming
status: {
code: 2,
message: 'Error inside Suspense boundary',
},
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 },
},
{
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 },
},
],
},
...(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 },
},
]),
],
},
])
})
})
describe('pages', () => {
it('should handle getServerSideProps', async () => {
await next.fetch('/pages/param/getServerSideProps', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /pages/[param]/getServerSideProps',
attributes: {
'http.method': 'GET',
'http.route': '/pages/[param]/getServerSideProps',
'http.status_code': 200,
'http.target': '/pages/param/getServerSideProps',
'next.route': '/pages/[param]/getServerSideProps',
'next.span_name': 'GET /pages/[param]/getServerSideProps',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'getServerSideProps /pages/[param]/getServerSideProps',
attributes: {
'next.route': '/pages/[param]/getServerSideProps',
'next.span_name':
'getServerSideProps /pages/[param]/getServerSideProps',
'next.span_type': 'Render.getServerSideProps',
},
kind: 0,
status: { code: 0 },
},
{
name: 'render route (pages) /pages/[param]/getServerSideProps',
attributes: {
'next.route': '/pages/[param]/getServerSideProps',
'next.span_name':
'render route (pages) /pages/[param]/getServerSideProps',
'next.span_type': 'Render.renderDocument',
},
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 },
},
]),
],
},
])
})
it("should handle getStaticProps when fallback: 'blocking'", async () => {
const v = env.span.rootParentId ? '2' : ''
await next.fetch(`/pages/param/getStaticProps${v}`, env.fetchInit)
await expectTrace(getCollector(), [
{
name: `GET /pages/[param]/getStaticProps${v}`,
attributes: {
'http.method': 'GET',
'http.route': `/pages/[param]/getStaticProps${v}`,
'http.status_code': 200,
'http.target': `/pages/param/getStaticProps${v}`,
'next.route': `/pages/[param]/getStaticProps${v}`,
'next.span_name': `GET /pages/[param]/getStaticProps${v}`,
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: `getStaticProps /pages/[param]/getStaticProps${v}`,
attributes: {
'next.route': `/pages/[param]/getStaticProps${v}`,
'next.span_name': `getStaticProps /pages/[param]/getStaticProps${v}`,
'next.span_type': 'Render.getStaticProps',
},
kind: 0,
status: { code: 0 },
},
{
name: `render route (pages) /pages/[param]/getStaticProps${v}`,
attributes: {
'next.route': `/pages/[param]/getStaticProps${v}`,
'next.span_name': `render route (pages) /pages/[param]/getStaticProps${v}`,
'next.span_type': 'Render.renderDocument',
},
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 },
},
]),
],
},
])
})
itEdge('should handle getServerSideProps on edge', async () => {
await next.fetch(
'/pages/param/edge/getServerSideProps',
env.fetchInit
)
await expectTrace(
getCollector(),
[
{
runtime: 'edge',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'GET /pages/[param]/edge/getServerSideProps',
kind: 1,
attributes: {
'next.span_name':
'GET /pages/[param]/edge/getServerSideProps',
'next.span_type': 'BaseServer.handleRequest',
'http.method': 'GET',
'http.target':
'/pages/param/edge/getServerSideProps?param=param',
'http.status_code': 200,
'next.route': '/pages/[param]/edge/getServerSideProps',
'http.route': '/pages/[param]/edge/getServerSideProps',
},
status: { code: 0 },
spans: [
{
name: 'getServerSideProps /pages/[param]/edge/getServerSideProps',
kind: 0,
attributes: {
'next.span_name':
'getServerSideProps /pages/[param]/edge/getServerSideProps',
'next.span_type': 'Render.getServerSideProps',
'next.route': '/pages/[param]/edge/getServerSideProps',
},
status: { code: 0 },
},
{
name: 'render route (pages) /pages/[param]/edge/getServerSideProps',
kind: 0,
attributes: {
'next.span_name':
'render route (pages) /pages/[param]/edge/getServerSideProps',
'next.span_type': 'Render.renderDocument',
'next.route': '/pages/[param]/edge/getServerSideProps',
},
status: { code: 0 },
},
],
},
],
true
)
})
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.target': '/pages/param/getServerSidePropsError',
'next.route': '/pages/[param]/getServerSidePropsError',
'next.span_name':
'GET /pages/[param]/getServerSidePropsError',
'next.span_type': 'BaseServer.handleRequest',
...(useDirectEntrypointHandler
? {
// With direct entrypoints, this 500 error has to be handled by whatever is
// invoking the handler. And that same invoker is then also responsible for
// setting OTEL correctly.
'error.type': 'Error',
'http.status_code': 200,
}
: {
'error.type': '500',
'http.status_code': 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',
},
},
],
},
...(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(
'/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 },
},
]
: []),
...(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 },
},
]),
],
},
])
})
it('should handle api routes in pages', async () => {
await next.fetch('/api/pages/param/basic', env.fetchInit)
await expectTrace(getCollector(), [
{
name: 'GET /api/pages/[param]/basic',
attributes: {
'http.method': 'GET',
'http.route': '/api/pages/[param]/basic',
'http.status_code': 200,
'http.target': '/api/pages/param/basic',
'next.route': '/api/pages/[param]/basic',
'next.span_name': 'GET /api/pages/[param]/basic',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
traceId: env.span.traceId,
parentId: env.span.rootParentId,
spans: [
{
name: 'executing api route (pages) /api/pages/[param]/basic',
attributes: {
'next.span_name':
'executing api route (pages) /api/pages/[param]/basic',
'next.span_type': 'Node.runHandler',
},
kind: 0,
status: { code: 0 },
},
],
},
])
})
itEdge('should handle api routes in pages on edge', async () => {
await next.fetch('/api/pages/param/edge', env.fetchInit)
await expectTrace(
getCollector(),
[
{
runtime: 'edge',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'executing api route (pages) /api/pages/[param]/edge',
attributes: {
'next.span_name':
'executing api route (pages) /api/pages/[param]/edge',
'next.span_type': 'Node.runHandler',
},
kind: 0,
status: { code: 0 },
},
],
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
)
}
)
})
}
)
}
})
describe.each(
[
{ name: 'default', useDirectEntrypointHandler: false },
isNextStart && {
name: 'direct entrypoints',
useDirectEntrypointHandler: true,
},
].filter(Boolean)
)('opentelemetry - middleware $name', ({ useDirectEntrypointHandler }) => {
describe.each(['edge', 'nodejs'])('%s runtime', (runtime) => {
const {
next: { next, skipped },
getCollector,
} = setup({
useDirectEntrypointHandler,
useNodeMiddleware: runtime === 'nodejs',
})
if (skipped) {
return
}
if (useDirectEntrypointHandler && runtime === 'edge') {
it.skip('direct entrypoint handler is not implemented for edge runtime', () => {})
return
}
for (const env of [
{
name: 'root context',
fetchInit: undefined,
span: {
traceId: '[trace-id]',
rootParentId: undefined,
},
},
{
name: 'incoming context propagation',
fetchInit: {
headers: {
traceparent: `00-${EXTERNAL.traceId}-${EXTERNAL.spanId}-01`,
},
},
span: {
traceId: EXTERNAL.traceId,
rootParentId: EXTERNAL.spanId,
},
},
]) {
;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)(
env.name,
() => {
it('should trace middleware', async () => {
await next.fetch('/behind-middleware', env.fetchInit)
let expected = [
{
runtime: runtime,
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'middleware GET',
attributes: {
'http.method': 'GET',
'http.target': '/behind-middleware',
'next.span_name': 'middleware GET',
'next.span_type': 'Middleware.execute',
},
status: { code: 0 },
spans: [],
},
{
runtime: 'nodejs',
traceId: env.span.traceId,
parentId: env.span.rootParentId,
name: 'GET /behind-middleware',
attributes: {
'http.method': 'GET',
'http.route': '/behind-middleware',
'http.status_code': 200,
'http.target': '/behind-middleware',
'next.route': '/behind-middleware',
'next.span_name': 'GET /behind-middleware',
'next.span_type': 'BaseServer.handleRequest',
},
},
]
if (runtime === 'nodejs') {
// TODO unclear why this is reversed for Node.js runtime
expected.reverse()
}
await expectTrace(getCollector(), expected)
})
}
)
}
})
})
;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)(
'opentelemetry NEXT_OTEL_VERBOSE=1',
() => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
env: {
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
NEXT_TELEMETRY_DISABLED: '1',
NEXT_OTEL_VERBOSE: '1',
},
})
if (skipped) {
return
}
let collector: Collector | undefined
beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})
afterEach(async () => {
await collector?.shutdown()
collector = undefined
})
// Regression for https://github.com/vercel/otel/issues/107.
it('all spans (including verbose) inherit traceId from incoming traceparent header', async () => {
const pathname = '/app/param/rsc-fetch'
await next.fetch(pathname, {
headers: {
traceparent: `00-${EXTERNAL.traceId}-${EXTERNAL.spanId}-01`,
},
})
let spans: SavedSpan[] = []
await retry(async () => {
const all = collector?.getSpans() ?? []
const root = all.find(
(s) =>
s.attributes?.['next.span_type'] === 'BaseServer.handleRequest' &&
s.attributes?.['http.target'] === pathname
)
expect(root).toBeDefined()
expect(root!.traceId).toBe(EXTERNAL.traceId)
spans = all.filter((s) => s.traceId === root!.traceId)
expect(spans.length).toBeGreaterThan(1)
const verbose = spans.find(
(s) =>
s.attributes?.['next.span_type'] === 'NextServer.getRequestHandler'
)
expect(verbose).toBeDefined()
expect(verbose!.traceId).toBe(EXTERNAL.traceId)
const parentSpanId = verbose!.parentId
expect(parentSpanId).toBe(EXTERNAL.spanId)
})
for (const span of spans) {
expect(span.traceId).toBe(EXTERNAL.traceId)
}
})
}
)
describe('opentelemetry with disabled fetch tracing', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
env: {
NEXT_OTEL_FETCH_DISABLED: '1',
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
},
})
if (skipped) {
return
}
let collector: Collector
function getCollector(): Collector {
return collector
}
beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})
afterEach(async () => {
await collector.shutdown()
await new Promise((r) => setTimeout(r, 1000))
})
;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)(
'root context',
() => {
describe('app router with disabled fetch', () => {
it('should handle RSC with disabled fetch', async () => {
await next.fetch('/app/param/rsc-fetch')
await expectTrace(getCollector(), [
{
name: 'GET /app/[param]/rsc-fetch',
traceId: '[trace-id]',
parentId: undefined,
spans: [
{
name: 'render route (app) /app/[param]/rsc-fetch',
spans: [
{
name: 'build component tree',
spans: [
{
name: 'resolve segment modules',
},
{
name: 'resolve segment modules',
},
],
},
{
name: 'generateMetadata /app/[param]/layout',
},
{
name: 'generateMetadata /app/[param]/rsc-fetch/page',
},
{
name: 'NextNodeServer.clientComponentLoading',
},
{
name: 'start response',
},
],
},
{
name: 'resolve page components',
},
],
},
])
})
})
}
)
})
describe('opentelemetry with custom server', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
startCommand: 'pnpm start',
packageJson: {
scripts: {
start: 'pnpm tsx custom-server.ts',
},
},
serverReadyPattern: /- Local:/,
env: {
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
NEXT_TELEMETRY_DISABLED: '1',
NODE_ENV: isNextDev ? 'development' : 'production',
},
})
if (skipped) {
return
}
let collector: Collector
function getCollector(): Collector {
return collector
}
beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})
afterEach(async () => {
await collector.shutdown()
})
it('should set attributes correctly on handleRequest span', async () => {
await next.fetch('/app/param/rsc-fetch')
await expectTrace(getCollector(), [
{
name: 'custom-server-request',
traceId: '[trace-id]',
parentId: undefined,
spans: [
{
name: 'GET /app/[param]/rsc-fetch',
attributes: {
'http.method': 'GET',
'http.route': '/app/[param]/rsc-fetch',
'http.status_code': 200,
'http.target': '/app/param/rsc-fetch',
'next.route': '/app/[param]/rsc-fetch',
'next.rsc': false,
'next.span_name': 'GET /app/[param]/rsc-fetch',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
spans: [
{
name: 'render route (app) /app/[param]/rsc-fetch',
attributes: {
'next.route': '/app/[param]/rsc-fetch',
'next.span_name': 'render route (app) /app/[param]/rsc-fetch',
'next.span_type': 'AppRender.getBodyResult',
},
kind: 0,
status: { code: 0 },
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: 'fetch GET https://example.vercel.sh/',
attributes: {
'http.method': 'GET',
'http.url': 'https://example.vercel.sh/',
'net.peer.name': 'example.vercel.sh',
'next.span_name': 'fetch GET https://example.vercel.sh/',
'next.span_type': 'AppRender.fetch',
},
kind: 2,
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]/rsc-fetch/page',
attributes: {
'next.page': '/app/[param]/rsc-fetch/page',
'next.span_name':
'generateMetadata /app/[param]/rsc-fetch/page',
'next.span_type': 'ResolveMetadata.generateMetadata',
},
kind: 0,
status: { code: 0 },
},
{
attributes: {
'next.clientComponentLoadCount': isNextDev ? 8 : 7,
'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',
'next.span_name': 'resolve page components',
'next.span_type': 'NextNodeServer.findPageComponents',
},
kind: 0,
status: { code: 0 },
},
],
},
],
},
])
})
})
if (isNextStart) {
describe('opentelemetry with direct entrypoint handler', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
startCommand: 'pnpm start-entrypoint',
packageJson: {
scripts: {
'start-entrypoint': 'pnpm tsx custom-entrypoint-server.ts',
},
},
serverReadyPattern: /- Local:/,
env: {
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
NEXT_TELEMETRY_DISABLED: '1',
NODE_ENV: 'production',
},
})
if (skipped) {
return
}
let collector: Collector
function getCollector(): Collector {
return collector
}
beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})
afterEach(async () => {
await collector.shutdown()
})
const directEntrypointCases = [
{ pathname: '/app/param/rsc-fetch', route: '/app/[param]/rsc-fetch' },
{ pathname: '/api/app/param/data', route: '/api/app/[param]/data' },
{
pathname: '/pages/param/getServerSideProps',
route: '/pages/[param]/getServerSideProps',
},
{
pathname: '/api/pages/param/basic',
route: '/api/pages/[param]/basic',
},
] as const
describe.each(directEntrypointCases)(
'direct entrypoint $pathname',
({ pathname, route }) => {
it(`should add route names to handleRequest and parent spans for direct entrypoint ${pathname}`, async () => {
const response = await next.fetch(pathname)
expect(response.status).toBe(200)
await retry(
async () => {
const spans = collector.getSpans()
const handleRequestSpan = spans.find((span) => {
if (
span.attributes?.['next.span_type'] !==
'BaseServer.handleRequest'
) {
return false
}
const target = span.attributes?.['http.target'] as
| string
| undefined
return Boolean(target && target.includes(pathname))
})
expect(handleRequestSpan).toBeDefined()
expect(handleRequestSpan!.name).toBe(`GET ${route}`)
expect(handleRequestSpan!.attributes?.['http.target']).toContain(
pathname
)
expect(handleRequestSpan!.attributes?.['next.route']).toBe(route)
expect(handleRequestSpan!.attributes?.['http.route']).toBe(route)
expect(handleRequestSpan!.attributes?.['next.span_name']).toBe(
`GET ${route}`
)
const parentSpan = spans.find(
(span) =>
span.traceId === handleRequestSpan!.traceId &&
!span.parentId &&
!span.attributes?.['next.span_type'] &&
span.name === handleRequestSpan!.name
)
expect(parentSpan).toBeDefined()
expect(parentSpan!.name).toBe(`GET ${route}`)
},
30_000,
1_000,
`direct entrypoint span route naming ${pathname}`
)
})
it(`should propagate incoming context without next-server wrapper for direct entrypoint ${pathname}`, async () => {
const response = await next.fetch(pathname, {
headers: {
traceparent: `00-${EXTERNAL.traceId}-${EXTERNAL.spanId}-01`,
},
})
expect(response.status).toBe(200)
await expectTrace(getCollector(), [
{
name: `GET ${route}`,
traceId: EXTERNAL.traceId,
parentId: EXTERNAL.spanId,
attributes: {
'http.target': pathname,
'next.span_type': 'BaseServer.handleRequest',
'http.route': route,
'next.route': route,
},
},
])
})
}
)
})
}
type HierSavedSpan = SavedSpan & { spans?: HierSavedSpan[] }
type SpanMatch = Omit<Partial<HierSavedSpan>, 'spans'> & { spans?: SpanMatch[] }
async function expectTrace(
collector: Collector,
match: SpanMatch[],
edgeOnly?: boolean
) {
// Extract expected http.target values from the match to filter out extra spans
// that may be generated in production mode (e.g., RSC prefetch requests)
const expectedTargets = new Set(
match
.map((m) => m.attributes?.['http.target'] as string | undefined)
.filter(Boolean)
)
await retry(async () => {
const traces = collector.getSpans()
const tree: HierSavedSpan[] = []
const spansForTree: HierSavedSpan[] = traces.map((span) => ({
...span,
spans: [],
}))
for (const span of spansForTree) {
// for edge runtime with `next start` the nodejs runtime spans
// will not be updated as the sandbox can't update spans across
// node -> edge boundary, these spans also are not present when
// deployed as next-server is not invoking edge functions there
if (span.runtime === 'nodejs' && edgeOnly) {
continue
}
const parent =
!span.parentId || span.parentId === EXTERNAL.spanId
? null
: spansForTree.find((s) => s.id === span.parentId)
if (parent) {
parent.spans.push(span)
} else {
tree.push(span)
}
}
for (const span of spansForTree) {
delete span.duration
delete span.timestamp
span.traceId =
span.traceId === EXTERNAL.traceId ? span.traceId : '[trace-id]'
span.parentId = span.parentId || undefined
span.spans.sort((a, b) => {
const nameDiff = a.name.localeCompare(b.name)
if (nameDiff !== 0) {
return nameDiff
}
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
)
})
}
// Filter root spans to only those matching expected http.target values
// This prevents flakiness from extra spans in prod mode (RSC prefetch, etc.)
const filteredTree =
expectedTargets.size > 0
? tree.filter((span) => {
const target = span.attributes?.['http.target'] as
| string
| undefined
return target && expectedTargets.has(target)
})
: tree
filteredTree.sort((a, b) => {
const runtimeDiff = (a.runtime ?? '').localeCompare(b.runtime ?? '')
if (runtimeDiff !== 0) {
return runtimeDiff
}
return a.name.localeCompare(b.name)
})
expect(filteredTree).toMatchObject(match)
})
}