Skip to content

Commit 4ccdc93

Browse files
crysmagsjuan-fernandez
authored andcommitted
test(ai): add preserve OpenTelemetry span receiver test (#9309)
Preserve OpenTelemetry arguments
1 parent e47226b commit 4ccdc93

2 files changed

Lines changed: 181 additions & 38 deletions

File tree

packages/datadog-instrumentations/src/ai.js

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,66 @@ function wrapModelWithLifecycle (model) {
116116
}
117117
}
118118

119+
/**
120+
* Wraps an OTel span without changing its method receivers.
121+
*
122+
* OTel spans may use private fields, so methods must run against the original
123+
* span. A per-invocation wrapper also preserves `ctx` without mutating the AI
124+
* SDK's shared no-op span.
125+
*
126+
* @param {import('@opentelemetry/api').Span} span
127+
* @param {object} ctx
128+
* @returns {import('@opentelemetry/api').Span}
129+
*/
130+
function createDelegatingSpan (span, ctx) {
131+
return {
132+
spanContext () {
133+
return span.spanContext.apply(span, arguments)
134+
},
135+
setAttribute () {
136+
span.setAttribute.apply(span, arguments)
137+
return this
138+
},
139+
setAttributes (attributes) {
140+
vercelAiSpanSetAttributesChannel.publish({ ctx, attributes })
141+
span.setAttributes.apply(span, arguments)
142+
return this
143+
},
144+
addEvent () {
145+
span.addEvent.apply(span, arguments)
146+
return this
147+
},
148+
addLink () {
149+
span.addLink.apply(span, arguments)
150+
return this
151+
},
152+
addLinks () {
153+
span.addLinks.apply(span, arguments)
154+
return this
155+
},
156+
setStatus () {
157+
span.setStatus.apply(span, arguments)
158+
return this
159+
},
160+
updateName () {
161+
span.updateName.apply(span, arguments)
162+
return this
163+
},
164+
isRecording () {
165+
return span.isRecording.apply(span, arguments)
166+
},
167+
recordException (exception) {
168+
ctx.error = exception
169+
vercelAiTracingChannel.error.publish(ctx)
170+
return span.recordException.apply(span, arguments)
171+
},
172+
end () {
173+
vercelAiTracingChannel.asyncEnd.publish(ctx)
174+
return span.end.apply(span, arguments)
175+
},
176+
}
177+
}
178+
119179
function wrapTracer (tracer) {
120180
if (tracers.has(tracer)) {
121181
return
@@ -136,40 +196,7 @@ function wrapTracer (tracer) {
136196

137197
args[args.length - 1] = shimmer.wrapFunction(cb, function (originalCb) {
138198
return function (span) {
139-
// A plain delegating wrapper is used instead of Object.create(span) because
140-
// Object.create (and Proxy) cannot cross private-field brand checks — any span
141-
// method that reads a private field (e.g. BridgeSpanBase#statusCode) would throw
142-
// "Cannot read private member" on a prototype clone. Every method here calls
143-
// through to the real span instance so private fields always resolve correctly.
144-
const freshSpan = {
145-
spanContext () { return span.spanContext() },
146-
setAttribute (key, value) { span.setAttribute(key, value); return freshSpan },
147-
setAttributes (attributes) {
148-
vercelAiSpanSetAttributesChannel.publish({ ctx, attributes })
149-
span.setAttributes(attributes)
150-
return freshSpan
151-
},
152-
addEvent (name, attributesOrStartTime, startTime) {
153-
span.addEvent(name, attributesOrStartTime, startTime)
154-
return freshSpan
155-
},
156-
addLink (link, attrs) { span.addLink(link, attrs); return freshSpan },
157-
addLinks (links) { span.addLinks(links); return freshSpan },
158-
setStatus (status) { span.setStatus(status); return freshSpan },
159-
updateName (spanName) { span.updateName(spanName); return freshSpan },
160-
isRecording () { return span.isRecording() },
161-
recordException (exception, timeInput) {
162-
ctx.error = exception
163-
vercelAiTracingChannel.error.publish(ctx)
164-
span.recordException(exception, timeInput)
165-
},
166-
end (...endArgs) {
167-
vercelAiTracingChannel.asyncEnd.publish(ctx)
168-
span.end(...endArgs)
169-
},
170-
}
171-
172-
return originalCb.call(this, freshSpan)
199+
return originalCb.call(this, createDelegatingSpan(span, ctx))
173200
}
174201
})
175202

packages/datadog-plugin-ai/test/index.spec.js

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,9 @@ function withAiSdkOpenAiVersions (versionRange, callback) {
3636
})
3737
}
3838

39-
// making a different reference from the default no-op tracer in the instrumentation
40-
// attempted to use the DD tracer provider, but it double-traces the request
41-
// in practice, there is no need to pass in the DD OTel tracer provider, so this
42-
// case shouldn't be an issue in practice
39+
// Use a distinct tracer without creating additional dd-trace spans in tests that
40+
// only exercise custom-tracer behavior. The error-path regression below uses the
41+
// real dd-trace OTel provider because its private span fields are part of the bug.
4342
const myTracer = {
4443
startActiveSpan () {
4544
const fn = arguments[arguments.length - 1]
@@ -86,6 +85,40 @@ describe('Plugin', () => {
8685
})
8786

8887
describe('patching behavior with experimental_telemetry options', () => {
88+
if (semifies(realVersion, '>=6.0.0')) {
89+
it('preserves the original model error with a dd-trace OTel tracer', async () => {
90+
const originalError = new Error('original model error')
91+
const model = {
92+
specificationVersion: 'v3',
93+
provider: 'test',
94+
modelId: 'test',
95+
supportedUrls: {},
96+
doGenerate () { return Promise.reject(originalError) },
97+
doStream () { return Promise.reject(originalError) },
98+
}
99+
const TracerProvider = require('../../dd-trace/src/opentelemetry/tracer_provider')
100+
const otelTracer = new TracerProvider().getTracer('ai')
101+
const checkTraces = agent.assertSomeTraces(traces => {
102+
const errorSpan = traces.flat().find(span => span.meta?.['error.message'] === originalError.message)
103+
104+
assert.ok(errorSpan, 'Expected a span for the original model error')
105+
assert.strictEqual(errorSpan.error, 1)
106+
})
107+
108+
await assert.rejects(
109+
ai.generateText({
110+
model,
111+
prompt: 'trigger an error',
112+
maxRetries: 0,
113+
experimental_telemetry: { isEnabled: true, tracer: otelTracer },
114+
}),
115+
error => error === originalError
116+
)
117+
118+
await checkTraces
119+
})
120+
}
121+
89122
it('should not error when `isEnabled` is false', async () => {
90123
const experimentalTelemetry = { isEnabled: false }
91124
const result = await ai.generateText({
@@ -223,6 +256,89 @@ describe('Plugin', () => {
223256
assert.ok(result.text, 'Expected result to be truthy')
224257
})
225258

259+
if (semifies(realVersion, '>=6.0.0')) {
260+
it('delegates the complete span interface to the original span', async () => {
261+
const calls = []
262+
const context = { traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 1 }
263+
const originalError = new Error('model error')
264+
class RecordingSpan {
265+
// eslint-disable-next-line no-unused-private-class-members
266+
#statusCode = 0
267+
268+
spanContext () { calls.push(['spanContext', this, []]); return context }
269+
setAttribute (...args) { calls.push(['setAttribute', this, args]); return this }
270+
setAttributes (...args) { calls.push(['setAttributes', this, args]); return this }
271+
addEvent (...args) { calls.push(['addEvent', this, args]); return this }
272+
addLink (...args) { calls.push(['addLink', this, args]); return this }
273+
addLinks (...args) { calls.push(['addLinks', this, args]); return this }
274+
setStatus (...args) { this.#statusCode = args[0].code; calls.push(['setStatus', this, args]); return this }
275+
updateName (...args) { calls.push(['updateName', this, args]); return this }
276+
end (...args) { calls.push(['end', this, args]) }
277+
isRecording () { calls.push(['isRecording', this, []]); return true }
278+
recordException (...args) { calls.push(['recordException', this, args]) }
279+
}
280+
281+
const originalSpan = new RecordingSpan()
282+
const tracer = {
283+
startActiveSpan (...args) {
284+
const fn = args[args.length - 1]
285+
return fn(originalSpan)
286+
},
287+
}
288+
289+
await assert.rejects(
290+
ai.generateText({
291+
model: {
292+
specificationVersion: 'v3',
293+
provider: 'test',
294+
modelId: 'test',
295+
supportedUrls: {},
296+
doGenerate () { return Promise.reject(originalError) },
297+
doStream () { return Promise.reject(originalError) },
298+
},
299+
prompt: 'trigger an error',
300+
maxRetries: 0,
301+
experimental_telemetry: { isEnabled: true, tracer },
302+
}),
303+
error => error === originalError
304+
)
305+
306+
calls.length = 0
307+
const delegatedSpan = tracer.startActiveSpan('delegation-check', span => span)
308+
const attributes = { key: 'value' }
309+
const eventAttributes = { event: 'value' }
310+
const exception = new Error('delegated error')
311+
const link = { context }
312+
const links = [link]
313+
314+
assert.strictEqual(delegatedSpan.spanContext(), context)
315+
assert.strictEqual(delegatedSpan.setAttribute('key', 'value'), delegatedSpan)
316+
assert.strictEqual(delegatedSpan.setAttributes(attributes), delegatedSpan)
317+
assert.strictEqual(delegatedSpan.addEvent('event', eventAttributes, 123), delegatedSpan)
318+
assert.strictEqual(delegatedSpan.addLink(link), delegatedSpan)
319+
assert.strictEqual(delegatedSpan.addLinks(links), delegatedSpan)
320+
assert.strictEqual(delegatedSpan.setStatus({ code: 1 }), delegatedSpan)
321+
assert.strictEqual(delegatedSpan.updateName('renamed'), delegatedSpan)
322+
assert.strictEqual(delegatedSpan.isRecording(), true)
323+
assert.strictEqual(delegatedSpan.recordException(exception, 456), undefined)
324+
delegatedSpan.end(123)
325+
326+
assert.deepStrictEqual(calls, [
327+
['spanContext', originalSpan, []],
328+
['setAttribute', originalSpan, ['key', 'value']],
329+
['setAttributes', originalSpan, [attributes]],
330+
['addEvent', originalSpan, ['event', eventAttributes, 123]],
331+
['addLink', originalSpan, [link]],
332+
['addLinks', originalSpan, [links]],
333+
['setStatus', originalSpan, [{ code: 1 }]],
334+
['updateName', originalSpan, ['renamed']],
335+
['isRecording', originalSpan, []],
336+
['recordException', originalSpan, [exception, 456]],
337+
['end', originalSpan, [123]],
338+
])
339+
})
340+
}
341+
226342
it('should use the passed in `tracer`', async () => {
227343
const checkTraces = agent.assertSomeTraces(traces => {
228344
const generateTextSpan = traces[0][0]

0 commit comments

Comments
 (0)