dd-trace 5.115.0 → 5.117.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 (185) hide show
  1. package/README.electron.md +7 -0
  2. package/README.md +17 -0
  3. package/ci/diagnose.js +2100 -0
  4. package/ci/init.js +23 -2
  5. package/ci/runbook.md +198 -0
  6. package/ci/test-optimization-validation/approval-artifacts.js +143 -0
  7. package/ci/test-optimization-validation/approval.js +299 -0
  8. package/ci/test-optimization-validation/artifact-id.js +20 -0
  9. package/ci/test-optimization-validation/bounded-json.js +121 -0
  10. package/ci/test-optimization-validation/ci-command-candidate.js +83 -0
  11. package/ci/test-optimization-validation/ci-discovery.js +181 -0
  12. package/ci/test-optimization-validation/ci-remediation.js +210 -0
  13. package/ci/test-optimization-validation/cli.js +903 -0
  14. package/ci/test-optimization-validation/command-blocker.js +81 -0
  15. package/ci/test-optimization-validation/command-output-policy.js +206 -0
  16. package/ci/test-optimization-validation/command-runner.js +707 -0
  17. package/ci/test-optimization-validation/command-suitability.js +471 -0
  18. package/ci/test-optimization-validation/executable-approval.js +48 -0
  19. package/ci/test-optimization-validation/executable.js +480 -0
  20. package/ci/test-optimization-validation/generated-file-policy.js +63 -0
  21. package/ci/test-optimization-validation/generated-files.js +351 -0
  22. package/ci/test-optimization-validation/generated-verifier.js +196 -0
  23. package/ci/test-optimization-validation/init-probe-preload.js +163 -0
  24. package/ci/test-optimization-validation/init-probe.js +246 -0
  25. package/ci/test-optimization-validation/late-initialization.js +63 -0
  26. package/ci/test-optimization-validation/local-command.js +163 -0
  27. package/ci/test-optimization-validation/manifest-loader.js +133 -0
  28. package/ci/test-optimization-validation/manifest-scaffold.js +738 -0
  29. package/ci/test-optimization-validation/manifest-schema.js +862 -0
  30. package/ci/test-optimization-validation/offline-fixtures.js +327 -0
  31. package/ci/test-optimization-validation/offline-output.js +406 -0
  32. package/ci/test-optimization-validation/payload-normalizer.js +72 -0
  33. package/ci/test-optimization-validation/plan-writer.js +1120 -0
  34. package/ci/test-optimization-validation/preflight-runner.js +114 -0
  35. package/ci/test-optimization-validation/redaction.js +331 -0
  36. package/ci/test-optimization-validation/report-writer.js +1508 -0
  37. package/ci/test-optimization-validation/safe-files.js +203 -0
  38. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +159 -0
  39. package/ci/test-optimization-validation/scenarios/basic-reporting.js +657 -0
  40. package/ci/test-optimization-validation/scenarios/ci-wiring.js +780 -0
  41. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +141 -0
  42. package/ci/test-optimization-validation/scenarios/helpers.js +539 -0
  43. package/ci/test-optimization-validation/scenarios/test-management.js +218 -0
  44. package/ci/test-optimization-validation/setup-runner.js +97 -0
  45. package/ci/test-optimization-validation/static-diagnosis.js +159 -0
  46. package/ci/test-optimization-validation/test-output.js +129 -0
  47. package/ci/test-optimization-validation-manifest.schema.json +1 -0
  48. package/ci/validate-test-optimization.js +3 -0
  49. package/ext/exporters.js +1 -0
  50. package/index.d.ts +121 -8
  51. package/init.js +18 -0
  52. package/initialize.mjs +1 -1
  53. package/loader-hook.mjs +28 -18
  54. package/openfeature.d.ts +1 -0
  55. package/openfeature.js +4 -0
  56. package/package.json +11 -8
  57. package/packages/datadog-instrumentations/src/ai.js +8 -2
  58. package/packages/datadog-instrumentations/src/child_process.js +1 -1
  59. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +1 -1
  60. package/packages/datadog-instrumentations/src/cucumber-worker-threads.js +9 -3
  61. package/packages/datadog-instrumentations/src/cucumber.js +31 -19
  62. package/packages/datadog-instrumentations/src/cypress-config.js +398 -52
  63. package/packages/datadog-instrumentations/src/fastify.js +7 -11
  64. package/packages/datadog-instrumentations/src/helpers/channel.js +74 -0
  65. package/packages/datadog-instrumentations/src/helpers/hook.js +27 -6
  66. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +8 -8
  67. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +28 -2
  68. package/packages/datadog-instrumentations/src/jest.js +44 -17
  69. package/packages/datadog-instrumentations/src/mariadb.js +1 -1
  70. package/packages/datadog-instrumentations/src/mocha/main.js +79 -32
  71. package/packages/datadog-instrumentations/src/mocha/utils.js +0 -4
  72. package/packages/datadog-instrumentations/src/mongodb.js +3 -3
  73. package/packages/datadog-instrumentations/src/mongoose.js +3 -3
  74. package/packages/datadog-instrumentations/src/mquery.js +2 -2
  75. package/packages/datadog-instrumentations/src/mysql.js +1 -1
  76. package/packages/datadog-instrumentations/src/next.js +24 -0
  77. package/packages/datadog-instrumentations/src/nyc.js +0 -2
  78. package/packages/datadog-instrumentations/src/oracledb.js +2 -2
  79. package/packages/datadog-instrumentations/src/pg.js +2 -2
  80. package/packages/datadog-instrumentations/src/playwright.js +162 -46
  81. package/packages/datadog-instrumentations/src/process.js +3 -0
  82. package/packages/datadog-instrumentations/src/router.js +18 -8
  83. package/packages/datadog-instrumentations/src/selenium.js +52 -26
  84. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +3 -3
  85. package/packages/datadog-instrumentations/src/vitest-main.js +14 -28
  86. package/packages/datadog-instrumentations/src/vitest-util.js +0 -7
  87. package/packages/datadog-instrumentations/src/vitest-worker.js +5 -8
  88. package/packages/datadog-instrumentations/src/ws.js +2 -1
  89. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/context.js +2 -2
  90. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/util.js +2 -1
  91. package/packages/datadog-plugin-aws-sdk/src/util.js +2 -2
  92. package/packages/datadog-plugin-cucumber/src/index.js +3 -2
  93. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +19 -57
  94. package/packages/datadog-plugin-cypress/src/index.js +6 -1
  95. package/packages/datadog-plugin-cypress/src/support.js +92 -26
  96. package/packages/datadog-plugin-electron/src/ipc.js +1 -1
  97. package/packages/datadog-plugin-graphql/src/execute.js +87 -45
  98. package/packages/datadog-plugin-graphql/src/validate.js +2 -2
  99. package/packages/datadog-plugin-http2/src/client.js +1 -1
  100. package/packages/datadog-plugin-langchain/src/handlers/embedding.js +2 -1
  101. package/packages/datadog-plugin-langchain/src/handlers/language_models.js +2 -1
  102. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  103. package/packages/datadog-plugin-mocha/src/index.js +2 -1
  104. package/packages/datadog-plugin-playwright/src/index.js +181 -65
  105. package/packages/datadog-plugin-router/src/index.js +11 -2
  106. package/packages/datadog-plugin-selenium/src/index.js +5 -36
  107. package/packages/datadog-plugin-vitest/src/index.js +5 -5
  108. package/packages/datadog-plugin-ws/src/close.js +2 -1
  109. package/packages/datadog-plugin-ws/src/producer.js +2 -1
  110. package/packages/datadog-plugin-ws/src/receiver.js +2 -1
  111. package/packages/datadog-plugin-ws/src/server.js +2 -1
  112. package/packages/dd-trace/src/aiguard/sdk.js +1 -1
  113. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +2 -2
  114. package/packages/dd-trace/src/appsec/iast/telemetry/span-tags.js +3 -1
  115. package/packages/dd-trace/src/azure_metadata.js +2 -1
  116. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +93 -28
  117. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +1 -1
  118. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/index.js +160 -0
  119. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +288 -0
  120. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/payload-projection.js +84 -0
  121. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +369 -0
  122. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/writer.js +60 -0
  123. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +41 -3
  124. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +7 -2
  125. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +1 -1
  126. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +6 -2
  127. package/packages/dd-trace/src/ci-visibility/rum.js +7 -0
  128. package/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js +4 -0
  129. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +1 -1
  130. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +137 -9
  131. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +168 -8
  132. package/packages/dd-trace/src/ci-visibility/test-screenshot.js +90 -0
  133. package/packages/dd-trace/src/config/generated-config-types.d.ts +16 -0
  134. package/packages/dd-trace/src/config/helper.js +1 -0
  135. package/packages/dd-trace/src/config/index.js +10 -0
  136. package/packages/dd-trace/src/config/supported-configurations.json +66 -0
  137. package/packages/dd-trace/src/exporter.js +2 -0
  138. package/packages/dd-trace/src/exporters/common/client-library-headers.js +21 -0
  139. package/packages/dd-trace/src/exporters/common/request.js +59 -30
  140. package/packages/dd-trace/src/exporters/common/url.js +15 -1
  141. package/packages/dd-trace/src/feature-registry.js +10 -3
  142. package/packages/dd-trace/src/llmobs/experiments/client.js +113 -0
  143. package/packages/dd-trace/src/llmobs/experiments/dataset.js +154 -0
  144. package/packages/dd-trace/src/llmobs/experiments/experiment.js +283 -0
  145. package/packages/dd-trace/src/llmobs/experiments/index.js +152 -0
  146. package/packages/dd-trace/src/llmobs/experiments/noop.js +30 -0
  147. package/packages/dd-trace/src/llmobs/experiments/result.js +34 -0
  148. package/packages/dd-trace/src/llmobs/noop.js +6 -0
  149. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +20 -3
  150. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +2 -2
  151. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
  152. package/packages/dd-trace/src/llmobs/plugins/openai/constants.js +8 -0
  153. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +48 -14
  154. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +43 -0
  155. package/packages/dd-trace/src/llmobs/sdk.js +17 -0
  156. package/packages/dd-trace/src/llmobs/tagger.js +68 -0
  157. package/packages/dd-trace/src/llmobs/telemetry.js +2 -1
  158. package/packages/dd-trace/src/llmobs/util.js +32 -0
  159. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +322 -0
  160. package/packages/dd-trace/src/openfeature/configuration_source.js +90 -0
  161. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -23
  162. package/packages/dd-trace/src/openfeature/index.js +0 -2
  163. package/packages/dd-trace/src/openfeature/noop.js +1 -28
  164. package/packages/dd-trace/src/openfeature/register.js +16 -27
  165. package/packages/dd-trace/src/openfeature/remote_config.js +15 -18
  166. package/packages/dd-trace/src/openfeature/require-provider.js +18 -0
  167. package/packages/dd-trace/src/opentelemetry/span-ending-hook.js +10 -0
  168. package/packages/dd-trace/src/opentelemetry/span.js +5 -0
  169. package/packages/dd-trace/src/plugins/ci_plugin.js +20 -6
  170. package/packages/dd-trace/src/plugins/util/git.js +3 -2
  171. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +29 -5
  172. package/packages/dd-trace/src/plugins/util/test.js +56 -4
  173. package/packages/dd-trace/src/profiling/profilers/space.js +1 -1
  174. package/packages/dd-trace/src/profiling/profilers/wall.js +1 -0
  175. package/packages/dd-trace/src/proxy.js +84 -7
  176. package/packages/dd-trace/src/span_processor.js +5 -0
  177. package/packages/dd-trace/src/standalone/index.js +0 -14
  178. package/packages/dd-trace/src/telemetry/send-data.js +2 -2
  179. package/packages/dd-trace/src/util.js +26 -0
  180. package/vendor/dist/@apm-js-collab/code-transformer/index.js +1 -1
  181. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  182. package/vendor/dist/protobufjs/index.js +1 -1
  183. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  184. package/vendor/dist/shell-quote/index.js +1 -1
  185. package/packages/datadog-instrumentations/src/helpers/require-optional-peer.js +0 -17
