Tracer Version(s)
5.108.0
Node.js Version(s)
24.10.0
Bug Report
After an instrumented undici HTTP request completes, the finished undici.request span remains the active span in AsyncLocalStorage.
Any later manual tracer.trace(...) (or other work that inherits the active context) becomes a child of that finished undici span, so many unrelated operations share one trace_id for the lifetime of the process.
This is especially visible for long-lived workers that:
- Make undici requests at startup (e.g. health checks), then
- Process many independent units of work with
tracer.trace(...) expecting each to be a new root.
Suspected cause
In packages/datadog-plugin-undici/src/index.js (native DC handlers):
On create:
const store = storage('legacy').getStore()
// ...
storage('legacy').enterWith({ ...store, span })
On trailers / error / CONNECT bodySent:
span.finish()
if (store) {
storage('legacy').enterWith(store)
}
When the request starts with no parent (store is null/undefined), finish does not clear the active store, so the finished undici span stays ambient.
This reproduces with a single undici request (not only parallel Promise.all).
Expected
After an undici request finishes, tracer.scope().active() should be null (or restored to the real parent). Subsequent tracer.trace('work') calls should each get a new root trace_id.
Actual
After undici request(s) finish:
tracer.scope().active() is still the finished undici.request span
- N later
tracer.trace('work') calls → 1 shared trace_id
Reproduction Code
'use strict'
process.env.DD_TRACE_STARTUP_LOGS = 'false'
process.env.DD_TRACE_TELEMETRY_ENABLED = 'false'
process.env.DD_INSTRUMENTATION_TELEMETRY_ENABLED = 'false'
// IMPORTANT: init tracer BEFORE requiring undici
const tracer = require('dd-trace').init({
service: 'undici-als-leak-repro',
env: 'local',
sampleRate: 1,
})
const http = require('http')
const { Agent, request } = require('undici')
function activeName () {
const span = tracer.scope().active()
return span ? span._name : null
}
;(async () => {
const server = http.createServer((req, res) => {
res.writeHead(200)
res.end('ok')
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const { port } = server.address()
const agent = new Agent()
const origin = `http://127.0.0.1:${port}`
// Startup-style health checks (parallel or sequential both leak)
await Promise.all([
request(`${origin}/a`, { dispatcher: agent }).then((r) => r.body.text()),
request(`${origin}/b`, { dispatcher: agent }).then((r) => r.body.text()),
])
console.log('active after undici:', activeName())
// actual: "undici.request"
// expected: null
const ids = []
for (let i = 0; i < 5; i++) {
tracer.trace('independent-work', () => {
ids.push(tracer.scope().active().context().toTraceId())
})
}
console.log({
uniqueTraceIds: new Set(ids).size,
// actual: 1
// expected: 5
ids,
})
server.close()
await agent.close()
process.exit(new Set(ids).size === 1 ? 2 : 0)
})().catch((err) => {
console.error(err)
process.exit(1)
})
npm i dd-trace@5.114.0 undici@6
node repro.js
# exit 2, uniqueTraceIds: 1, active after undici: undici.request
Error Logs
No exception. Silent context leak with incorrect trace parenting in APM.
Tracer Config
require('dd-trace').init({
service: '...',
env: process.env.APP_ENV,
sampleRate: 1,
})
tracer.use('net', { enabled: false })
Operating System
Darwin MPro0142 25.5.0 Darwin Kernel Version 25.5.0
Bundling
No Bundling
Tracer Version(s)
5.108.0
Node.js Version(s)
24.10.0
Bug Report
After an instrumented
undiciHTTP request completes, the finishedundici.requestspan remains the active span in AsyncLocalStorage.Any later manual
tracer.trace(...)(or other work that inherits the active context) becomes a child of that finished undici span, so many unrelated operations share onetrace_idfor the lifetime of the process.This is especially visible for long-lived workers that:
tracer.trace(...)expecting each to be a new root.Suspected cause
In
packages/datadog-plugin-undici/src/index.js(native DC handlers):On create:
On trailers / error / CONNECT bodySent:
When the request starts with no parent (
storeisnull/undefined), finish does not clear the active store, so the finished undici span stays ambient.This reproduces with a single undici request (not only parallel
Promise.all).Expected
After an undici request finishes,
tracer.scope().active()should benull(or restored to the real parent). Subsequenttracer.trace('work')calls should each get a new roottrace_id.Actual
After undici request(s) finish:
tracer.scope().active()is still the finishedundici.requestspantracer.trace('work')calls → 1 sharedtrace_idReproduction Code
npm i dd-trace@5.114.0 undici@6 node repro.js # exit 2, uniqueTraceIds: 1, active after undici: undici.requestError Logs
No exception. Silent context leak with incorrect trace parenting in APM.
Tracer Config
Operating System
Darwin MPro0142 25.5.0 Darwin Kernel Version 25.5.0
Bundling
No Bundling