Skip to content

Commit 10f370c

Browse files
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 <!-- NEXT_JS_LLM --> <!-- fleet c7f6c6e4-56b2-4648-bc2d-88b2f256bb7a --> 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>
1 parent d5276f0 commit 10f370c

2 files changed

Lines changed: 73 additions & 63 deletions

File tree

‎test/e2e/opentelemetry/instrumentation/collector.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SavedSpan } from './constants'
33

44
export interface Collector {
55
getSpans: () => SavedSpan[]
6+
reset: () => void
67
shutdown: () => Promise<void>
78
}
89

@@ -57,6 +58,9 @@ export async function connectCollector({
5758
getSpans() {
5859
return spans
5960
},
61+
reset() {
62+
spans.length = 0
63+
},
6064
shutdown() {
6165
return new Promise<void>((resolve, reject) =>
6266
server.close((err) => (err ? reject(err) : resolve()))

‎test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts‎

Lines changed: 69 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,39 @@ const ROUTE_PREPARATION_COLLECTOR_PORT = 9002
1616
const INSTRUMENTATION_STARTUP_COLLECTOR_PORT = 9003
1717
const APP_ROUTE_MODULE_LOADING_COLLECTOR_PORT = 9004
1818

19-
function setup({ useDirectEntrypointHandler, useNodeMiddleware }) {
20-
let collector: Collector
19+
type NextInstance = ReturnType<typeof nextTestSetup>['next']
2120

22-
function getCollector(): Collector {
23-
return collector
24-
}
21+
function setupCollector(next: NextInstance, port: number) {
22+
let collector: Collector | undefined
2523

26-
beforeEach(async () => {
27-
collector = await connectCollector({ port: COLLECTOR_PORT })
24+
// The app's span exporter remains active for the entire suite. Keep its
25+
// endpoint available for the same lifetime and only reset collected state.
26+
beforeAll(async () => {
27+
collector = await connectCollector({ port })
28+
await next.start()
2829
})
2930

30-
afterEach(async () => {
31-
await collector.shutdown()
31+
beforeEach(() => {
32+
collector?.reset()
3233
})
3334

34-
let next = nextTestSetup({
35+
afterAll(async () => {
36+
await collector?.shutdown()
37+
})
38+
39+
return function getCollector(): Collector {
40+
if (!collector) {
41+
throw new Error('OpenTelemetry collector is not connected')
42+
}
43+
return collector
44+
}
45+
}
46+
47+
function setup({ useDirectEntrypointHandler, useNodeMiddleware }) {
48+
const testSetup = nextTestSetup({
3549
files: __dirname,
3650
skipDeployment: true,
51+
skipStart: true,
3752
dependencies: require('./package.json').dependencies,
3853
...(!useDirectEntrypointHandler
3954
? {
@@ -65,7 +80,14 @@ function setup({ useDirectEntrypointHandler, useNodeMiddleware }) {
6580
}
6681
: undefined,
6782
})
68-
return { next, getCollector }
83+
return {
84+
next: testSetup,
85+
getCollector: testSetup.skipped
86+
? () => {
87+
throw new Error('OpenTelemetry test setup was skipped')
88+
}
89+
: setupCollector(testSetup.next, COLLECTOR_PORT),
90+
}
6991
}
7092

7193
describe.each(
@@ -89,6 +111,32 @@ describe.each(
89111
return
90112
}
91113

114+
let connectedCollector: Collector
115+
116+
async function expectAppRouteTrace(pathname: string) {
117+
expect((await next.fetch(pathname)).status).toBe(200)
118+
await expectTrace(getCollector(), [
119+
{
120+
name: 'GET /api/app/[param]/data',
121+
attributes: {
122+
'http.target': pathname,
123+
'next.span_type': 'BaseServer.handleRequest',
124+
},
125+
},
126+
])
127+
}
128+
129+
it('collects a trace before the per-test reset', async () => {
130+
connectedCollector = getCollector()
131+
await expectAppRouteTrace('/api/app/param/data')
132+
})
133+
134+
it('keeps the collector connected across per-test resets', async () => {
135+
expect(getCollector()).toBe(connectedCollector)
136+
expect(getCollector().getSpans()).toEqual([])
137+
await expectAppRouteTrace('/api/app/param/data')
138+
})
139+
92140
// Edge runtime is currently not implemented in custom-entrypoint-server.ts
93141
const itEdge = useDirectEntrypointHandler ? it.skip : it
94142

@@ -1945,6 +1993,7 @@ describe.each(
19451993
const { next, skipped } = nextTestSetup({
19461994
files: __dirname,
19471995
skipDeployment: true,
1996+
skipStart: true,
19481997
dependencies: require('./package.json').dependencies,
19491998
env: {
19501999
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
@@ -1957,16 +2006,7 @@ describe.each(
19572006
return
19582007
}
19592008

1960-
let collector: Collector | undefined
1961-
1962-
beforeEach(async () => {
1963-
collector = await connectCollector({ port: COLLECTOR_PORT })
1964-
})
1965-
1966-
afterEach(async () => {
1967-
await collector?.shutdown()
1968-
collector = undefined
1969-
})
2009+
const getCollector = setupCollector(next, COLLECTOR_PORT)
19702010

19712011
// Regression for https://github.com/vercel/otel/issues/107.
19722012
it('all spans (including verbose) inherit traceId from incoming traceparent header', async () => {
@@ -1979,7 +2019,7 @@ describe.each(
19792019

19802020
let spans: SavedSpan[] = []
19812021
await retry(async () => {
1982-
const all = collector?.getSpans() ?? []
2022+
const all = getCollector().getSpans()
19832023
const root = all.find(
19842024
(s) =>
19852025
s.attributes?.['next.span_type'] === 'BaseServer.handleRequest' &&
@@ -2012,6 +2052,7 @@ describe('opentelemetry with disabled fetch tracing', () => {
20122052
const { next, skipped } = nextTestSetup({
20132053
files: __dirname,
20142054
skipDeployment: true,
2055+
skipStart: true,
20152056
dependencies: require('./package.json').dependencies,
20162057
env: {
20172058
NEXT_OTEL_FETCH_DISABLED: '1',
@@ -2023,20 +2064,7 @@ describe('opentelemetry with disabled fetch tracing', () => {
20232064
return
20242065
}
20252066

2026-
let collector: Collector
2027-
2028-
function getCollector(): Collector {
2029-
return collector
2030-
}
2031-
2032-
beforeEach(async () => {
2033-
collector = await connectCollector({ port: COLLECTOR_PORT })
2034-
})
2035-
2036-
afterEach(async () => {
2037-
await collector.shutdown()
2038-
await new Promise((r) => setTimeout(r, 1000))
2039-
})
2067+
const getCollector = setupCollector(next, COLLECTOR_PORT)
20402068
;(process.env.__NEXT_CACHE_COMPONENTS ? describe.skip : describe)(
20412069
'root context',
20422070
() => {
@@ -2094,6 +2122,7 @@ describe('opentelemetry with custom server', () => {
20942122
const { next, skipped } = nextTestSetup({
20952123
files: __dirname,
20962124
skipDeployment: true,
2125+
skipStart: true,
20972126
dependencies: require('./package.json').dependencies,
20982127
startCommand: 'pnpm start',
20992128
packageJson: {
@@ -2113,19 +2142,7 @@ describe('opentelemetry with custom server', () => {
21132142
return
21142143
}
21152144

2116-
let collector: Collector
2117-
2118-
function getCollector(): Collector {
2119-
return collector
2120-
}
2121-
2122-
beforeEach(async () => {
2123-
collector = await connectCollector({ port: COLLECTOR_PORT })
2124-
})
2125-
2126-
afterEach(async () => {
2127-
await collector.shutdown()
2128-
})
2145+
const getCollector = setupCollector(next, COLLECTOR_PORT)
21292146

21302147
it('should set attributes correctly on handleRequest span', async () => {
21312148
await next.fetch('/app/param/rsc-fetch')
@@ -2272,6 +2289,7 @@ if (isNextStart) {
22722289
const { next, skipped } = nextTestSetup({
22732290
files: __dirname,
22742291
skipDeployment: true,
2292+
skipStart: true,
22752293
dependencies: require('./package.json').dependencies,
22762294
startCommand: 'pnpm start-entrypoint',
22772295
packageJson: {
@@ -2291,19 +2309,7 @@ if (isNextStart) {
22912309
return
22922310
}
22932311

2294-
let collector: Collector
2295-
2296-
function getCollector(): Collector {
2297-
return collector
2298-
}
2299-
2300-
beforeEach(async () => {
2301-
collector = await connectCollector({ port: COLLECTOR_PORT })
2302-
})
2303-
2304-
afterEach(async () => {
2305-
await collector.shutdown()
2306-
})
2312+
const getCollector = setupCollector(next, COLLECTOR_PORT)
23072313

23082314
const directEntrypointCases = [
23092315
{ pathname: '/app/param/rsc-fetch', route: '/app/[param]/rsc-fetch' },
@@ -2327,7 +2333,7 @@ if (isNextStart) {
23272333

23282334
await retry(
23292335
async () => {
2330-
const spans = collector.getSpans()
2336+
const spans = getCollector().getSpans()
23312337
const handleRequestSpan = spans.find((span) => {
23322338
if (
23332339
span.attributes?.['next.span_type'] !==

0 commit comments

Comments
 (0)