Skip to content

[BUG]: undici plugin leaves finished span active in ALS after request (shared trace_id leak) #9387

Description

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:

  1. Make undici requests at startup (e.g. health checks), then
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions