dd-trace 5.99.1 → 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 (101) hide show
  1. package/LICENSE-3rdparty.csv +0 -1
  2. package/index.d.ts +14 -0
  3. package/package.json +8 -8
  4. package/packages/datadog-instrumentations/src/cucumber.js +69 -5
  5. package/packages/datadog-instrumentations/src/cypress.js +5 -3
  6. package/packages/datadog-instrumentations/src/express.js +3 -2
  7. package/packages/datadog-instrumentations/src/helpers/hooks.js +1 -0
  8. package/packages/datadog-instrumentations/src/hono.js +15 -4
  9. package/packages/datadog-instrumentations/src/http/client.js +20 -3
  10. package/packages/datadog-instrumentations/src/jest.js +146 -90
  11. package/packages/datadog-instrumentations/src/mocha/common.js +4 -1
  12. package/packages/datadog-instrumentations/src/mocha/main.js +43 -26
  13. package/packages/datadog-instrumentations/src/mocha/utils.js +114 -96
  14. package/packages/datadog-instrumentations/src/mocha/worker.js +7 -4
  15. package/packages/datadog-instrumentations/src/otel-sdk-trace.js +11 -6
  16. package/packages/datadog-instrumentations/src/path-to-regexp.js +44 -0
  17. package/packages/datadog-instrumentations/src/playwright.js +108 -18
  18. package/packages/datadog-instrumentations/src/router.js +53 -33
  19. package/packages/datadog-instrumentations/src/vitest.js +76 -30
  20. package/packages/datadog-plugin-aws-sdk/src/base.js +1 -1
  21. package/packages/datadog-plugin-aws-sdk/src/services/dynamodb.js +1 -1
  22. package/packages/datadog-plugin-bullmq/src/consumer.js +5 -4
  23. package/packages/datadog-plugin-bullmq/src/producer.js +37 -29
  24. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +49 -9
  25. package/packages/datadog-plugin-cypress/src/plugin.js +5 -14
  26. package/packages/datadog-plugin-cypress/src/support.js +22 -21
  27. package/packages/datadog-plugin-grpc/src/client.js +1 -1
  28. package/packages/datadog-plugin-grpc/src/server.js +1 -1
  29. package/packages/datadog-plugin-kafkajs/src/consumer.js +2 -9
  30. package/packages/datadog-plugin-kafkajs/src/producer.js +2 -8
  31. package/packages/datadog-plugin-mongodb-core/src/index.js +2 -3
  32. package/packages/datadog-plugin-playwright/src/index.js +6 -0
  33. package/packages/datadog-plugin-router/src/index.js +13 -0
  34. package/packages/dd-trace/index.js +4 -3
  35. package/packages/dd-trace/src/aiguard/sdk.js +2 -2
  36. package/packages/dd-trace/src/appsec/reporter.js +4 -1
  37. package/packages/dd-trace/src/baggage.js +10 -0
  38. package/packages/dd-trace/src/ci-visibility/lage.js +2 -1
  39. package/packages/dd-trace/src/ci-visibility/requests/request.js +11 -33
  40. package/packages/dd-trace/src/config/config-types.d.ts +0 -2
  41. package/packages/dd-trace/src/config/generated-config-types.d.ts +17 -41
  42. package/packages/dd-trace/src/config/index.js +7 -60
  43. package/packages/dd-trace/src/config/normalize-service.js +31 -0
  44. package/packages/dd-trace/src/config/supported-configurations.json +15 -32
  45. package/packages/dd-trace/src/datastreams/checkpointer.js +4 -10
  46. package/packages/dd-trace/src/datastreams/encoding.js +39 -28
  47. package/packages/dd-trace/src/datastreams/pathway.js +29 -26
  48. package/packages/dd-trace/src/datastreams/processor.js +17 -15
  49. package/packages/dd-trace/src/datastreams/size.js +6 -2
  50. package/packages/dd-trace/src/debugger/config.js +6 -3
  51. package/packages/dd-trace/src/debugger/devtools_client/index.js +2 -5
  52. package/packages/dd-trace/src/debugger/devtools_client/send.js +2 -1
  53. package/packages/dd-trace/src/dogstatsd.js +10 -7
  54. package/packages/dd-trace/src/encode/0.4.js +3 -3
  55. package/packages/dd-trace/src/encode/0.5.js +2 -2
  56. package/packages/dd-trace/src/encode/agentless-json.js +2 -2
  57. package/packages/dd-trace/src/encode/tags-processors.js +2 -27
  58. package/packages/dd-trace/src/exporters/common/request.js +22 -11
  59. package/packages/dd-trace/src/exporters/common/retry.js +104 -0
  60. package/packages/dd-trace/src/git_metadata.js +66 -0
  61. package/packages/dd-trace/src/git_metadata_tagger.js +13 -5
  62. package/packages/dd-trace/src/heap_snapshots.js +4 -4
  63. package/packages/dd-trace/src/id.js +15 -26
  64. package/packages/dd-trace/src/llmobs/constants/tags.js +2 -0
  65. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +27 -16
  66. package/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +3 -0
  67. package/packages/dd-trace/src/llmobs/plugins/genai/util.js +30 -13
  68. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +20 -50
  69. package/packages/dd-trace/src/llmobs/sdk.js +5 -1
  70. package/packages/dd-trace/src/llmobs/span_processor.js +28 -2
  71. package/packages/dd-trace/src/llmobs/tagger.js +42 -0
  72. package/packages/dd-trace/src/llmobs/telemetry.js +29 -0
  73. package/packages/dd-trace/src/llmobs/util.js +80 -5
  74. package/packages/dd-trace/src/openfeature/eval-metrics-hook.js +2 -2
  75. package/packages/dd-trace/src/opentelemetry/active-span-proxy.js +42 -0
  76. package/packages/dd-trace/src/opentelemetry/bridge-span-base.js +106 -0
  77. package/packages/dd-trace/src/opentelemetry/context_manager.js +22 -10
  78. package/packages/dd-trace/src/opentelemetry/span-helpers.js +308 -0
  79. package/packages/dd-trace/src/opentelemetry/span.js +42 -108
  80. package/packages/dd-trace/src/opentelemetry/tracer.js +11 -36
  81. package/packages/dd-trace/src/opentracing/propagation/text_map.js +95 -36
  82. package/packages/dd-trace/src/opentracing/propagation/tracestate.js +98 -32
  83. package/packages/dd-trace/src/opentracing/span.js +58 -49
  84. package/packages/dd-trace/src/opentracing/span_context.js +1 -0
  85. package/packages/dd-trace/src/plugins/util/ci.js +119 -32
  86. package/packages/dd-trace/src/plugins/util/test.js +293 -27
  87. package/packages/dd-trace/src/priority_sampler.js +6 -4
  88. package/packages/dd-trace/src/profiling/config.js +5 -4
  89. package/packages/dd-trace/src/profiling/ssi-heuristics.js +2 -2
  90. package/packages/dd-trace/src/propagation-hash/index.js +1 -1
  91. package/packages/dd-trace/src/proxy.js +3 -3
  92. package/packages/dd-trace/src/remote_config/index.js +5 -3
  93. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +1 -1
  94. package/packages/dd-trace/src/span_format.js +52 -5
  95. package/packages/dd-trace/src/span_processor.js +1 -5
  96. package/packages/dd-trace/src/spanleak.js +0 -1
  97. package/packages/dd-trace/src/telemetry/telemetry.js +7 -5
  98. package/packages/dd-trace/src/tracer_metadata.js +1 -1
  99. package/packages/dd-trace/src/util.js +17 -0
  100. package/vendor/dist/path-to-regexp/LICENSE +0 -21
  101. package/vendor/dist/path-to-regexp/index.js +0 -1
@@ -8,11 +8,11 @@ const { threadId } = require('worker_threads')
8
8
  const log = require('./log')
9
9
 
10
10
  async function scheduleSnapshot (config, total) {
11
- if (total > config.heapSnapshot.count) return
11
+ if (total > config.DD_HEAP_SNAPSHOT_COUNT) return
12
12
 
13
- await setTimeout(config.heapSnapshot.interval * 1000, null, { ref: false })
13
+ await setTimeout(config.DD_HEAP_SNAPSHOT_INTERVAL * 1000, null, { ref: false })
14
14
  await clearMemory()
15
- writeHeapSnapshot(getName(config.heapSnapshot.destination))
15
+ writeHeapSnapshot(getName(config.DD_HEAP_SNAPSHOT_DESTINATION))
16
16
  await scheduleSnapshot(config, total + 1)
17
17
  }
18
18
 
@@ -49,7 +49,7 @@ module.exports = {
49
49
  * @param {import('./config/config-base')} config - Tracer configuration
50
50
  */
51
51
  async start (config) {
52
- const destination = config.heapSnapshot.destination
52
+ const destination = config.DD_HEAP_SNAPSHOT_DESTINATION
53
53
 
54
54
  try {
55
55
  await scheduleSnapshot(config, 1)
@@ -7,19 +7,19 @@ const UINT_MAX = 4_294_967_296
7
7
  const data = new Uint8Array(8 * 8192)
8
8
  const zeroId = new Uint8Array(8)
9
9
 
10
- const map = Array.prototype.map
11
- const pad = byte => `${byte < 16 ? '0' : ''}${byte.toString(16)}`
12
-
13
10
  let batch = 0
14
11
 
15
12
  // Internal representation of a trace or span ID.
16
13
  class Identifier {
14
+ /** @type {number[] | Uint8Array} */
15
+ #buffer
16
+
17
17
  /**
18
18
  * @param {string} value
19
19
  * @param {number} [radix]
20
20
  */
21
21
  constructor (value, radix = 16) {
22
- this._buffer = radix === 16
22
+ this.#buffer = radix === 16
23
23
  ? createBuffer(value)
24
24
  : fromString(value, radix)
25
25
  }
@@ -30,32 +30,32 @@ class Identifier {
30
30
  */
31
31
  toString (radix = 16) {
32
32
  return radix === 16
33
- ? toHexString(this._buffer)
34
- : toNumberString(this._buffer, radix)
33
+ ? Buffer.from(this.#buffer).toString('hex')
34
+ : toNumberString(this.#buffer, radix)
35
35
  }
36
36
 
37
37
  /**
38
38
  * @returns {bigint}
39
39
  */
40
40
  toBigInt () {
41
- return Buffer.from(this._buffer).readBigUInt64BE(0)
41
+ return Buffer.from(this.#buffer).readBigUInt64BE(0)
42
42
  }
43
43
 
44
44
  /**
45
45
  * @returns {number[] | Uint8Array}
46
46
  */
47
47
  toBuffer () {
48
- return this._buffer
48
+ return this.#buffer
49
49
  }
50
50
 
51
51
  /**
52
52
  * @returns {number[] | Uint8Array}
53
53
  */
54
54
  toArray () {
55
- if (this._buffer.length === 8) {
56
- return this._buffer
55
+ if (this.#buffer.length === 8) {
56
+ return this.#buffer
57
57
  }
58
- return this._buffer.slice(-8)
58
+ return this.#buffer.slice(-8)
59
59
  }
60
60
 
61
61
  /**
@@ -70,12 +70,10 @@ class Identifier {
70
70
  * @returns {boolean}
71
71
  */
72
72
  equals (other) {
73
- const length = this._buffer.length
74
- const otherLength = other._buffer.length
75
-
76
- // Only compare the bytes available in both IDs.
77
- for (let i = length, j = otherLength; i >= 0 && j >= 0; i--, j--) {
78
- if (this._buffer[i] !== other._buffer[j]) return false
73
+ // Big-endian suffix compare: when buffers differ in length, only the
74
+ // rightmost `min(this.length, other.length)` bytes are checked.
75
+ for (let i = this.#buffer.length - 1, j = other.#buffer.length - 1; i >= 0 && j >= 0; i--, j--) {
76
+ if (this.#buffer[i] !== other.#buffer[j]) return false
79
77
  }
80
78
 
81
79
  return true
@@ -174,15 +172,6 @@ function toNumberString (buffer, radix) {
174
172
  return str
175
173
  }
176
174
 
177
- // Convert a buffer to a hexadecimal string.
178
- /**
179
- * @param {number[] | Uint8Array} buffer
180
- * @returns {string}
181
- */
182
- function toHexString (buffer) {
183
- return map.call(buffer, pad).join('')
184
- }
185
-
186
175
  // Simple pseudo-random 64-bit ID generator.
187
176
  /**
188
177
  * @returns {number[] | Uint8Array}
@@ -7,7 +7,9 @@ module.exports = {
7
7
  DECORATOR: '_ml_obs.decorator',
8
8
  INTEGRATION: '_ml_obs.integration',
9
9
  METADATA: '_ml_obs.meta.metadata',
10
+ COST_TAGS: '_ml_obs.meta.metadata._dd.cost_tags',
10
11
  METRICS: '_ml_obs.metrics',
12
+ TOOL_DEFINITIONS: '_ml_obs.meta.tool_definitions',
11
13
  ML_APP: '_ml_obs.meta.ml_app',
12
14
  PROPAGATED_PARENT_ID_KEY: '_dd.p.llmobs_parent_id',
13
15
  PROPAGATED_ML_APP_KEY: '_dd.p.llmobs_ml_app',
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { UNKNOWN_MODEL_PROVIDER } = require('../../constants/tags')
4
+ const { safeJsonParse } = require('../../util')
4
5
  const LLMObsPlugin = require('../base')
5
6
  const { appendMessage } = require('./util')
6
7
 
@@ -47,6 +48,8 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
47
48
  const { type } = contentBlock
48
49
  if (type === 'text') {
49
50
  response.content.push({ type, text: contentBlock.text })
51
+ } else if (type === 'thinking') {
52
+ response.content.push({ type, thinking: contentBlock.thinking ?? '' })
50
53
  } else if (type === 'tool_use') {
51
54
  response.content.push({ type, name: contentBlock.name, input: '', id: contentBlock.id })
52
55
  }
@@ -56,20 +59,29 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
56
59
  const { delta } = chunk
57
60
  if (!delta) continue
58
61
 
59
- const { text } = delta
60
- if (text) response.content[response.content.length - 1].text += text
61
-
62
- const partialJson = delta.partial_json
63
- if (partialJson && delta.type === 'input_json_delta') {
64
- response.content[response.content.length - 1].input += partialJson
62
+ const lastBlock = response.content[response.content.length - 1]
63
+ if (!lastBlock) continue
64
+
65
+ if (delta.type === 'thinking_delta') {
66
+ const { thinking } = delta
67
+ if (thinking) lastBlock.thinking += thinking
68
+ } else if (delta.type === 'signature_delta') {
69
+ // Signature is for internal verification only; skip it.
70
+ } else if (delta.type === 'input_json_delta') {
71
+ const partialJson = delta.partial_json
72
+ if (partialJson) lastBlock.input += partialJson
73
+ } else {
74
+ const { text } = delta
75
+ if (text) lastBlock.text += text
65
76
  }
66
77
  break
67
78
  }
68
79
  case 'content_block_stop': {
69
- const type = response.content[response.content.length - 1].type
70
- if (type === 'tool_use') {
71
- const input = response.content[response.content.length - 1].input ?? '{}'
72
- response.content[response.content.length - 1].input = JSON.parse(input)
80
+ const lastBlock = response.content[response.content.length - 1]
81
+ if (!lastBlock) break
82
+ if (lastBlock.type === 'tool_use') {
83
+ const input = lastBlock.input ?? '{}'
84
+ lastBlock.input = safeJsonParse(input, {})
73
85
  }
74
86
  break
75
87
  }
@@ -167,18 +179,17 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
167
179
 
168
180
  const outputMessages = []
169
181
  for (const block of content) {
182
+ if (block.type === 'thinking') {
183
+ outputMessages.push({ content: block.thinking ?? '', role: 'reasoning' })
184
+ continue
185
+ }
170
186
  const { text } = block
171
187
  if (typeof text === 'string') {
172
188
  outputMessages.push({ content: text, role })
173
189
  } else if (block.type === 'tool_use') {
174
- let input = block.input
175
- if (typeof input === 'string') {
176
- input = JSON.parse(input)
177
- }
178
-
179
190
  const toolCall = {
180
191
  name: block.name,
181
- arguments: input,
192
+ arguments: safeJsonParse(block.input, {}),
182
193
  toolId: block.id,
183
194
  type: block.type,
184
195
  }
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * @typedef {{type: 'text', text: string}} TextBlock
5
5
  * @typedef {{type: 'image'}} ImageBlock
6
+ * @typedef {{type: 'thinking', thinking: string, signature?: string}} ThinkingBlock
6
7
  * @typedef {{
7
8
  * type: 'tool_use', text: string, name: string, id: string, input: string | Record<string, unknown>
8
9
  * }} ToolUseBlock
@@ -70,6 +71,8 @@ function appendMessage (messages, { role, content }) {
70
71
  messages.push({ content: block.text, role })
71
72
  } else if (block.type === 'image') {
72
73
  messages.push({ content: '([IMAGE DETECTED])', role })
74
+ } else if (block.type === 'thinking') {
75
+ messages.push({ content: block.thinking ?? '', role: 'reasoning' })
73
76
  } else if (block.type === 'tool_use') {
74
77
  const { text, name, id, type } = block
75
78
  let input = block.input
@@ -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,