@@ -7,11 +7,13 @@ const {
7
7
  INSTRUMENTATION_METHOD_AUTO,
8
8
  UNKNOWN_MODEL_PROVIDER,
9
9
  } = require('../../constants/tags')
10
- const { safeJsonParse } = require('../../util')
10
+ const { audioMimeTypeFromFormat, formatAudioPart, safeJsonParse } = require('../../util')
11
+ const { AUDIO_MIME_TYPES } = require('./constants')
11
12
  const {
12
13
  extractChatTemplateFromInstructions,
13
14
  normalizePromptVariables,
14
15
  extractTextFromContentItem,
16
+ extractContentParts,
15
17
  hasMultimodalInputs,
16
18
  } = require('./utils')
17
19
 
@@ -29,6 +31,22 @@ function isIterable (obj) {
29
31
  return typeof obj[Symbol.iterator] === 'function'
30
32
  }
31
33
 
34
+ // Flattens multimodal chat input messages (array `content`) into readable text plus structured
35
+ // `audioParts`, leaving plain-string messages untouched. Model-agnostic: keys off message
36
+ // structure, so it works for any audio-capable chat model (gpt-audio*, gpt-4o-audio-preview, ...).
37
+ function flattenChatInputMessages (messages) {
38
+ if (!Array.isArray(messages)) return messages
39
+
40
+ return messages.map(message => {
41
+ if (!Array.isArray(message?.content)) return message
42
+
43
+ const { content, audioParts } = extractContentParts(message.content)
44
+ const flattenedMessage = { ...message, content }
45
+ if (audioParts.length) flattenedMessage.audioParts = audioParts
46
+ return flattenedMessage
47
+ })
48
+ }
49
+
32
50
  class OpenAiLLMObsPlugin extends LLMObsPlugin {
33
51
  static id = 'openai'
34
52
  static integration = 'openai'
@@ -193,47 +211,63 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
193
211
 
194
212
  this._tagger.tagMetadata(span, metadata)
195
213
 
214
+ const inputMessages = flattenChatInputMessages(messages)
215
+
196
216
  if (error) {
197
- this._tagger.tagLLMIO(span, messages, [{ content: '' }])
217
+ this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
198
218
  return
199
219
  }
200
220
 
201
221
  const outputMessages = []
202
222
  const { choices } = response
203
223
  if (!isIterable(choices)) {
204
- this._tagger.tagLLMIO(span, messages, [{ content: '' }])
224
+ this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
205
225
  return
206
226
  }
207
227
 
228
+ // Output audio (non-streamed) is returned in the requested format; chat-completions
229
+ const outputAudioFormat = inputs.audio?.format
230
+
208
231
  for (const choice of choices) {
209
232
  const message = choice.message || choice.delta
210
- const content = message.content || ''
233
+ let content = message.content || ''
211
234
  const role = message.role
212
235
 
236
+ const audio = message.audio
237
+ let audioParts
238
+ if (audio) {
239
+ if (audio.data) {
240
+ audioParts = [formatAudioPart(audio.data, audioMimeTypeFromFormat(outputAudioFormat, AUDIO_MIME_TYPES))]
241
+ }
242
+ // gpt-audio* / gpt-4o-audio-preview return null content; surface the transcript as text.
243
+ if (!content) content = audio.transcript || ''
244
+ }
245
+
246
+ const outputMessage = { content, role }
247
+ if (audioParts) outputMessage.audioParts = audioParts
248
+
213
249
  if (message.function_call) {
214
- const functionCallInfo = {
250
+ outputMessage.toolCalls = [{
215
251
  name: message.function_call.name,
216
252
  arguments: safeJsonParse(message.function_call.arguments),
217
- }
218
- outputMessages.push({ content, role, toolCalls: [functionCallInfo] })
253
+ }]
219
254
  } else if (message.tool_calls) {
220
255
  const toolCallsInfo = []
221
256
  for (const toolCall of message.tool_calls) {
222
- const toolCallInfo = {
257
+ toolCallsInfo.push({
223
258
  arguments: safeJsonParse(toolCall.function.arguments),
224
259
  name: toolCall.function.name,
225
260
  toolId: toolCall.id,
226
261
  type: toolCall.type,
227
- }
228
- toolCallsInfo.push(toolCallInfo)
262
+ })
229
263
  }
230
- outputMessages.push({ content, role, toolCalls: toolCallsInfo })
231
- } else {
232
- outputMessages.push({ content, role })
264
+ outputMessage.toolCalls = toolCallsInfo
233
265
  }
266
+
267
+ outputMessages.push(outputMessage)
234
268
  }
235
269
 
236
- this._tagger.tagLLMIO(span, messages, outputMessages)
270
+ this._tagger.tagLLMIO(span, inputMessages, outputMessages)
237
271
  }
