dd-trace 6.4.0 → 6.5.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 (178) 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 +10 -7
  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 +20 -7
  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/hook.js +27 -6
  65. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +8 -8
  66. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +28 -2
  67. package/packages/datadog-instrumentations/src/jest.js +26 -0
  68. package/packages/datadog-instrumentations/src/mariadb.js +1 -1
  69. package/packages/datadog-instrumentations/src/mocha/main.js +0 -2
  70. package/packages/datadog-instrumentations/src/mocha/utils.js +0 -4
  71. package/packages/datadog-instrumentations/src/mongodb.js +3 -3
  72. package/packages/datadog-instrumentations/src/mongoose.js +3 -3
  73. package/packages/datadog-instrumentations/src/mquery.js +2 -2
  74. package/packages/datadog-instrumentations/src/mysql.js +1 -1
  75. package/packages/datadog-instrumentations/src/next.js +24 -0
  76. package/packages/datadog-instrumentations/src/nyc.js +0 -2
  77. package/packages/datadog-instrumentations/src/oracledb.js +2 -2
  78. package/packages/datadog-instrumentations/src/pg.js +2 -2
  79. package/packages/datadog-instrumentations/src/playwright.js +159 -22
  80. package/packages/datadog-instrumentations/src/process.js +3 -0
  81. package/packages/datadog-instrumentations/src/router.js +18 -8
  82. package/packages/datadog-instrumentations/src/selenium.js +52 -26
  83. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +2 -2
  84. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  85. package/packages/datadog-instrumentations/src/ws.js +2 -1
  86. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/context.js +2 -2
  87. package/packages/datadog-plugin-aws-durable-execution-sdk-js/src/util.js +2 -1
  88. package/packages/datadog-plugin-aws-sdk/src/util.js +2 -2
  89. package/packages/datadog-plugin-cucumber/src/index.js +1 -1
  90. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +19 -57
  91. package/packages/datadog-plugin-cypress/src/index.js +6 -1
  92. package/packages/datadog-plugin-cypress/src/support.js +92 -26
  93. package/packages/datadog-plugin-electron/src/ipc.js +1 -1
  94. package/packages/datadog-plugin-graphql/src/execute.js +87 -45
  95. package/packages/datadog-plugin-graphql/src/validate.js +2 -2
  96. package/packages/datadog-plugin-http2/src/client.js +1 -1
  97. package/packages/datadog-plugin-langchain/src/handlers/embedding.js +2 -1
  98. package/packages/datadog-plugin-langchain/src/handlers/language_models.js +2 -1
  99. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  100. package/packages/datadog-plugin-playwright/src/index.js +177 -64
  101. package/packages/datadog-plugin-router/src/index.js +11 -2
  102. package/packages/datadog-plugin-selenium/src/index.js +5 -36
  103. package/packages/datadog-plugin-ws/src/close.js +2 -1
  104. package/packages/datadog-plugin-ws/src/producer.js +2 -1
  105. package/packages/datadog-plugin-ws/src/receiver.js +2 -1
  106. package/packages/datadog-plugin-ws/src/server.js +2 -1
  107. package/packages/dd-trace/src/aiguard/sdk.js +1 -1
  108. package/packages/dd-trace/src/appsec/iast/taint-tracking/plugin.js +2 -2
  109. package/packages/dd-trace/src/appsec/iast/telemetry/span-tags.js +3 -1
  110. package/packages/dd-trace/src/azure_metadata.js +2 -1
  111. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/index.js +93 -28
  112. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +1 -1
  113. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/index.js +160 -0
  114. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/msgpack-to-json.js +288 -0
  115. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/payload-projection.js +84 -0
  116. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +369 -0
  117. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/writer.js +60 -0
  118. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +41 -3
  119. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +7 -2
  120. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +1 -1
  121. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +6 -2
  122. package/packages/dd-trace/src/ci-visibility/rum.js +7 -0
  123. package/packages/dd-trace/src/ci-visibility/test-api-manual/test-api-manual-plugin.js +4 -0
  124. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +1 -1
  125. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +137 -9
  126. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +168 -8
  127. package/packages/dd-trace/src/ci-visibility/test-screenshot.js +90 -0
  128. package/packages/dd-trace/src/config/generated-config-types.d.ts +16 -0
  129. package/packages/dd-trace/src/config/helper.js +1 -0
  130. package/packages/dd-trace/src/config/index.js +10 -0
  131. package/packages/dd-trace/src/config/supported-configurations.json +66 -0
  132. package/packages/dd-trace/src/exporter.js +2 -0
  133. package/packages/dd-trace/src/exporters/common/client-library-headers.js +21 -0
  134. package/packages/dd-trace/src/exporters/common/request.js +59 -30
  135. package/packages/dd-trace/src/exporters/common/url.js +15 -1
  136. package/packages/dd-trace/src/feature-registry.js +10 -3
  137. package/packages/dd-trace/src/llmobs/experiments/client.js +113 -0
  138. package/packages/dd-trace/src/llmobs/experiments/dataset.js +154 -0
  139. package/packages/dd-trace/src/llmobs/experiments/experiment.js +283 -0
  140. package/packages/dd-trace/src/llmobs/experiments/index.js +152 -0
  141. package/packages/dd-trace/src/llmobs/experiments/noop.js +30 -0
  142. package/packages/dd-trace/src/llmobs/experiments/result.js +34 -0
  143. package/packages/dd-trace/src/llmobs/noop.js +6 -0
  144. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +20 -3
  145. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +2 -2
  146. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +1 -1
  147. package/packages/dd-trace/src/llmobs/plugins/openai/constants.js +8 -0
  148. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +48 -14
  149. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +43 -0
  150. package/packages/dd-trace/src/llmobs/sdk.js +17 -0
  151. package/packages/dd-trace/src/llmobs/tagger.js +68 -0
  152. package/packages/dd-trace/src/llmobs/telemetry.js +2 -1
  153. package/packages/dd-trace/src/llmobs/util.js +32 -0
  154. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +322 -0
  155. package/packages/dd-trace/src/openfeature/configuration_source.js +90 -0
  156. package/packages/dd-trace/src/openfeature/flagging_provider.js +14 -23
  157. package/packages/dd-trace/src/openfeature/index.js +0 -2
  158. package/packages/dd-trace/src/openfeature/noop.js +1 -28
  159. package/packages/dd-trace/src/openfeature/register.js +16 -27
  160. package/packages/dd-trace/src/openfeature/remote_config.js +15 -18
  161. package/packages/dd-trace/src/openfeature/require-provider.js +18 -0
  162. package/packages/dd-trace/src/opentelemetry/span-ending-hook.js +10 -0
  163. package/packages/dd-trace/src/opentelemetry/span.js +5 -0
  164. package/packages/dd-trace/src/plugins/ci_plugin.js +20 -6
  165. package/packages/dd-trace/src/plugins/util/git.js +3 -2
  166. package/packages/dd-trace/src/plugins/util/inferred_proxy.js +29 -5
  167. package/packages/dd-trace/src/plugins/util/test.js +56 -4
  168. package/packages/dd-trace/src/proxy.js +84 -7
  169. package/packages/dd-trace/src/span_processor.js +5 -0
  170. package/packages/dd-trace/src/standalone/index.js +0 -14
  171. package/packages/dd-trace/src/telemetry/send-data.js +2 -2
  172. package/packages/dd-trace/src/util.js +26 -0
  173. package/vendor/dist/@apm-js-collab/code-transformer/index.js +1 -1
  174. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  175. package/vendor/dist/protobufjs/index.js +1 -1
  176. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  177. package/vendor/dist/shell-quote/index.js +1 -1
  178. package/packages/datadog-instrumentations/src/helpers/require-optional-peer.js +0 -17
