dd-trace 6.7.0 → 6.9.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 (272) hide show
  1. package/ci/diagnose.js +27 -25
  2. package/ci/init.js +6 -6
  3. package/ci/runbook.md +114 -20
  4. package/ci/test-optimization-validation/approval-artifacts.js +36 -3
  5. package/ci/test-optimization-validation/approval.js +78 -22
  6. package/ci/test-optimization-validation/blocker-category.js +24 -0
  7. package/ci/test-optimization-validation/ci-discovery.js +23 -17
  8. package/ci/test-optimization-validation/ci-package-scripts.js +34 -6
  9. package/ci/test-optimization-validation/ci-remediation.js +1 -1
  10. package/ci/test-optimization-validation/cli.js +280 -52
  11. package/ci/test-optimization-validation/command-blocker.js +159 -20
  12. package/ci/test-optimization-validation/command-output-policy.js +14 -8
  13. package/ci/test-optimization-validation/command-runner.js +9 -7
  14. package/ci/test-optimization-validation/environment.js +4 -2
  15. package/ci/test-optimization-validation/executable.js +15 -13
  16. package/ci/test-optimization-validation/execution-lock.js +70 -0
  17. package/ci/test-optimization-validation/framework-adapters/cucumber-validation.json +3 -0
  18. package/ci/test-optimization-validation/framework-adapters/cucumber.js +657 -5
  19. package/ci/test-optimization-validation/framework-adapters/playwright.js +5 -5
  20. package/ci/test-optimization-validation/framework-adapters/vitest.js +517 -0
  21. package/ci/test-optimization-validation/generated-files.js +47 -37
  22. package/ci/test-optimization-validation/generated-test-contract.js +9 -7
  23. package/ci/test-optimization-validation/generated-verifier.js +24 -17
  24. package/ci/test-optimization-validation/literal-glob.js +52 -0
  25. package/ci/test-optimization-validation/manifest-loader.js +3 -0
  26. package/ci/test-optimization-validation/manifest-scaffold.js +301 -52
  27. package/ci/test-optimization-validation/manifest-schema.js +80 -17
  28. package/ci/test-optimization-validation/offline-fixtures.js +13 -4
  29. package/ci/test-optimization-validation/offline-output.js +6 -6
  30. package/ci/test-optimization-validation/package-check.js +94 -0
  31. package/ci/test-optimization-validation/plan-writer.js +183 -10
  32. package/ci/test-optimization-validation/preflight-runner.js +110 -31
  33. package/ci/test-optimization-validation/project-build-artifact.js +31 -0
  34. package/ci/test-optimization-validation/redaction.js +18 -27
  35. package/ci/test-optimization-validation/report-writer.js +182 -48
  36. package/ci/test-optimization-validation/result-semantics.js +20 -3
  37. package/ci/test-optimization-validation/runner-command.js +41 -21
  38. package/ci/test-optimization-validation/runner-contract.js +79 -43
  39. package/ci/test-optimization-validation/safe-files.js +4 -4
  40. package/ci/test-optimization-validation/scenarios/basic-reporting.js +60 -6
  41. package/ci/test-optimization-validation/scenarios/ci-wiring.js +141 -35
  42. package/ci/test-optimization-validation/scenarios/helpers.js +30 -6
  43. package/ci/test-optimization-validation/scenarios/test-management.js +2 -2
  44. package/ci/test-optimization-validation/static-diagnosis.js +10 -3
  45. package/ci/test-optimization-validation/test-output.js +2 -1
  46. package/ci/test-optimization-validation/validation-blocker.js +21 -0
  47. package/ci/vitest-no-worker-init-setup.mjs +377 -84
  48. package/index.d.ts +175 -14
  49. package/init.js +1 -17
  50. package/initialize.mjs +11 -0
  51. package/loader-hook.mjs +6 -4
  52. package/package.json +12 -4
  53. package/packages/datadog-core/src/utils/src/kebabcase.js +8 -2
  54. package/packages/datadog-core/src/utils/src/parse-tags.js +0 -1
  55. package/packages/datadog-esbuild/index.js +4 -2
  56. package/packages/datadog-esbuild/src/utils.js +17 -2
  57. package/packages/datadog-instrumentations/src/ai.js +61 -34
  58. package/packages/datadog-instrumentations/src/bluebird.js +6 -6
  59. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +85 -9
  60. package/packages/datadog-instrumentations/src/cucumber.js +131 -51
  61. package/packages/datadog-instrumentations/src/dns.js +21 -2
  62. package/packages/datadog-instrumentations/src/electron.js +1 -0
  63. package/packages/datadog-instrumentations/src/fastify.js +3 -1
  64. package/packages/datadog-instrumentations/src/grpc/client.js +4 -2
  65. package/packages/datadog-instrumentations/src/helpers/callback-instrumentor.js +3 -1
  66. package/packages/datadog-instrumentations/src/helpers/extract-package-and-module-path.js +10 -0
  67. package/packages/datadog-instrumentations/src/helpers/hook.js +19 -2
  68. package/packages/datadog-instrumentations/src/helpers/hooks.js +4 -0
  69. package/packages/datadog-instrumentations/src/helpers/register.js +9 -3
  70. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +34 -9
  71. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  72. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +175 -0
  73. package/packages/datadog-instrumentations/src/helpers/rewriter/loader.mjs +20 -2
  74. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.js +32 -0
  75. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +61 -0
  76. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +50 -18
  77. package/packages/datadog-instrumentations/src/helpers/router-helper.js +38 -7
  78. package/packages/datadog-instrumentations/src/helpers/shared-utils.js +39 -0
  79. package/packages/datadog-instrumentations/src/http/client.js +1 -3
  80. package/packages/datadog-instrumentations/src/jest/coverage-backfill.js +1 -1
  81. package/packages/datadog-instrumentations/src/jest.js +986 -246
  82. package/packages/datadog-instrumentations/src/mocha/common.js +1 -3
  83. package/packages/datadog-instrumentations/src/mocha/main.js +109 -51
  84. package/packages/datadog-instrumentations/src/mocha/utils.js +120 -32
  85. package/packages/datadog-instrumentations/src/mocha/webdriverio-protocol.js +44 -0
  86. package/packages/datadog-instrumentations/src/mocha/worker.js +441 -43
  87. package/packages/datadog-instrumentations/src/moleculer/client.js +1 -1
  88. package/packages/datadog-instrumentations/src/mysql.js +1 -1
  89. package/packages/datadog-instrumentations/src/next.js +155 -23
  90. package/packages/datadog-instrumentations/src/openai.js +14 -0
  91. package/packages/datadog-instrumentations/src/pg.js +180 -6
  92. package/packages/datadog-instrumentations/src/playwright.js +176 -81
  93. package/packages/datadog-instrumentations/src/promise-js.js +4 -4
  94. package/packages/datadog-instrumentations/src/promise.js +3 -3
  95. package/packages/datadog-instrumentations/src/rhea.js +1 -1
  96. package/packages/datadog-instrumentations/src/router.js +195 -19
  97. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +567 -65
  98. package/packages/datadog-instrumentations/src/vitest-main.js +438 -54
  99. package/packages/datadog-instrumentations/src/vitest-util.js +42 -7
  100. package/packages/datadog-instrumentations/src/vitest-worker.js +234 -17
  101. package/packages/datadog-instrumentations/src/webdriverio.js +1090 -0
  102. package/packages/datadog-instrumentations/src/when.js +3 -3
  103. package/packages/datadog-instrumentations/src/ws.js +5 -1
  104. package/packages/datadog-plugin-aerospike/src/index.js +1 -3
  105. package/packages/datadog-plugin-amqplib/src/client.js +1 -1
  106. package/packages/datadog-plugin-amqplib/src/producer.js +1 -1
  107. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +59 -48
  108. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +2 -2
  109. package/packages/datadog-plugin-aws-sdk/src/services/stepfunctions.js +2 -2
  110. package/packages/datadog-plugin-azure-event-hubs/src/producer.js +1 -3
  111. package/packages/datadog-plugin-azure-service-bus/src/producer.js +5 -3
  112. package/packages/datadog-plugin-bullmq/src/producer.js +3 -3
  113. package/packages/datadog-plugin-child_process/src/index.js +3 -3
  114. package/packages/datadog-plugin-child_process/src/scrub-cmd-params.js +6 -11
  115. package/packages/datadog-plugin-cucumber/src/index.js +5 -1
  116. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +50 -46
  117. package/packages/datadog-plugin-cypress/src/source-map-utils.js +1 -1
  118. package/packages/datadog-plugin-cypress/src/support.js +7 -4
  119. package/packages/datadog-plugin-electron/src/net.js +10 -4
  120. package/packages/datadog-plugin-fastify/src/tracing.js +1 -1
  121. package/packages/datadog-plugin-fs/src/index.js +0 -4
  122. package/packages/datadog-plugin-google-cloud-pubsub/src/consumer.js +14 -7
  123. package/packages/datadog-plugin-google-cloud-pubsub/src/producer.js +19 -10
  124. package/packages/datadog-plugin-graphql/src/execute.js +1 -0
  125. package/packages/datadog-plugin-graphql/src/tools/signature.js +1 -1
  126. package/packages/datadog-plugin-http/src/client.js +3 -2
  127. package/packages/datadog-plugin-http2/src/client.js +1 -1
  128. package/packages/datadog-plugin-jest/src/index.js +3 -21
  129. package/packages/datadog-plugin-jest/src/util.js +4 -1
  130. package/packages/datadog-plugin-kafkajs/src/producer.js +2 -1
  131. package/packages/datadog-plugin-langchain/src/tokens.js +2 -2
  132. package/packages/datadog-plugin-mocha/src/index.js +342 -6
  133. package/packages/datadog-plugin-next/src/index.js +57 -9
  134. package/packages/datadog-plugin-openai/src/tracing.js +4 -0
  135. package/packages/datadog-plugin-pg/src/index.js +65 -1
  136. package/packages/datadog-plugin-playwright/src/index.js +1 -1
  137. package/packages/datadog-plugin-prisma/src/index.js +5 -1
  138. package/packages/datadog-plugin-rhea/src/consumer.js +16 -13
  139. package/packages/datadog-plugin-rhea/src/producer.js +1 -1
  140. package/packages/datadog-plugin-undici/src/index.js +2 -1
  141. package/packages/datadog-plugin-vitest/src/index.js +179 -18
  142. package/packages/datadog-shimmer/src/shimmer.js +6 -2
  143. package/packages/datadog-webpack/index.js +2 -2
  144. package/packages/dd-trace/src/aiguard/sdk.js +3 -0
  145. package/packages/dd-trace/src/appsec/{blocking.js → blocking/index.js} +26 -6
  146. package/packages/dd-trace/src/appsec/downstream_requests.js +1 -1
  147. package/packages/dd-trace/src/appsec/handlers/auth.js +62 -0
  148. package/packages/dd-trace/src/appsec/handlers/http-request.js +215 -0
  149. package/packages/dd-trace/src/appsec/handlers/http-response.js +78 -0
  150. package/packages/dd-trace/src/appsec/handlers/http-shared.js +24 -0
  151. package/packages/dd-trace/src/appsec/handlers/payments.js +104 -0
  152. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-base-analyzer.js +1 -1
  153. package/packages/dd-trace/src/appsec/iast/analyzers/hardcoded-password-analyzer.js +1 -1
  154. package/packages/dd-trace/src/appsec/iast/analyzers/hsts-header-missing-analyzer.js +1 -1
  155. package/packages/dd-trace/src/appsec/iast/analyzers/sql-injection-analyzer.js +1 -1
  156. package/packages/dd-trace/src/appsec/iast/taint-tracking/taint-tracking-impl.js +1 -3
  157. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +2 -2
  158. package/packages/dd-trace/src/appsec/iast/vulnerability-reporter.js +2 -2
  159. package/packages/dd-trace/src/appsec/index.js +48 -476
  160. package/packages/dd-trace/src/appsec/sdk/track_event.js +10 -1
  161. package/packages/dd-trace/src/appsec/waf/waf_manager.js +1 -1
  162. package/packages/dd-trace/src/ci-visibility/coverage-report-discovery.js +46 -14
  163. package/packages/dd-trace/src/ci-visibility/efd-retry-policy.js +89 -0
  164. package/packages/dd-trace/src/ci-visibility/exporters/ci-validation/sink.js +7 -6
  165. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +29 -15
  166. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +19 -1
  167. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +25 -1
  168. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/webdriverio.js +26 -0
  169. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -1
  170. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +22 -7
  171. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +9 -14
  172. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -3
  173. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +21 -5
  174. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache-schema.js +2 -2
  175. package/packages/dd-trace/src/ci-visibility/test-optimization-http-cache.js +5 -1
  176. package/packages/dd-trace/src/config/defaults.js +14 -6
  177. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -2
  178. package/packages/dd-trace/src/config/helper.js +2 -0
  179. package/packages/dd-trace/src/config/index.js +31 -5
  180. package/packages/dd-trace/src/config/parsers.js +19 -1
  181. package/packages/dd-trace/src/config/remote_config.js +1 -1
  182. package/packages/dd-trace/src/config/supported-configurations.json +13 -6
  183. package/packages/dd-trace/src/datastreams/encoding.js +3 -4
  184. package/packages/dd-trace/src/debugger/devtools_client/log.js +2 -1
  185. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +1 -4
  186. package/packages/dd-trace/src/debugger/devtools_client/send.js +1 -2
  187. package/packages/dd-trace/src/debugger/devtools_client/snapshot/index.js +2 -2
  188. package/packages/dd-trace/src/debugger/devtools_client/snapshot/redaction.js +16 -2
  189. package/packages/dd-trace/src/debugger/devtools_client/status.js +1 -1
  190. package/packages/dd-trace/src/encode/coverage-ci-visibility.js +0 -1
  191. package/packages/dd-trace/src/encode/tags-processors.js +2 -2
  192. package/packages/dd-trace/src/exporters/common/url.js +3 -5
  193. package/packages/dd-trace/src/exporters/electron/index.js +4 -1
  194. package/packages/dd-trace/src/external-logger/src/index.js +1 -2
  195. package/packages/dd-trace/src/flare/index.js +2 -2
  196. package/packages/dd-trace/src/guardrails/apply-pm2-env.js +16 -0
  197. package/packages/dd-trace/src/guardrails/log.js +1 -10
  198. package/packages/dd-trace/src/id.js +1 -1
  199. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -1
  200. package/packages/dd-trace/src/llmobs/eval-metric.js +249 -0
  201. package/packages/dd-trace/src/llmobs/experiments/client.js +4 -2
  202. package/packages/dd-trace/src/llmobs/experiments/dataset.js +66 -14
  203. package/packages/dd-trace/src/llmobs/experiments/experiment.js +289 -95
  204. package/packages/dd-trace/src/llmobs/experiments/index.js +66 -19
  205. package/packages/dd-trace/src/llmobs/experiments/noop.js +95 -10
  206. package/packages/dd-trace/src/llmobs/experiments/result.js +13 -2
  207. package/packages/dd-trace/src/llmobs/experiments/util.js +220 -0
  208. package/packages/dd-trace/src/llmobs/noop.js +2 -0
  209. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +1 -1
  210. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +1 -1
  211. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +1 -1
  212. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +5 -5
  213. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +1 -1
  214. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +1 -1
  215. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +1 -1
  216. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +1 -1
  217. package/packages/dd-trace/src/llmobs/sdk.js +152 -78
  218. package/packages/dd-trace/src/llmobs/span_processor.js +9 -7
  219. package/packages/dd-trace/src/llmobs/tagger.js +1 -2
  220. package/packages/dd-trace/src/llmobs/telemetry.js +18 -0
  221. package/packages/dd-trace/src/llmobs/writers/base.js +3 -0
  222. package/packages/dd-trace/src/llmobs/writers/spans.js +12 -6
  223. package/packages/dd-trace/src/llmobs/writers/util.js +2 -0
  224. package/packages/dd-trace/src/log/channels.js +1 -9
  225. package/packages/dd-trace/src/log/levels.js +12 -0
  226. package/packages/dd-trace/src/noop/proxy.js +2 -2
  227. package/packages/dd-trace/src/openfeature/writers/exposures.js +1 -1
  228. package/packages/dd-trace/src/openfeature/writers/util.js +1 -1
  229. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +1 -1
  230. package/packages/dd-trace/src/opentelemetry/metrics/meter.js +8 -4
  231. package/packages/dd-trace/src/opentelemetry/metrics/otlp_transformer.js +1 -1
  232. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +3 -2
  233. package/packages/dd-trace/src/opentelemetry/span.js +4 -0
  234. package/packages/dd-trace/src/opentracing/span.js +1 -1
  235. package/packages/dd-trace/src/opentracing/span_context.js +1 -1
  236. package/packages/dd-trace/src/plugins/ci_plugin.js +124 -23
  237. package/packages/dd-trace/src/plugins/index.js +2 -0
  238. package/packages/dd-trace/src/plugins/plugin.js +1 -1
  239. package/packages/dd-trace/src/plugins/tracing.js +1 -0
  240. package/packages/dd-trace/src/plugins/util/ci.js +13 -6
  241. package/packages/dd-trace/src/plugins/util/git.js +7 -5
  242. package/packages/dd-trace/src/plugins/util/http-otel-semantics.js +3 -3
  243. package/packages/dd-trace/src/plugins/util/ip_extractor.js +1 -1
  244. package/packages/dd-trace/src/plugins/util/llm.js +2 -2
  245. package/packages/dd-trace/src/plugins/util/stacktrace.js +3 -0
  246. package/packages/dd-trace/src/plugins/util/tags.js +2 -0
  247. package/packages/dd-trace/src/plugins/util/test.js +356 -86
  248. package/packages/dd-trace/src/plugins/util/url.js +5 -5
  249. package/packages/dd-trace/src/plugins/util/user-provided-git.js +1 -1
  250. package/packages/dd-trace/src/plugins/util/web.js +24 -2
  251. package/packages/dd-trace/src/priority_sampler.js +6 -3
  252. package/packages/dd-trace/src/profiler.js +1 -1
  253. package/packages/dd-trace/src/profiling/exporters/event_serializer.js +1 -1
  254. package/packages/dd-trace/src/profiling/profilers/events.js +2 -2
  255. package/packages/dd-trace/src/profiling/profilers/poisson.js +1 -1
  256. package/packages/dd-trace/src/profiling/profilers/shared.js +2 -2
  257. package/packages/dd-trace/src/proxy.js +1 -0
  258. package/packages/dd-trace/src/ritm.js +10 -20
  259. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +4 -2
  260. package/packages/dd-trace/src/sampling_rule.js +4 -2
  261. package/packages/dd-trace/src/service-naming/schemas/v0/storage.js +1 -1
  262. package/packages/dd-trace/src/service-naming/schemas/v1/storage.js +1 -1
  263. package/packages/dd-trace/src/startup-log.js +3 -0
  264. package/packages/dd-trace/src/telemetry/logs/index.js +3 -0
  265. package/packages/dd-trace/src/telemetry/logs/log-collector.js +3 -1
  266. package/packages/dd-trace/src/telemetry/telemetry.js +1 -1
  267. package/packages/dd-trace/src/util.js +16 -0
  268. package/vendor/dist/@apm-js-collab/code-transformer/index.js +12 -12
  269. package/vendor/dist/pprof-format/index.js +1 -1
  270. package/vendor/dist/source-map/index.js +1 -1
  271. package/version.js +10 -8
  272. /package/packages/dd-trace/src/appsec/{blocked_templates.js → blocking/templates.js} +0 -0
