dd-trace 6.10.0 → 6.12.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 (173) hide show
  1. package/LICENSE-3rdparty.csv +5 -0
  2. package/README.md +0 -17
  3. package/index.d.ts +128 -6
  4. package/initialize.mjs +28 -17
  5. package/openfeature.js +2 -2
  6. package/package.json +27 -23
  7. package/packages/datadog-esbuild/index.js +0 -34
  8. package/packages/datadog-instrumentations/src/anthropic.js +210 -15
  9. package/packages/datadog-instrumentations/src/aws-sdk.js +9 -1
  10. package/packages/datadog-instrumentations/src/browser-bunyan.js +11 -0
  11. package/packages/datadog-instrumentations/src/bunyan.js +3 -16
  12. package/packages/datadog-instrumentations/src/cucumber.js +30 -15
  13. package/packages/datadog-instrumentations/src/cypress-config.js +182 -16
  14. package/packages/datadog-instrumentations/src/cypress-legacy-finalizers.js +30 -0
  15. package/packages/datadog-instrumentations/src/cypress.js +2 -0
  16. package/packages/datadog-instrumentations/src/fastify.js +2 -0
  17. package/packages/datadog-instrumentations/src/helpers/bunyan.js +22 -0
  18. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -1
  19. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +9 -4
  20. package/packages/datadog-instrumentations/src/helpers/register.js +10 -8
  21. package/packages/datadog-instrumentations/src/jest.js +25 -17
  22. package/packages/datadog-instrumentations/src/mariadb.js +278 -21
  23. package/packages/datadog-instrumentations/src/mocha/main.js +539 -55
  24. package/packages/datadog-instrumentations/src/mocha/utils.js +135 -19
  25. package/packages/datadog-instrumentations/src/playwright-reporter.js +247 -0
  26. package/packages/datadog-instrumentations/src/playwright.js +182 -35
  27. package/packages/datadog-instrumentations/src/router.js +2 -0
  28. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +6 -8
  29. package/packages/datadog-instrumentations/src/vitest-main.js +11 -3
  30. package/packages/datadog-plugin-aws-sdk/src/base.js +1 -0
  31. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +163 -44
  32. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +2 -8
  33. package/packages/datadog-plugin-azure-functions/src/index.js +1 -1
  34. package/packages/datadog-plugin-browser-bunyan/src/index.js +9 -0
  35. package/packages/datadog-plugin-bunyan/src/index.js +5 -8
  36. package/packages/datadog-plugin-cucumber/src/index.js +11 -0
  37. package/packages/datadog-plugin-cypress/src/after-run.js +5 -1
  38. package/packages/datadog-plugin-cypress/src/after-spec.js +5 -1
  39. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +81 -29
  40. package/packages/datadog-plugin-cypress/src/finalization.js +76 -0
  41. package/packages/datadog-plugin-cypress/src/index.js +12 -6
  42. package/packages/datadog-plugin-cypress/src/plugin.js +16 -3
  43. package/packages/datadog-plugin-dd-trace-api/src/index.js +4 -0
  44. package/packages/datadog-plugin-electron/src/index.js +2 -0
  45. package/packages/datadog-plugin-fs/src/index.js +1 -1
  46. package/packages/datadog-plugin-jest/src/index.js +6 -0
  47. package/packages/datadog-plugin-mocha/src/index.js +56 -1
  48. package/packages/datadog-plugin-nats/src/index.js +1 -1
  49. package/packages/datadog-plugin-openai/src/tracing.js +41 -30
  50. package/packages/datadog-plugin-openai-agents/src/integration.js +1 -0
  51. package/packages/datadog-plugin-playwright/src/index.js +46 -13
  52. package/packages/datadog-plugin-vitest/src/index.js +6 -0
  53. package/packages/datadog-webpack/index.js +0 -11
  54. package/packages/dd-trace/index.js +0 -1
  55. package/packages/dd-trace/src/agent/info.js +6 -5
  56. package/packages/dd-trace/src/aiguard/client.js +100 -1
  57. package/packages/dd-trace/src/aiguard/errors.js +41 -0
  58. package/packages/dd-trace/src/aiguard/evaluation.js +357 -0
  59. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +70 -0
  60. package/packages/dd-trace/src/aiguard/integrations/index.js +4 -0
  61. package/packages/dd-trace/src/aiguard/messages/anthropic.js +423 -0
  62. package/packages/dd-trace/src/aiguard/messages/openai.js +1 -24
  63. package/packages/dd-trace/src/aiguard/messages/utils.js +28 -0
  64. package/packages/dd-trace/src/aiguard/messages/vercel-ai.js +1 -11
  65. package/packages/dd-trace/src/aiguard/sdk.js +22 -278
  66. package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter.js +9 -12
  67. package/packages/dd-trace/src/appsec/reporter.js +2 -0
  68. package/packages/dd-trace/src/appsec/telemetry/index.js +13 -0
  69. package/packages/dd-trace/src/appsec/telemetry/rasp.js +16 -0
  70. package/packages/dd-trace/src/appsec/telemetry/waf.js +24 -0
  71. package/packages/dd-trace/src/carrier.js +107 -48
  72. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +1 -0
  73. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +5 -5
  74. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +29 -3
  75. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +27 -9
  76. package/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js +21 -5
  77. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +164 -0
  78. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +26 -8
  79. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +365 -32
  80. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +6 -4
  81. package/packages/dd-trace/src/ci-visibility/exporters/request.js +201 -0
  82. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +5 -3
  83. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -5
  84. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +7 -4
  85. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  86. package/packages/dd-trace/src/ci-visibility/requests/rate-limit.js +39 -0
  87. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -6
  88. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +4 -2
  89. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +10 -4
  90. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +5 -5
  91. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  92. package/packages/dd-trace/src/config/index.js +13 -2
  93. package/packages/dd-trace/src/config/major-overrides.js +7 -0
  94. package/packages/dd-trace/src/config/supported-configurations.json +14 -0
  95. package/packages/dd-trace/src/datastreams/index.js +2 -0
  96. package/packages/dd-trace/src/datastreams/manager.js +9 -2
  97. package/packages/dd-trace/src/datastreams/processor.js +15 -13
  98. package/packages/dd-trace/src/datastreams/size.js +5 -0
  99. package/packages/dd-trace/src/debugger/constants.js +1 -0
  100. package/packages/dd-trace/src/debugger/devtools_client/condition.js +1 -1
  101. package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -11
  102. package/packages/dd-trace/src/debugger/index.js +4 -2
  103. package/packages/dd-trace/src/debugger/inspect-segment.js +154 -0
  104. package/packages/dd-trace/src/evp_proxy/constants.js +8 -0
  105. package/packages/dd-trace/src/evp_proxy/direct.js +64 -0
  106. package/packages/dd-trace/src/evp_proxy/discovery.js +133 -0
  107. package/packages/dd-trace/src/evp_proxy/path.js +22 -0
  108. package/packages/dd-trace/src/exporters/agent/writer.js +49 -9
  109. package/packages/dd-trace/src/exporters/common/buffering-exporter.js +6 -4
  110. package/packages/dd-trace/src/exporters/common/request.js +5 -2
  111. package/packages/dd-trace/src/exporters/common/writer.js +11 -6
  112. package/packages/dd-trace/src/llmobs/experiments/client.js +167 -5
  113. package/packages/dd-trace/src/llmobs/experiments/dataset.js +484 -92
  114. package/packages/dd-trace/src/llmobs/experiments/experiment.js +302 -29
  115. package/packages/dd-trace/src/llmobs/experiments/index.js +75 -64
  116. package/packages/dd-trace/src/llmobs/experiments/noop.js +135 -7
  117. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  118. package/packages/dd-trace/src/llmobs/experiments/util.js +101 -9
  119. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +12 -3
  120. package/packages/dd-trace/src/llmobs/plugins/base.js +4 -2
  121. package/packages/dd-trace/src/noop/proxy.js +3 -4
  122. package/packages/dd-trace/src/openfeature/constants/constants.js +0 -18
  123. package/packages/dd-trace/src/openfeature/flagging_provider.js +18 -2
  124. package/packages/dd-trace/src/openfeature/index.js +7 -4
  125. package/packages/dd-trace/src/openfeature/remote_config.js +1 -1
  126. package/packages/dd-trace/src/openfeature/writers/base.js +153 -11
  127. package/packages/dd-trace/src/openfeature/writers/exposures.js +62 -14
  128. package/packages/dd-trace/src/openfeature/writers/util.js +97 -15
  129. package/packages/dd-trace/src/opentelemetry/context_manager.js +7 -7
  130. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +2 -1
  131. package/packages/dd-trace/src/opentelemetry/logs/logger.js +67 -11
  132. package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +14 -3
  133. package/packages/dd-trace/src/opentelemetry/metrics/index.js +25 -12
  134. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +2 -5
  135. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +96 -43
  136. package/packages/dd-trace/src/opentracing/propagation/text_map.js +22 -13
  137. package/packages/dd-trace/src/opentracing/tracer.js +5 -2
  138. package/packages/dd-trace/src/otel-thread-ctx.js +387 -0
  139. package/packages/dd-trace/src/plugin_manager.js +3 -3
  140. package/packages/dd-trace/src/plugins/ci_plugin.js +6 -1
  141. package/packages/dd-trace/src/plugins/index.js +1 -0
  142. package/packages/dd-trace/src/plugins/util/jest.js +4 -3
  143. package/packages/dd-trace/src/plugins/util/test.js +9 -9
  144. package/packages/dd-trace/src/plugins/util/web.js +3 -0
  145. package/packages/dd-trace/src/profiling/profiler.js +1 -1
  146. package/packages/dd-trace/src/profiling/profilers/space.js +1 -3
  147. package/packages/dd-trace/src/profiling/profilers/wall.js +37 -90
  148. package/packages/dd-trace/src/profiling/webspan-utils.js +45 -0
  149. package/packages/dd-trace/src/proxy.js +38 -29
  150. package/packages/dd-trace/src/ritm.js +9 -2
  151. package/packages/dd-trace/src/serverless.js +37 -0
  152. package/packages/dd-trace/src/span_format.js +1 -0
  153. package/packages/dd-trace/src/span_stats.js +30 -3
  154. package/packages/dd-trace/src/storage-channels.js +86 -0
  155. package/packages/dd-trace/src/telemetry/metrics.js +62 -8
  156. package/packages/dd-trace/src/telemetry/send-data.js +1 -0
  157. package/packages/dd-trace/src/tracer.js +9 -2
  158. package/packages/dd-trace/src/tracer_metadata.js +14 -1
  159. package/packages/dd-trace/src/web-tags-cache.js +132 -0
  160. package/vendor/dist/@datadog/openfeature-node-server/LICENSE +201 -0
  161. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -0
  162. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  163. package/vendor/dist/https-proxy-agent/LICENSE +22 -0
  164. package/vendor/dist/https-proxy-agent/index.js +4 -0
  165. package/vendor/dist/protobufjs/index.js +1 -1
  166. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  167. package/vendor/dist/proxy-from-env/LICENSE +20 -0
  168. package/vendor/dist/proxy-from-env/index.js +1 -0
  169. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +0 -65
  170. package/packages/datadog-webpack/src/optional-peer-loader.js +0 -17
  171. package/packages/dd-trace/src/feature-registry.js +0 -29
  172. package/packages/dd-trace/src/openfeature/register.js +0 -35
  173. package/packages/dd-trace/src/openfeature/require-provider.js +0 -18