@@ -0,0 +1,283 @@
1
+ 'use strict'
2
+
3
+ const id = require('../../id')
4
+
5
+ const { API_BASE_PATH } = require('./client')
6
+ const { Row, ExperimentResult } = require('./result')
7
+
8
+ // Mirrors dd-trace-py's _generate_metric_from_evaluation: plain objects are
9
+ // json, everything else falls through to the lowercased categorical fallback.
10
+ function inferMetricType (value) {
11
+ if (typeof value === 'boolean') return 'boolean'
12
+ if (typeof value === 'number' && Number.isFinite(value)) return 'score'
13
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) return 'json'
14
+ return 'categorical'
15
+ }
16
+
17
+ function stringify (value) {
18
+ if (value === null || value === undefined) return ''
19
+ if (typeof value === 'string') return value.toLowerCase()
20
+ if (typeof value === 'object') return JSON.stringify(value).toLowerCase()
21
+ return String(value).toLowerCase()
22
+ }
23
+
24
+ // Build the tag list, letting auto tags win over user tags on key conflict.
25
+ function buildTags (userTags, autoTags) {
26
+ const tags = new Map()
27
+ for (const [k, v] of Object.entries(userTags ?? {})) {
28
+ tags.set(k, `${k}:${v}`)
29
+ }
30
+ for (const [k, v] of Object.entries(autoTags)) {
31
+ tags.set(k, `${k}:${v}`)
32
+ }
33
+ return [...tags.values()]
34
+ }
35
+
36
+ // One span per experiment row (LLM Obs experiment span wire format).
37
+ function toSpan (row, metadata, ids, spanName, userTags) {
38
+ const meta = {
39
+ input: row.input ?? null,
40
+ output: row.output ?? null,
41
+ expected_output: row.expectedOutput ?? null,
42
+ }
43
+ if (metadata && Object.keys(metadata).length > 0) {
44
+ meta.metadata = metadata
45
+ }
46
+ if (row.isError) {
47
+ meta.error = { type: row.errorType ?? '', message: row.errorMessage ?? '', stack: '' }
48
+ }
49
+
50
+ return {
51
+ span_id: row.spanId,
52
+ trace_id: row.traceId,
53
+ project_id: ids.projectId,
54
+ dataset_id: ids.datasetId,
55
+ name: spanName,
56
+ start_ns: row.startNs,
57
+ duration: row.durationNs,
58
+ status: row.isError ? 'error' : 'ok',
59
+ meta,
60
+ tags: buildTags(userTags, {
61
+ experiment_id: ids.experimentId,
62
+ dataset_id: ids.datasetId,
63
+ dataset_record_id: ids.datasetRecordId,
64
+ }),
65
+ }
66
+ }
67
+
68
+ // One metric per evaluator per row.
69
+ function toMetric (label, value, errorMessage, spanId, timestampMs, experimentId, userTags) {
70
+ const metric = {
71
+ label,
72
+ span_id: spanId,
73
+ timestamp_ms: timestampMs,
74
+ tags: buildTags(userTags, { experiment_id: experimentId }),
75
+ }
76
+
77
+ if (errorMessage !== null) {
78
+ metric.metric_type = 'categorical'
79
+ metric.error = { message: errorMessage }
80
+ return metric
81
+ }
82
+
83
+ const type = inferMetricType(value)
84
+ metric.metric_type = type
85
+ if (type === 'boolean') metric.boolean_value = value
86
+ else if (type === 'score') metric.score_value = value
87
+ else if (type === 'json') metric.json_value = value
88
+ else metric.categorical_value = stringify(value)
89
+ return metric
90
+ }
91
+
92
+ // Builder + run() orchestration: runs rows sequentially, emits one root span
93
+ // per dataset row, and posts all spans + metrics in a single events call.
94
+ class Experiment {
95
+ #client
96
+ #name
97
+ #description
98
+ #dataset
99
+ #task
100
+ #evaluators
101
+ #config
102
+ #tags
103
+ #experimentId
104
+
105
+ constructor (client, options = {}) {
106
+ if (!options.name) throw new Error('Experiment name is required')
107
+ if (!options.dataset) throw new Error('Experiment dataset is required')
108
+ if (typeof options.task !== 'function') throw new Error('Experiment task is required')
109
+
110
+ this.#client = client
111
+ this.#name = options.name
112
+ this.#description = options.description ?? ''
113
+ this.#dataset = options.dataset
114
+ this.#task = options.task
115
+ this.#evaluators = new Map(Object.entries(options.evaluators ?? {}))
116
+ this.#config = { ...options.config }
117
+ this.#tags = { ...options.tags }
118
+ this.#experimentId = null
119
+ }
120
+
121
+ name () {
122
+ return this.#name
123
+ }
124
+
125
+ experimentId () {
126
+ return this.#experimentId
127
+ }
128
+
129
+ url () {
130
+ if (this.#experimentId === null) return null
131
+ return `${this.#client.appBase}/llm/experiments/${this.#experimentId}`
132
+ }
133
+
134
+ async run () {
135
+ const projectId = await this.#client.ensureProjectId()
136
+
137
+ await this.#dataset.ensureCreatedAndPushed(projectId)
138
+ const datasetId = this.#dataset.id()
139
+ if (datasetId === null) {
140
+ throw new Error(`Dataset '${this.#dataset.name()}' has no id after push`)
141
+ }
142
+
143
+ // Create the experiment. ensure_unique makes the backend mint a fresh
144
+ // experiment under the project on every run.
145
+ const attributes = {
146
+ name: this.#name,
147
+ project_id: projectId,
148
+ dataset_id: datasetId,
149
+ description: this.#description,
150
+ ensure_unique: true,
151
+ }
152
+ if (Object.keys(this.#config).length > 0) attributes.config = this.#config
153
+
154
+ let created
155
+ try {
156
+ created = await this.#client.request('POST', `${API_BASE_PATH}/experiments`, {
157
+ data: { type: 'experiments', attributes },
158
+ })
159
+ } catch (err) {
160
+ throw new Error(`Failed to create experiment '${this.#name}': ${err.message}`)
161
+ }
162
+ this.#experimentId = created?.data?.id ?? null
163
+ const experimentId = this.#experimentId
164
+
165
+ try {
166
+ const records = this.#dataset.records()
167
+ const recordIds = this.#dataset.recordIds()
168
+ const rows = []
169
+ const spans = []
170
+ const metrics = []
171
+ let hasRowError = false
172
+
173
+ for (let i = 0; i < records.length; i++) {
174
+ const record = records[i]
175
+ const datasetRecordId = i < recordIds.length ? recordIds[i] : ''
176
+ const startNs = Date.now() * 1e6
177
+ const startHr = process.hrtime.bigint()
178
+
179
+ // Root-span id generation, same convention as opentracing/span.js: a single
180
+ // random 64-bit id for the span, reused as the trace id's low 64 bits with a
181
+ // start-time-derived high 64 bits (like the `_dd.p.tid` 128-bit trace id tag).
182
+ const spanIdentifier = id()
183
+ const traceIdHigh = Math.floor(startNs / 1e9).toString(16).padStart(8, '0').padEnd(16, '0')
184
+ const spanId = spanIdentifier.toString(16).padStart(16, '0')
185
+ const traceId = spanIdentifier.toTraceIdHex(traceIdHigh).padStart(32, '0')
186
+
187
+ let output = null
188
+ let errorType = null
189
+ let errorMessage = null
190
+ try {
191
+ // Rows run one at a time by design.
192
+ // eslint-disable-next-line no-await-in-loop
193
+ output = await this.#task(record.input, this.#config)
194
+ } catch (err) {
195
+ errorType = err.name || 'Error'
196
+ errorMessage = err.message ?? String(err)
197
+ }
198
+
199
+ const durationNs = Number(process.hrtime.bigint() - startHr)
200
+ const evaluations = {}
201
+ const evaluationErrors = {}
202
+ const timestampMs = Date.now()
203
+
204
+ for (const [label, evaluator] of this.#evaluators) {
205
+ if (errorType !== null) {
206
+ const msg = 'task error; evaluation skipped'
207
+ evaluationErrors[label] = msg
208
+ metrics.push(toMetric(label, null, msg, spanId, timestampMs, experimentId, this.#tags))
209
+ continue
210
+ }
211
+ try {
212
+ // eslint-disable-next-line no-await-in-loop
213
+ const value = await evaluator(record.input, output, record.expectedOutput)
214
+ evaluations[label] = value
215
+ metrics.push(toMetric(label, value, null, spanId, timestampMs, experimentId, this.#tags))
216
+ } catch (err) {
217
+ const msg = err.message ?? String(err)
218
+ evaluationErrors[label] = msg
219
+ metrics.push(toMetric(label, null, msg, spanId, timestampMs, experimentId, this.#tags))
220
+ }
221
+ }
222
+
223
+ const row = new Row({
224
+ index: i,
225
+ spanId,
226
+ traceId,
227
+ startNs,
228
+ durationNs,
229
+ input: record.input,
230
+ output,
231
+ expectedOutput: record.expectedOutput,
232
+ errorType,
233
+ errorMessage,
234
+ evaluations,
235
+ evaluationErrors,
236
+ })
237
+ rows.push(row)
238
+ if (row.isError || Object.keys(evaluationErrors).length > 0) hasRowError = true
239
+ spans.push(toSpan(row, record.metadata, {
240
+ experimentId,
241
+ projectId,
242
+ datasetId,
243
+ datasetRecordId,
244
+ }, this.#name, this.#tags))
245
+ }
246
+
247
+ await this.#postEvents(experimentId, spans, metrics)
248
+ // A row error doesn't abort the run, but the experiment didn't succeed cleanly.
249
+ await this.#updateStatus(
250
+ experimentId,
251
+ hasRowError ? 'failed' : 'completed',
252
+ hasRowError ? 'one or more rows failed' : null
253
+ )
254
+
255
+ return new ExperimentResult(experimentId, rows, this.url())
256
+ } catch (err) {
257
+ await this.#updateStatus(experimentId, 'failed', err.message ?? String(err))
258
+ throw err
259
+ }
260
+ }
261
+
262
+ async #postEvents (experimentId, spans, metrics) {
263
+ await this.#client.request('POST', `${API_BASE_PATH}/experiments/${experimentId}/events`, {
264
+ data: { type: 'experiments', attributes: { spans, metrics } },
265
+ })
266
+ }
267
+
268
+ async #updateStatus (experimentId, status, error) {
269
+ if (!experimentId) return
270
+ // The experiment-update model has no status field, so this is a direct PATCH.
271
+ const attributes = { status }
272
+ if (error !== null) attributes.error = error
273
+ try {
274
+ await this.#client.request('PATCH', `${API_BASE_PATH}/experiments/${experimentId}`, {
275
+ data: { type: 'experiments', attributes },
276
+ })
277
+ } catch {
278
+ // Status update is best-effort; never let it mask the real result/error.
279
+ }
280
+ }
281
+ }
282
+
283
+ module.exports = { Experiment }
@@ -0,0 +1,152 @@
1
+ 'use strict'
2
+
3
+ const log = require('../../log')
4
+ const { ExperimentsClient, API_BASE_PATH } = require('./client')
5
+ const { Dataset, DatasetRecord } = require('./dataset')
6
+ const { Experiment } = require('./experiment')
7
+ const NoopExperiments = require('./noop')
8
+
9
+ // Poll `attempt` with exponential backoff until it returns true or the time
10
+ // budget is spent. Used for eventually-consistent reads (pullDataset).
11
+ async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 250, maxDelayMs = 8000 } = {}) {
12
+ const start = Date.now()
13
+ let delay = baseDelayMs
14
+ for (;;) {
15
+ // eslint-disable-next-line no-await-in-loop
16
+ if (await attempt()) return true
17
+ const remaining = maxTotalMs - (Date.now() - start)
18
+ if (remaining <= 0) return false
19
+ // eslint-disable-next-line no-await-in-loop
20
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs, remaining)))
21
+ delay *= 2
22
+ }
23
+ }
24
+
25
+ // Entry point exposed as `tracer.llmobs.experiments`. Builds datasets and runs
26
+ // experiments against the LLM Obs backend using the tracer's own config.
27
+ class Experiments {
28
+ #client
29
+ #projectName
30
+
31
+ constructor (config) {
32
+ this.#projectName = config.llmobs?.mlApp || config.service
33
+ this.#client = new ExperimentsClient({
34
+ apiKey: config.DD_API_KEY,
35
+ appKey: config.DD_APP_KEY,
36
+ site: config.site,
37
+ projectName: this.#projectName,
38
+ })
39
+ }
40
+
41
+ // Create a local dataset buffer. Pushed remotely on first experiment run.
42
+ createDataset (name, description = '') {
43
+ return new Dataset(this.#client, name, description)
44
+ }
45
+
46
+ // Pull an existing dataset by name (with its records). Polls with exponential
47
+ // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
48
+ // wait until that many records are readable.
49
+ async pullDataset (name, options = {}) {
50
+ const { expectedRecordCount, maxWaitMs = 30_000 } = options
51
+ const projectId = await this.#client.ensureProjectId()
52
+
53
+ let datasetId = null
54
+ let description = ''
55
+ let records = []
56
+ let recordIds = []
57
+ let lastError = ''
58
+
59
+ const succeeded = await retryWithBackoff(async () => {
60
+ try {
61
+ if (datasetId === null) {
62
+ const listed = await this.#client.request(
63
+ 'GET',
64
+ `${API_BASE_PATH}/${projectId}/datasets?filter[name]=${encodeURIComponent(name)}`
65
+ )
66
+ for (const item of listed?.data ?? []) {
67
+ if (item?.attributes?.name === name) {
68
+ datasetId = String(item?.id ?? '')
69
+ description = String(item?.attributes?.description ?? '')
70
+ break
71
+ }
72
+ }
73
+ if (datasetId === null) return false
74
+ }
75
+
76
+ const recs = []
77
+ const ids = []
78
+ let cursor = ''
79
+ // Follow the meta.after / page[cursor] pagination until the last page.
80
+ for (;;) {
81
+ const query = cursor ? `?page[cursor]=${encodeURIComponent(cursor)}` : ''
82
+ // eslint-disable-next-line no-await-in-loop
83
+ const resp = await this.#client.request(
84
+ 'GET',
85
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records${query}`
86
+ )
87
+ for (const item of resp?.data ?? []) {
88
+ const attrs = item?.attributes ?? item
89
+ recs.push(new DatasetRecord(attrs?.input ?? null, attrs?.expected_output ?? null, attrs?.metadata ?? {}))
90
+ ids.push(String(item?.id ?? ''))
91
+ }
92
+ cursor = resp?.meta?.after ?? ''
93
+ if (!cursor) break
94
+ }
95
+ records = recs
96
+ recordIds = ids
97
+ lastError = ''
98
+
99
+ return expectedRecordCount == null || recs.length >= expectedRecordCount
100
+ } catch (err) {
101
+ lastError = err.message
102
+ return false
103
+ }
104
+ }, { maxTotalMs: maxWaitMs })
105
+
106
+ if (datasetId === null && lastError) {
107
+ throw new Error(`Failed to list datasets in project '${this.#projectName}': ${lastError}`)
108
+ }
109
+ if (datasetId === null) {
110
+ throw new Error(`Dataset '${name}' not found in project '${this.#projectName}' (after ${maxWaitMs}ms)`)
111
+ }
112
+ if (!succeeded && lastError) {
113
+ throw new Error(`Failed to fetch records for dataset '${name}' in project '${this.#projectName}': ${lastError}`)
114
+ }
115
+ if (!succeeded && expectedRecordCount != null) {
116
+ throw new Error(
117
+ `Dataset '${name}' has ${records.length} record(s) after ${maxWaitMs}ms, expected ${expectedRecordCount} ` +
118
+ '— backend may not have finished ingesting the push'
119
+ )
120
+ }
121
+
122
+ return Dataset.fromExisting(this.#client, name, description, datasetId, projectId, records, recordIds)
123
+ }
124
+
125
+ // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }.
126
+ experiment (options) {
127
+ return new Experiment(this.#client, options)
128
+ }
129
+ }
130
+
131
+ // Factory used by the LLMObs SDK: returns a real Experiments instance when
132
+ // enabled and credentialed, otherwise a no-op that explains what's missing.
133
+ function createExperiments (config) {
134
+ if (!config.llmobs?.DD_LLMOBS_ENABLED) {
135
+ return new NoopExperiments('LLM Observability is not enabled')
136
+ }
137
+ if (!(config.DD_API_KEY) || !config.DD_APP_KEY) {
138
+ log.warn('LLMObs experiments: missing api and/or app keys, set DD_API_KEY and DD_APP_KEY')
139
+ return new NoopExperiments('DD_API_KEY and DD_APP_KEY are required for experiments')
140
+ }
141
+ if (!config.llmobs?.mlApp && !config.service) {
142
+ log.warn('LLMObs experiments: no project name configured, set DD_LLMOBS_ML_APP or DD_SERVICE')
143
+ return new NoopExperiments(
144
+ 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' +
145
+ 'tracer.init()) to name the LLM Obs project, or DD_SERVICE (or service in tracer.init()) as a fallback, ' +
146
+ 'then retry'
147
+ )
148
+ }
149
+ return new Experiments(config)
150
+ }
151
+
152
+ module.exports = { Experiments, createExperiments }
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ // No-op Experiments used when LLM Observability is disabled or the API/APP keys
4
+ // are not configured. Every operation throws a clear, actionable error rather
5
+ // than silently doing nothing, so misconfiguration surfaces immediately.
6
+ class NoopExperiments {
7
+ #reason
8
+
9
+ constructor (reason) {
10
+ this.#reason = reason || 'LLMObs experiments are not available'
11
+ }
12
+
13
+ #unavailable () {
14
+ return new Error(`LLMObs experiments unavailable: ${this.#reason}`)
15
+ }
16
+
17
+ createDataset () {
18
+ throw this.#unavailable()
19
+ }
20
+
21
+ pullDataset () {
22
+ return Promise.reject(this.#unavailable())
23
+ }
24
+
25
+ experiment () {
26
+ throw this.#unavailable()
27
+ }
28
+ }
29
+
30
+ module.exports = NoopExperiments
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ // One row of an experiment run.
4
+ class Row {
5
+ constructor (fields) {
6
+ this.index = fields.index
7
+ this.spanId = fields.spanId
8
+ this.traceId = fields.traceId
9
+ this.startNs = fields.startNs
10
+ this.durationNs = fields.durationNs
11
+ this.input = fields.input
12
+ this.output = fields.output
13
+ this.expectedOutput = fields.expectedOutput
14
+ this.errorType = fields.errorType
15
+ this.errorMessage = fields.errorMessage
16
+ this.evaluations = fields.evaluations
17
+ this.evaluationErrors = fields.evaluationErrors
18
+ }
19
+
20
+ get isError () {
21
+ return this.errorType !== null
22
+ }
23
+ }
24
+
25
+ // Returned by Experiment.run().
26
+ class ExperimentResult {
27
+ constructor (experimentId, rows, url) {
28
+ this.experimentId = experimentId
29
+ this.rows = rows
30
+ this.url = url
31
+ }
32
+ }
33
+
34
+ module.exports = { Row, ExperimentResult }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const NoopExperiments = require('./experiments/noop')
4
+
3
5
  class NoopLLMObs {
4
6
  constructor (noopTracer) {
5
7
  this._tracer = noopTracer
@@ -9,6 +11,10 @@ class NoopLLMObs {
9
11
  return false
10
12
  }
11
13
 
14
+ get experiments () {
15
+ return new NoopExperiments('LLM Observability is not enabled')
16
+ }
17
+
12
18
  enable (options) {}
13
19
 
14
20
  disable () {}
@@ -13,6 +13,7 @@ const {
13
13
  const SPAN_NAME_TO_KIND_MAPPING = {
14
14
  // embeddings
15
15
  embed: 'embedding',
16
+ embedMany: 'embedding',
16
17
  // text generation
17
18
  generateText: 'workflow',
18
19
  streamText: 'workflow',
@@ -237,6 +238,7 @@ class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
237
238
 
238
239
  switch (operation) {
239
240
  case 'embed':
241
+ case 'embedMany':
240
242
  this.setEmbeddingTags(span, ctx)
241
243
  break
242
244
  case 'generateText':
@@ -260,14 +262,29 @@ class VercelAiTelemetryPlugin extends BaseLLMObsPlugin {
260
262
  setEmbeddingTags (span, ctx) {
261
263
  const { event, result } = ctx
262
264
 
265
+ // works for both embed and embedMany, either single string or array of strings
263
266
  const input = event.value
264
- const embedding = result?.embedding
265
- const embeddingTextResult = embedding ? `[1 embedding(s) returned with size ${embedding.length}]` : ''
267
+
268
+ if (!result) {
269
+ this._tagger.tagEmbeddingIO(span, input)
270
+ return
271
+ }
272
+
273
+ // embed returns single embedding, embedMany returns an array of embeddings
274
+ const singleEmbedding = result.embedding
275
+ const multiEmbedding = result.embeddings
276
+
277
+ let embeddingTextResult = ''
278
+ if (singleEmbedding) {
279
+ embeddingTextResult = `[1 embedding(s) returned with size ${singleEmbedding.length}]`
280
+ } else if (Array.isArray(multiEmbedding)) {
281
+ embeddingTextResult = `[${multiEmbedding.length} embedding(s) returned with size ${multiEmbedding[0]?.length}]`
282
+ }
266
283
 
267
284
  this._tagger.tagEmbeddingIO(span, input, embeddingTextResult)
268
285
 
269
286
  this._tagger.tagMetrics(span, {
270
- inputTokens: result?.usage?.tokens,
287
+ inputTokens: result.usage?.tokens,
271
288
  })
272
289
  }
273
290
 
@@ -59,7 +59,7 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
59
59
  const { delta } = chunk
60
60
  if (!delta) continue
61
61
 
62
- const lastBlock = response.content[response.content.length - 1]
62
+ const lastBlock = response.content.at(-1)
63
63
  if (!lastBlock) continue
64
64
 
65
65
  if (delta.type === 'thinking_delta') {
@@ -77,7 +77,7 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
77
77
  break
78
78
  }
79
79
  case 'content_block_stop': {
80
- const lastBlock = response.content[response.content.length - 1]
80
+ const lastBlock = response.content.at(-1)
81
81
  if (!lastBlock) break
82
82
  if (lastBlock.type === 'tool_use') {
83
83
  const input = lastBlock.input ?? '{}'
@@ -74,7 +74,7 @@ class NextStreamLLMObsPlugin extends LLMObsPlugin {
74
74
 
75
75
  const { streamInputs: inputs, chunks } = streamData
76
76
  const input = inputs == null ? undefined : formatIO(inputs)
77
- const lastChunk = chunks.length > 0 ? chunks[chunks.length - 1] : undefined
77
+ const lastChunk = chunks.length > 0 ? chunks.at(-1) : undefined
78
78
  const output = !hasError && lastChunk != null ? formatIO(lastChunk) : undefined
79
79
 
80
80
  this._tagger.tagTextIO(span, input, output)
@@ -6,6 +6,12 @@ const INPUT_TYPE_TEXT = 'input_text'
6
6
 
7
7
  const IMAGE_FALLBACK = '[image]'
8
8
  const FILE_FALLBACK = '[file]'
9
+ const AUDIO_FALLBACK = '[audio]'
10
+
11
+ // OpenAI audio `format` values that don't map cleanly to `audio/<format>`.
12
+ const AUDIO_MIME_TYPES = {
13
+ mp3: 'audio/mpeg',
14
+ }
9
15
 
10
16
  module.exports = {
11
17
  INPUT_TYPE_IMAGE,
@@ -13,4 +19,6 @@ module.exports = {
13
19
  INPUT_TYPE_TEXT,
14
20
  IMAGE_FALLBACK,
15
21
  FILE_FALLBACK,
22
+ AUDIO_FALLBACK,
23
+ AUDIO_MIME_TYPES,
16
24
  }