Tracer Version(s)
5.104.0
Node.js Version(s)
20.20.0
Bug Report
Summary
In our app we register dd-trace as the global OpenTelemetry TracerProvider and enable the Vercel AI SDK (ai) integration. With that setup, our AI SDK telemetry crashes whenever an AI SDK operation records an error on its span:
TypeError: Cannot read private member #statusCode from an object whose class did not declare it
After digging in, I found the crash actually originates inside dd-trace's own ai instrumentation, not in the AI SDK. The instrumentation hands the AI SDK a prototype clone of the span (Object.create(span)), but dd-trace's OTel-bridge span stores its status in a private class field (#statusCode). Private fields aren't carried over by Object.create, so when the SDK calls setStatus() on the clone it fails the private-field brand check and throws.
What makes this especially painful for us: the AI SDK calls setStatus() only on its error path, so this TypeError is thrown while handling another error and ends up masking the original error. The real failure never surfaces — all we see is the cryptic #statusCode message.
Affected versions
Our configuration
const tracer = require('dd-trace').init({
llmobs: { agentlessEnabled: true, mlApp: 'my-app' },
plugins: false,
})
tracer.use('ai', { enabled: true })
// We register dd-trace as the global OTel TracerProvider
new tracer.TracerProvider().register()
Then we run the Vercel AI SDK with telemetry enabled, passing the dd-trace OTel-bridge tracer:
import { generateText } from 'ai'
import { trace } from '@opentelemetry/api'
await generateText({
model,
messages,
experimental_telemetry: { isEnabled: true, tracer: trace.getTracer('ai') },
})
Minimal reproduction
I was able to isolate the defect down to the interaction between the instrumentation's Object.create(span) and the bridge span's private field, without involving the AI SDK at all:
const tracer = require('dd-trace').init({ /* …as above… */ })
new tracer.TracerProvider().register()
const { trace, SpanStatusCode } = require('@opentelemetry/api')
trace.getTracer('repro').startActiveSpan('repro', (span) => {
const clone = Object.create(span) // what ai.js wrapTracer does
clone.setStatus({ code: SpanStatusCode.ERROR }) // ← throws: Cannot read private member #statusCode
span.end()
})
End-to-end, it fires whenever an ai generateText/streamText operation throws, because the SDK's recordErrorOnSpan calls span.setStatus({ code: SpanStatusCode.ERROR }) on the cloned span.
Representative stack
TypeError: Cannot read private member #statusCode from an object whose class did not declare it at BridgeSpanBase.setStatus (dd-trace/packages/dd-trace/src/opentelemetry/bridge-span-base.js:101) at recordErrorOnSpan (ai/dist/index.js:2061) at recordSpan (ai/dist/index.js:2045) ... (the original error thrown by the AI SDK operation is swallowed here)
Expected vs. actual
Expected: setStatus() records the status on the span, and the AI SDK's original error propagates normally.
Actual: setStatus() throws a TypeError, which propagates instead of the original error (the SDK's recordSpan calls recordErrorOnSpan before it re-throws the original), so the real failure is masked.
Root cause
In packages/datadog-instrumentations/src/ai.js, wrapTracer() wraps the tracer's startActiveSpan and hands the SDK callback a clone of the span:
// packages/datadog-instrumentations/src/ai.js (line 143 in 5.110.0; line 138 in 5.104.0)
const freshSpan = Object.create(span) // TODO: does this cause memory leaks?
shimmer.wrap(freshSpan, 'end', /* … */)
shimmer.wrap(freshSpan, 'setAttributes', /* … */)
shimmer.wrap(freshSpan, 'recordException', /* … */)
return originalCb.call(this, freshSpan)
The span produced by the OTel bridge is a BridgeSpanBase subclass:
// packages/dd-trace/src/opentelemetry/bridge-span-base.js
class BridgeSpanBase {
#statusCode = 0 // line 26 — PRIVATE field
// ...
setStatus (status) { // line 101
this.#statusCode = applyOtelStatus(this._ddSpan, this.#statusCode, status /*, … */)
return this
}
}
Object.create(span) produces an object whose prototype is the real bridge span but which does not carry the #statusCode private-field brand. The other wrapped methods (end, setAttributes, recordException) work on the clone because they read the non-private _ddSpan property (reachable through the prototype chain), but setStatus() reads the true private field #statusCode, which only exists on the original instance — so freshSpan.setStatus() throws the brand-check TypeError.
In other words, the instrumentation's Object.create(span) is incompatible with dd-trace's own OTel-bridge span, which intentionally uses a private field for status (the comment in bridge-span-base.js explains that _ddSpan is deliberately non-private while status tracking is private).
One thing to note: Object.create(span) can't simply be swapped for new Proxy(span, …) either — a Proxy also fails private-field brand checks, so proxy.setStatus() would throw the same error.
Fixes
I see two ways to fix this, and I'm happy to open the PR — just let me know which direction you'd prefer.
###Quick Fix (minimal, targeted)
Forward the one private-field-dependent method, setStatus, to the original span, alongside the existing wraps in wrapTracer. The original span still carries the #statusCode brand, so the brand check passes and status is recorded on the real span:
shimmer.wrap(freshSpan, 'setStatus', function (setStatus) {
return function () {
setStatus.apply(span, arguments) // run on the real span (has the #statusCode brand)
return this // preserve OTel chaining (returns the wrapper span)
}
})
Why it works: the crash happens only because setStatus reads a private field on freshSpan, which the prototype clone doesn't own. Forwarding the call to span runs it on the instance that actually has the field.
Limitation: this treats the symptom, not the cause. It patches the single method that happens to use a private field today. If the OTel bridge span (or any other span implementation someone plugs in) adds another private-field-backed method, the exact same Cannot read private member crash comes back. It also keeps the Object.create(span) clone and its open // TODO: does this cause memory leaks?.
Robust Fix (addresses the root incompatibility)
Stop handing the SDK a prototype clone of a real span. Object.create(span) — and equally new Proxy(span, …) — can't preserve a private-field brand, so it's fundamentally unsafe as a generic span wrapper: it breaks for any span method that reads a private field, not just setStatus/#statusCode.
As far as I can tell, the clone is only actually needed for the AI SDK's shared noopSpan: a fresh object is required there so the wrapped end/setAttributes/recordException can close over the per-invocation ctx without mutating the shared singleton. Real spans returned by startActiveSpan are already a distinct instance per call, so they don't need cloning at all.
Two ways to implement, depending on how general you want to go:
(A) Clone only the noop span; wrap real spans in place. Branch on whether span is a real, per-call span vs. the reused noop (e.g. via isRecording() / presence of the bridge's _ddSpan), and shimmer.wrap the real span's three methods directly — no clone, brand preserved.
(B) Replace the clone with an explicit delegating wrapper (most general). Build a plain wrapper object whose OTel Span methods each forward to span with this === span (so every private-field access lands on the real instance), overriding only end/setAttributes/recordException to publish to the channels and then delegate. The OTel Span interface is small and stable (spanContext, setAttribute(s), addEvent, addLink(s), setStatus, updateName, end, isRecording, recordException), so this stays bounded and works for every span implementation — dd-trace's bridge, a real OTel SDK, anything — regardless of private fields.
Either variant removes the prototype-clone footgun for all current and future private-field methods and resolves the existing // TODO: does this cause memory leaks?. My preference is (B) as the more defensible long-term fix; (A) is the smaller diff if you'd rather stay close to the current structure.
One small caveat to double-check before you post: the representative stack line numbers for ai/dist/index.js (2061/2045) are from your installed [email protected] and are illustrative — if you'd rather not anchor to specific SDK line numbers, you can trim that block to just the error message and the BridgeSpanBase.setStatus frame. Want me to draft the matching PR (code change + a regression test under packages/datadog-plugin-ai/test/) for whichever fix direction you pick?
Reproduction Code
No response
Error Logs
No response
Tracer Config
No response
Operating System
No response
Bundling
No Bundling
Tracer Version(s)
5.104.0
Node.js Version(s)
20.20.0
Bug Report
Summary
In our app we register dd-trace as the global OpenTelemetry TracerProvider and enable the Vercel AI SDK (ai) integration. With that setup, our AI SDK telemetry crashes whenever an AI SDK operation records an error on its span:
TypeError: Cannot read private member #statusCode from an object whose class did not declare itAfter digging in, I found the crash actually originates inside dd-trace's own ai instrumentation, not in the AI SDK. The instrumentation hands the AI SDK a prototype clone of the span (Object.create(span)), but dd-trace's OTel-bridge span stores its status in a private class field (#statusCode). Private fields aren't carried over by Object.create, so when the SDK calls setStatus() on the clone it fails the private-field brand check and throws.
What makes this especially painful for us: the AI SDK calls setStatus() only on its error path, so this TypeError is thrown while handling another error and ends up masking the original error. The real failure never surfaces — all we see is the cryptic #statusCode message.
Affected versions
Our configuration
Then we run the Vercel AI SDK with telemetry enabled, passing the dd-trace OTel-bridge tracer:
Minimal reproduction
I was able to isolate the defect down to the interaction between the instrumentation's Object.create(span) and the bridge span's private field, without involving the AI SDK at all:
End-to-end, it fires whenever an ai
generateText/streamTextoperation throws, because the SDK'srecordErrorOnSpancallsspan.setStatus({ code: SpanStatusCode.ERROR })on the cloned span.Representative stack
TypeError: Cannot read private member #statusCode from an object whose class did not declare it at BridgeSpanBase.setStatus (dd-trace/packages/dd-trace/src/opentelemetry/bridge-span-base.js:101) at recordErrorOnSpan (ai/dist/index.js:2061) at recordSpan (ai/dist/index.js:2045) ... (the original error thrown by the AI SDK operation is swallowed here)Expected vs. actual
Expected:
setStatus()records the status on the span, and the AI SDK's original error propagates normally.Actual:
setStatus()throws aTypeError, which propagates instead of the original error (the SDK's recordSpan callsrecordErrorOnSpanbefore it re-throws the original), so the real failure is masked.Root cause
In
packages/datadog-instrumentations/src/ai.js,wrapTracer()wraps the tracer's startActiveSpan and hands the SDK callback a clone of the span:The span produced by the OTel bridge is a BridgeSpanBase subclass:
Object.create(span) produces an object whose prototype is the real bridge span but which does not carry the #statusCode private-field brand. The other wrapped methods (end, setAttributes, recordException) work on the clone because they read the non-private _ddSpan property (reachable through the prototype chain), but setStatus() reads the true private field #statusCode, which only exists on the original instance — so freshSpan.setStatus() throws the brand-check TypeError.
In other words, the instrumentation's Object.create(span) is incompatible with dd-trace's own OTel-bridge span, which intentionally uses a private field for status (the comment in bridge-span-base.js explains that _ddSpan is deliberately non-private while status tracking is private).
One thing to note: Object.create(span) can't simply be swapped for new Proxy(span, …) either — a Proxy also fails private-field brand checks, so proxy.setStatus() would throw the same error.
Fixes
I see two ways to fix this, and I'm happy to open the PR — just let me know which direction you'd prefer.
###Quick Fix (minimal, targeted)
Forward the one private-field-dependent method, setStatus, to the original span, alongside the existing wraps in wrapTracer. The original span still carries the #statusCode brand, so the brand check passes and status is recorded on the real span:
Why it works: the crash happens only because setStatus reads a private field on freshSpan, which the prototype clone doesn't own. Forwarding the call to span runs it on the instance that actually has the field.
Limitation: this treats the symptom, not the cause. It patches the single method that happens to use a private field today. If the OTel bridge span (or any other span implementation someone plugs in) adds another private-field-backed method, the exact same Cannot read private member crash comes back. It also keeps the Object.create(span) clone and its open // TODO: does this cause memory leaks?.
Robust Fix (addresses the root incompatibility)
Stop handing the SDK a prototype clone of a real span. Object.create(span) — and equally new Proxy(span, …) — can't preserve a private-field brand, so it's fundamentally unsafe as a generic span wrapper: it breaks for any span method that reads a private field, not just setStatus/#statusCode.
As far as I can tell, the clone is only actually needed for the AI SDK's shared noopSpan: a fresh object is required there so the wrapped end/setAttributes/recordException can close over the per-invocation ctx without mutating the shared singleton. Real spans returned by startActiveSpan are already a distinct instance per call, so they don't need cloning at all.
Two ways to implement, depending on how general you want to go:
(A) Clone only the noop span; wrap real spans in place. Branch on whether span is a real, per-call span vs. the reused noop (e.g. via isRecording() / presence of the bridge's _ddSpan), and shimmer.wrap the real span's three methods directly — no clone, brand preserved.
(B) Replace the clone with an explicit delegating wrapper (most general). Build a plain wrapper object whose OTel Span methods each forward to span with this === span (so every private-field access lands on the real instance), overriding only end/setAttributes/recordException to publish to the channels and then delegate. The OTel Span interface is small and stable (spanContext, setAttribute(s), addEvent, addLink(s), setStatus, updateName, end, isRecording, recordException), so this stays bounded and works for every span implementation — dd-trace's bridge, a real OTel SDK, anything — regardless of private fields.
Either variant removes the prototype-clone footgun for all current and future private-field methods and resolves the existing // TODO: does this cause memory leaks?. My preference is (B) as the more defensible long-term fix; (A) is the smaller diff if you'd rather stay close to the current structure.
One small caveat to double-check before you post: the representative stack line numbers for ai/dist/index.js (2061/2045) are from your installed [email protected] and are illustrative — if you'd rather not anchor to specific SDK line numbers, you can trim that block to just the error message and the BridgeSpanBase.setStatus frame. Want me to draft the matching PR (code change + a regression test under packages/datadog-plugin-ai/test/) for whichever fix direction you pick?
Reproduction Code
No response
Error Logs
No response
Tracer Config
No response
Operating System
No response
Bundling
No Bundling