dd-trace 6.6.0 → 6.8.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 (209) hide show
  1. package/ci/diagnose.js +162 -21
  2. package/ci/init.js +0 -1
  3. package/ci/runbook.md +187 -180
  4. package/ci/test-optimization-validation/approval-artifacts.js +3 -1
  5. package/ci/test-optimization-validation/approval.js +52 -15
  6. package/ci/test-optimization-validation/ci-command-candidate.js +26 -32
  7. package/ci/test-optimization-validation/ci-discovery.js +1 -1
  8. package/ci/test-optimization-validation/ci-package-scripts.js +141 -0
  9. package/ci/test-optimization-validation/ci-remediation.js +112 -39
  10. package/ci/test-optimization-validation/cli.js +516 -681
  11. package/ci/test-optimization-validation/command-blocker.js +188 -33
  12. package/ci/test-optimization-validation/command-output-policy.js +8 -31
  13. package/ci/test-optimization-validation/command-runner.js +189 -245
  14. package/ci/test-optimization-validation/environment.js +90 -0
  15. package/ci/test-optimization-validation/executable.js +159 -388
  16. package/ci/test-optimization-validation/framework-adapters/cucumber.js +119 -0
  17. package/ci/test-optimization-validation/framework-adapters/cypress.js +107 -0
  18. package/ci/test-optimization-validation/framework-adapters/playwright.js +181 -0
  19. package/ci/test-optimization-validation/generated-files.js +84 -22
  20. package/ci/test-optimization-validation/generated-test-contract.js +281 -0
  21. package/ci/test-optimization-validation/generated-verifier.js +49 -16
  22. package/ci/test-optimization-validation/literal-environment.js +57 -0
  23. package/ci/test-optimization-validation/manifest-loader.js +2 -22
  24. package/ci/test-optimization-validation/manifest-scaffold.js +815 -515
  25. package/ci/test-optimization-validation/manifest-schema.js +494 -702
  26. package/ci/test-optimization-validation/offline-fixtures.js +4 -1
  27. package/ci/test-optimization-validation/offline-output.js +6 -6
  28. package/ci/test-optimization-validation/plan-writer.js +243 -984
  29. package/ci/test-optimization-validation/preflight-runner.js +49 -55
  30. package/ci/test-optimization-validation/redaction.js +2 -1
  31. package/ci/test-optimization-validation/report-writer.js +391 -1357
  32. package/ci/test-optimization-validation/result-semantics.js +86 -0
  33. package/ci/test-optimization-validation/runner-command.js +249 -0
  34. package/ci/test-optimization-validation/runner-contract.js +664 -0
  35. package/ci/test-optimization-validation/safe-files.js +4 -4
  36. package/ci/test-optimization-validation/scenarios/auto-test-retries.js +29 -3
  37. package/ci/test-optimization-validation/scenarios/basic-reporting.js +240 -543
  38. package/ci/test-optimization-validation/scenarios/ci-wiring.js +450 -670
  39. package/ci/test-optimization-validation/scenarios/early-flake-detection.js +4 -3
  40. package/ci/test-optimization-validation/scenarios/helpers.js +67 -9
  41. package/ci/test-optimization-validation/scenarios/test-management.js +6 -5
  42. package/ci/test-optimization-validation/source-text.js +87 -0
  43. package/ci/test-optimization-validation/test-output.js +12 -22
  44. package/ci/vitest-no-worker-init-setup.mjs +348 -51
  45. package/ext/tags.d.ts +1 -0
  46. package/index.d.ts +84 -13
  47. package/package.json +10 -4
  48. package/packages/datadog-core/src/utils/src/kebabcase.js +8 -2
  49. package/packages/datadog-esbuild/index.js +4 -2
  50. package/packages/datadog-esbuild/src/utils.js +1 -1
  51. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +4 -4
  52. package/packages/datadog-instrumentations/src/cucumber.js +9 -6
  53. package/packages/datadog-instrumentations/src/helpers/extract-package-and-module-path.js +10 -0
  54. package/packages/datadog-instrumentations/src/helpers/hook.js +19 -2
  55. package/packages/datadog-instrumentations/src/helpers/hooks.js +4 -0
  56. package/packages/datadog-instrumentations/src/helpers/register.js +9 -3
  57. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +1 -0
  58. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +2 -0
  59. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/openai-agents.js +31 -0
  60. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +57 -0
  61. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +50 -18
  62. package/packages/datadog-instrumentations/src/helpers/router-helper.js +1 -1
  63. package/packages/datadog-instrumentations/src/helpers/shared-utils.js +39 -0
  64. package/packages/datadog-instrumentations/src/http/client.js +1 -3
  65. package/packages/datadog-instrumentations/src/jest.js +8 -14
  66. package/packages/datadog-instrumentations/src/mocha/common.js +1 -3
  67. package/packages/datadog-instrumentations/src/mocha/main.js +2 -42
  68. package/packages/datadog-instrumentations/src/mocha/utils.js +68 -2
  69. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +19 -0
  70. package/packages/datadog-instrumentations/src/mocha/worker.js +446 -42
  71. package/packages/datadog-instrumentations/src/moleculer/client.js +1 -1
  72. package/packages/datadog-instrumentations/src/openai-agents.js +159 -0
  73. package/packages/datadog-instrumentations/src/playwright.js +3 -2
  74. package/packages/datadog-instrumentations/src/rhea.js +1 -1
  75. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +525 -56
  76. package/packages/datadog-instrumentations/src/vitest-main.js +90 -10
  77. package/packages/datadog-instrumentations/src/webdriverio.js +914 -0
  78. package/packages/datadog-plugin-aerospike/src/index.js +1 -3
  79. package/packages/datadog-plugin-amqplib/src/client.js +1 -1
  80. package/packages/datadog-plugin-amqplib/src/producer.js +1 -1
  81. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +2 -2
  82. package/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js +2 -2
  83. package/packages/datadog-plugin-azure-event-hubs/src/producer.js +1 -3
  84. package/packages/datadog-plugin-azure-service-bus/src/producer.js +5 -3
  85. package/packages/datadog-plugin-bullmq/src/producer.js +3 -3
  86. package/packages/datadog-plugin-child_process/src/index.js +3 -3
  87. package/packages/datadog-plugin-child_process/src/scrub-cmd-params.js +5 -8
  88. package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -1
  89. package/packages/datadog-plugin-fastify/src/tracing.js +1 -1
  90. package/packages/datadog-plugin-fs/src/index.js +0 -4
  91. package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +14 -7
  92. package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +19 -10
  93. package/packages/datadog-plugin-graphql/src/execute.js +1 -0
  94. package/packages/datadog-plugin-graphql/src/tools/signature.js +1 -1
  95. package/packages/datadog-plugin-http/src/client.js +2 -1
  96. package/packages/datadog-plugin-jest/src/index.js +2 -3
  97. package/packages/datadog-plugin-jest/src/util.js +2 -1
  98. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  99. package/packages/datadog-plugin-mocha/src/index.js +25 -6
  100. package/packages/datadog-plugin-openai-agents/src/index.js +74 -0
  101. package/packages/datadog-plugin-openai-agents/src/integration.js +503 -0
  102. package/packages/datadog-plugin-openai-agents/src/processor.js +108 -0
  103. package/packages/datadog-plugin-openai-agents/src/util.js +60 -0
  104. package/packages/datadog-plugin-prisma/src/index.js +5 -1
  105. package/packages/datadog-plugin-rhea/src/consumer.js +16 -13
  106. package/packages/datadog-plugin-rhea/src/producer.js +1 -1
  107. package/packages/datadog-plugin-undici/src/index.js +2 -1
  108. package/packages/datadog-plugin-vitest/src/index.js +84 -11
  109. package/packages/datadog-webpack/index.js +2 -2
  110. package/packages/dd-trace/src/appsec/downstream_requests.js +1 -1
  111. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-base-analyzer.js +1 -1
  112. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-analyzer.js +1 -1
  113. package/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js +1 -1
  114. package/packages/dd-trace/src/appsec/iast/taint-tracking/taint-tracking-impl.js +1 -3
  115. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +1 -1
  116. package/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +2 -2
  117. package/packages/dd-trace/src/appsec/sdk/track_event.js +1 -1
  118. package/packages/dd-trace/src/appsec/waf/waf_manager.js +1 -1
  119. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +7 -6
  120. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +50 -51
  121. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +2 -1
  122. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +4 -0
  123. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/webdriverio.js +26 -0
  124. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -1
  125. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +161 -33
  126. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -3
  127. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +7 -5
  128. package/packages/dd-trace/src/config/defaults.js +14 -6
  129. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -2
  130. package/packages/dd-trace/src/config/index.js +15 -0
  131. package/packages/dd-trace/src/config/parsers.js +19 -1
  132. package/packages/dd-trace/src/config/supported-configurations.json +12 -6
  133. package/packages/dd-trace/src/datastreams/encoding.js +3 -4
  134. package/packages/dd-trace/src/debugger/devtools_client/log.js +2 -1
  135. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +1 -4
  136. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -2
  137. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +2 -2
  138. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +16 -2
  139. package/packages/dd-trace/src/encode/span-stats.js +7 -1
  140. package/packages/dd-trace/src/encode/tags-processors.js +2 -2
  141. package/packages/dd-trace/src/exporter.js +16 -9
  142. package/packages/dd-trace/src/exporters/common/url.js +3 -5
  143. package/packages/dd-trace/src/exporters/electron/index.js +4 -1
  144. package/packages/dd-trace/src/external-logger/src/index.js +1 -2
  145. package/packages/dd-trace/src/flare/index.js +2 -2
  146. package/packages/dd-trace/src/id.js +1 -1
  147. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -1
  148. package/packages/dd-trace/src/llmobs/experiments/client.js +4 -2
  149. package/packages/dd-trace/src/llmobs/experiments/dataset.js +66 -14
  150. package/packages/dd-trace/src/llmobs/experiments/experiment.js +289 -95
  151. package/packages/dd-trace/src/llmobs/experiments/index.js +51 -12
  152. package/packages/dd-trace/src/llmobs/experiments/noop.js +95 -10
  153. package/packages/dd-trace/src/llmobs/experiments/result.js +13 -2
  154. package/packages/dd-trace/src/llmobs/experiments/util.js +218 -0
  155. package/packages/dd-trace/src/llmobs/index.js +9 -1
  156. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +104 -22
  157. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +1 -1
  158. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +1 -1
  159. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +1 -1
  160. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +1 -1
  161. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +5 -9
  162. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +20 -0
  163. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +321 -0
  164. package/packages/dd-trace/src/llmobs/sdk.js +6 -12
  165. package/packages/dd-trace/src/llmobs/span_processor.js +14 -7
  166. package/packages/dd-trace/src/llmobs/tagger.js +24 -5
  167. package/packages/dd-trace/src/llmobs/util.js +56 -3
  168. package/packages/dd-trace/src/llmobs/writers/spans.js +12 -6
  169. package/packages/dd-trace/src/noop/proxy.js +2 -2
  170. package/packages/dd-trace/src/openfeature/agentless_configuration_source.js +3 -6
  171. package/packages/dd-trace/src/openfeature/configuration_source.js +5 -8
  172. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +5 -1
  173. package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
  174. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +2 -1
  175. package/packages/dd-trace/src/opentracing/span.js +1 -1
  176. package/packages/dd-trace/src/opentracing/span_context.js +1 -1
  177. package/packages/dd-trace/src/plugins/ci_plugin.js +114 -23
  178. package/packages/dd-trace/src/plugins/index.js +2 -0
  179. package/packages/dd-trace/src/plugins/outbound.js +4 -1
  180. package/packages/dd-trace/src/plugins/plugin.js +1 -1
  181. package/packages/dd-trace/src/plugins/tracing.js +1 -0
  182. package/packages/dd-trace/src/plugins/util/ci.js +7 -6
  183. package/packages/dd-trace/src/plugins/util/git.js +1 -1
  184. package/packages/dd-trace/src/plugins/util/http-otel-semantics.js +1 -1
  185. package/packages/dd-trace/src/plugins/util/llm.js +2 -2
  186. package/packages/dd-trace/src/plugins/util/tags.js +2 -0
  187. package/packages/dd-trace/src/plugins/util/test.js +38 -12
  188. package/packages/dd-trace/src/plugins/util/url.js +5 -5
  189. package/packages/dd-trace/src/plugins/util/user-provided-git.js +1 -1
  190. package/packages/dd-trace/src/plugins/util/web.js +1 -1
  191. package/packages/dd-trace/src/priority_sampler.js +6 -3
  192. package/packages/dd-trace/src/profiling/profilers/poisson.js +1 -1
  193. package/packages/dd-trace/src/profiling/profilers/shared.js +2 -2
  194. package/packages/dd-trace/src/ritm.js +5 -20
  195. package/packages/dd-trace/src/span_processor.js +1 -1
  196. package/packages/dd-trace/src/span_stats.js +22 -4
  197. package/packages/dd-trace/src/startup-log.js +3 -0
  198. package/packages/dd-trace/src/telemetry/logs/log-collector.js +3 -1
  199. package/packages/dd-trace/src/util.js +16 -0
  200. package/vendor/dist/@apm-js-collab/code-transformer/index.js +12 -12
  201. package/vendor/dist/pprof-format/index.js +1 -1
  202. package/vendor/dist/source-map/index.js +1 -1
  203. package/ci/test-optimization-validation/command-suitability.js +0 -471
  204. package/ci/test-optimization-validation/init-probe-preload.js +0 -163
  205. package/ci/test-optimization-validation/init-probe.js +0 -246
  206. package/ci/test-optimization-validation/late-initialization.js +0 -63
  207. package/ci/test-optimization-validation/local-command.js +0 -163
  208. package/ci/test-optimization-validation/setup-runner.js +0 -97
  209. package/ci/test-optimization-validation-manifest.schema.json +0 -1
