diff --git a/packages/next/src/server/lib/trace/tracer.ts b/packages/next/src/server/lib/trace/tracer.ts index 9e41309c0da84..f26399c3fd720 100644 --- a/packages/next/src/server/lib/trace/tracer.ts +++ b/packages/next/src/server/lib/trace/tracer.ts @@ -285,14 +285,18 @@ class NextTracerImpl implements NextTracer { let spanContext = this.getSpanContext( options?.parentSpan ?? this.getActiveScopeSpan() ) - let isRootSpan = false if (!spanContext) { spanContext = context?.active() ?? ROOT_CONTEXT - isRootSpan = true - } else if (trace.getSpanContext(spanContext)?.isRemote) { - isRootSpan = true } + // Check if there's already a root span in the store for this trace + // We are intentionally not checking whether there is an active context + // from outside of nextjs to ensure that we can provide the same level + // of telemetry when using a custom server + const existingRootSpanId = spanContext.getValue(rootSpanIdKey) + const isRootSpan = + typeof existingRootSpanId !== 'number' || + !rootSpanAttributesStore.has(existingRootSpanId) const spanId = getSpanId() diff --git a/test/e2e/opentelemetry/instrumentation/custom-server.ts b/test/e2e/opentelemetry/instrumentation/custom-server.ts new file mode 100644 index 0000000000000..abeb817d5aa2c --- /dev/null +++ b/test/e2e/opentelemetry/instrumentation/custom-server.ts @@ -0,0 +1,49 @@ +import { createServer } from 'http' +import { parse } from 'url' +import next from 'next' +import getPort from 'get-port' +import { trace } from '@opentelemetry/api' + +import { register } from './instrumentation-custom-server' +register() + +async function main() { + const port = await getPort() + const hostname = 'localhost' + + const app = next({ + dev: process.env.NODE_ENV === 'development', + hostname, + port, + dir: __dirname, + }) + const handle = app.getRequestHandler() + + await app.prepare() + + const tracer = trace.getTracer('custom-server', '1.0.0') + + createServer((req, res) => { + // Create a local parent span to simulate custom server behavior + tracer.startActiveSpan('custom-server-request', async (span) => { + try { + const parsedUrl = parse(req.url!, true) + await handle(req, res, parsedUrl) + span.end() + } catch (err) { + span.recordException(err as Error) + span.end() + res.statusCode = 500 + res.end('Internal Server Error') + } + }) + }).listen(port, undefined, (err?: Error) => { + if (err) throw err + console.log(`- Local: http://${hostname}:${port}`) + }) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/test/e2e/opentelemetry/instrumentation/instrumentation-custom-server.ts b/test/e2e/opentelemetry/instrumentation/instrumentation-custom-server.ts new file mode 100644 index 0000000000000..246af63e09ff9 --- /dev/null +++ b/test/e2e/opentelemetry/instrumentation/instrumentation-custom-server.ts @@ -0,0 +1,107 @@ +import { Resource } from '@opentelemetry/resources' +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' +import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { + ExportResult, + ExportResultCode, + hrTimeToMicroseconds, +} from '@opentelemetry/core' +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' + +interface SerializedSpan { + runtime: string | undefined + traceId: string + parentId: string | undefined + traceState: string | undefined + name: string + id: string + kind: number + timestamp: number + duration: number + attributes: Record + status: { code: number; message?: string } + events: ReadableSpan['events'] + links: ReadableSpan['links'] +} + +const serializeSpan = (span: ReadableSpan): SerializedSpan => ({ + runtime: process.env.NEXT_RUNTIME, + traceId: span.spanContext().traceId, + parentId: span.parentSpanId, + traceState: span.spanContext().traceState?.serialize(), + name: span.name, + id: span.spanContext().spanId, + kind: span.kind, + timestamp: hrTimeToMicroseconds(span.startTime), + duration: hrTimeToMicroseconds(span.duration), + attributes: span.attributes, + status: span.status, + events: span.events, + links: span.links, +}) + +class TestExporter implements SpanExporter { + private port: string + + constructor(port: string) { + this.port = port + } + + async export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void + ): Promise { + try { + const response = await fetch(`http://localhost:${this.port}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(spans.map(serializeSpan)), + }) + try { + await response.arrayBuffer() + } catch (e) { + // ignore + } + if (response.status >= 400) { + console.warn('WARN: TestExporter: response status:', response.status) + return resultCallback({ + code: ExportResultCode.FAILED, + error: new Error(`http status ${response.status}`), + }) + } + } catch (e) { + console.warn('WARN: TestExporter: error:', e) + return resultCallback({ + code: ExportResultCode.FAILED, + error: e as Error, + }) + } + + resultCallback({ code: ExportResultCode.SUCCESS }) + } + + shutdown(): Promise { + return Promise.resolve() + } +} + +export function register() { + const provider = new NodeTracerProvider({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'custom-server-test', + }), + }) + + const port = process.env.TEST_OTEL_COLLECTOR_PORT + + if (!port) { + throw new Error('TEST_OTEL_COLLECTOR_PORT is not set') + } + + provider.addSpanProcessor(new SimpleSpanProcessor(new TestExporter(port))) + + provider.register() +} diff --git a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts index bc129bce6be7f..ec1f58129898f 100644 --- a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts @@ -1,4 +1,4 @@ -import { nextTestSetup } from 'e2e-utils' +import { isNextDev, nextTestSetup } from 'e2e-utils' import { check } from 'next-test-utils' import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers' @@ -1118,6 +1118,183 @@ describe('opentelemetry with disabled fetch tracing', () => { ) }) +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 ? 7 : 6, + '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 }, + }, + ], + }, + ], + }, + ]) + }) +}) + type HierSavedSpan = SavedSpan & { spans?: HierSavedSpan[] } type SpanMatch = Omit, 'spans'> & { spans?: SpanMatch[] } diff --git a/test/e2e/opentelemetry/instrumentation/package.json b/test/e2e/opentelemetry/instrumentation/package.json index 432e1470a1660..62f0052da3b4e 100644 --- a/test/e2e/opentelemetry/instrumentation/package.json +++ b/test/e2e/opentelemetry/instrumentation/package.json @@ -1,6 +1,8 @@ { "dependencies": { "fs-extra": "^8.0.0", + "tsx": "4.20.6", + "get-port": "5.1.1", "@types/fs-extra": "^8.0.0", "@opentelemetry/api": "^1.7.0", "@opentelemetry/context-async-hooks": "^1.21.0",