From 10f370c1f32d59fbfdc70b69b399706f3a214980 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Sat, 12 Sep 2026 20:52:31 +0200 Subject: [PATCH] test: stabilize OpenTelemetry collector lifecycle (#98427) ## What? Stabilize the OpenTelemetry instrumentation e2e suite by keeping its local span collector available for each application's full lifetime. Reset collected span state between tests without closing the collector, and cover continuity across a test boundary. ## Why? The suite kept Next.js and custom-server exporters alive across tests while closing and rebinding their collector port after every test. Exports during that gap failed with closed/reset/refused socket errors before trace assertions timed out or observed incomplete trees. Aligning the collector lifetime with the exporter removes that race instead of masking it with retries, longer timeouts, or weaker assertions. ## How? - Start the collector before starting each managed application. - Keep the listener bound until application teardown, while clearing only buffered spans in `beforeEach`. - Replace per-test collector reconnects across the affected suites and remove the disabled-fetch suite's fixed teardown delay. - Assert that one collector remains connected, resets its state, and receives a fresh trace across sequential tests. ## Verification - Webpack dev / React 18.3.1: 5 consecutive focused runs, 54 tests passed per run - Webpack start / React 18.3.1: 5 consecutive focused runs, 97 passed and 13 existing skips per run - `pnpm test-types` - Prettier and ESLint on the changed files Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> --- .../instrumentation/collector.ts | 4 + .../instrumentation/opentelemetry.test.ts | 132 +++++++++--------- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/test/e2e/opentelemetry/instrumentation/collector.ts b/test/e2e/opentelemetry/instrumentation/collector.ts index 8099cd5dd517..9c03d03c7783 100644 --- a/test/e2e/opentelemetry/instrumentation/collector.ts +++ b/test/e2e/opentelemetry/instrumentation/collector.ts @@ -3,6 +3,7 @@ import { SavedSpan } from './constants' export interface Collector { getSpans: () => SavedSpan[] + reset: () => void shutdown: () => Promise } @@ -57,6 +58,9 @@ export async function connectCollector({ getSpans() { return spans }, + reset() { + spans.length = 0 + }, shutdown() { return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())) diff --git a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts index 4ab9140e54c8..f391ef6236ac 100644 --- a/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts +++ b/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts @@ -16,24 +16,39 @@ const ROUTE_PREPARATION_COLLECTOR_PORT = 9002 const INSTRUMENTATION_STARTUP_COLLECTOR_PORT = 9003 const APP_ROUTE_MODULE_LOADING_COLLECTOR_PORT = 9004 -function setup({ useDirectEntrypointHandler, useNodeMiddleware }) { - let collector: Collector +type NextInstance = ReturnType['next'] - function getCollector(): Collector { - return collector - } +function setupCollector(next: NextInstance, port: number) { + let collector: Collector | undefined - beforeEach(async () => { - collector = await connectCollector({ port: COLLECTOR_PORT }) + // The app's span exporter remains active for the entire suite. Keep its + // endpoint available for the same lifetime and only reset collected state. + beforeAll(async () => { + collector = await connectCollector({ port }) + await next.start() }) - afterEach(async () => { - await collector.shutdown() + beforeEach(() => { + collector?.reset() }) - let next = nextTestSetup({ + afterAll(async () => { + await collector?.shutdown() + }) + + return function getCollector(): Collector { + if (!collector) { + throw new Error('OpenTelemetry collector is not connected') + } + return collector + } +} + +function setup({ useDirectEntrypointHandler, useNodeMiddleware }) { + const testSetup = nextTestSetup({ files: __dirname, skipDeployment: true, + skipStart: true, dependencies: require('./package.json').dependencies, ...(!useDirectEntrypointHandler ? { @@ -65,7 +80,14 @@ function setup({ useDirectEntrypointHandler, useNodeMiddleware }) { } : undefined, }) - return { next, getCollector } + return { + next: testSetup, + getCollector: testSetup.skipped + ? () => { + throw new Error('OpenTelemetry test setup was skipped') + } + : setupCollector(testSetup.next, COLLECTOR_PORT), + } } describe.each( @@ -89,6 +111,32 @@ describe.each( return } + let connectedCollector: Collector + + async function expectAppRouteTrace(pathname: string) { + expect((await next.fetch(pathname)).status).toBe(200) + await expectTrace(getCollector(), [ + { + name: 'GET /api/app/[param]/data', + attributes: { + 'http.target': pathname, + 'next.span_type': 'BaseServer.handleRequest', + }, + }, + ]) + } + + it('collects a trace before the per-test reset', async () => { + connectedCollector = getCollector() + await expectAppRouteTrace('/api/app/param/data') + }) + + it('keeps the collector connected across per-test resets', async () => { + expect(getCollector()).toBe(connectedCollector) + expect(getCollector().getSpans()).toEqual([]) + await expectAppRouteTrace('/api/app/param/data') + }) + // Edge runtime is currently not implemented in custom-entrypoint-server.ts const itEdge = useDirectEntrypointHandler ? it.skip : it @@ -1945,6 +1993,7 @@ describe.each( const { next, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true, + skipStart: true, dependencies: require('./package.json').dependencies, env: { TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT), @@ -1957,16 +2006,7 @@ describe.each( return } - let collector: Collector | undefined - - beforeEach(async () => { - collector = await connectCollector({ port: COLLECTOR_PORT }) - }) - - afterEach(async () => { - await collector?.shutdown() - collector = undefined - }) + const getCollector = setupCollector(next, COLLECTOR_PORT) // Regression for https://github.com/vercel/otel/issues/107. it('all spans (including verbose) inherit traceId from incoming traceparent header', async () => { @@ -1979,7 +2019,7 @@ describe.each( let spans: SavedSpan[] = [] await retry(async () => { - const all = collector?.getSpans() ?? [] + const all = getCollector().getSpans() const root = all.find( (s) => s.attributes?.['next.span_type'] === 'BaseServer.handleRequest' && @@ -2012,6 +2052,7 @@ describe('opentelemetry with disabled fetch tracing', () => { const { next, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true, + skipStart: true, dependencies: require('./package.json').dependencies, env: { NEXT_OTEL_FETCH_DISABLED: '1', @@ -2023,20 +2064,7 @@ describe('opentelemetry with disabled fetch tracing', () => { 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)) - }) + const getCollector = setupCollector(next, COLLECTOR_PORT) ;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)( 'root context', () => { @@ -2094,6 +2122,7 @@ describe('opentelemetry with custom server', () => { const { next, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true, + skipStart: true, dependencies: require('./package.json').dependencies, startCommand: 'pnpm start', packageJson: { @@ -2113,19 +2142,7 @@ describe('opentelemetry with custom server', () => { return } - let collector: Collector - - function getCollector(): Collector { - return collector - } - - beforeEach(async () => { - collector = await connectCollector({ port: COLLECTOR_PORT }) - }) - - afterEach(async () => { - await collector.shutdown() - }) + const getCollector = setupCollector(next, COLLECTOR_PORT) it('should set attributes correctly on handleRequest span', async () => { await next.fetch('/app/param/rsc-fetch') @@ -2272,6 +2289,7 @@ if (isNextStart) { const { next, skipped } = nextTestSetup({ files: __dirname, skipDeployment: true, + skipStart: true, dependencies: require('./package.json').dependencies, startCommand: 'pnpm start-entrypoint', packageJson: { @@ -2291,19 +2309,7 @@ if (isNextStart) { return } - let collector: Collector - - function getCollector(): Collector { - return collector - } - - beforeEach(async () => { - collector = await connectCollector({ port: COLLECTOR_PORT }) - }) - - afterEach(async () => { - await collector.shutdown() - }) + const getCollector = setupCollector(next, COLLECTOR_PORT) const directEntrypointCases = [ { pathname: '/app/param/rsc-fetch', route: '/app/[param]/rsc-fetch' }, @@ -2327,7 +2333,7 @@ if (isNextStart) { await retry( async () => { - const spans = collector.getSpans() + const spans = getCollector().getSpans() const handleRequestSpan = spans.find((span) => { if ( span.attributes?.['next.span_type'] !==