238
272
 
239
273
  #tagResponse (span, inputs, response, error) {
@@ -1,10 +1,13 @@
1
1
  'use strict'
2
2
 
3
+ const { audioMimeTypeFromFormat, formatAudioPart } = require('../../util')
3
4
  const {
4
5
  INPUT_TYPE_IMAGE,
5
6
  INPUT_TYPE_FILE,
6
7
  IMAGE_FALLBACK,
7
8
  FILE_FALLBACK,
9
+ AUDIO_FALLBACK,
10
+ AUDIO_MIME_TYPES,
8
11
  } = require('./constants')
9
12
 
10
13
  const REGEX_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g
@@ -118,9 +121,49 @@ function hasMultimodalInputs (variables) {
118
121
  )
119
122
  }
120
123
 
124
+ /**
125
+ * Flattens an array of OpenAI chat message content parts into readable text plus structured audio.
126
+ *
127
+ * Text parts are concatenated (newline-joined),
128
+ * images collapse to an `[image]` marker, and `input_audio` parts with data are captured as audio
129
+ * parts (rendered as a player). The `[audio]` marker is only emitted as a fallback when an audio
130
+ * part carries no data.
131
+ *
132
+ * @param {Array<object>} parts - Array of content parts from a chat message `content`
133
+ * @returns {{ content: string, audioParts: Array<{ mimeType: string, content: string }> }}
134
+ */
135
+ function extractContentParts (parts) {
136
+ const extracted = []
137
+ const audioParts = []
138
+
139
+ for (const part of parts) {
140
+ const partType = part?.type ?? ''
141
+ if (partType === 'text') {
142
+ extracted.push(part.text ?? '')
143
+ } else if (partType === 'image_url') {
144
+ extracted.push(IMAGE_FALLBACK)
145
+ } else if (partType === 'input_audio') {
146
+ const inputAudio = part.input_audio ?? {}
147
+ const data = inputAudio.data
148
+ if (data) {
149
+ // Audio is captured as a structured audio part (rendered as a player), so no text marker
150
+ // is needed. Only fall back to "[audio]" when there's no audio to capture.
151
+ audioParts.push(formatAudioPart(data, audioMimeTypeFromFormat(inputAudio.format, AUDIO_MIME_TYPES)))
152
+ } else {
153
+ extracted.push(AUDIO_FALLBACK)
154
+ }
155
+ } else {
156
+ extracted.push(`[${partType}]`)
157
+ }
158
+ }
159
+
160
+ return { content: extracted.join('\n'), audioParts }
161
+ }
162
+
121
163
  module.exports = {
122
164
  extractChatTemplateFromInstructions,
123
165
  normalizePromptVariables,
124
166
  extractTextFromContentItem,
167
+ extractContentParts,
125
168
  hasMultimodalInputs,
126
169
  }
@@ -19,6 +19,7 @@ const {
19
19
  const { storage } = require('./storage')
20
20
  const telemetry = require('./telemetry')
21
21
  const LLMObsTagger = require('./tagger')
22
+ const { createExperiments } = require('./experiments')
22
23
 
23
24
  // communicating with writer
24
25
  const evalMetricAppendCh = channel('llmobs:eval-metric:append')
@@ -33,6 +34,12 @@ class LLMObs extends NoopLLMObs {
33
34
  */
34
35
  #hasUserSpanProcessor = false
35
36
 
37
+ /**
38
+ * Lazily-created experiments facade (see ./experiments).
39
+ * @type {import('./experiments').Experiments | undefined}
40
+ */
41
+ #experiments
42
+
36
43
  /**
37
44
  * @param {import('../tracer')} tracer - Tracer instance
38
45
  * @param {import('./index')} llmobsModule - LLMObs module instance
@@ -52,6 +59,16 @@ class LLMObs extends NoopLLMObs {
52
59
  return this._config.llmobs.DD_LLMOBS_ENABLED ?? false
53
60
  }
54
61
 
62
+ /**
63
+ * Datasets & Experiments API. Requires LLM Observability to be enabled and
64
+ * DD_API_KEY / DD_APP_KEY to be set; otherwise the returned facade throws with
65
+ * a clear message on use.
66
+ */
67
+ get experiments () {
68
+ this.#experiments ??= createExperiments(this._config)
69
+ return this.#experiments
70
+ }
71
+
55
72
  enable (options = {}) {
56
73
  logger.warn(
57
74
  'Enabling LLM Observability via `llmobs.enable()` is deprecated and will be removed in dd-trace@7.0.0. ' +
@@ -618,6 +618,65 @@ class LLMObsTagger {
618
618
  return filteredToolResults
619
619
  }
620
620
 
621
+ // Validates audio segments on a message and emits the snake_case wire shape
622
+ // `{ mime_type, content | attachment_key }`. Mirrors dd-trace-py's
623
+ // `_extract_audio_part`: a part requires `mimeType` and exactly one of
624
+ // `content` (inline base64) or `attachmentKey` (backend-offloaded).
625
+ #filterAudioParts (audioParts) {
626
+ if (!Array.isArray(audioParts)) {
627
+ audioParts = [audioParts]
628
+ }
629
+
630
+ const filteredAudioParts = []
631
+ for (const audioPart of audioParts) {
632
+ if (audioPart == null || typeof audioPart !== 'object') {
633
+ this.#handleFailure('Audio part must be an object.', 'invalid_io_messages')
634
+ continue
635
+ }
636
+
637
+ const { mimeType, content, attachmentKey } = audioPart
638
+
639
+ if (typeof mimeType !== 'string' || !mimeType) {
640
+ this.#handleFailure('Audio part mimeType must be a non-empty string.', 'invalid_io_messages')
641
+ continue
642
+ }
643
+
644
+ if (content == null && attachmentKey == null) {
645
+ this.#handleFailure("Audio part must have either 'content' or 'attachmentKey'.", 'invalid_io_messages')
646
+ continue
647
+ }
648
+
649
+ if (content != null && attachmentKey != null) {
650
+ this.#handleFailure(
651
+ "Audio part must have only one of 'content' or 'attachmentKey', not both.", 'invalid_io_messages'
652
+ )
653
+ continue
654
+ }
655
+
656
+ const audioPartObj = { mime_type: mimeType }
657
+
658
+ // Exactly one of content / attachmentKey is set here (guarded above). Validate its type
659
+ // explicitly so the failure carries the `invalid_io_messages` tag for telemetry, instead
660
+ // of routing through `#tagConditionalString` which omits it.
661
+ if (content == null) {
662
+ if (typeof attachmentKey !== 'string') {
663
+ this.#handleFailure('Audio part attachmentKey must be a string.', 'invalid_io_messages')
664
+ continue
665
+ }
666
+ audioPartObj.attachment_key = attachmentKey
667
+ } else {
668
+ if (typeof content !== 'string') {
669
+ this.#handleFailure('Audio part content must be a base64-encoded string.', 'invalid_io_messages')
670
+ continue
671
+ }
672
+ audioPartObj.content = content
673
+ }
674
+
675
+ filteredAudioParts.push(audioPartObj)
676
+ }
677
+ return filteredAudioParts
678
+ }
679
+
621
680
  #tagMessages (span, data, key) {
622
681
  if (!data) {
623
682
  return
@@ -644,6 +703,7 @@ class LLMObsTagger {
644
703
  toolCalls,
645
704
  toolResults,
646
705
  toolId,
706
+ audioParts,
647
707
  } = message
648
708
  const messageObj = {}
649
709
 
@@ -677,6 +737,14 @@ class LLMObsTagger {
677
737
  }
678
738
  }
679
739
 
740
+ if (audioParts != null) {
741
+ const filteredAudioParts = this.#filterAudioParts(audioParts)
742
+
743
+ if (filteredAudioParts.length) {
744
+ messageObj.audio_parts = filteredAudioParts
745
+ }
746
+ }
747
+
680
748
  if (toolId) {
681
749
  if (role === 'tool') {
682
750
  condition = this.#tagConditionalString(toolId, 'Tool ID', messageObj, 'tool_id') && condition
@@ -3,6 +3,7 @@
3
3
  const ERROR_TYPE = require('../constants')
4
4
 
5
5
  const telemetryMetrics = require('../telemetry/metrics')
6
+ const { getSegment } = require('../util')
6
7
  const {
7
8
  SPAN_KIND,
8
9
  MODEL_PROVIDER,
@@ -22,7 +23,7 @@ function extractIntegrationFromTags (tags) {
22
23
  if (!Array.isArray(tags)) return null
23
24
  const integrationTag = tags.find(tag => tag.startsWith('integration:'))
24
25
  if (!integrationTag) return null
25
- return integrationTag.split(':')[1] || null
26
+ return getSegment(integrationTag, ':', 1) || null
26
27
  }
27
28
 
28
29
  function extractTagsFromSpanEvent (event) {
@@ -362,9 +362,41 @@ function findGenAIAncestorSpanId (span) {
362
362
  return null
363
363
  }
364
364
 
365
+ // Maps an audio `format` (e.g. "wav", "mp3") to a MIME type. Defaults to `audio/wav` when the
366
+ // format is missing. Provider-specific overrides (e.g. OpenAI's mp3 -> audio/mpeg) are passed in
367
+ // via `mimeTypeLookup` so this stays provider-agnostic. A non-string `format` is treated as missing
368
+ // so a malformed auto-instrumented payload can't throw and disable the plugin.
369
+ /**
370
+ * @param {string} fmt
371
+ * @param {Record<string, string>} [mimeTypeLookup]
372
+ * @returns {string}
373
+ */
374
+ function audioMimeTypeFromFormat (fmt, mimeTypeLookup = {}) {
375
+ fmt = typeof fmt === 'string' ? fmt.trim().toLowerCase() : ''
376
+ if (!fmt) return 'audio/wav'
377
+ return mimeTypeLookup[fmt] ?? `audio/${fmt}`
378
+ }
379
+
380
+ // Builds an audio part from raw audio bytes (base64-encoded) or an existing base64 string. Only
381
+ // Buffer/Uint8Array inputs are base64-encoded; any other shape is passed through so a malformed
382
+ // auto-instrumented payload can't throw (the tagger soft-skips a non-string `content`).
383
+ /**
384
+ * @param {Buffer | Uint8Array | string} data
385
+ * @param {string} mimeType
386
+ * @returns {{ mimeType: string, content: string }}
387
+ */
388
+ function formatAudioPart (data, mimeType) {
389
+ const content = Buffer.isBuffer(data) || ArrayBuffer.isView(data)
390
+ ? Buffer.from(data).toString('base64')
391
+ : data
392
+ return { mimeType, content }
393
+ }
394
+
365
395
  module.exports = {
396
+ audioMimeTypeFromFormat,
366
397
  encodeUnicode,
367
398
  findGenAIAncestorSpanId,
399
+ formatAudioPart,
368
400
  validateCostTags,
369
401
  validateKind,
370
402
  getFunctionArguments,
@@ -0,0 +1,322 @@
1
+ 'use strict'
2
+
3
+ /* eslint-disable no-await-in-loop -- Polls and retries must remain sequential. */
4
+
5
+ const { setTimeout: sleep } = require('node:timers/promises')
6
+
7
+ const request = require('../exporters/common/request')
8
+ const { getClientLibraryHeaders } = require('../exporters/common/client-library-headers')
9
+ const log = require('../log')
10
+
11
+ const MAX_ATTEMPTS = 3
12
+ const FIRST_RETRY_MIN_MS = 2000
13
+ const FIRST_RETRY_MAX_MS = 10_000
14
+ const SECOND_RETRY_MIN_MS = 5000
15
+ const SECOND_RETRY_MAX_MS = 30_000
16
+ const RETRY_JITTER = 0.2
17
+
18
+ /**
19
+ * @typedef {object} AgentlessSourceConfig
20
+ * @property {URL} endpoint
21
+ * @property {number} pollIntervalMs
22
+ * @property {number} requestTimeoutMs
23
+ * @property {string} apiKey
24
+ */
25
+
26
+ /**
27
+ * @typedef {import('@datadog/openfeature-node-server').UniversalFlagConfigurationV1} UniversalFlagConfiguration
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} PollResponse
32
+ * @property {Error | null | undefined} error
33
+ * @property {string | undefined} body
34
+ * @property {number | undefined} statusCode
35
+ * @property {import('node:http').IncomingHttpHeaders | undefined} headers
36
+ */
37
+
38
+ class AgentlessConfigurationSource {
39
+ /** @type {AbortController | undefined} */
40
+ #abortController
41
+
42
+ /** @type {(configuration: UniversalFlagConfiguration) => void} */
43
+ #applyConfiguration
44
+
45
+ #applicationFailureLogged = false
46
+
47
+ /** @type {AgentlessSourceConfig} */
48
+ #config
49
+
50
+ /** @type {string | undefined} */
51
+ #etag
52
+
53
+ /** @type {Set<string>} */
54
+ #failureWarnings = new Set()
55
+
56
+ #malformedPayloadLogged = false
57
+
58
+ /**
59
+ * @param {AgentlessSourceConfig} config
60
+ * @param {(configuration: UniversalFlagConfiguration) => void} applyConfiguration
61
+ */
62
+ constructor (config, applyConfiguration) {
63
+ this.#config = config
64
+ this.#applyConfiguration = applyConfiguration
65
+ }
66
+
67
+ /**
68
+ * @returns {void}
69
+ */
70
+ start () {
71
+ if (this.#abortController) return
72
+
73
+ const abortController = new AbortController()
74
+ this.#abortController = abortController
75
+ this.#poll(abortController)
76
+ }
77
+
78
+ /**
79
+ * @returns {void}
80
+ */
81
+ stop () {
82
+ this.#abortController?.abort()
83
+ this.#abortController = undefined
84
+ }
85
+
86
+ /**
87
+ * @param {AbortController} abortController
88
+ * @returns {Promise<void>}
89
+ */
90
+ async #poll (abortController) {
91
+ try {
92
+ do {
93
+ await this.#pollOnce(abortController)
94
+ } while (
95
+ this.#abortController === abortController &&
96
+ await wait(this.#config.pollIntervalMs, abortController.signal)
97
+ )
98
+ } catch (error) {
99
+ if (this.#abortController === abortController) {
100
+ this.#warnFailure(undefined, error, 1)
101
+ }
102
+ } finally {
103
+ if (this.#abortController === abortController) {
104
+ this.#abortController = undefined
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {AbortController} abortController
111
+ * @returns {Promise<void>}
112
+ */
113
+ async #pollOnce (abortController) {
114
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
115
+ const response = await this.#request(abortController.signal)
116
+ if (this.#abortController !== abortController) return
117
+
118
+ const retryable = response.statusCode === undefined || isRetryableStatus(response.statusCode)
119
+ if (!retryable) {
120
+ this.#apply(response)
121
+ return
122
+ }
123
+
124
+ if (attempt === MAX_ATTEMPTS) {
125
+ this.#warnFailure(response.statusCode, response.error, attempt)
126
+ return
127
+ }
128
+
129
+ const delay = retryDelay(this.#config.pollIntervalMs, attempt, Math.random())
130
+ if (!await wait(delay, abortController.signal)) return
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @param {AbortSignal} signal
136
+ * @returns {Promise<PollResponse>}
137
+ */
138
+ #request (signal) {
139
+ const headers = getClientLibraryHeaders()
140
+ headers['Accept-Encoding'] = 'gzip'
141
+ headers['DD-API-KEY'] = this.#config.apiKey
142
+ if (this.#etag) headers['If-None-Match'] = this.#etag
143
+
144
+ /**
145
+ * @param {(response: PollResponse) => void} resolve
146
+ */
147
+ const execute = (resolve) => {
148
+ /**
149
+ * @param {Error | null} error
150
+ * @param {string | import('node:http').IncomingMessage | null | undefined} body
151
+ * @param {number | undefined} statusCode
152
+ * @param {import('node:http').IncomingHttpHeaders | undefined} responseHeaders
153
+ */
154
+ const onResponse = (error, body, statusCode, responseHeaders) => {
155
+ resolve({
156
+ error,
157
+ body: typeof body === 'string' ? body : undefined,
158
+ statusCode,
159
+ headers: responseHeaders,
160
+ })
161
+ }
162
+
163
+ request('', {
164
+ url: this.#config.endpoint,
165
+ method: 'GET',
166
+ headers,
167
+ retry: false,
168
+ signal,
169
+ timeout: this.#config.requestTimeoutMs,
170
+ }, onResponse)
171
+ }
172
+
173
+ return new Promise(execute)
174
+ }
175
+
176
+ /**
177
+ * @param {PollResponse} response
178
+ * @returns {void}
179
+ */
180
+ #apply (response) {
181
+ const statusCode = response.statusCode
182
+ if (statusCode === 304) return
183
+
184
+ if (statusCode === 401 || statusCode === 403) {
185
+ this.#warnFailure(statusCode, undefined, 1)
186
+ return
187
+ }
188
+
189
+ if (statusCode !== 200) return
190
+
191
+ let configuration
192
+ try {
193
+ configuration = parseConfiguration(response.body)
194
+ } catch {
195
+ if (!this.#malformedPayloadLogged) {
196
+ this.#malformedPayloadLogged = true
197
+ log.error('Feature Flagging agentless endpoint returned malformed UFC payload')
198
+ }
199
+ return
200
+ }
201
+
202
+ try {
203
+ this.#applyConfiguration(configuration)
204
+ } catch (error) {
205
+ if (!this.#applicationFailureLogged) {
206
+ this.#applicationFailureLogged = true
207
+ log.warn('Feature Flagging agentless UFC payload could not be applied: %s', errorMessage(error))
208
+ }
209
+ return
210
+ }
211
+
212
+ const etag = response.headers?.etag
213
+ const value = Array.isArray(etag) ? etag[0] : etag
214
+ this.#etag = value?.trim() || undefined
215
+ }
216
+
217
+ /**
218
+ * @param {number | undefined} statusCode
219
+ * @param {unknown} error
220
+ * @param {number} attempts
221
+ * @returns {void}
222
+ */
223
+ #warnFailure (statusCode, error, attempts) {
224
+ const category = statusCode === 401 || statusCode === 403
225
+ ? 'authentication'
226
+ : statusCode ? 'http' : 'request'
227
+ if (this.#failureWarnings.has(category)) return
228
+ this.#failureWarnings.add(category)
229
+
230
+ if (statusCode === 401 || statusCode === 403) {
231
+ log.warn(
232
+ 'Feature Flagging agentless endpoint returned HTTP %d; verify DD_API_KEY is configured and valid',
233
+ statusCode
234
+ )
235
+ } else if (statusCode) {
236
+ log.warn('Feature Flagging agentless endpoint returned HTTP %d after %d attempts', statusCode, attempts)
237
+ } else if (attempts > 1) {
238
+ log.warn('Feature Flagging agentless request failed after %d attempts: %s', attempts, errorMessage(error))
239
+ } else {
240
+ log.warn('Feature Flagging agentless request failed: %s', errorMessage(error))
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * @param {number} delay
247
+ * @param {AbortSignal} signal
248
+ * @returns {Promise<boolean>}
249
+ */
250
+ async function wait (delay, signal) {
251
+ try {
252
+ await sleep(delay, undefined, { ref: false, signal })
253
+ return true
254
+ } catch (error) {
255
+ if (error?.name === 'AbortError') return false
256
+ throw error
257
+ }
258
+ }
259
+
260
+ /**
261
+ * @param {string | undefined} body
262
+ * @returns {UniversalFlagConfiguration}
263
+ */
264
+ function parseConfiguration (body) {
265
+ const { data } = JSON.parse(body)
266
+ if (data?.type !== 'universal-flag-configuration') {
267
+ throw new Error('Expected a JSON:API Universal Flag Configuration resource')
268
+ }
269
+
270
+ const { attributes } = data
271
+ if (typeof attributes?.format !== 'string' ||
272
+ typeof attributes.createdAt !== 'string' ||
273
+ typeof attributes.environment?.name !== 'string' ||
274
+ !attributes.flags ||
275
+ typeof attributes.flags !== 'object' ||
276
+ Array.isArray(attributes.flags)) {
277
+ throw new Error('Expected a Universal Flag Configuration v1 object')
278
+ }
279
+
280
+ return attributes
281
+ }
282
+
283
+ /**
284
+ * @param {unknown} error
285
+ * @returns {string}
286
+ */
287
+ function errorMessage (error) {
288
+ return error instanceof Error ? error.message : String(error ?? 'request was not sent')
289
+ }
290
+
291
+ /**
292
+ * @param {number | undefined} status
293
+ * @returns {boolean}
294
+ */
295
+ function isRetryableStatus (status) {
296
+ return status === 408 || status === 429 || (status >= 500 && status <= 599)
297
+ }
298
+
299
+ /**
300
+ * @param {number} pollIntervalMs
301
+ * @param {number} attempt
302
+ * @param {number} random
303
+ * @returns {number}
304
+ */
305
+ function retryDelay (pollIntervalMs, attempt, random) {
306
+ const base = attempt === 1
307
+ ? clamp(pollIntervalMs / 6, FIRST_RETRY_MIN_MS, FIRST_RETRY_MAX_MS)
308
+ : clamp(pollIntervalMs / 3, SECOND_RETRY_MIN_MS, SECOND_RETRY_MAX_MS)
309
+ return Math.max(1, Math.round(base * (1 - RETRY_JITTER + random * RETRY_JITTER * 2)))
310
+ }
311
+
312
+ /**
313
+ * @param {number} value
314
+ * @param {number} minimum
315
+ * @param {number} maximum
316
+ * @returns {number}
317
+ */
318
+ function clamp (value, minimum, maximum) {
319
+ return Math.max(minimum, Math.min(maximum, value))
320
+ }
321
+
322
+ module.exports = AgentlessConfigurationSource