@@ -3,35 +3,19 @@
3
3
  const id = require('../../id')
4
4
 
5
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
- }
6
+ const { Row, ExperimentResult, ExperimentRun } = require('./result')
7
+ const {
8
+ buildExperimentTagObject,
9
+ buildSpanMetadata,
10
+ buildTags,
11
+ hasEntries,
12
+ inferMetricType,
13
+ normalizeEvaluators,
14
+ normalizeJsonMetricValue,
15
+ sleep,
16
+ stringify,
17
+ validateEvaluatorName,
18
+ } = require('./util')
35
19
 
36
20
  // One span per experiment row (LLM Obs experiment span wire format).
37
21
  function toSpan (row, metadata, ids, spanName, userTags) {
@@ -40,7 +24,7 @@ function toSpan (row, metadata, ids, spanName, userTags) {
40
24
  output: row.output ?? null,
41
25
  expected_output: row.expectedOutput ?? null,
42
26
  }
43
- if (metadata && Object.keys(metadata).length > 0) {
27
+ if (hasEntries(metadata)) {
44
28
  meta.metadata = metadata
45
29
  }
46
30
  if (row.isError) {
@@ -59,19 +43,26 @@ function toSpan (row, metadata, ids, spanName, userTags) {
59
43
  meta,
60
44
  tags: buildTags(userTags, {
61
45
  experiment_id: ids.experimentId,
46
+ run_id: ids.runId,
47
+ run_iteration: ids.runIteration,
62
48
  dataset_id: ids.datasetId,
63
49
  dataset_record_id: ids.datasetRecordId,
64
50
  }),
65
51
  }
66
52
  }
67
53
 
68
- // One metric per evaluator per row.
69
- function toMetric (label, value, errorMessage, spanId, timestampMs, experimentId, userTags) {
54
+ // One metric per evaluator per row or summary evaluator.
55
+ function toMetric (
56
+ label, value, errorMessage, spanId, traceId, timestampMs, experimentId, userTags, source = 'custom'
57
+ ) {
70
58
  const metric = {
59
+ metric_source: source,
71
60
  label,
72
61
  span_id: spanId,
62
+ trace_id: traceId,
73
63
  timestamp_ms: timestampMs,
74
64
  tags: buildTags(userTags, { experiment_id: experimentId }),
65
+ experiment_id: experimentId,
75
66
  }
76
67
 
77
68
  if (errorMessage !== null) {
@@ -84,35 +75,50 @@ function toMetric (label, value, errorMessage, spanId, timestampMs, experimentId
84
75
  metric.metric_type = type
85
76
  if (type === 'boolean') metric.boolean_value = value
86
77
  else if (type === 'score') metric.score_value = value
87
- else if (type === 'json') metric.json_value = value
78
+ else if (type === 'json') metric.json_value = normalizeJsonMetricValue(value)
88
79
  else metric.categorical_value = stringify(value)
89
80
  return metric
90
81
  }
91
82
 
83
+ function createFallbackSpanContext (startNs) {
84
+ // Root-span id generation, same convention as opentracing/span.js: a single
85
+ // random 64-bit id for the span, reused as the trace id's low 64 bits with a
86
+ // start-time-derived high 64 bits (like the `_dd.p.tid` 128-bit trace id tag).
87
+ const spanIdentifier = id()
88
+ const traceIdHigh = Math.floor(startNs / 1e9).toString(16).padStart(8, '0').padEnd(16, '0')
89
+ const spanId = spanIdentifier.toString(16).padStart(16, '0')
90
+ const traceId = spanIdentifier.toTraceIdHex(traceIdHigh).padStart(32, '0')
91
+ return { spanId, traceId }
92
+ }
93
+
92
94
  // Builder + run() orchestration: runs rows sequentially, emits one root span
93
- // per dataset row, and posts all spans + metrics in a single events call.
95
+ // per dataset row, and posts spans + metrics to the experiments events API.
94
96
  class Experiment {
95
97
  #client
98
+ #llmobs
96
99
  #name
97
100
  #description
98
101
  #dataset
99
102
  #task
100
103
  #evaluators
104
+ #summaryEvaluators
101
105
  #config
102
106
  #tags
103
107
  #experimentId
104
108
 
105
- constructor (client, options = {}) {
109
+ constructor (client, options = {}, llmobs) {
106
110
  if (!options.name) throw new Error('Experiment name is required')
107
111
  if (!options.dataset) throw new Error('Experiment dataset is required')
108
112
  if (typeof options.task !== 'function') throw new Error('Experiment task is required')
109
113
 
110
114
  this.#client = client
115
+ this.#llmobs = llmobs
111
116
  this.#name = options.name
112
117
  this.#description = options.description ?? ''
113
118
  this.#dataset = options.dataset
114
119
  this.#task = options.task
115
- this.#evaluators = new Map(Object.entries(options.evaluators ?? {}))
120
+ this.#evaluators = normalizeEvaluators(options.evaluators, 'row')
121
+ this.#summaryEvaluators = normalizeEvaluators(options.summaryEvaluators, 'summary')
116
122
  this.#config = { ...options.config }
117
123
  this.#tags = { ...options.tags }
118
124
  this.#experimentId = null
@@ -131,7 +137,16 @@ class Experiment {
131
137
  return `${this.#client.appBase}/llm/experiments/${this.#experimentId}`
132
138
  }
133
139
 
134
- async run () {
140
+ async run (options = {}) {
141
+ const {
142
+ maxRetries = 0,
143
+ retryDelay = (attempt) => 100 * (attempt + 1),
144
+ throwOnErrors = false,
145
+ } = options
146
+
147
+ if (maxRetries < 0) throw new Error('maxRetries must be >= 0')
148
+ if (typeof retryDelay !== 'function') throw new TypeError('retryDelay must be a function')
149
+
135
150
  const projectId = await this.#client.ensureProjectId()
136
151
 
137
152
  await this.#dataset.ensureCreatedAndPushed(projectId)
@@ -148,7 +163,11 @@ class Experiment {
148
163
  dataset_id: datasetId,
149
164
  description: this.#description,
150
165
  ensure_unique: true,
166
+ run_count: 1,
167
+ metadata: { tags: buildTags(this.#tags, {}) },
151
168
  }
169
+ const datasetVersion = this.#dataset.version()
170
+ if (datasetVersion !== null) attributes.dataset_version = datasetVersion
152
171
  if (Object.keys(this.#config).length > 0) attributes.config = this.#config
153
172
 
154
173
  let created
@@ -161,6 +180,8 @@ class Experiment {
161
180
  }
162
181
  this.#experimentId = created?.data?.id ?? null
163
182
  const experimentId = this.#experimentId
183
+ const runId = id().toString(16).padStart(16, '0')
184
+ const runIteration = 0
164
185
 
165
186
  try {
166
187
  const records = this.#dataset.records()
@@ -168,83 +189,87 @@ class Experiment {
168
189
  const rows = []
169
190
  const spans = []
170
191
  const metrics = []
192
+ const evaluatorResults = {}
193
+ const usesLLMObsTrace = Boolean(this.#llmobs?.enabled)
171
194
  let hasRowError = false
172
195
 
173
196
  for (let i = 0; i < records.length; i++) {
174
197
  const record = records[i]
175
198
  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
- }
199
+ // Rows currently run sequentially by design; jobs/concurrency is a P1 follow-up.
200
+ // eslint-disable-next-line no-await-in-loop
201
+ const row = await this.#processRecord({
202
+ index: i,
203
+ record,
204
+ datasetRecordId,
205
+ projectId,
206
+ datasetId,
207
+ experimentId,
208
+ runId,
209
+ runIteration,
210
+ maxRetries,
211
+ retryDelay,
212
+ throwOnErrors,
213
+ })
198
214
 
199
- const durationNs = Number(process.hrtime.bigint() - startHr)
200
- const evaluations = {}
201
- const evaluationErrors = {}
202
215
  const timestampMs = Date.now()
203
-
204
216
  for (const [label, evaluator] of this.#evaluators) {
205
- if (errorType !== null) {
217
+ if (!evaluatorResults[label]) evaluatorResults[label] = []
218
+ if (row.isError) {
206
219
  const msg = 'task error; evaluation skipped'
207
- evaluationErrors[label] = msg
208
- metrics.push(toMetric(label, null, msg, spanId, timestampMs, experimentId, this.#tags))
220
+ row.evaluationErrors[label] = msg
221
+ evaluatorResults[label].push(null)
222
+ metrics.push(toMetric(label, null, msg, row.spanId, row.traceId, timestampMs, experimentId, this.#tags))
209
223
  continue
210
224
  }
211
225
  try {
212
226
  // 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))
227
+ const value = await this.#runWithRetries(
228
+ () => evaluator(record.input, row.output, record.expectedOutput),
229
+ maxRetries,
230
+ retryDelay
231
+ )
232
+ row.evaluations[label] = value
233
+ evaluatorResults[label].push(value)
234
+ metrics.push(toMetric(label, value, null, row.spanId, row.traceId, timestampMs, experimentId, this.#tags))
216
235
  } catch (err) {
236
+ if (throwOnErrors) throw err
217
237
  const msg = err.message ?? String(err)
218
- evaluationErrors[label] = msg
219
- metrics.push(toMetric(label, null, msg, spanId, timestampMs, experimentId, this.#tags))
238
+ row.evaluationErrors[label] = msg
239
+ evaluatorResults[label].push(null)
240
+ metrics.push(toMetric(label, null, msg, row.spanId, row.traceId, timestampMs, experimentId, this.#tags))
220
241
  }
221
242
  }
222
243
 
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
244
  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
+ if (row.isError || hasEntries(row.evaluationErrors)) hasRowError = true
246
+ if (!usesLLMObsTrace) {
247
+ spans.push(toSpan(row, record.metadata, {
248
+ experimentId,
249
+ projectId,
250
+ datasetId,
251
+ datasetRecordId,
252
+ runId,
253
+ runIteration,
254
+ }, this.#task.name || this.#name, this.#tags))
255
+ }
256
+ }
257
+
258
+ const summaryEvaluations = await this.#runSummaryEvaluators(rows, records, evaluatorResults, {
259
+ maxRetries,
260
+ retryDelay,
261
+ throwOnErrors,
262
+ experimentId,
263
+ metrics,
264
+ })
265
+ if (hasEntries(summaryEvaluations)) {
266
+ for (const value of Object.values(summaryEvaluations)) {
267
+ if (value?.error) hasRowError = true
268
+ }
245
269
  }
246
270
 
247
271
  await this.#postEvents(experimentId, spans, metrics)
272
+ this.#llmobs?.flush?.()
248
273
  // A row error doesn't abort the run, but the experiment didn't succeed cleanly.
249
274
  await this.#updateStatus(
250
275
  experimentId,
@@ -252,16 +277,185 @@ class Experiment {
252
277
  hasRowError ? 'one or more rows failed' : null
253
278
  )
254
279
 
255
- return new ExperimentResult(experimentId, rows, this.url())
280
+ const run = new ExperimentRun({ runId, runIteration, rows, summaryEvaluations })
281
+ return new ExperimentResult(experimentId, rows, this.url(), [run], summaryEvaluations)
256
282
  } catch (err) {
257
283
  await this.#updateStatus(experimentId, 'failed', err.message ?? String(err))
258
284
  throw err
259
285
  }
260
286
  }
261
287
 
288
+ async #processRecord ({
289
+ index,
290
+ record,
291
+ datasetRecordId,
292
+ projectId,
293
+ datasetId,
294
+ experimentId,
295
+ runId,
296
+ runIteration,
297
+ maxRetries,
298
+ retryDelay,
299
+ throwOnErrors,
300
+ }) {
301
+ const startNs = Date.now() * 1e6
302
+ const startHr = process.hrtime.bigint()
303
+ const fallbackContext = createFallbackSpanContext(startNs)
304
+ let spanContext = fallbackContext
305
+ let output = null
306
+ let errorType = null
307
+ let errorMessage = null
308
+
309
+ const autoTags = {
310
+ experiment_id: experimentId,
311
+ run_id: runId,
312
+ run_iteration: runIteration,
313
+ project_id: projectId,
314
+ dataset_id: datasetId,
315
+ dataset_record_id: datasetRecordId,
316
+ dataset_name: this.#dataset.name(),
317
+ experiment_name: this.#name,
318
+ }
319
+ const tags = buildExperimentTagObject(this.#tags, autoTags)
320
+
321
+ const execute = () => this.#runWithRetries(
322
+ () => this.#task(record.input, this.#config, record.metadata),
323
+ maxRetries,
324
+ retryDelay
325
+ )
326
+
327
+ if (this.#llmobs?.enabled) {
328
+ try {
329
+ await this.#llmobs.trace({
330
+ kind: 'experiment',
331
+ name: this.#task.name || this.#name,
332
+ }, async (span) => {
333
+ spanContext = this.#llmobs.exportSpan(span)
334
+ try {
335
+ output = await execute()
336
+ } catch (err) {
337
+ this.#llmobs.annotate(span, {
338
+ inputData: record.input,
339
+ outputData: output,
340
+ metadata: buildSpanMetadata(record.metadata, this.#config),
341
+ tags,
342
+ })
343
+ throw err
344
+ }
345
+ this.#llmobs.annotate(span, {
346
+ inputData: record.input,
347
+ outputData: output,
348
+ metadata: buildSpanMetadata(record.metadata, this.#config),
349
+ tags,
350
+ })
351
+ })
352
+ } catch (err) {
353
+ if (throwOnErrors) throw err
354
+ errorType = err.name || 'Error'
355
+ errorMessage = err.message ?? String(err)
356
+ }
357
+ } else {
358
+ try {
359
+ output = await execute()
360
+ } catch (err) {
361
+ if (throwOnErrors) throw err
362
+ errorType = err.name || 'Error'
363
+ errorMessage = err.message ?? String(err)
364
+ }
365
+ }
366
+
367
+ const durationNs = Number(process.hrtime.bigint() - startHr)
368
+ return new Row({
369
+ index,
370
+ spanId: spanContext?.spanId ?? fallbackContext.spanId,
371
+ traceId: spanContext?.traceId ?? fallbackContext.traceId,
372
+ startNs,
373
+ durationNs,
374
+ input: record.input,
375
+ output,
376
+ expectedOutput: record.expectedOutput,
377
+ errorType,
378
+ errorMessage,
379
+ evaluations: {},
380
+ evaluationErrors: {},
381
+ })
382
+ }
383
+
384
+ async #runWithRetries (fn, maxRetries, retryDelay) {
385
+ let lastError
386
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
387
+ try {
388
+ // eslint-disable-next-line no-await-in-loop
389
+ return await fn()
390
+ } catch (err) {
391
+ lastError = err
392
+ if (attempt >= maxRetries) break
393
+ const delayMs = retryDelay(attempt)
394
+ if (typeof delayMs !== 'number' || delayMs < 0 || !Number.isFinite(delayMs)) {
395
+ throw new TypeError('retryDelay must return a non-negative finite number of milliseconds')
396
+ }
397
+ // eslint-disable-next-line no-await-in-loop
398
+ await sleep(delayMs)
399
+ }
400
+ }
401
+ throw lastError
402
+ }
403
+
404
+ async #runSummaryEvaluators (rows, records, evaluatorResults, options) {
405
+ if (this.#summaryEvaluators.length === 0) return {}
406
+
407
+ const inputs = rows.map(row => row.input)
408
+ const outputs = rows.map(row => row.output)
409
+ const expectedOutputs = rows.map(row => row.expectedOutput)
410
+ const metadata = records.map(record => buildSpanMetadata(record.metadata, this.#config))
411
+ const summaryEvaluations = {}
412
+ const timestampMs = Date.now()
413
+
414
+ for (const [label, evaluator] of this.#summaryEvaluators) {
415
+ try {
416
+ // eslint-disable-next-line no-await-in-loop
417
+ const value = await this.#runWithRetries(
418
+ () => evaluator(inputs, outputs, expectedOutputs, evaluatorResults, metadata),
419
+ options.maxRetries,
420
+ options.retryDelay
421
+ )
422
+ summaryEvaluations[label] = { value, error: null }
423
+ options.metrics.push(toMetric(
424
+ label,
425
+ value,
426
+ null,
427
+ '',
428
+ '',
429
+ timestampMs,
430
+ options.experimentId,
431
+ this.#tags,
432
+ 'summary'
433
+ ))
434
+ } catch (err) {
435
+ if (options.throwOnErrors) throw err
436
+ const msg = err.message ?? String(err)
437
+ summaryEvaluations[label] = { value: null, error: msg }
438
+ options.metrics.push(toMetric(
439
+ label,
440
+ null,
441
+ msg,
442
+ '',
443
+ '',
444
+ timestampMs,
445
+ options.experimentId,
446
+ this.#tags,
447
+ 'summary'
448
+ ))
449
+ }
450
+ }
451
+ return summaryEvaluations
452
+ }
453
+
262
454
  async #postEvents (experimentId, spans, metrics) {
455
+ const attributes = { metrics }
456
+ if (spans.length > 0) attributes.spans = spans
263
457
  await this.#client.request('POST', `${API_BASE_PATH}/experiments/${experimentId}/events`, {
264
- data: { type: 'experiments', attributes: { spans, metrics } },
458
+ data: { type: 'experiments', attributes },
265
459
  })
266
460
  }
267
461
 
@@ -280,4 +474,4 @@ class Experiment {
280
474
  }
281
475
  }
282
476
 
283
- module.exports = { Experiment }
477
+ module.exports = { Experiment, normalizeEvaluators, validateEvaluatorName }
@@ -26,9 +26,11 @@ async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 2
26
26
  // experiments against the LLM Obs backend using the tracer's own config.
27
27
  class Experiments {
28
28
  #client
29
+ #llmobs
29
30
  #projectName
30
31
 
31
- constructor (config) {
32
+ constructor (config, llmobs) {
33
+ this.#llmobs = llmobs
32
34
  this.#projectName = config.llmobs?.mlApp || config.service
33
35
  this.#client = new ExperimentsClient({
34
36
  apiKey: config.DD_API_KEY,
@@ -39,21 +41,38 @@ class Experiments {
39
41
  }
40
42
 
41
43
  // Create a local dataset buffer. Pushed remotely on first experiment run.
42
- createDataset (name, description = '') {
43
- return new Dataset(this.#client, name, description)
44
+ createDataset (name, descriptionOrOptions = '') {
45
+ const options = typeof descriptionOrOptions === 'string'
46
+ ? { description: descriptionOrOptions }
47
+ : (descriptionOrOptions ?? {})
48
+ const dataset = new Dataset(this.#client, name, options.description ?? '')
49
+ const recordIds = new Set()
50
+ for (const record of options.records ?? []) {
51
+ if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) {
52
+ throw new Error('record id must be a non-empty string')
53
+ }
54
+ if (record.id !== undefined) {
55
+ if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
56
+ recordIds.add(record.id)
57
+ }
58
+ dataset.addRecord(new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id))
59
+ }
60
+ return dataset
44
61
  }
45
62
 
46
63
  // Pull an existing dataset by name (with its records). Polls with exponential
47
64
  // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
48
65
  // wait until that many records are readable.
49
66
  async pullDataset (name, options = {}) {
50
- const { expectedRecordCount, maxWaitMs = 30_000 } = options
67
+ const { expectedRecordCount, maxWaitMs = 30_000, version } = options
51
68
  const projectId = await this.#client.ensureProjectId()
52
69
 
53
70
  let datasetId = null
54
71
  let description = ''
55
72
  let records = []
56
73
  let recordIds = []
74
+ let datasetVersion = version ?? null
75
+ let latestVersion = null
57
76
  let lastError = ''
58
77
 
59
78
  const succeeded = await retryWithBackoff(async () => {
@@ -67,6 +86,8 @@ class Experiments {
67
86
  if (item?.attributes?.name === name) {
68
87
  datasetId = String(item?.id ?? '')
69
88
  description = String(item?.attributes?.description ?? '')
89
+ latestVersion = item?.attributes?.current_version ?? null
90
+ datasetVersion = version ?? latestVersion
70
91
  break
71
92
  }
72
93
  }
@@ -78,16 +99,24 @@ class Experiments {
78
99
  let cursor = ''
79
100
  // Follow the meta.after / page[cursor] pagination until the last page.
80
101
  for (;;) {
81
- const query = cursor ? `?page[cursor]=${encodeURIComponent(cursor)}` : ''
102
+ const query = new URLSearchParams()
103
+ if (cursor) query.set('page[cursor]', cursor)
104
+ if (datasetVersion !== null) query.set('filter[version]', String(datasetVersion))
82
105
  // eslint-disable-next-line no-await-in-loop
83
106
  const resp = await this.#client.request(
84
107
  'GET',
85
- `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records${query}`
108
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records?${query.toString()}`
86
109
  )
87
110
  for (const item of resp?.data ?? []) {
88
111
  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 ?? ''))
112
+ const recordId = String(item?.id ?? attrs?.id ?? '')
113
+ recs.push(new DatasetRecord(
114
+ attrs?.input ?? null,
115
+ attrs?.expected_output ?? null,
116
+ attrs?.metadata ?? {},
117
+ recordId === '' ? null : recordId
118
+ ))
119
+ ids.push(recordId)
91
120
  }
92
121
  cursor = resp?.meta?.after ?? ''
93
122
  if (!cursor) break
@@ -119,18 +148,28 @@ class Experiments {
119
148
  )
120
149
  }
121
150
 
122
- return Dataset.fromExisting(this.#client, name, description, datasetId, projectId, records, recordIds)
151
+ return Dataset.fromExisting(
152
+ this.#client,
153
+ name,
154
+ description,
155
+ datasetId,
156
+ projectId,
157
+ records,
158
+ recordIds,
159
+ datasetVersion,
160
+ latestVersion
161
+ )
123
162
  }
124
163
 
125
164
  // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }.
126
165
  experiment (options) {
127
- return new Experiment(this.#client, options)
166
+ return new Experiment(this.#client, options, this.#llmobs)
128
167
  }
129
168
  }
130
169
 
131
170
  // Factory used by the LLMObs SDK: returns a real Experiments instance when
132
171
  // enabled and credentialed, otherwise a no-op that explains what's missing.
133
- function createExperiments (config) {
172
+ function createExperiments (config, llmobs) {
134
173
  if (!config.llmobs?.DD_LLMOBS_ENABLED) {
135
174
  return new NoopExperiments('LLM Observability is not enabled')
136
175
  }
@@ -146,7 +185,7 @@ function createExperiments (config) {
146
185
  'then retry'
147
186
  )
148
187
  }
149
- return new Experiments(config)
188
+ return new Experiments(config, llmobs)
150
189
  }
151
190
 
152
191
  module.exports = { Experiments, createExperiments }