dd-trace 5.100.0 → 5.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/index.d.ts +14 -0
  2. package/package.json +5 -5
  3. package/packages/datadog-instrumentations/src/cypress.js +5 -3
  4. package/packages/datadog-instrumentations/src/http/client.js +20 -3
  5. package/packages/datadog-instrumentations/src/jest.js +62 -32
  6. package/packages/datadog-instrumentations/src/mocha/common.js +4 -1
  7. package/packages/datadog-instrumentations/src/mocha/main.js +25 -4
  8. package/packages/datadog-instrumentations/src/mocha/worker.js +5 -2
  9. package/packages/datadog-instrumentations/src/otel-sdk-trace.js +11 -6
  10. package/packages/datadog-plugin-bullmq/src/consumer.js +2 -2
  11. package/packages/datadog-plugin-bullmq/src/producer.js +14 -20
  12. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +17 -0
  13. package/packages/datadog-plugin-cypress/src/plugin.js +5 -14
  14. package/packages/datadog-plugin-kafkajs/src/consumer.js +2 -9
  15. package/packages/datadog-plugin-kafkajs/src/producer.js +2 -8
  16. package/packages/dd-trace/src/appsec/reporter.js +4 -1
  17. package/packages/dd-trace/src/ci-visibility/lage.js +2 -1
  18. package/packages/dd-trace/src/ci-visibility/requests/request.js +11 -33
  19. package/packages/dd-trace/src/config/config-types.d.ts +0 -2
  20. package/packages/dd-trace/src/config/index.js +1 -55
  21. package/packages/dd-trace/src/datastreams/checkpointer.js +4 -10
  22. package/packages/dd-trace/src/datastreams/encoding.js +39 -28
  23. package/packages/dd-trace/src/datastreams/pathway.js +29 -26
  24. package/packages/dd-trace/src/datastreams/processor.js +17 -15
  25. package/packages/dd-trace/src/datastreams/size.js +6 -2
  26. package/packages/dd-trace/src/debugger/config.js +5 -2
  27. package/packages/dd-trace/src/debugger/devtools_client/index.js +2 -5
  28. package/packages/dd-trace/src/debugger/devtools_client/send.js +2 -1
  29. package/packages/dd-trace/src/dogstatsd.js +10 -7
  30. package/packages/dd-trace/src/encode/0.4.js +2 -2
  31. package/packages/dd-trace/src/encode/0.5.js +2 -2
  32. package/packages/dd-trace/src/encode/agentless-json.js +2 -2
  33. package/packages/dd-trace/src/encode/tags-processors.js +2 -27
  34. package/packages/dd-trace/src/exporters/common/request.js +22 -11
  35. package/packages/dd-trace/src/exporters/common/retry.js +104 -0
  36. package/packages/dd-trace/src/git_metadata.js +66 -0
  37. package/packages/dd-trace/src/git_metadata_tagger.js +13 -5
  38. package/packages/dd-trace/src/id.js +15 -26
  39. package/packages/dd-trace/src/llmobs/constants/tags.js +2 -0
  40. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +27 -16
  41. package/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +3 -0
  42. package/packages/dd-trace/src/llmobs/plugins/genai/util.js +30 -13
  43. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +20 -50
  44. package/packages/dd-trace/src/llmobs/sdk.js +5 -1
  45. package/packages/dd-trace/src/llmobs/span_processor.js +28 -2
  46. package/packages/dd-trace/src/llmobs/tagger.js +42 -0
  47. package/packages/dd-trace/src/llmobs/telemetry.js +29 -0
  48. package/packages/dd-trace/src/llmobs/util.js +80 -5
  49. package/packages/dd-trace/src/opentelemetry/active-span-proxy.js +42 -0
  50. package/packages/dd-trace/src/opentelemetry/bridge-span-base.js +106 -0
  51. package/packages/dd-trace/src/opentelemetry/context_manager.js +11 -2
  52. package/packages/dd-trace/src/opentelemetry/span-helpers.js +188 -50
  53. package/packages/dd-trace/src/opentelemetry/span.js +42 -80
  54. package/packages/dd-trace/src/opentracing/propagation/text_map.js +65 -27
  55. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +58 -22
  56. package/packages/dd-trace/src/opentracing/span.js +56 -48
  57. package/packages/dd-trace/src/opentracing/span_context.js +1 -0
  58. package/packages/dd-trace/src/priority_sampler.js +6 -4
  59. package/packages/dd-trace/src/profiling/config.js +5 -4
  60. package/packages/dd-trace/src/remote_config/index.js +5 -3
  61. package/packages/dd-trace/src/span_format.js +52 -5
  62. package/packages/dd-trace/src/span_processor.js +0 -4
  63. package/packages/dd-trace/src/spanleak.js +0 -1
  64. package/packages/dd-trace/src/util.js +17 -0
