diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index ce1d5f92b77d2..1e9c30e1bc937 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -757,50 +757,54 @@ export default abstract class Server { ): Promise { await this.prepare() const method = req.method.toUpperCase() - return getTracer().trace( - BaseServerSpan.handleRequest, - { - spanName: `${method} ${req.url}`, - kind: SpanKind.SERVER, - attributes: { - 'http.method': method, - 'http.target': req.url, - }, - }, - async (span) => - this.handleRequestImpl(req, res, parsedUrl).finally(() => { - if (!span) return - span.setAttributes({ - 'http.status_code': res.statusCode, - }) - const rootSpanAttributes = getTracer().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') - if (route) { - const newName = `${method} ${route}` + const tracer = getTracer() + return tracer.withPropagatedContext(req, () => { + return tracer.trace( + BaseServerSpan.handleRequest, + { + spanName: `${method} ${req.url}`, + kind: SpanKind.SERVER, + attributes: { + 'http.method': method, + 'http.target': req.url, + }, + }, + async (span) => + this.handleRequestImpl(req, res, parsedUrl).finally(() => { + if (!span) return span.setAttributes({ - 'next.route': route, - 'http.route': route, - 'next.span_name': newName, + 'http.status_code': res.statusCode, }) - span.updateName(newName) - } - }) - ) + 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') + if (route) { + const newName = `${method} ${route}` + span.setAttributes({ + 'next.route': route, + 'http.route': route, + 'next.span_name': newName, + }) + span.updateName(newName) + } + }) + ) + }) } private async handleRequestImpl( diff --git a/packages/next/src/server/lib/trace/tracer.ts b/packages/next/src/server/lib/trace/tracer.ts index 2183a2eff6c3e..5307aa93ecdbc 100644 --- a/packages/next/src/server/lib/trace/tracer.ts +++ b/packages/next/src/server/lib/trace/tracer.ts @@ -1,3 +1,4 @@ +import type { BaseNextRequest } from '../../base-http' import type { SpanTypes } from './constants' import { NextVanillaSpanAllowlist } from './constants' @@ -28,7 +29,8 @@ if (process.env.NEXT_RUNTIME === 'edge') { } } -const { context, trace, SpanStatusCode, SpanKind } = api +const { context, propagation, trace, SpanStatusCode, SpanKind, ROOT_CONTEXT } = + api const isPromise = (p: any): p is Promise => { return p !== null && typeof p === 'object' && typeof p.then === 'function' @@ -171,6 +173,14 @@ class NextTracerImpl implements NextTracer { return trace.getSpan(context?.active()) } + public withPropagatedContext(req: BaseNextRequest, fn: () => T): T { + if (context.active() !== ROOT_CONTEXT) { + return fn() + } + const remoteContext = propagation.extract(ROOT_CONTEXT, req.headers) + return context.with(remoteContext, fn) + } + // Trace, wrap implementation is inspired by datadog trace implementation // (https://datadoghq.dev/dd-trace-js/interfaces/tracer.html#trace). public trace( @@ -229,7 +239,9 @@ class NextTracerImpl implements NextTracer { let isRootSpan = false if (!spanContext) { - spanContext = api.ROOT_CONTEXT + spanContext = ROOT_CONTEXT + isRootSpan = true + } else if (trace.getSpanContext(spanContext)?.isRemote) { isRootSpan = true } @@ -241,7 +253,7 @@ class NextTracerImpl implements NextTracer { ...options.attributes, } - return api.context.with(spanContext.setValue(rootSpanIdKey, spanId), () => + return context.with(spanContext.setValue(rootSpanIdKey, spanId), () => this.getTracerInstance().startActiveSpan( spanName, options, diff --git a/test/e2e/opentelemetry/opentelemetry.test.ts b/test/e2e/opentelemetry/opentelemetry.test.ts index 89be53d94d7d3..cfc09210b1434 100644 --- a/test/e2e/opentelemetry/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/opentelemetry.test.ts @@ -3,6 +3,11 @@ import { check } from 'next-test-utils' import { SavedSpan, traceFile } from './constants' +const EXTERNAL = { + traceId: 'ee75cd9e534ff5e9ed78b4a0c706f0f2', + spanId: '0f6a325411bdc432', +} as const + createNextDescribe( 'opentelemetry', { @@ -33,8 +38,12 @@ createNextDescribe( delete span.links delete span.events delete span.timestamp - delete span.traceId - span.parentId = span.parentId === undefined ? undefined : '[parent-id]' + span.traceId = + span.traceId === EXTERNAL.traceId ? span.traceId : '[trace-id]' + span.parentId = + span.parentId === undefined || span.parentId === EXTERNAL.spanId + ? span.parentId + : '[parent-id]' return span } const sanitizeSpans = (spans: SavedSpan[]) => { @@ -71,286 +80,376 @@ createNextDescribe( await cleanTraces() }) - // turbopack does not support experimental.instrumentationHook - ;(process.env.TURBOPACK ? describe.skip : describe)('app router', () => { - it('should handle RSC with fetch', async () => { - await next.fetch('/app/param/rsc-fetch') + 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, + }, + }, + ]) { + // turbopack does not support experimental.instrumentationHook + ;(process.env.TURBOPACK ? 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 check(async () => { - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - [ - { - "attributes": { - "http.method": "GET", - "http.url": "https://vercel.com/", - "net.peer.name": "vercel.com", - "next.span_name": "fetch GET https://vercel.com/", - "next.span_type": "AppRender.fetch", - }, - "kind": 2, - "name": "fetch GET https://vercel.com/", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.route": "/app/[param]/rsc-fetch", - "next.span_name": "render route (app) /app/[param]/rsc-fetch", - "next.span_type": "AppRender.getBodyResult", - }, - "kind": 0, - "name": "render route (app) /app/[param]/rsc-fetch", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "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.span_name": "GET /app/[param]/rsc-fetch", - "next.span_type": "BaseServer.handleRequest", - }, - "kind": 1, - "name": "GET /app/[param]/rsc-fetch", - "parentId": undefined, - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.page": "/app/[param]/layout", - "next.span_name": "generateMetadata /app/[param]/layout", - "next.span_type": "ResolveMetadata.generateMetadata", - }, - "kind": 0, - "name": "generateMetadata /app/[param]/layout", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.page": "/app/[param]/rsc-fetch/page", - "next.span_name": "generateMetadata /app/[param]/rsc-fetch/page", - "next.span_type": "ResolveMetadata.generateMetadata", - }, - "kind": 0, - "name": "generateMetadata /app/[param]/rsc-fetch/page", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - ] - `) - return 'success' - }, 'success') - }) + await check(async () => { + const numberOfRootTraces = + env.span.rootParentId === undefined ? 1 : 0 + const traces = await getSanitizedTraces(numberOfRootTraces) + if (traces.length < 5) { + return `not enough traces, expected 5, but got ${traces.length}` + } + expect(traces).toMatchInlineSnapshot(` + [ + { + "attributes": { + "http.method": "GET", + "http.url": "https://vercel.com/", + "net.peer.name": "vercel.com", + "next.span_name": "fetch GET https://vercel.com/", + "next.span_type": "AppRender.fetch", + }, + "kind": 2, + "name": "fetch GET https://vercel.com/", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.route": "/app/[param]/rsc-fetch", + "next.span_name": "render route (app) /app/[param]/rsc-fetch", + "next.span_type": "AppRender.getBodyResult", + }, + "kind": 0, + "name": "render route (app) /app/[param]/rsc-fetch", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "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.span_name": "GET /app/[param]/rsc-fetch", + "next.span_type": "BaseServer.handleRequest", + }, + "kind": 1, + "name": "GET /app/[param]/rsc-fetch", + "parentId": ${ + env.span.rootParentId + ? `"${env.span.rootParentId}"` + : undefined + }, + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.page": "/app/[param]/layout", + "next.span_name": "generateMetadata /app/[param]/layout", + "next.span_type": "ResolveMetadata.generateMetadata", + }, + "kind": 0, + "name": "generateMetadata /app/[param]/layout", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.page": "/app/[param]/rsc-fetch/page", + "next.span_name": "generateMetadata /app/[param]/rsc-fetch/page", + "next.span_type": "ResolveMetadata.generateMetadata", + }, + "kind": 0, + "name": "generateMetadata /app/[param]/rsc-fetch/page", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + ] + `) + return 'success' + }, 'success') + }) - it('should handle route handlers in app router', async () => { - await next.fetch('/api/app/param/data') + it('should handle route handlers in app router', async () => { + await next.fetch('/api/app/param/data', env.fetchInit) - await check(async () => { - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - [ - { - "attributes": { - "next.route": "/api/app/[param]/data/route", - "next.span_name": "executing api route (app) /api/app/[param]/data/route", - "next.span_type": "AppRouteRouteHandlers.runHandler", - }, - "kind": 0, - "name": "executing api route (app) /api/app/[param]/data/route", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "attributes": { - "http.method": "GET", - "http.route": "/api/app/[param]/data/route", - "http.status_code": 200, - "http.target": "/api/app/param/data", - "next.route": "/api/app/[param]/data/route", - "next.span_name": "GET /api/app/[param]/data/route", - "next.span_type": "BaseServer.handleRequest", - }, - "kind": 1, - "name": "GET /api/app/[param]/data/route", - "parentId": undefined, - "status": { - "code": 0, - }, - }, - ] - `) - return 'success' - }, 'success') - }) - }) + await check(async () => { + const numberOfRootTraces = + env.span.rootParentId === undefined ? 1 : 0 + const traces = await getSanitizedTraces(numberOfRootTraces) + if (traces.length < 2) { + return `not enough traces, expected 2, but got ${traces.length}` + } + expect(traces).toMatchInlineSnapshot(` + [ + { + "attributes": { + "next.route": "/api/app/[param]/data/route", + "next.span_name": "executing api route (app) /api/app/[param]/data/route", + "next.span_type": "AppRouteRouteHandlers.runHandler", + }, + "kind": 0, + "name": "executing api route (app) /api/app/[param]/data/route", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "http.method": "GET", + "http.route": "/api/app/[param]/data/route", + "http.status_code": 200, + "http.target": "/api/app/param/data", + "next.route": "/api/app/[param]/data/route", + "next.span_name": "GET /api/app/[param]/data/route", + "next.span_type": "BaseServer.handleRequest", + }, + "kind": 1, + "name": "GET /api/app/[param]/data/route", + "parentId": ${ + env.span.rootParentId + ? `"${env.span.rootParentId}"` + : undefined + }, + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + ] + `) + return 'success' + }, 'success') + }) + }) - // turbopack does not support experimental.instrumentationHook - ;(process.env.TURBOPACK ? describe.skip : describe)('pages', () => { - it('should handle getServerSideProps', async () => { - await next.fetch('/pages/param/getServerSideProps') + describe('pages', () => { + it('should handle getServerSideProps', async () => { + await next.fetch('/pages/param/getServerSideProps', env.fetchInit) - await check(async () => { - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - [ - { - "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, - "name": "GET /pages/[param]/getServerSideProps", - "parentId": undefined, - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.route": "/pages/[param]/getServerSideProps", - "next.span_name": "getServerSideProps /pages/[param]/getServerSideProps", - "next.span_type": "Render.getServerSideProps", - }, - "kind": 0, - "name": "getServerSideProps /pages/[param]/getServerSideProps", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.route": "/pages/[param]/getServerSideProps", - "next.span_name": "render route (pages) /pages/[param]/getServerSideProps", - "next.span_type": "Render.renderDocument", - }, - "kind": 0, - "name": "render route (pages) /pages/[param]/getServerSideProps", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - ] - `) - return 'success' - }, 'success') - }) + await check(async () => { + const numberOfRootTraces = + env.span.rootParentId === undefined ? 1 : 0 + const traces = await getSanitizedTraces(numberOfRootTraces) + if (traces.length < 3) { + return `not enough traces, expected 3, but got ${traces.length}` + } + expect(traces).toMatchInlineSnapshot(` + [ + { + "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, + "name": "GET /pages/[param]/getServerSideProps", + "parentId": ${ + env.span.rootParentId + ? `"${env.span.rootParentId}"` + : undefined + }, + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.route": "/pages/[param]/getServerSideProps", + "next.span_name": "getServerSideProps /pages/[param]/getServerSideProps", + "next.span_type": "Render.getServerSideProps", + }, + "kind": 0, + "name": "getServerSideProps /pages/[param]/getServerSideProps", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.route": "/pages/[param]/getServerSideProps", + "next.span_name": "render route (pages) /pages/[param]/getServerSideProps", + "next.span_type": "Render.renderDocument", + }, + "kind": 0, + "name": "render route (pages) /pages/[param]/getServerSideProps", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + ] + `) + return 'success' + }, 'success') + }) - it("should handle getStaticProps when fallback: 'blocking'", async () => { - await next.fetch('/pages/param/getStaticProps') + it("should handle getStaticProps when fallback: 'blocking'", async () => { + const v = env.span.rootParentId ? '2' : '' + await next.fetch(`/pages/param/getStaticProps${v}`, env.fetchInit) - await check(async () => { - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - [ - { - "attributes": { - "http.method": "GET", - "http.route": "/pages/[param]/getStaticProps", - "http.status_code": 200, - "http.target": "/pages/param/getStaticProps", - "next.route": "/pages/[param]/getStaticProps", - "next.span_name": "GET /pages/[param]/getStaticProps", - "next.span_type": "BaseServer.handleRequest", - }, - "kind": 1, - "name": "GET /pages/[param]/getStaticProps", - "parentId": undefined, - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.route": "/pages/[param]/getStaticProps", - "next.span_name": "getStaticProps /pages/[param]/getStaticProps", - "next.span_type": "Render.getStaticProps", - }, - "kind": 0, - "name": "getStaticProps /pages/[param]/getStaticProps", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.route": "/pages/[param]/getStaticProps", - "next.span_name": "render route (pages) /pages/[param]/getStaticProps", - "next.span_type": "Render.renderDocument", - }, - "kind": 0, - "name": "render route (pages) /pages/[param]/getStaticProps", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - ] - `) - return 'success' - }, 'success') - }) + await check(async () => { + const numberOfRootTraces = + env.span.rootParentId === undefined ? 1 : 0 + const traces = await getSanitizedTraces(numberOfRootTraces) + if (traces.length < 3) { + return `not enough traces, expected 3, but got ${traces.length}` + } + expect(traces).toMatchInlineSnapshot(` + [ + { + "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, + "name": "GET /pages/[param]/getStaticProps${v}", + "parentId": ${ + env.span.rootParentId + ? `"${env.span.rootParentId}"` + : undefined + }, + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.route": "/pages/[param]/getStaticProps${v}", + "next.span_name": "getStaticProps /pages/[param]/getStaticProps${v}", + "next.span_type": "Render.getStaticProps", + }, + "kind": 0, + "name": "getStaticProps /pages/[param]/getStaticProps${v}", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.route": "/pages/[param]/getStaticProps${v}", + "next.span_name": "render route (pages) /pages/[param]/getStaticProps${v}", + "next.span_type": "Render.renderDocument", + }, + "kind": 0, + "name": "render route (pages) /pages/[param]/getStaticProps${v}", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + ] + `) + return 'success' + }, 'success') + }) - it('should handle api routes in pages', async () => { - await next.fetch('/api/pages/param/basic') + it('should handle api routes in pages', async () => { + await next.fetch('/api/pages/param/basic', env.fetchInit) - await check(async () => { - expect(await getSanitizedTraces(1)).toMatchInlineSnapshot(` - [ - { - "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, - "name": "GET /api/pages/[param]/basic", - "parentId": undefined, - "status": { - "code": 0, - }, - }, - { - "attributes": { - "next.span_name": "executing api route (pages) /api/pages/[param]/basic", - "next.span_type": "Node.runHandler", - }, - "kind": 0, - "name": "executing api route (pages) /api/pages/[param]/basic", - "parentId": "[parent-id]", - "status": { - "code": 0, - }, - }, - ] - `) - return 'success' - }, 'success') + await check(async () => { + const numberOfRootTraces = + env.span.rootParentId === undefined ? 1 : 0 + const traces = await getSanitizedTraces(numberOfRootTraces) + if (traces.length < 2) { + return `not enough traces, expected 2, but got ${traces.length}` + } + expect(traces).toMatchInlineSnapshot(` + [ + { + "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, + "name": "GET /api/pages/[param]/basic", + "parentId": ${ + env.span.rootParentId + ? `"${env.span.rootParentId}"` + : undefined + }, + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + { + "attributes": { + "next.span_name": "executing api route (pages) /api/pages/[param]/basic", + "next.span_type": "Node.runHandler", + }, + "kind": 0, + "name": "executing api route (pages) /api/pages/[param]/basic", + "parentId": "[parent-id]", + "status": { + "code": 0, + }, + "traceId": "${env.span.traceId}", + }, + ] + `) + return 'success' + }, 'success') + }) + }) }) - }) + } } ) diff --git a/test/e2e/opentelemetry/pages/pages/[param]/getStaticProps2.tsx b/test/e2e/opentelemetry/pages/pages/[param]/getStaticProps2.tsx new file mode 100644 index 0000000000000..3ba125ed2fe9c --- /dev/null +++ b/test/e2e/opentelemetry/pages/pages/[param]/getStaticProps2.tsx @@ -0,0 +1,17 @@ +export default function Page() { + return
Page
+} + +export function getStaticProps() { + return { + props: {}, + } +} + +// We don't want to render in build time +export async function getStaticPaths() { + return { + paths: [], + fallback: 'blocking', + } +}