@@ -1,24 +1,27 @@
1
1
  'use strict'
2
2
 
3
3
  const id = require('../../id')
4
+ const log = require('../../log')
4
5
 
5
- const { API_BASE_PATH } = require('./client')
6
6
  const { Row, ExperimentResult, ExperimentRun } = require('./result')
7
7
  const {
8
- buildExperimentTagObject,
9
8
  buildSpanMetadata,
10
9
  buildTags,
10
+ durationNs,
11
11
  hasEntries,
12
12
  inferMetricType,
13
13
  normalizeEvaluators,
14
+ mergeTags,
14
15
  normalizeJsonMetricValue,
16
+ recordTagsToObject,
15
17
  sleep,
16
18
  stringify,
19
+ timestampMs,
17
20
  validateEvaluatorName,
18
21
  } = require('./util')
19
22
 
20
23
  // One span per experiment row (LLM Obs experiment span wire format).
21
- function toSpan (row, metadata, ids, spanName, userTags) {
24
+ function toSpan (row, metadata, ids, spanName, userTags, recordTags) {
22
25
  const meta = {
23
26
  input: row.input ?? null,
24
27
  output: row.output ?? null,
@@ -28,9 +31,23 @@ function toSpan (row, metadata, ids, spanName, userTags) {
28
31
  meta.metadata = metadata
29
32
  }
30
33
  if (row.isError) {
31
- meta.error = { type: row.errorType ?? '', message: row.errorMessage ?? '', stack: '' }
34
+ meta.error = { type: row.errorType ?? '', message: row.errorMessage ?? '', stack: row.errorStack ?? '' }
32
35
  }
33
36
 
37
+ const tags = buildTags({
38
+ ...userTags,
39
+ ...recordTagsToObject(recordTags),
40
+ }, {
41
+ experiment_id: ids.experimentId,
42
+ run_id: ids.runId,
43
+ run_iteration: ids.runIteration,
44
+ project_id: ids.projectId,
45
+ dataset_id: ids.datasetId,
46
+ dataset_record_id: ids.datasetRecordId,
47
+ dataset_name: ids.datasetName,
48
+ experiment_name: ids.experimentName,
49
+ })
50
+
34
51
  return {
35
52
  span_id: row.spanId,
36
53
  trace_id: row.traceId,
@@ -41,13 +58,7 @@ function toSpan (row, metadata, ids, spanName, userTags) {
41
58
  duration: row.durationNs,
42
59
  status: row.isError ? 'error' : 'ok',
43
60
  meta,
44
- tags: buildTags(userTags, {
45
- experiment_id: ids.experimentId,
46
- run_id: ids.runId,
47
- run_iteration: ids.runIteration,
48
- dataset_id: ids.datasetId,
49
- dataset_record_id: ids.datasetRecordId,
50
- }),
61
+ tags,
51
62
  }
52
63
  }
53
64
 
@@ -91,11 +102,34 @@ function createFallbackSpanContext (startNs) {
91
102
  return { spanId, traceId }
92
103
  }
93
104
 
105
+ function normalizeError (error) {
106
+ if (error == null) {
107
+ return { errorType: null, errorMessage: null, errorStack: '' }
108
+ }
109
+
110
+ if (typeof error === 'string') {
111
+ return { errorType: 'Error', errorMessage: error, errorStack: '' }
112
+ }
113
+
114
+ return {
115
+ errorType: error.type ?? error.name ?? 'Error',
116
+ errorMessage: error.message ?? String(error),
117
+ errorStack: error.stack ?? '',
118
+ }
119
+ }
120
+
121
+ function errorMessage (error) {
122
+ if (error == null) return null
123
+ if (typeof error === 'string') return error
124
+ return error.message ?? String(error)
125
+ }
126
+
94
127
  // Builder + run() orchestration: runs rows sequentially, emits one root span
95
128
  // per dataset row, and posts spans + metrics to the experiments events API.
96
129
  class Experiment {
97
130
  #client
98
131
  #llmobs
132
+ #external
99
133
  #name
100
134
  #description
101
135
  #dataset
@@ -104,24 +138,37 @@ class Experiment {
104
138
  #summaryEvaluators
105
139
  #config
106
140
  #tags
141
+ #metadata
142
+ #projectId
107
143
  #experimentId
144
+ #runId
145
+ #runIteration
108
146
 
109
147
  constructor (client, options = {}, llmobs) {
110
148
  if (!options.name) throw new Error('Experiment name is required')
111
- if (!options.dataset) throw new Error('Experiment dataset is required')
112
- if (typeof options.task !== 'function') throw new Error('Experiment task is required')
149
+ this.#external = options.external === true
150
+ if (!this.#external) {
151
+ if (!options.dataset) throw new Error('Experiment dataset is required')
152
+ if (typeof options.task !== 'function') throw new Error('Experiment task is required')
153
+ }
113
154
 
114
155
  this.#client = client
115
156
  this.#llmobs = llmobs
116
157
  this.#name = options.name
117
158
  this.#description = options.description ?? ''
118
- this.#dataset = options.dataset
159
+ this.#dataset = options.dataset ?? {}
119
160
  this.#task = options.task
120
161
  this.#evaluators = normalizeEvaluators(options.evaluators, 'row')
121
162
  this.#summaryEvaluators = normalizeEvaluators(options.summaryEvaluators, 'summary')
122
163
  this.#config = { ...options.config }
164
+ const filterTags = this.#dataset.filterTags?.() ?? []
165
+ if (filterTags.length > 0) this.#config.filtered_record_tags = filterTags
123
166
  this.#tags = { ...options.tags }
167
+ this.#metadata = { ...options.metadata }
168
+ this.#projectId = null
124
169
  this.#experimentId = null
170
+ this.#runId = null
171
+ this.#runIteration = null
125
172
  }
126
173
 
127
174
  name () {
@@ -137,7 +184,177 @@ class Experiment {
137
184
  return `${this.#client.appBase}/llm/experiments/${this.#experimentId}`
138
185
  }
139
186
 
187
+ /**
188
+ * @returns {Promise<Experiment>}
189
+ */
190
+ async start () {
191
+ if (!this.#external) throw new Error('Experiment is not externally driven')
192
+
193
+ this.#projectId = await this.#client.ensureProjectId()
194
+
195
+ const dataset = await this.#ensureExternalDataset()
196
+ const attributes = {
197
+ name: this.#name,
198
+ project_id: this.#projectId,
199
+ dataset_id: dataset.id,
200
+ description: this.#description,
201
+ ensure_unique: true,
202
+ run_count: 1,
203
+ }
204
+ if (dataset.version != null) attributes.dataset_version = dataset.version
205
+ if (hasEntries(this.#config)) attributes.config = this.#config
206
+
207
+ const metadata = { ...this.#metadata }
208
+ if (hasEntries(this.#tags)) metadata.tags = buildTags(this.#tags, {})
209
+ if (hasEntries(metadata)) attributes.metadata = metadata
210
+
211
+ let created
212
+ try {
213
+ created = await this.#client.createExperiment(attributes)
214
+ } catch (err) {
215
+ throw new Error(`Failed to create experiment '${this.#name}': ${err.message}`)
216
+ }
217
+ this.#experimentId = created.experimentId
218
+ this.#runId = id().toString(16).padStart(16, '0')
219
+ this.#runIteration = 0
220
+ return this
221
+ }
222
+
223
+ async #ensureExternalDataset () {
224
+ if (this.#dataset.id) {
225
+ return { id: this.#dataset.id, version: this.#dataset.version }
226
+ }
227
+
228
+ const name = this.#dataset.name ?? `${this.#name} dataset`
229
+ const description = this.#dataset.description ??
230
+ `Placeholder dataset for externally-driven experiment '${this.#name}'`
231
+ let response
232
+ try {
233
+ response = await this.#client.createDataset(this.#projectId, { name, description })
234
+ } catch (err) {
235
+ throw new Error(`Failed to create placeholder dataset '${name}': ${err.message}`)
236
+ }
237
+
238
+ const id = response.id()
239
+ if (id === null) {
240
+ throw new Error(`Failed to create placeholder dataset '${name}': backend response is missing dataset id`)
241
+ }
242
+
243
+ this.#dataset = {
244
+ ...this.#dataset,
245
+ id,
246
+ version: this.#dataset.version ?? response.version(),
247
+ }
248
+ return { id: this.#dataset.id, version: this.#dataset.version }
249
+ }
250
+
251
+ /**
252
+ * @param {object} input
253
+ * @returns {Promise<{experimentId: string, spanId: string, traceId: string, url: string | null}>}
254
+ */
255
+ async submitSpan (input = {}) {
256
+ if (this.#experimentId === null || this.#projectId === null) {
257
+ throw new Error('Experiment has not been started')
258
+ }
259
+
260
+ const startMs = timestampMs(input.startedAt)
261
+ const startNs = Math.round(startMs * 1e6)
262
+ const { spanId, traceId } = createFallbackSpanContext(startNs)
263
+ const error = normalizeError(input.error)
264
+ const row = new Row({
265
+ spanId,
266
+ traceId,
267
+ startNs,
268
+ durationNs: durationNs(input, startMs),
269
+ input: input.input,
270
+ output: input.output,
271
+ expectedOutput: input.expectedOutput,
272
+ errorType: error.errorType,
273
+ errorMessage: error.errorMessage,
274
+ errorStack: error.errorStack,
275
+ evaluations: {},
276
+ evaluationErrors: {},
277
+ })
278
+
279
+ const span = toSpan(row, input.metadata, {
280
+ experimentId: this.#experimentId,
281
+ projectId: this.#projectId,
282
+ datasetId: this.#dataset.id,
283
+ datasetRecordId: input.datasetRecordId,
284
+ runId: input.runId ?? this.#runId,
285
+ runIteration: input.runIteration ?? this.#runIteration,
286
+ }, input.name ?? this.#name, mergeTags(this.#tags, input.tags))
287
+
288
+ await this.#postEvents(this.#experimentId, [span], [])
289
+
290
+ return {
291
+ experimentId: this.#experimentId,
292
+ spanId,
293
+ traceId,
294
+ url: this.url(),
295
+ }
296
+ }
297
+
298
+ /**
299
+ * @param {{experimentId?: string, spanId: string, traceId?: string}} span
300
+ * @param {object[]} metrics
301
+ * @returns {Promise<void>}
302
+ */
303
+ async submitEvaluationMetrics (span, metrics) {
304
+ if (this.#experimentId === null) {
305
+ throw new Error('Experiment has not been started')
306
+ }
307
+ if (!span?.spanId) {
308
+ throw new Error('Experiment span id is required')
309
+ }
310
+
311
+ const experimentId = span.experimentId ?? this.#experimentId
312
+ if (experimentId !== this.#experimentId) {
313
+ throw new Error(`Experiment span belongs to '${experimentId}', not '${this.#experimentId}'`)
314
+ }
315
+ if (!span.traceId) {
316
+ throw new Error('Experiment trace id is required')
317
+ }
318
+
319
+ const payload = []
320
+ for (const metric of metrics) {
321
+ validateEvaluatorName(metric.label)
322
+ const metricError = errorMessage(metric.error)
323
+ if (metric.value === undefined && metricError == null) {
324
+ log.warn('LLMObs experiments: skipping external metric %s because it has neither value nor error', metric.label)
325
+ continue
326
+ }
327
+ payload.push(toMetric(
328
+ metric.label,
329
+ metric.value,
330
+ metricError,
331
+ span.spanId,
332
+ span.traceId,
333
+ timestampMs(metric.timestamp),
334
+ experimentId,
335
+ mergeTags(this.#tags, metric.tags),
336
+ metric.source ?? 'custom'
337
+ ))
338
+ }
339
+
340
+ if (payload.length === 0) return
341
+ await this.#postEvents(experimentId, [], payload)
342
+ }
343
+
344
+ /**
345
+ * @param {object} options
346
+ * @returns {Promise<void>}
347
+ */
348
+ async close (options = {}) {
349
+ if (this.#experimentId === null) return
350
+ const error = errorMessage(options.error)
351
+ const status = options.status ?? (error === null ? 'completed' : 'failed')
352
+ await this.#updateStatus(this.#experimentId, status, error, false)
353
+ }
354
+
140
355
  async run (options = {}) {
356
+ if (this.#external) throw new Error('Externally-driven experiments cannot run local tasks')
357
+
141
358
  const {
142
359
  maxRetries = 0,
143
360
  retryDelay = (attempt) => 100 * (attempt + 1),
@@ -168,17 +385,16 @@ class Experiment {
168
385
  }
169
386
  const datasetVersion = this.#dataset.version()
170
387
  if (datasetVersion !== null) attributes.dataset_version = datasetVersion
388
+ // eslint-disable-next-line no-restricted-syntax -- faster than tracking entries while copying arbitrary config
171
389
  if (Object.keys(this.#config).length > 0) attributes.config = this.#config
172
390
 
173
391
  let created
174
392
  try {
175
- created = await this.#client.request('POST', `${API_BASE_PATH}/experiments`, {
176
- data: { type: 'experiments', attributes },
177
- })
393
+ created = await this.#client.createExperiment(attributes)
178
394
  } catch (err) {
179
395
  throw new Error(`Failed to create experiment '${this.#name}': ${err.message}`)
180
396
  }
181
- this.#experimentId = created?.data?.id ?? null
397
+ this.#experimentId = created.experimentId
182
398
  const experimentId = this.#experimentId
183
399
  const runId = id().toString(16).padStart(16, '0')
184
400
  const runIteration = 0
@@ -249,9 +465,11 @@ class Experiment {
249
465
  projectId,
250
466
  datasetId,
251
467
  datasetRecordId,
468
+ datasetName: this.#dataset.name(),
469
+ experimentName: this.#name,
252
470
  runId,
253
471
  runIteration,
254
- }, this.#task.name || this.#name, this.#tags))
472
+ }, this.#task.name || this.#name, this.#tags, record.tags))
255
473
  }
256
474
  }
257
475
 
@@ -316,7 +534,7 @@ class Experiment {
316
534
  dataset_name: this.#dataset.name(),
317
535
  experiment_name: this.#name,
318
536
  }
319
- const tags = buildExperimentTagObject(this.#tags, autoTags)
537
+ const tags = mergeTags(this.#tags, { ...recordTagsToObject(record.tags), ...autoTags })
320
538
 
321
539
  const execute = () => this.#runWithRetries(
322
540
  () => this.#task(record.input, this.#config, record.metadata),
@@ -454,24 +672,79 @@ class Experiment {
454
672
  async #postEvents (experimentId, spans, metrics) {
455
673
  const attributes = { metrics }
456
674
  if (spans.length > 0) attributes.spans = spans
457
- await this.#client.request('POST', `${API_BASE_PATH}/experiments/${experimentId}/events`, {
458
- data: { type: 'experiments', attributes },
459
- })
675
+ await this.#client.postExperimentEvents(experimentId, attributes)
460
676
  }
461
677
 
462
- async #updateStatus (experimentId, status, error) {
678
+ async #updateStatus (experimentId, status, error, suppressErrors = true) {
463
679
  if (!experimentId) return
464
680
  // The experiment-update model has no status field, so this is a direct PATCH.
465
681
  const attributes = { status }
466
682
  if (error !== null) attributes.error = error
467
683
  try {
468
- await this.#client.request('PATCH', `${API_BASE_PATH}/experiments/${experimentId}`, {
469
- data: { type: 'experiments', attributes },
470
- })
471
- } catch {
684
+ await this.#client.updateExperiment(experimentId, attributes)
685
+ } catch (err) {
686
+ if (!suppressErrors) throw err
472
687
  // Status update is best-effort; never let it mask the real result/error.
473
688
  }
474
689
  }
475
690
  }
476
691
 
477
- module.exports = { Experiment, normalizeEvaluators, validateEvaluatorName }
692
+ class ExternalExperiment {
693
+ #experiment
694
+
695
+ /**
696
+ * @param {Pick<Experiment, 'name' | 'experimentId' | 'url' | 'submitSpan' | 'submitEvaluationMetrics' |
697
+ * 'close'>} experiment
698
+ */
699
+ constructor (experiment) {
700
+ this.#experiment = experiment
701
+ }
702
+
703
+ /**
704
+ * @returns {string}
705
+ */
706
+ name () {
707
+ return this.#experiment.name()
708
+ }
709
+
710
+ /**
711
+ * @returns {string | null}
712
+ */
713
+ experimentId () {
714
+ return this.#experiment.experimentId()
715
+ }
716
+
717
+ /**
718
+ * @returns {string | null}
719
+ */
720
+ url () {
721
+ return this.#experiment.url()
722
+ }
723
+
724
+ /**
725
+ * @param {object} [input]
726
+ * @returns {Promise<object>}
727
+ */
728
+ submitSpan (input) {
729
+ return this.#experiment.submitSpan(input)
730
+ }
731
+
732
+ /**
733
+ * @param {object} span
734
+ * @param {object[]} metrics
735
+ * @returns {Promise<void>}
736
+ */
737
+ submitEvaluationMetrics (span, metrics) {
738
+ return this.#experiment.submitEvaluationMetrics(span, metrics)
739
+ }
740
+
741
+ /**
742
+ * @param {object} [options]
743
+ * @returns {Promise<void>}
744
+ */
745
+ close (options) {
746
+ return this.#experiment.close(options)
747
+ }
748
+ }
749
+
750
+ module.exports = { Experiment, ExternalExperiment, normalizeEvaluators, validateEvaluatorName }
@@ -1,9 +1,10 @@
1
1
  'use strict'
2
2
 
3
3
  const log = require('../../log')
4
- const { ExperimentsClient, API_BASE_PATH } = require('./client')
4
+ const { ExperimentsClient } = require('./client')
5
5
  const { Dataset, DatasetRecord } = require('./dataset')
6
- const { Experiment } = require('./experiment')
6
+ const { Experiment, ExternalExperiment } = require('./experiment')
7
+ const { validateTagsList } = require('./util')
7
8
  const NoopExperiments = require('./noop')
8
9
 
9
10
  // Poll `attempt` with exponential backoff until it returns true or the time
@@ -26,17 +27,27 @@ async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 2
26
27
  // experiments against the LLM Obs backend using the tracer's own config.
27
28
  class Experiments {
28
29
  #client
30
+ #config
29
31
  #llmobs
30
32
  #projectName
31
33
 
32
34
  constructor (config, llmobs) {
35
+ this.#config = config
33
36
  this.#llmobs = llmobs
34
37
  this.#projectName = config.llmobs?.mlApp || config.service
35
- this.#client = new ExperimentsClient({
36
- apiKey: config.DD_API_KEY,
37
- appKey: config.DD_APP_KEY,
38
- site: config.site,
39
- projectName: this.#projectName,
38
+ this.#client = this.#clientForProject(this.#projectName)
39
+ }
40
+
41
+ /**
42
+ * @param {string} projectName
43
+ * @returns {ExperimentsClient}
44
+ */
45
+ #clientForProject (projectName) {
46
+ return new ExperimentsClient({
47
+ apiKey: this.#config.DD_API_KEY,
48
+ appKey: this.#config.DD_APP_KEY,
49
+ site: this.#config.site,
50
+ projectName,
40
51
  })
41
52
  }
42
53
 
@@ -56,7 +67,9 @@ class Experiments {
56
67
  if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
57
68
  recordIds.add(record.id)
58
69
  }
59
- dataset.addRecord(new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id))
70
+ dataset.addRecord(
71
+ new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id, record.tags)
72
+ )
60
73
  }
61
74
  }
62
75
  return dataset
@@ -64,73 +77,48 @@ class Experiments {
64
77
 
65
78
  // Pull an existing dataset by name (with its records). Polls with exponential
66
79
  // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
67
- // wait until that many records are readable.
80
+ // wait until that many records are readable. Pass `tags` to filter records by
81
+ // dataset record tags.
68
82
  async pullDataset (name, options = {}) {
69
- const { expectedRecordCount, maxWaitMs = 30_000, version } = options
83
+ const { expectedRecordCount, maxWaitMs = 30_000, tags, version } = options
84
+ const filterTags = validateTagsList(tags)
70
85
  const projectId = await this.#client.ensureProjectId()
71
86
 
72
- let datasetId = null
73
- let description = ''
87
+ let pulledDataset = null
74
88
  let records = []
75
- let recordIds = []
76
- let datasetVersion = version ?? null
89
+ const datasetVersion = version ?? null
77
90
  let latestVersion = null
78
91
  let lastError = ''
79
92
 
80
93
  const succeeded = await retryWithBackoff(async () => {
81
94
  try {
82
- if (datasetId === null) {
83
- const listed = await this.#client.request(
84
- 'GET',
85
- `${API_BASE_PATH}/${projectId}/datasets?filter[name]=${encodeURIComponent(name)}`
86
- )
87
- const datasets = listed?.data
88
- if (datasets) {
89
- for (const item of datasets) {
90
- if (item?.attributes?.name === name) {
91
- datasetId = String(item?.id ?? '')
92
- description = String(item?.attributes?.description ?? '')
93
- latestVersion = item?.attributes?.current_version ?? null
94
- datasetVersion = version ?? latestVersion
95
- break
96
- }
95
+ if (pulledDataset === null) {
96
+ const datasets = await this.#client.listDatasets(projectId, { name })
97
+ for (const dataset of datasets) {
98
+ if (dataset.name() === name) {
99
+ pulledDataset = dataset
100
+ latestVersion = dataset.latestVersion()
101
+ break
97
102
  }
98
103
  }
99
- if (datasetId === null) return false
104
+ if (pulledDataset === null) return false
100
105
  }
101
106
 
102
107
  const recs = []
103
- const ids = []
104
108
  let cursor = ''
105
109
  // Follow the meta.after / page[cursor] pagination until the last page.
106
110
  for (;;) {
107
- const query = new URLSearchParams()
108
- if (cursor) query.set('page[cursor]', cursor)
109
- if (datasetVersion !== null) query.set('filter[version]', String(datasetVersion))
110
111
  // eslint-disable-next-line no-await-in-loop
111
- const resp = await this.#client.request(
112
- 'GET',
113
- `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records?${query.toString()}`
114
- )
115
- const recordData = resp?.data
116
- if (recordData) {
117
- for (const item of recordData) {
118
- const attrs = item?.attributes ?? item
119
- const recordId = String(item?.id ?? attrs?.id ?? '')
120
- recs.push(new DatasetRecord(
121
- attrs?.input ?? null,
122
- attrs?.expected_output ?? null,
123
- attrs?.metadata ?? {},
124
- recordId === '' ? null : recordId
125
- ))
126
- ids.push(recordId)
127
- }
128
- }
129
- cursor = resp?.meta?.after ?? ''
112
+ const page = await this.#client.listDatasetRecords(projectId, pulledDataset.id(), {
113
+ cursor,
114
+ tags: filterTags,
115
+ version: datasetVersion,
116
+ })
117
+ for (const record of page.records) recs.push(record)
118
+ cursor = page.after
130
119
  if (!cursor) break
131
120
  }
132
121
  records = recs
133
- recordIds = ids
134
122
  lastError = ''
135
123
 
136
124
  return expectedRecordCount == null || recs.length >= expectedRecordCount
@@ -140,10 +128,10 @@ class Experiments {
140
128
  }
141
129
  }, { maxTotalMs: maxWaitMs })
142
130
 
143
- if (datasetId === null && lastError) {
131
+ if (pulledDataset === null && lastError) {
144
132
  throw new Error(`Failed to list datasets in project '${this.#projectName}': ${lastError}`)
145
133
  }
146
- if (datasetId === null) {
134
+ if (pulledDataset === null) {
147
135
  throw new Error(`Dataset '${name}' not found in project '${this.#projectName}' (after ${maxWaitMs}ms)`)
148
136
  }
149
137
  if (!succeeded && lastError) {
@@ -156,16 +144,22 @@ class Experiments {
156
144
  )
157
145
  }
158
146
 
147
+ for (const record of records) {
148
+ if (record.id === null || record.id === undefined || record.id === '') {
149
+ throw new Error(`Failed to pull dataset '${name}': backend returned a record without an id`)
150
+ }
151
+ }
152
+
159
153
  return Dataset.fromExisting(
160
154
  this.#client,
161
155
  name,
162
- description,
163
- datasetId,
156
+ pulledDataset.description(),
157
+ pulledDataset.id(),
164
158
  projectId,
165
159
  records,
166
- recordIds,
167
160
  datasetVersion,
168
- latestVersion
161
+ latestVersion,
162
+ filterTags
169
163
  )
170
164
  }
171
165
 
@@ -173,6 +167,22 @@ class Experiments {
173
167
  experiment (options) {
174
168
  return new Experiment(this.#client, options, this.#llmobs)
175
169
  }
170
+
171
+ /**
172
+ * Start an externally-driven experiment for eval frameworks that already own
173
+ * task execution. Call submitSpan() once per completed row, then
174
+ * submitEvaluationMetrics() with the generated span id.
175
+ *
176
+ * @param {object} options
177
+ * @returns {Promise<ExternalExperiment>}
178
+ */
179
+ startExperiment (options) {
180
+ const client = options?.projectName === undefined || options.projectName === this.#projectName
181
+ ? this.#client
182
+ : this.#clientForProject(options.projectName)
183
+ return new Experiment(client, { ...options, external: true }).start()
184
+ .then(experiment => new ExternalExperiment(experiment))
185
+ }
176
186
  }
177
187
 
178
188
  // Factory used by the LLMObs SDK: returns a real Experiments instance when
@@ -186,12 +196,13 @@ function createExperiments (config, llmobs) {
186
196
  return new NoopExperiments('DD_API_KEY and DD_APP_KEY are required for experiments')
187
197
  }
188
198
  if (!config.llmobs?.mlApp && !config.service) {
189
- log.warn('LLMObs experiments: no project name configured, set DD_LLMOBS_ML_APP or DD_SERVICE')
190
- return new NoopExperiments(
191
- 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' +
199
+ const reason = 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' +
192
200
  'tracer.init()) to name the LLM Obs project, or DD_SERVICE (or service in tracer.init()) as a fallback, ' +
193
201
  'then retry'
194
- )
202
+ const experiments = new Experiments(config, llmobs)
203
+ return new NoopExperiments(reason, {
204
+ startExperiment: (options) => experiments.startExperiment(options),
205
+ })
195
206
  }
196
207
  return new Experiments(config, llmobs)
197
208
  }