@@ -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,40 @@ 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
+ if ((options.records) != null) {
51
+ for (const record of options.records) {
52
+ if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) {
53
+ throw new Error('record id must be a non-empty string')
54
+ }
55
+ if (record.id !== undefined) {
56
+ if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
57
+ recordIds.add(record.id)
58
+ }
59
+ dataset.addRecord(new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id))
60
+ }
61
+ }
62
+ return dataset
44
63
  }
45
64
 
46
65
  // Pull an existing dataset by name (with its records). Polls with exponential
47
66
  // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
48
67
  // wait until that many records are readable.
49
68
  async pullDataset (name, options = {}) {
50
- const { expectedRecordCount, maxWaitMs = 30_000 } = options
69
+ const { expectedRecordCount, maxWaitMs = 30_000, version } = options
51
70
  const projectId = await this.#client.ensureProjectId()
52
71
 
53
72
  let datasetId = null
54
73
  let description = ''
55
74
  let records = []
56
75
  let recordIds = []
76
+ let datasetVersion = version ?? null
77
+ let latestVersion = null
57
78
  let lastError = ''
58
79
 
59
80
  const succeeded = await retryWithBackoff(async () => {
@@ -63,11 +84,16 @@ class Experiments {
63
84
  'GET',
64
85
  `${API_BASE_PATH}/${projectId}/datasets?filter[name]=${encodeURIComponent(name)}`
65
86
  )
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
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
+ }
71
97
  }
72
98
  }