@@ -237,15 +237,23 @@ function formatContentObject (content) {
237
237
  }
238
238
  }
239
239
 
240
- // Check for function calls
241
- const functionCalls = parts.filter(part => part.functionCall)
242
- if (functionCalls.length > 0) {
240
+ // Two filter passes over `parts` collapse to a single walk. Most parts are
241
+ // text-only so neither bucket is allocated unless a matching part appears.
242
+ let functionCalls
243
+ let functionResponses
244
+ for (const part of parts) {
245
+ if (part.functionCall) {
246
+ (functionCalls ??= []).push(part)
247
+ } else if (part.functionResponse) {
248
+ (functionResponses ??= []).push(part)
249
+ }
250
+ }
251
+
252
+ if (functionCalls) {
243
253
  return formatFunctionCallMessage(parts, functionCalls, role)
244
254
  }
245
255
 
246
- // Check for function responses
247
- const functionResponses = parts.filter(part => part.functionResponse)
248
- if (functionResponses.length > 0) {
256
+ if (functionResponses) {
249
257
  return formatFunctionResponseMessage(functionResponses, role)
250
258
  }
251
259
 
@@ -326,15 +334,26 @@ function formatNonStreamingCandidate (candidate) {
326
334
 
327
335
  const { parts } = content
328
336
 
329
- // Check for function calls
330
- const functionCalls = parts.filter(part => part.functionCall)
331
- if (functionCalls.length > 0) {
337
+ // One walk replaces three (`filter` + two `find`); priority order is
338
+ // functionCall > executableCode > codeExecutionResult, same as before.
339
+ let functionCalls
340
+ let executableCode
341
+ let codeExecutionResult
342
+ for (const part of parts) {
343
+ if (part.functionCall) {
344
+ (functionCalls ??= []).push(part)
345
+ } else if (!executableCode && part.executableCode) {
346
+ executableCode = part
347
+ } else if (!codeExecutionResult && part.codeExecutionResult) {
348
+ codeExecutionResult = part
349
+ }
350
+ }
351
+
352
+ if (functionCalls) {
332
353
  messages.push(formatFunctionCallMessage(parts, functionCalls, ROLES.ASSISTANT))
333
354
  return messages
334
355
  }
335
356
 
336
- // Check for executable code
337
- const executableCode = parts.find(part => part.executableCode)
338
357
  if (executableCode) {
339
358
  messages.push({
340
359
  role: ROLES.ASSISTANT,
@@ -346,8 +365,6 @@ function formatNonStreamingCandidate (candidate) {
346
365
  return messages
347
366
  }
348
367
 
349
- // Check for code execution result
350
- const codeExecutionResult = parts.find(part => part.codeExecutionResult)
351
368
  if (codeExecutionResult) {
352
369
  messages.push({
353
370
  role: ROLES.ASSISTANT,
@@ -7,6 +7,7 @@ const {
7
7
  INSTRUMENTATION_METHOD_AUTO,
8
8
  UNKNOWN_MODEL_PROVIDER,
9
9
  } = require('../../constants/tags')
10
+ const { safeJsonParse } = require('../../util')
10
11
  const {
11
12
  extractChatTemplateFromInstructions,
12
13
  normalizePromptVariables,
@@ -183,13 +184,12 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
183
184
  _tagChatCompletion (span, inputs, response, error) {
184
185
  const { messages, model, ...parameters } = inputs
185
186
 
186
- const metadata = Object.entries(parameters).reduce((obj, [key, value]) => {
187
- if (!['tools', 'functions'].includes(key)) {
188
- obj[key] = value
187
+ const metadata = {}
188
+ for (const key of Object.keys(parameters)) {
189
+ if (key !== 'tools' && key !== 'functions') {
190
+ metadata[key] = parameters[key]
189
191
  }
190
-
191
- return obj
192
- }, {})
192
+ }
193
193
 
194
194
  this._tagger.tagMetadata(span, metadata)
195
195
 
@@ -213,14 +213,14 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
213
213
  if (message.function_call) {
214
214
  const functionCallInfo = {
215
215
  name: message.function_call.name,
216
- arguments: JSON.parse(message.function_call.arguments),
216
+ arguments: safeJsonParse(message.function_call.arguments),
217
217
  }
218
218
  outputMessages.push({ content, role, toolCalls: [functionCallInfo] })
219
219
  } else if (message.tool_calls) {
220
220
  const toolCallsInfo = []
221
221
  for (const toolCall of message.tool_calls) {
222
222
  const toolCallInfo = {
223
- arguments: JSON.parse(toolCall.function.arguments),
223
+ arguments: safeJsonParse(toolCall.function.arguments),
224
224
  name: toolCall.function.name,
225
225
  toolId: toolCall.id,
226
226
  type: toolCall.type,
@@ -277,22 +277,12 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
277
277
  inputMessages.push({ role, content })
278
278
  }
279
279
  } else if (item.type === 'function_call') {
280
- // Function call: convert to message with tool_calls
281
- // Parse arguments if it's a JSON string
282
- let parsedArgs = item.arguments
283
- if (typeof parsedArgs === 'string') {
284
- try {
285
- parsedArgs = JSON.parse(parsedArgs)
286
- } catch {
287
- parsedArgs = {}
288
- }
289
- }
290
280
  inputMessages.push({
291
281
  role: 'assistant',
292
282
  toolCalls: [{
293
283
  toolId: item.call_id,
294
284
  name: item.name,
295
- arguments: parsedArgs,
285
+ arguments: safeJsonParse(item.arguments, {}),
296
286
  type: item.type,
297
287
  }],
298
288
  })
@@ -317,12 +307,12 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
317
307
  inputMessages.push({ role: 'user', content: input })
318
308
  }
319
309
 
320
- const inputMetadata = Object.entries(parameters).reduce((obj, [key, value]) => {
310
+ const inputMetadata = {}
311
+ for (const key of Object.keys(parameters)) {
321
312
  if (allowedParamKeys.has(key)) {
322
- obj[key] = value
313
+ inputMetadata[key] = parameters[key]
323
314
  }
324
- return obj
325
- }, {})
315
+ }
326
316
 
327
317
  this._tagger.tagMetadata(span, inputMetadata)
328
318
 
@@ -354,21 +344,12 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
354
344
  })
355
345
  } else if (item.type === 'function_call') {
356
346
  // Handle function_call type (responses API tool calls)
357
- let args = item.arguments
358
- // Parse arguments if it's a JSON string
359
- if (typeof args === 'string') {
360
- try {
361
- args = JSON.parse(args)
362
- } catch {
363
- args = {}
364
- }
365
- }
366
347
  outputMessages.push({
367
348
  role: 'assistant',
368
349
  toolCalls: [{
369
350
  toolId: item.call_id,
370
351
  name: item.name,
371
- arguments: args,
352
+ arguments: safeJsonParse(item.arguments, {}),
372
353
  type: item.type,
373
354
  }],
374
355
  })
@@ -390,23 +371,12 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
390
371
 
391
372
  // Extract tool calls if present in message.tool_calls
392
373
  if (Array.isArray(item.tool_calls)) {
393
- outputMsg.toolCalls = item.tool_calls.map(tc => {
394
- let args = tc.function?.arguments || tc.arguments
395
- // Parse arguments if it's a JSON string
396
- if (typeof args === 'string') {
397
- try {
398
- args = JSON.parse(args)
399
- } catch {
400
- args = {}
401
- }
402
- }
403
- return {
404
- toolId: tc.id,
405
- name: tc.function?.name || tc.name,
406
- arguments: args,
407
- type: tc.type || 'function_call',
408
- }
409
- })
374
+ outputMsg.toolCalls = item.tool_calls.map(tc => ({
375
+ toolId: tc.id,
376
+ name: tc.function?.name || tc.name,
377
+ arguments: safeJsonParse(tc.function?.arguments || tc.arguments, {}),
378
+ type: tc.type || 'function_call',
379
+ }))
410
380
  }
411
381
 
412
382
  outputMessages.push(outputMsg)
@@ -251,7 +251,7 @@ class LLMObs extends NoopLLMObs {
251
251
  throw new Error('LLMObs span must have a span kind specified')
252
252
  }
253
253
 
254
- const { inputData, outputData, metadata, metrics, tags, prompt } = options
254
+ const { inputData, outputData, metadata, metrics, tags, prompt, costTags } = options
255
255
 
256
256
  if (inputData || outputData) {
257
257
  if (spanKind === 'llm') {
@@ -271,9 +271,13 @@ class LLMObs extends NoopLLMObs {
271
271
  if (metrics) {
272
272
  this._tagger.tagMetrics(span, metrics)
273
273
  }
274
+ // Apply tags before costTags so costTags can reference tags from the same annotation.
274
275
  if (tags) {
275
276
  this._tagger.tagSpanTags(span, tags)
276
277
  }
278
+ if (costTags != null) {
279
+ this._tagger.tagCostTags(span, costTags, 'annotate')
280
+ }
277
281
  if (prompt) {
278
282
  this._tagger.tagPrompt(span, prompt)
279
283
  }
@@ -14,6 +14,8 @@ const {
14
14
  MODEL_NAME,
15
15
  MODEL_PROVIDER,
16
16
  METADATA,
17
+ COST_TAGS,
18
+ TOOL_DEFINITIONS,
17
19
  INPUT_MESSAGES,
18
20
  INPUT_VALUE,
19
21
  INTEGRATION,
@@ -130,8 +132,20 @@ class LLMObsSpanProcessor {
130
132
  meta.model_provider = (mlObsTags[MODEL_PROVIDER] || 'custom').toLowerCase()
131
133
  }
132
134
 
133
- if (mlObsTags[METADATA]) {
134
- this.#addObject(mlObsTags[METADATA], meta.metadata = {})
135
+ if (mlObsTags[METADATA] || mlObsTags[COST_TAGS]) {
136
+ const metadata = {}
137
+ if (mlObsTags[METADATA]) this.#addObject(mlObsTags[METADATA], metadata)
138
+ // Only seed `metadata._dd` when there's something to put in it (currently cost_tags). Mirrors
139
+ // dd-trace-py and the cross-language wire format enforced by system-tests — metadata-only
140
+ // spans must not carry an empty `_dd: {}` block.
141
+ if (mlObsTags[COST_TAGS]) {
142
+ this.#getDdMetadata(metadata).cost_tags = mlObsTags[COST_TAGS]
143
+ }
144
+ meta.metadata = metadata
145
+ }
146
+
147
+ if (mlObsTags[TOOL_DEFINITIONS]) {
148
+ this.#addObject(mlObsTags[TOOL_DEFINITIONS], meta.tool_definitions = [])
135
149
  }
136
150
 
137
151
  if (spanKind === 'llm' && mlObsTags[INPUT_MESSAGES]) {
@@ -259,6 +273,18 @@ class LLMObsSpanProcessor {
259
273
  add(obj, carrier)
260
274
  }
261
275
 
276
+ /**
277
+ * Returns `metadata._dd`, normalizing it to a fresh object if missing or invalid.
278
+ * @param {Record<string, unknown>} metadata
279
+ * @returns {Record<string, unknown>}
280
+ */
281
+ #getDdMetadata (metadata) {
282
+ if (!metadata._dd || typeof metadata._dd !== 'object' || Array.isArray(metadata._dd)) {
283
+ metadata._dd = {}
284
+ }
285
+ return metadata._dd
286
+ }
287
+
262
288
  #getTags (span, mlApp, sessionId, error) {
263
289
  let tags = {
264
290
  ...this.#config.parsedDdTags,
@@ -12,7 +12,9 @@ const {
12
12
  INPUT_DOCUMENTS,
13
13
  OUTPUT_VALUE,
14
14
  METADATA,
15
+ COST_TAGS,
15
16
  METRICS,
17
+ TOOL_DEFINITIONS,
16
18
  PARENT_ID_KEY,
17
19
  INPUT_MESSAGES,
18
20
  OUTPUT_MESSAGES,
@@ -41,6 +43,7 @@ const {
41
43
  INSTRUMENTATION_METHOD_ANNOTATED,
42
44
  } = require('./constants/tags')
43
45
  const { storage } = require('./storage')
46
+ const { validateCostTags } = require('./util')
44
47
 
45
48
  // global registry of LLMObs spans
46
49
  // maps LLMObs spans to their annotations
@@ -120,6 +123,11 @@ class LLMObsTagger {
120
123
  const tags = annotationContext?.tags
121
124
  if (tags) this.tagSpanTags(span, tags)
122
125
 
126
+ // apply after tags so only keys present at span start are accepted.
127
+ if (annotationContext?.costTags != null) {
128
+ this.tagCostTags(span, annotationContext.costTags, 'annotation_context')
129
+ }
130
+
123
131
  // apply annotation context name
124
132
  const annotationContextName = annotationContext?.name
125
133
  if (annotationContextName) this._setTag(span, NAME, annotationContextName)
@@ -159,6 +167,14 @@ class LLMObsTagger {
159
167
  this.#tagText(span, outputData, OUTPUT_VALUE)
160
168
  }
161
169
 
170
+ tagToolDefinitions (span, toolDefinitions) {
171
+ if (Array.isArray(toolDefinitions) && toolDefinitions.length > 0) {
172
+ this._setTag(span, TOOL_DEFINITIONS, toolDefinitions)
173
+ } else {
174
+ this.#handleFailure('Tool definitions must be a non-empty array.', 'invalid_tool_definitions')
175
+ }
176
+ }
177
+
162
178
  tagMetadata (span, metadata) {
163
179
  const existingMetadata = registry.get(span)?.[METADATA]
164
180
  if (existingMetadata) {
@@ -225,6 +241,32 @@ class LLMObsTagger {
225
241
  }
226
242
  }
227
243
 
244
+ /**
245
+ * Validates and tags cost tag keys on an LLMObs span. Cost tag references are validated against
246
+ * the span's already-applied tags, which are read from the registry.
247
+ * @param {import('../opentracing/span')} span
248
+ * @param {unknown} costTags Raw user-provided cost tags; validated here.
249
+ * @param {'annotate' | 'annotation_context'} source
250
+ */
251
+ tagCostTags (span, costTags, source) {
252
+ const spanTags = registry.get(span)?.[TAGS] || {}
253
+ const validatedCostTags = validateCostTags(span, costTags, source, spanTags)
254
+ if (!validatedCostTags.length) return
255
+
256
+ // Might consider switching to a `Set` if per-span cost tag cardinality grows large enough that
257
+ // this `.includes`/`.push` merge becomes a hot spot
258
+ const currentCostTags = registry.get(span)?.[COST_TAGS]
259
+ if (currentCostTags) {
260
+ for (const costTag of validatedCostTags) {
261
+ if (!currentCostTags.includes(costTag)) {
262
+ currentCostTags.push(costTag)
263
+ }
264
+ }
265
+ } else {
266
+ this._setTag(span, COST_TAGS, validatedCostTags)
267
+ }
268
+ }
269
+
228
270
  /**
229
271
  * Tags a prompt on an LLMObs span.
230
272
  * @param {import('../opentracing/span')} span
@@ -6,6 +6,7 @@ const telemetryMetrics = require('../telemetry/metrics')
6
6
  const {
7
7
  SPAN_KIND,
8
8
  MODEL_PROVIDER,
9
+ ML_APP,
9
10
  PARENT_ID_KEY,
10
11
  SESSION_ID,
11
12
  ROOT_PARENT_ID,
@@ -123,6 +124,32 @@ function recordLLMObsAnnotate (span, err, value = 1) {
123
124
  llmobsMetrics.count('annotations', tags).inc(value)
124
125
  }
125
126
 
127
+ function recordCostTagsAnnotated (span, source, value = 1) {
128
+ const mlObsTags = LLMObsTagger.tagMap.get(span) || {}
129
+ const tags = {
130
+ span_kind: mlObsTags[SPAN_KIND] || 'N/A',
131
+ source,
132
+ ml_app: mlObsTags[ML_APP] || 'N/A',
133
+ model_provider: mlObsTags[MODEL_PROVIDER] || 'N/A',
134
+ }
135
+
136
+ llmobsMetrics.count('cost_tags.annotated', tags).inc(value)
137
+ }
138
+
139
+ function recordCostTagsSubmitted (span, count, source, state, reason = 'none') {
140
+ const mlObsTags = LLMObsTagger.tagMap.get(span) || {}
141
+ const tags = {
142
+ span_kind: mlObsTags[SPAN_KIND] || 'N/A',
143
+ source,
144
+ ml_app: mlObsTags[ML_APP] || 'N/A',
145
+ model_provider: mlObsTags[MODEL_PROVIDER] || 'N/A',
146
+ state,
147
+ reason,
148
+ }
149
+
150
+ llmobsMetrics.count('cost_tags.submitted', tags).inc(count)
151
+ }
152
+
126
153
  function recordUserFlush (err, value = 1) {
127
154
  const tags = { error: Number(!!err) }
128
155
  if (err) tags.error_type = err
@@ -167,6 +194,8 @@ module.exports = {
167
194
  recordLLMObsSpanSize,
168
195
  recordDroppedPayload,
169
196
  recordLLMObsAnnotate,
197
+ recordCostTagsAnnotated,
198
+ recordCostTagsSubmitted,
170
199
  recordUserFlush,
171
200
  recordExportSpan,
172
201
  recordSubmitEvaluation,
@@ -1,14 +1,25 @@
1
1
  'use strict'
2
2
 
3
+ const log = require('../log')
3
4
  const { SPAN_KINDS } = require('./constants/tags')
4
5
 
6
+ // LLM I/O is overwhelmingly ASCII (English prompts and code). Walk once
7
+ // looking for the first non-ASCII char; if there is none, hand the input
8
+ // straight back. Otherwise pick up the slow path from the byte that needed
9
+ // escaping. ~5x faster on typical prompt strings than the per-char `+=`
10
+ // loop the function used to do unconditionally.
5
11
  function encodeUnicode (str = '') {
6
- let result = ''
7
- for (let i = 0; i < str.length; i++) {
8
- const code = str.charCodeAt(i)
9
- result += code > 127 ? String.raw`\u${code.toString(16).padStart(4, '0')}` : str[i]
12
+ for (let index = 0; index < str.length; index++) {
13
+ if (str.charCodeAt(index) > 127) {
14
+ let result = str.slice(0, index)
15
+ for (; index < str.length; index++) {
16
+ const code = str.charCodeAt(index)
17
+ result += code > 127 ? String.raw`\u${code.toString(16).padStart(4, '0')}` : str[index]
18
+ }
19
+ return result
20
+ }
10
21
  }
11
- return result
22
+ return str
12
23
  }
13
24
 
14
25
  function validateKind (kind) {
@@ -22,6 +33,57 @@ function validateKind (kind) {
22
33
  return kind
23
34
  }
24
35
 
36
+ /**
37
+ * Validates cost tag keys and records telemetry for the annotation source.
38
+ * @param {import('../opentracing/span')} span
39
+ * @param {unknown} costTags
40
+ * @param {string} source
41
+ * @param {Record<string, unknown>} spanTags
42
+ * @returns {string[]}
43
+ */
44
+ function validateCostTags (span, costTags, source, spanTags) {
45
+ // Lazy-required to avoid the `index.js -> telemetry -> tagger -> util` module cycle.
46
+ const telemetry = require('./telemetry')
47
+
48
+ telemetry.recordCostTagsAnnotated(span, source)
49
+
50
+ if (!Array.isArray(costTags)) {
51
+ log.warn('costTags must be an array of strings. Ignoring value.')
52
+ telemetry.recordCostTagsSubmitted(span, 1, source, 'error', 'non_list')
53
+ return []
54
+ }
55
+
56
+ const validatedCostTags = new Set()
57
+ let nonStringEntries = 0
58
+ let missingSpanTags = 0
59
+
60
+ for (const costTag of costTags) {
61
+ if (typeof costTag !== 'string') {
62
+ log.warn('costTags entries must be strings. Skipping entry %s.', costTag)
63
+ nonStringEntries++
64
+ continue
65
+ }
66
+ if (!Object.hasOwn(spanTags, costTag)) {
67
+ log.warn('costTags entry "%s" must reference a key present in span tags. Skipping entry.', costTag)
68
+ missingSpanTags++
69
+ continue
70
+ }
71
+ validatedCostTags.add(costTag)
72
+ }
73
+
74
+ if (nonStringEntries) {
75
+ telemetry.recordCostTagsSubmitted(span, nonStringEntries, source, 'error', 'non_string_entry')
76
+ }
77
+ if (missingSpanTags) {
78
+ telemetry.recordCostTagsSubmitted(span, missingSpanTags, source, 'error', 'missing_span_tag')
79
+ }
80
+ if (validatedCostTags.size) {
81
+ telemetry.recordCostTagsSubmitted(span, validatedCostTags.size, source, 'success')
82
+ }
83
+
84
+ return [...validatedCostTags]
85
+ }
86
+
25
87
  // extracts the argument names from a function string
26
88
  function parseArgumentNames (str) {
27
89
  const result = []
@@ -174,9 +236,22 @@ function spanHasError (span) {
174
236
  return !!(tags.error || tags['error.type'])
175
237
  }
176
238
 
239
+ // LLM SDKs stream tool-call argument JSON across SSE chunks; a malformed
240
+ // accumulation would otherwise throw straight into the chunk subscriber.
241
+ function safeJsonParse (value, fallback) {
242
+ if (typeof value !== 'string') return value
243
+ try {
244
+ return JSON.parse(value)
245
+ } catch {
246
+ return fallback === undefined ? value : fallback
247
+ }
248
+ }
249
+
177
250
  module.exports = {
178
251
  encodeUnicode,
252
+ validateCostTags,
179
253
  validateKind,
180
254
  getFunctionArguments,
255
+ safeJsonParse,
181
256
  spanHasError,
182
257
  }
@@ -0,0 +1,42 @@
1
+ 'use strict'
2
+
3
+ const BridgeSpanBase = require('./bridge-span-base')
4
+ const { setOtelResource } = require('./span-helpers')
5
+
6
+ /**
7
+ * OTel `Span`-compatible proxy around an already-active Datadog span.
8
+ *
9
+ * Makes `trace.getActiveSpan()` forward attribute/link/event/status/exception writes onto
10
+ * the Datadog span. `end()` is intentionally a no-op: the span's lifecycle belongs to
11
+ * whoever created it. Mutation methods all bail out once the underlying Datadog span has
12
+ * finished (gated inside the helpers), matching OTel `Span` semantics.
13
+ */
14
+ class ActiveSpanProxy extends BridgeSpanBase {
15
+ /** @type {import('./span_context')} */
16
+ #otelSpanContext
17
+
18
+ /**
19
+ * @param {import('../opentracing/span')} ddSpan
20
+ * @param {import('./span_context')} otelSpanContext
21
+ */
22
+ constructor (ddSpan, otelSpanContext) {
23
+ super(ddSpan)
24
+ this.#otelSpanContext = otelSpanContext
25
+ }
26
+
27
+ spanContext () {
28
+ return this.#otelSpanContext
29
+ }
30
+
31
+ /**
32
+ * @param {string} name
33
+ */
34
+ updateName (name) {
35
+ setOtelResource(this._ddSpan, name)
36
+ return this
37
+ }
38
+
39
+ end () {}
40
+ }
41
+
42
+ module.exports = ActiveSpanProxy
@@ -0,0 +1,106 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ addOtelEvent,
5
+ addOtelLink,
6
+ addOtelLinks,
7
+ applyOtelStatus,
8
+ recordException,
9
+ setOtelAttribute,
10
+ setOtelAttributes,
11
+ } = require('./span-helpers')
12
+
13
+ /**
14
+ * Shared base for the OTel-bridge span classes (`Span` and `ActiveSpanProxy`). Subclasses
15
+ * pass the underlying Datadog span to `super(ddSpan)` and provide `spanContext()`, `end()`,
16
+ * and `updateName()`. The writable-span gate lives in the helpers in `span-helpers.js`,
17
+ * so neither bridge can drift from it.
18
+ *
19
+ * `_ddSpan` is left as a `_underscore` field rather than `#private` so the bridge does not
20
+ * expand its published API to expose the underlying DD span. External callers that need
21
+ * the reference (`ContextManager` proxy-cache check, OTLP serialization, tests) reach in
22
+ * via `_ddSpan`, matching the existing convention for "internal, may break".
23
+ */
24
+ class BridgeSpanBase {
25
+ // OTel SpanStatusCode: 0 = UNSET, 1 = OK, 2 = ERROR. Tracked for OK-is-final precedence.
26
+ #statusCode = 0
27
+
28
+ /**
29
+ * @param {import('../opentracing/span')} ddSpan
30
+ */
31
+ constructor (ddSpan) {
32
+ this._ddSpan = ddSpan
33
+ }
34
+
35
+ get ended () {
36
+ return this._ddSpan._duration !== undefined
37
+ }
38
+
39
+ isRecording () {
40
+ return !this.ended
41
+ }
42
+
43
+ /**
44
+ * @param {string} key
45
+ * @param {import('@opentelemetry/api').AttributeValue} value
46
+ */
47
+ setAttribute (key, value) {
48
+ setOtelAttribute(this._ddSpan, key, value)
49
+ return this
50
+ }
51
+
52
+ /**
53
+ * @param {import('@opentelemetry/api').Attributes} attributes
54
+ */
55
+ setAttributes (attributes) {
56
+ setOtelAttributes(this._ddSpan, attributes)
57
+ return this
58
+ }
59
+
60
+ /**
61
+ * @param {string} name
62
+ * @param {import('@opentelemetry/api').Attributes | import('@opentelemetry/api').TimeInput} [attributesOrStartTime]
63
+ * @param {import('@opentelemetry/api').TimeInput} [startTime]
64
+ */
65
+ addEvent (name, attributesOrStartTime, startTime) {
66
+ addOtelEvent(this._ddSpan, name, attributesOrStartTime, startTime)
67
+ return this
68
+ }
69
+
70
+ /**
71
+ * Accepts the OTel `Link` shape and the deprecated `(SpanContext, Attributes)` form.
72
+ *
73
+ * @param {import('@opentelemetry/api').Link | import('@opentelemetry/api').SpanContext} link
74
+ * @param {import('@opentelemetry/api').Attributes} [attrs]
75
+ */
76
+ addLink (link, attrs) {
77
+ addOtelLink(this._ddSpan, link, attrs)
78
+ return this
79
+ }
80
+
81
+ /**
82
+ * @param {import('@opentelemetry/api').Link[]} links
83
+ */
84
+ addLinks (links) {
85
+ addOtelLinks(this._ddSpan, links)
86
+ return this
87
+ }
88
+
89
+ /**
90
+ * @param {import('@opentelemetry/api').Exception} exception
91
+ * @param {import('@opentelemetry/api').TimeInput} [timeInput]
92
+ */
93
+ recordException (exception, timeInput) {
94
+ recordException(this._ddSpan, exception, timeInput)
95
+ }
96
+
97
+ /**
98
+ * @param {import('@opentelemetry/api').SpanStatus} status
99
+ */
100
+ setStatus (status) {
101
+ this.#statusCode = applyOtelStatus(this._ddSpan, this.#statusCode, status)
102
+ return this
103
+ }
104
+ }
105
+
106
+ module.exports = BridgeSpanBase