73
99
  if (datasetId === null) return false
@@ -78,16 +104,27 @@ class Experiments {
78
104
  let cursor = ''
79
105
  // Follow the meta.after / page[cursor] pagination until the last page.
80
106
  for (;;) {
81
- const query = cursor ? `?page[cursor]=${encodeURIComponent(cursor)}` : ''
107
+ const query = new URLSearchParams()
108
+ if (cursor) query.set('page[cursor]', cursor)
109
+ if (datasetVersion !== null) query.set('filter[version]', String(datasetVersion))
82
110
  // eslint-disable-next-line no-await-in-loop
83
111
  const resp = await this.#client.request(
84
112
  'GET',
85
- `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records${query}`
113
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records?${query.toString()}`
86
114
  )
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 ?? ''))
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
+ }
91
128
  }
92
129
  cursor = resp?.meta?.after ?? ''
93
130
  if (!cursor) break
@@ -119,18 +156,28 @@ class Experiments {
119
156
  )
120
157
  }
121
158
 
122
- return Dataset.fromExisting(this.#client, name, description, datasetId, projectId, records, recordIds)
159
+ return Dataset.fromExisting(
160
+ this.#client,
161
+ name,
162
+ description,
163
+ datasetId,
164
+ projectId,
165
+ records,
166
+ recordIds,
167
+ datasetVersion,
168
+ latestVersion
169
+ )
123
170
  }
124
171
 
125
172
  // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }.
126
173
  experiment (options) {
127
- return new Experiment(this.#client, options)
174
+ return new Experiment(this.#client, options, this.#llmobs)
128
175
  }
129
176
  }
130
177
 
131
178
  // Factory used by the LLMObs SDK: returns a real Experiments instance when
132
179
  // enabled and credentialed, otherwise a no-op that explains what's missing.
133
- function createExperiments (config) {
180
+ function createExperiments (config, llmobs) {
134
181
  if (!config.llmobs?.DD_LLMOBS_ENABLED) {
135
182
  return new NoopExperiments('LLM Observability is not enabled')
136
183
  }
@@ -146,7 +193,7 @@ function createExperiments (config) {
146
193
  'then retry'
147
194
  )
148
195
  }
149
- return new Experiments(config)
196
+ return new Experiments(config, llmobs)
150
197
  }
151
198
 
152
199
  module.exports = { Experiments, createExperiments }