dd-trace 6.11.0 → 6.13.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 (139) hide show
  1. package/LICENSE-3rdparty.csv +5 -0
  2. package/ci/test-optimization-validation/framework-adapters/cypress.js +9 -11
  3. package/ci/test-optimization-validation/scenarios/ci-wiring.js +5 -4
  4. package/index.d.ts +143 -25
  5. package/package.json +26 -25
  6. package/packages/datadog-instrumentations/src/bunyan.js +4 -2
  7. package/packages/datadog-instrumentations/src/cucumber.js +4 -4
  8. package/packages/datadog-instrumentations/src/helpers/bunyan.js +12 -3
  9. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +1 -0
  10. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/webdriverio.js +28 -0
  11. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +1 -0
  12. package/packages/datadog-instrumentations/src/jest.js +140 -18
  13. package/packages/datadog-instrumentations/src/mariadb-bundle.js +1597 -0
  14. package/packages/datadog-instrumentations/src/mariadb.js +7 -0
  15. package/packages/datadog-instrumentations/src/mocha/utils.js +5 -2
  16. package/packages/datadog-instrumentations/src/pino.js +15 -4
  17. package/packages/datadog-instrumentations/src/playwright-reporter.js +149 -1
  18. package/packages/datadog-instrumentations/src/playwright.js +67 -53
  19. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +13 -12
  20. package/packages/datadog-instrumentations/src/vitest-worker.js +11 -0
  21. package/packages/datadog-instrumentations/src/webdriverio.js +26 -0
  22. package/packages/datadog-instrumentations/src/winston.js +6 -3
  23. package/packages/datadog-plugin-cypress/src/support.js +1 -0
  24. package/packages/datadog-plugin-http/src/client.js +2 -15
  25. package/packages/datadog-plugin-http2/src/client.js +2 -15
  26. package/packages/datadog-plugin-openai-agents/src/integration.js +15 -11
  27. package/packages/datadog-plugin-playwright/src/index.js +13 -1
  28. package/packages/datadog-plugin-undici/src/index.js +2 -15
  29. package/packages/dd-trace/src/aiguard/evaluation.js +94 -20
  30. package/packages/dd-trace/src/aiguard/integrations/vercel-ai.js +2 -3
  31. package/packages/dd-trace/src/aiguard/messages/anthropic.js +21 -9
  32. package/packages/dd-trace/src/aiguard/noop.js +9 -1
  33. package/packages/dd-trace/src/aiguard/redaction.js +183 -0
  34. package/packages/dd-trace/src/aiguard/sdk.js +7 -2
  35. package/packages/dd-trace/src/aiguard/tags.js +2 -0
  36. package/packages/dd-trace/src/appsec/iast/vulnerabilities-formatter/utils.js +10 -9
  37. package/packages/dd-trace/src/appsec/reporter.js +2 -0
  38. package/packages/dd-trace/src/appsec/rule_manager.js +2 -0
  39. package/packages/dd-trace/src/appsec/telemetry/index.js +13 -0
  40. package/packages/dd-trace/src/appsec/telemetry/rasp.js +16 -0
  41. package/packages/dd-trace/src/appsec/telemetry/waf.js +24 -0
  42. package/packages/dd-trace/src/carrier.js +107 -48
  43. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +5 -5
  44. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +2 -0
  45. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +5 -3
  46. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +51 -26
  47. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +4 -2
  48. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +5 -11
  49. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +6 -4
  50. package/packages/dd-trace/src/ci-visibility/final-flush.js +20 -0
  51. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +5 -3
  52. package/packages/dd-trace/src/ci-visibility/log-submission/log-submission-plugin.js +196 -29
  53. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  54. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +4 -2
  55. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +4 -2
  56. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +5 -5
  57. package/packages/dd-trace/src/config/generated-config-types.d.ts +6 -0
  58. package/packages/dd-trace/src/config/index.js +2 -1
  59. package/packages/dd-trace/src/config/supported-configurations.json +27 -0
  60. package/packages/dd-trace/src/debugger/constants.js +1 -0
  61. package/packages/dd-trace/src/debugger/devtools_client/condition.js +1 -1
  62. package/packages/dd-trace/src/debugger/devtools_client/index.js +3 -10
  63. package/packages/dd-trace/src/debugger/devtools_client/send.js +4 -3
  64. package/packages/dd-trace/src/debugger/index.js +4 -2
  65. package/packages/dd-trace/src/debugger/inspect-segment.js +154 -0
  66. package/packages/dd-trace/src/dogstatsd.js +45 -18
  67. package/packages/dd-trace/src/encode/agentless-json.js +5 -4
  68. package/packages/dd-trace/src/evp_proxy/constants.js +8 -0
  69. package/packages/dd-trace/src/evp_proxy/direct.js +64 -0
  70. package/packages/dd-trace/src/evp_proxy/discovery.js +133 -0
  71. package/packages/dd-trace/src/evp_proxy/path.js +22 -0
  72. package/packages/dd-trace/src/exporters/agent/index.js +22 -2
  73. package/packages/dd-trace/src/exporters/agent/writer.js +4 -4
  74. package/packages/dd-trace/src/exporters/common/request.js +5 -2
  75. package/packages/dd-trace/src/exporters/common/writer.js +24 -2
  76. package/packages/dd-trace/src/exporters/span-stats/index.js +23 -4
  77. package/packages/dd-trace/src/external-logger/src/index.js +4 -3
  78. package/packages/dd-trace/src/flush.js +102 -0
  79. package/packages/dd-trace/src/llmobs/experiments/client.js +10 -2
  80. package/packages/dd-trace/src/llmobs/experiments/dataset.js +301 -14
  81. package/packages/dd-trace/src/llmobs/experiments/experiment.js +440 -112
  82. package/packages/dd-trace/src/llmobs/experiments/index.js +68 -44
  83. package/packages/dd-trace/src/llmobs/experiments/noop.js +103 -12
  84. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  85. package/packages/dd-trace/src/llmobs/experiments/util.js +81 -4
  86. package/packages/dd-trace/src/llmobs/index.js +80 -17
  87. package/packages/dd-trace/src/llmobs/noop.js +2 -1
  88. package/packages/dd-trace/src/llmobs/plugins/anthropic/util.js +8 -4
  89. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +6 -3
  90. package/packages/dd-trace/src/llmobs/plugins/openai/utils.js +13 -6
  91. package/packages/dd-trace/src/llmobs/plugins/openai-agents/utils.js +5 -7
  92. package/packages/dd-trace/src/llmobs/writers/base.js +60 -24
  93. package/packages/dd-trace/src/noop/span.js +5 -0
  94. package/packages/dd-trace/src/openfeature/constants/constants.js +0 -18
  95. package/packages/dd-trace/src/openfeature/index.js +7 -4
  96. package/packages/dd-trace/src/openfeature/writers/base.js +153 -11
  97. package/packages/dd-trace/src/openfeature/writers/exposures.js +70 -15
  98. package/packages/dd-trace/src/openfeature/writers/util.js +97 -15
  99. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +45 -3
  100. package/packages/dd-trace/src/opentelemetry/logs/index.js +4 -1
  101. package/packages/dd-trace/src/opentelemetry/logs/logger_provider.js +7 -4
  102. package/packages/dd-trace/src/opentelemetry/metrics/index.js +3 -1
  103. package/packages/dd-trace/src/opentelemetry/metrics/meter_provider.js +8 -0
  104. package/packages/dd-trace/src/opentelemetry/metrics/otlp_http_metric_exporter.js +4 -2
  105. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +4 -2
  106. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +25 -5
  107. package/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js +61 -25
  108. package/packages/dd-trace/src/opentelemetry/tracer.js +26 -2
  109. package/packages/dd-trace/src/opentracing/propagation/text_map.js +429 -352
  110. package/packages/dd-trace/src/opentracing/span.js +28 -0
  111. package/packages/dd-trace/src/otel-thread-ctx.js +23 -5
  112. package/packages/dd-trace/src/plugin_manager.js +2 -0
  113. package/packages/dd-trace/src/plugins/util/status-validator.js +98 -0
  114. package/packages/dd-trace/src/plugins/util/test.js +74 -21
  115. package/packages/dd-trace/src/plugins/util/web.js +2 -65
  116. package/packages/dd-trace/src/profiling/oom.js +4 -3
  117. package/packages/dd-trace/src/profiling/profiler.js +7 -0
  118. package/packages/dd-trace/src/profiling/profilers/events.js +3 -3
  119. package/packages/dd-trace/src/profiling/profilers/wall.js +14 -14
  120. package/packages/dd-trace/src/proxy.js +36 -8
  121. package/packages/dd-trace/src/runtime_metrics/index.js +5 -0
  122. package/packages/dd-trace/src/runtime_metrics/otlp_runtime_metrics.js +5 -0
  123. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +17 -6
  124. package/packages/dd-trace/src/serverless/telemetry-delivery-tracker.js +55 -0
  125. package/packages/dd-trace/src/serverless/vercel.js +122 -0
  126. package/packages/dd-trace/src/serverless.js +37 -20
  127. package/packages/dd-trace/src/span_stats.js +40 -3
  128. package/packages/dd-trace/src/telemetry/metrics.js +62 -8
  129. package/packages/dd-trace/src/telemetry/send-data.js +1 -0
  130. package/packages/dd-trace/src/tracer.js +22 -0
  131. package/packages/dd-trace/src/web-tags-cache.js +67 -13
  132. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
  133. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  134. package/vendor/dist/https-proxy-agent/LICENSE +22 -0
  135. package/vendor/dist/https-proxy-agent/index.js +4 -0
  136. package/vendor/dist/protobufjs/index.js +1 -1
  137. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  138. package/vendor/dist/proxy-from-env/LICENSE +20 -0
  139. package/vendor/dist/proxy-from-env/index.js +1 -0
@@ -2,10 +2,13 @@
2
2
 
3
3
  const log = require('../../log')
4
4
  const { ExperimentsClient } = require('./client')
5
- const { Dataset, DatasetRecord } = require('./dataset')
5
+ const { Dataset } = require('./dataset')
6
6
  const { Experiment, ExternalExperiment } = require('./experiment')
7
+ const { validateTagsList } = require('./util')
7
8
  const NoopExperiments = require('./noop')
8
9
 
10
+ const DEFAULT_PROJECT_NAME = 'default-project'
11
+
9
12
  // Poll `attempt` with exponential backoff until it returns true or the time
10
13
  // budget is spent. Used for eventually-consistent reads (pullDataset).
11
14
  async function retryWithBackoff (attempt, { maxTotalMs = 30_000, baseDelayMs = 250, maxDelayMs = 8000 } = {}) {
@@ -32,8 +35,8 @@ class Experiments {
32
35
 
33
36
  constructor (config, llmobs) {
34
37
  this.#config = config
35
- this.#llmobs = llmobs
36
- this.#projectName = config.llmobs?.mlApp || config.service
38
+ this.#llmobs = config.llmobs?.mlApp || config.service ? llmobs : undefined
39
+ this.#projectName = config.llmobs?.projectName || DEFAULT_PROJECT_NAME
37
40
  this.#client = this.#clientForProject(this.#projectName)
38
41
  }
39
42
 
@@ -50,50 +53,54 @@ class Experiments {
50
53
  })
51
54
  }
52
55
 
56
+ /**
57
+ * @param {string | undefined} projectName
58
+ * @returns {ExperimentsClient}
59
+ */
60
+ #clientForOperation (projectName) {
61
+ if (projectName !== undefined && projectName !== this.#projectName) {
62
+ return this.#clientForProject(projectName)
63
+ }
64
+ if (this.#client === undefined) this.#client = this.#clientForProject(projectName)
65
+ return this.#client
66
+ }
67
+
53
68
  // Create a local dataset buffer. Pushed remotely on first experiment run.
54
69
  createDataset (name, descriptionOrOptions = '') {
55
70
  const options = typeof descriptionOrOptions === 'string'
56
71
  ? { description: descriptionOrOptions }
57
72
  : (descriptionOrOptions ?? {})
58
- const dataset = new Dataset(this.#client, name, options.description ?? '')
59
- const recordIds = new Set()
60
- if ((options.records) != null) {
61
- for (const record of options.records) {
62
- if (record.id !== undefined && (typeof record.id !== 'string' || record.id.length === 0)) {
63
- throw new Error('record id must be a non-empty string')
64
- }
65
- if (record.id !== undefined) {
66
- if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
67
- recordIds.add(record.id)
68
- }
69
- dataset.addRecord(new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id))
70
- }
71
- }
73
+ const client = this.#clientForOperation(options.projectName)
74
+ const dataset = new Dataset(client, name, options.description ?? '')
75
+ if ((options.records) != null) dataset.addRecords(options.records)
72
76
  return dataset
73
77
  }
74
78
 
75
79
  // Pull an existing dataset by name (with its records). Polls with exponential
76
80
  // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
77
- // wait until that many records are readable.
81
+ // wait until that many records are readable. Pass `tags` to filter records by
82
+ // dataset record tags.
78
83
  async pullDataset (name, options = {}) {
79
- const { expectedRecordCount, maxWaitMs = 30_000, version } = options
80
- const projectId = await this.#client.ensureProjectId()
84
+ const { expectedRecordCount, maxWaitMs = 30_000, projectName, tags, version } = options
85
+ const filterTags = validateTagsList(tags)
86
+ const client = this.#clientForOperation(projectName)
87
+ const resolvedProjectName = projectName ?? this.#projectName
88
+ const projectId = await client.ensureProjectId()
81
89
 
82
90
  let pulledDataset = null
83
91
  let records = []
84
- let datasetVersion = version ?? null
92
+ const datasetVersion = version ?? null
85
93
  let latestVersion = null
86
94
  let lastError = ''
87
95
 
88
96
  const succeeded = await retryWithBackoff(async () => {
89
97
  try {
90
98
  if (pulledDataset === null) {
91
- const datasets = await this.#client.listDatasets(projectId, { name })
99
+ const datasets = await client.listDatasets(projectId, { name })
92
100
  for (const dataset of datasets) {
93
101
  if (dataset.name() === name) {
94
102
  pulledDataset = dataset
95
103
  latestVersion = dataset.latestVersion()
96
- datasetVersion = version ?? latestVersion
97
104
  break
98
105
  }
99
106
  }
@@ -105,8 +112,9 @@ class Experiments {
105
112
  // Follow the meta.after / page[cursor] pagination until the last page.
106
113
  for (;;) {
107
114
  // eslint-disable-next-line no-await-in-loop
108
- const page = await this.#client.listDatasetRecords(projectId, pulledDataset.id(), {
115
+ const page = await client.listDatasetRecords(projectId, pulledDataset.id(), {
109
116
  cursor,
117
+ tags: filterTags,
110
118
  version: datasetVersion,
111
119
  })
112
120
  for (const record of page.records) recs.push(record)
@@ -124,13 +132,13 @@ class Experiments {
124
132
  }, { maxTotalMs: maxWaitMs })
125
133
 
126
134
  if (pulledDataset === null && lastError) {
127
- throw new Error(`Failed to list datasets in project '${this.#projectName}': ${lastError}`)
135
+ throw new Error(`Failed to list datasets in project '${resolvedProjectName}': ${lastError}`)
128
136
  }
129
137
  if (pulledDataset === null) {
130
- throw new Error(`Dataset '${name}' not found in project '${this.#projectName}' (after ${maxWaitMs}ms)`)
138
+ throw new Error(`Dataset '${name}' not found in project '${resolvedProjectName}' (after ${maxWaitMs}ms)`)
131
139
  }
132
140
  if (!succeeded && lastError) {
133
- throw new Error(`Failed to fetch records for dataset '${name}' in project '${this.#projectName}': ${lastError}`)
141
+ throw new Error(`Failed to fetch records for dataset '${name}' in project '${resolvedProjectName}': ${lastError}`)
134
142
  }
135
143
  if (!succeeded && expectedRecordCount != null) {
136
144
  throw new Error(
@@ -139,21 +147,45 @@ class Experiments {
139
147
  )
140
148
  }
141
149
 
150
+ for (const record of records) {
151
+ if (record.id === null || record.id === undefined || record.id === '') {
152
+ throw new Error(`Failed to pull dataset '${name}': backend returned a record without an id`)
153
+ }
154
+ }
155
+
142
156
  return Dataset.fromExisting(
143
- this.#client,
157
+ client,
144
158
  name,
145
159
  pulledDataset.description(),
146
160
  pulledDataset.id(),
147
161
  projectId,
148
162
  records,
149
163
  datasetVersion,
150
- latestVersion
164
+ latestVersion,
165
+ filterTags
151
166
  )
152
167
  }
153
168
 
154
- // Build an experiment: { name, dataset, task, evaluators, description?, config?, tags? }.
169
+ // Build an experiment with a dataset, task, evaluators, and optional project/config/tags.
155
170
  experiment (options) {
156
- return new Experiment(this.#client, options, this.#llmobs)
171
+ const datasetProjectName = options?.dataset?.projectName?.()
172
+ if (options?.projectName !== undefined &&
173
+ datasetProjectName !== undefined &&
174
+ options.projectName !== datasetProjectName) {
175
+ throw new Error(
176
+ `Experiment project '${options.projectName}' does not match dataset project '${datasetProjectName}'`
177
+ )
178
+ }
179
+ const projectName = options?.projectName ?? datasetProjectName
180
+ const client = this.#clientForOperation(projectName)
181
+ const usesDatasetOverride = datasetProjectName !== undefined && datasetProjectName !== this.#projectName
182
+ const resolvedProjectName = projectName ?? this.#config.llmobs?.projectName
183
+ const experimentOptions = options?.projectName === undefined &&
184
+ (usesDatasetOverride || this.#config.llmobs?.projectName !== undefined) &&
185
+ resolvedProjectName !== undefined
186
+ ? { ...options, projectName: resolvedProjectName }
187
+ : options
188
+ return new Experiment(client, experimentOptions, this.#llmobs)
157
189
  }
158
190
 
159
191
  /**
@@ -165,10 +197,11 @@ class Experiments {
165
197
  * @returns {Promise<ExternalExperiment>}
166
198
  */
167
199
  startExperiment (options) {
168
- const client = options?.projectName === undefined || options.projectName === this.#projectName
169
- ? this.#client
170
- : this.#clientForProject(options.projectName)
171
- return new Experiment(client, { ...options, external: true }).start()
200
+ const client = this.#clientForOperation(options?.projectName)
201
+ const experimentOptions = options?.projectName === undefined && this.#config.llmobs?.projectName !== undefined
202
+ ? { ...options, projectName: this.#config.llmobs.projectName }
203
+ : options
204
+ return new Experiment(client, { ...experimentOptions, external: true }).start()
172
205
  .then(experiment => new ExternalExperiment(experiment))
173
206
  }
174
207
  }
@@ -183,15 +216,6 @@ function createExperiments (config, llmobs) {
183
216
  log.warn('LLMObs experiments: missing api and/or app keys, set DD_API_KEY and DD_APP_KEY')
184
217
  return new NoopExperiments('DD_API_KEY and DD_APP_KEY are required for experiments')
185
218
  }
186
- if (!config.llmobs?.mlApp && !config.service) {
187
- const reason = 'no project name configured; set the DD_LLMOBS_ML_APP environment variable (or llmobs.mlApp in ' +
188
- 'tracer.init()) to name the LLM Obs project, or DD_SERVICE (or service in tracer.init()) as a fallback, ' +
189
- 'then retry'
190
- const experiments = new Experiments(config, llmobs)
191
- return new NoopExperiments(reason, {
192
- startExperiment: (options) => experiments.startExperiment(options),
193
- })
194
- }
195
219
  return new Experiments(config, llmobs)
196
220
  }
197
221
 
@@ -7,24 +7,60 @@ const NOOP_EXPERIMENT_ID = '00000000-0000-0000-0000-000000000000'
7
7
  const NOOP_SPAN_ID = '0000000000000000'
8
8
  const NOOP_TRACE_ID = '00000000000000000000000000000000'
9
9
 
10
+ /**
11
+ * @typedef {object} DatasetRecordNew
12
+ * @property {string} [id]
13
+ * @property {unknown} inputData
14
+ * @property {unknown} [expectedOutput]
15
+ * @property {Record<string, unknown>} [metadata]
16
+ * @property {string[]} [tags]
17
+ */
18
+
10
19
  class NoopDataset {
11
20
  #name
12
21
  #description
13
22
  #records
23
+ #filterTags
14
24
 
15
25
  constructor (name = '', options = {}) {
16
26
  this.#name = name
17
27
  this.#description = typeof options === 'string' ? options : (options.description ?? '')
28
+ this.#filterTags = typeof options === 'string' ? [] : [...(options.filterTags ?? [])]
18
29
  this.#records = (typeof options === 'string' ? [] : (options.records ?? [])).map(record => ({
19
30
  id: record.id ?? null,
20
31
  input: record.inputData,
21
32
  expectedOutput: record.expectedOutput ?? null,
22
33
  metadata: record.metadata ?? {},
34
+ tags: [...(record.tags ?? [])],
23
35
  }))
24
36
  }
25
37
 
26
- addRecord (input, expectedOutput, metadata) {
27
- this.#records.push({ id: null, input, expectedOutput: expectedOutput ?? null, metadata: metadata ?? {} })
38
+ addRecord (input, expectedOutput, metadata, tags) {
39
+ this.#records.push({
40
+ id: null,
41
+ input,
42
+ expectedOutput: expectedOutput ?? null,
43
+ metadata: metadata ?? {},
44
+ tags: [...(tags ?? [])],
45
+ })
46
+ return this
47
+ }
48
+
49
+ /**
50
+ * Add multiple records to a dataset.
51
+ * @param {DatasetRecordNew[]} records
52
+ * @returns {NoopDataset} This dataset for chaining.
53
+ */
54
+ addRecords (records) {
55
+ for (const record of records) {
56
+ this.#records.push({
57
+ id: record.id ?? null,
58
+ input: record.inputData,
59
+ expectedOutput: record.expectedOutput ?? null,
60
+ metadata: record.metadata ?? {},
61
+ tags: [...(record.tags ?? [])],
62
+ })
63
+ }
28
64
  return this
29
65
  }
30
66
 
@@ -46,6 +82,49 @@ class NoopDataset {
46
82
  return Promise.resolve({ pushedCount: 0, totalCount: 0 })
47
83
  }
48
84
 
85
+ /**
86
+ * Add tags to a dataset record.
87
+ * @param {number} index Dataset record index.
88
+ * @param {string[]} tags Tags in key:value format.
89
+ * @returns {NoopDataset} This dataset for chaining.
90
+ */
91
+ addTags (index, tags) {
92
+ const record = this.#records[index]
93
+ if (!record) return this
94
+ const normalizedTags = tags ?? []
95
+ record.tags = [...new Set([...(record.tags ?? []), ...normalizedTags])].sort()
96
+ return this
97
+ }
98
+
99
+ /**
100
+ * Remove tags from a dataset record.
101
+ * @param {number} index Dataset record index.
102
+ * @param {string[]} tags Tags in key:value format.
103
+ * @returns {NoopDataset} This dataset for chaining.
104
+ */
105
+ removeTags (index, tags) {
106
+ const record = this.#records[index]
107
+ if (!record) return this
108
+ const normalizedTags = tags ?? []
109
+ const removed = new Set(normalizedTags)
110
+ record.tags = (record.tags ?? []).filter(tag => !removed.has(tag)).sort()
111
+ return this
112
+ }
113
+
114
+ /**
115
+ * Replace all tags on a dataset record.
116
+ * @param {number} index Dataset record index.
117
+ * @param {string[]} tags Tags in key:value format.
118
+ * @returns {NoopDataset} This dataset for chaining.
119
+ */
120
+ replaceTags (index, tags) {
121
+ const record = this.#records[index]
122
+ if (!record) return this
123
+ const normalizedTags = tags ?? []
124
+ record.tags = [...normalizedTags]
125
+ return this
126
+ }
127
+
49
128
  name () {
50
129
  return this.#name
51
130
  }
@@ -62,6 +141,10 @@ class NoopDataset {
62
141
  return null
63
142
  }
64
143
 
144
+ projectName () {
145
+ return null
146
+ }
147
+
65
148
  version () {
66
149
  return null
67
150
  }
@@ -70,6 +153,14 @@ class NoopDataset {
70
153
  return null
71
154
  }
72
155
 
156
+ /**
157
+ * Return the tags used to filter this dataset.
158
+ * @returns {string[]} Dataset record filter tags.
159
+ */
160
+ filterTags () {
161
+ return [...this.#filterTags]
162
+ }
163
+
73
164
  records () {
74
165
  return [...this.#records]
75
166
  }
@@ -105,7 +196,13 @@ class NoopExperiment {
105
196
  }
106
197
 
107
198
  run () {
108
- return Promise.resolve({ experimentId: null, rows: [], url: null })
199
+ return Promise.resolve({
200
+ experimentId: null,
201
+ rows: [],
202
+ summaryEvaluations: {},
203
+ runs: [],
204
+ url: null,
205
+ })
109
206
  }
110
207
 
111
208
  /**
@@ -140,11 +237,9 @@ class NoopExperiment {
140
237
  // throwing, so intentionally disabled experiments remain graceful.
141
238
  class NoopExperiments {
142
239
  #reason
143
- #startExperiment
144
240
 
145
- constructor (reason, options = {}) {
241
+ constructor (reason) {
146
242
  this.#reason = reason || 'LLMObs experiments are not available'
147
- this.#startExperiment = options.startExperiment
148
243
  }
149
244
 
150
245
  #warn () {
@@ -156,9 +251,9 @@ class NoopExperiments {
156
251
  return new NoopDataset(name, options)
157
252
  }
158
253
 
159
- pullDataset (name) {
254
+ pullDataset (name, options = {}) {
160
255
  this.#warn()
161
- return Promise.resolve(new NoopDataset(name))
256
+ return Promise.resolve(new NoopDataset(name, { filterTags: options.tags }))
162
257
  }
163
258
 
164
259
  experiment (options = {}) {
@@ -171,10 +266,6 @@ class NoopExperiments {
171
266
  * @returns {Promise<ExternalExperiment>}
172
267
  */
173
268
  startExperiment (options = {}) {
174
- if (this.#startExperiment !== undefined && options.projectName) {
175
- return this.#startExperiment(options)
176
- }
177
-
178
269
  this.#warn()
179
270
  return Promise.resolve(new ExternalExperiment(new NoopExperiment(options.name, true)))
180
271
  }
@@ -27,6 +27,7 @@ class ExperimentRun {
27
27
  constructor (fields) {
28
28
  this.runId = fields.runId
29
29
  this.runIteration = fields.runIteration
30
+ this.hasError = fields.hasError
30
31
  this.rows = fields.rows
31
32
  this.summaryEvaluations = fields.summaryEvaluations
32
33
  }
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { randomUUID } = require('node:crypto')
4
+
3
5
  const log = require('../../log')
4
6
 
5
7
  // Matches the backend and dd-trace-py evaluator metric label contract.
@@ -22,6 +24,43 @@ function hasEntries (value) {
22
24
  return false
23
25
  }
24
26
 
27
+ function validateTagsList (tags) {
28
+ if (tags == null) return []
29
+ if (!Array.isArray(tags)) throw new TypeError('Tags must be an array of strings')
30
+ for (const tag of tags) {
31
+ if (typeof tag !== 'string') throw new TypeError('Each tag must be a string')
32
+ if (tag.indexOf(':') <= 0) {
33
+ throw new Error(`Tag '${tag}' is malformed. Tags must be in 'key:value' format (e.g., 'env:prod').`)
34
+ }
35
+ }
36
+ return [...tags]
37
+ }
38
+
39
+ function tagOperationsAreEmpty (operations) {
40
+ return operations == null || (
41
+ !Object.hasOwn(operations, 'replace') &&
42
+ !Object.hasOwn(operations, 'add') &&
43
+ !Object.hasOwn(operations, 'remove')
44
+ )
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} value
49
+ * @param {string} name
50
+ * @returns {number}
51
+ */
52
+ function normalizePositiveInteger (value, name) {
53
+ if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`)
54
+ return value
55
+ }
56
+
57
+ /**
58
+ * @returns {string}
59
+ */
60
+ function generateRunId () {
61
+ return randomUUID()
62
+ }
63
+
25
64
  /**
26
65
  * @param {string} name
27
66
  */
@@ -165,16 +204,20 @@ function stringify (value) {
165
204
  * @returns {string[]}
166
205
  */
167
206
  function buildTags (userTags, autoTags) {
168
- const tags = new Map()
207
+ const tagsByKey = new Map()
169
208
  if ((userTags) != null) {
170
209
  for (const [key, value] of Object.entries(userTags)) {
171
- tags.set(key, `${key}:${value}`)
210
+ const values = Array.isArray(value) ? value : [value]
211
+ tagsByKey.set(key, values.map(item => `${key}:${item}`))
172
212
  }
173
213
  }
174
214
  for (const [key, value] of Object.entries(autoTags)) {
175
- if (value !== undefined && value !== null && value !== '') tags.set(key, `${key}:${value}`)
215
+ if (value !== undefined && value !== null && value !== '') tagsByKey.set(key, [`${key}:${value}`])
176
216
  }
177
- return [...tags.values()]
217
+
218
+ const tags = []
219
+ for (const values of tagsByKey.values()) tags.push(...values)
220
+ return tags
178
221
  }
179
222
 
180
223
  /**
@@ -186,6 +229,35 @@ function mergeTags (baseTags, overrideTags) {
186
229
  return { ...baseTags, ...overrideTags }
187
230
  }
188
231
 
232
+ /**
233
+ * @param {string[] | undefined} tags
234
+ * @returns {Record<string, string | string[]>}
235
+ */
236
+ function recordTagsToObject (tags) {
237
+ const result = {}
238
+ if (!Array.isArray(tags)) return result
239
+ for (const tag of tags) {
240
+ const separator = tag.indexOf(':')
241
+ if (separator <= 0) continue
242
+
243
+ const key = tag.slice(0, separator)
244
+ const value = tag.slice(separator + 1)
245
+ if (!Object.hasOwn(result, key)) {
246
+ Object.defineProperty(result, key, {
247
+ configurable: true,
248
+ enumerable: true,
249
+ value,
250
+ writable: true,
251
+ })
252
+ } else if (Array.isArray(result[key])) {
253
+ result[key].push(value)
254
+ } else {
255
+ result[key] = [result[key], value]
256
+ }
257
+ }
258
+ return result
259
+ }
260
+
189
261
  /**
190
262
  * @param {number} ms
191
263
  * @returns {Promise<void>}
@@ -244,13 +316,18 @@ module.exports = {
244
316
  buildSpanMetadata,
245
317
  buildTags,
246
318
  durationNs,
319
+ generateRunId,
247
320
  hasEntries,
248
321
  inferMetricType,
249
322
  mergeTags,
250
323
  normalizeEvaluators,
251
324
  normalizeJsonMetricValue,
325
+ normalizePositiveInteger,
326
+ recordTagsToObject,
252
327
  sleep,
253
328
  stringify,
329
+ tagOperationsAreEmpty,
254
330
  timestampMs,
255
331
  validateEvaluatorName,
332
+ validateTagsList,
256
333
  }
@@ -3,7 +3,9 @@
3
3
  const { channel } = require('dc-polyfill')
4
4
 
5
5
  const { readDatadogTags, writeDatadogTags } = require('../carrier')
6
+ const { registerTelemetryFlusher } = require('../flush')
6
7
  const log = require('../log')
8
+ const { createServerlessDeliveryTracker } = require('../serverless')
7
9
  const { DD_MAJOR } = require('../../../../version')
8
10
  const startupLogs = require('../startup-log')
9
11
  const {
@@ -57,6 +59,8 @@ let spanWriter
57
59
  /** @type {LLMObsEvalMetricsWriter | null} */
58
60
  let evalWriter
59
61
 
62
+ let unregisterTelemetryFlusher
63
+
60
64
  /** @type {import('../config/config-base')} */
61
65
  let globalTracerConfig
62
66
 
@@ -66,23 +70,36 @@ let globalTracerConfig
66
70
  function enable (config) {
67
71
  globalTracerConfig = config
68
72
 
73
+ const retiredSpanWriter = spanWriter
74
+ const retiredEvalWriter = evalWriter
75
+ const isReinitializing = Boolean(retiredSpanWriter || retiredEvalWriter)
76
+ unregisterTelemetryFlusher?.()
77
+ retireWriters(retiredSpanWriter, retiredEvalWriter)
78
+
69
79
  const startTime = performance.now()
70
80
  // create writers and eval writer append and flush channels
71
81
  // span writer append is handled by the span processor
72
82
  evalWriter = new LLMObsEvalMetricsWriter(config)
73
83
  spanWriter = new LLMObsSpanWriter(config)
84
+ const currentEvalWriter = evalWriter
85
+ const currentSpanWriter = spanWriter
86
+ unregisterTelemetryFlusher = registerTelemetryFlusher(done => {
87
+ flushWriters(done, currentSpanWriter, currentEvalWriter)
88
+ })
74
89
 
75
- evalMetricAppendCh.subscribe(handleEvalMetricAppend)
76
- flushCh.subscribe(handleFlush)
77
- registerUserSpanProcessorCh.subscribe(handleRegisterProcessor)
90
+ if (!isReinitializing) {
91
+ evalMetricAppendCh.subscribe(handleEvalMetricAppend)
92
+ flushCh.subscribe(handleFlush)
93
+ registerUserSpanProcessorCh.subscribe(handleRegisterProcessor)
94
+ }
78
95
 
79
96
  // span processing
80
97
  spanProcessor = new LLMObsSpanProcessor(config)
81
98
  spanProcessor.setWriter(spanWriter)
82
- spanFinishCh.subscribe(handleSpanProcess)
99
+ if (!isReinitializing) spanFinishCh.subscribe(handleSpanProcess)
83
100
 
84
101
  // distributed tracing for llmobs
85
- injectCh.subscribe(handleLLMObsInjection)
102
+ if (!isReinitializing) injectCh.subscribe(handleLLMObsInjection)
86
103
 
87
104
  setAgentStrategy(config, useAgentless => {
88
105
  if (useAgentless && !(config.DD_API_KEY && config.site)) {
@@ -94,8 +111,10 @@ function enable (config) {
94
111
  }
95
112
  }
96
113
 
97
- evalWriter?.setAgentless(useAgentless)
98
- spanWriter?.setAgentless(useAgentless)
114
+ // A disable can happen while transport selection is still pending. Keep
115
+ // configuring these writers so their queued lifecycle flushes can drain.
116
+ currentEvalWriter.setAgentless(useAgentless)
117
+ currentSpanWriter.setAgentless(useAgentless)
99
118
 
100
119
  telemetry.recordLLMObsEnabled(startTime, config)
101
120
  log.debug('[LLMObs] Enabled LLM Observability with configuration: %o', config.llmobs)
@@ -109,16 +128,39 @@ function disable () {
109
128
  if (injectCh.hasSubscribers) injectCh.unsubscribe(handleLLMObsInjection)
110
129
  if (registerUserSpanProcessorCh.hasSubscribers) registerUserSpanProcessorCh.unsubscribe(handleRegisterProcessor)
111
130
 
112
- spanWriter?.destroy()
113
- evalWriter?.destroy()
131
+ const retiredSpanWriter = spanWriter
132
+ const retiredEvalWriter = evalWriter
114
133
  spanProcessor?.setWriter(null)
134
+ unregisterTelemetryFlusher?.()
135
+ unregisterTelemetryFlusher = undefined
115
136
 
116
137
  spanWriter = null
117
138
  evalWriter = null
118
139
 
140
+ retireWriters(retiredSpanWriter, retiredEvalWriter)
141
+
119
142
  log.debug('[LLMObs] Disabled LLM Observability')
120
143
  }
121
144
 
145
+ /**
146
+ * Keeps retired writers reachable until their destroy-triggered deliveries complete.
147
+ * @param {LLMObsSpanWriter | null} retiredSpanWriter
148
+ * @param {LLMObsEvalMetricsWriter | null} retiredEvalWriter
149
+ * @returns {void}
150
+ */
151
+ function retireWriters (retiredSpanWriter, retiredEvalWriter) {
152
+ const retiredWriters = [retiredSpanWriter, retiredEvalWriter].filter(Boolean)
153
+ if (retiredWriters.length === 0) return
154
+ let remainingWriters = retiredWriters.length
155
+ const unregisterRetiredFlusher = registerTelemetryFlusher(done => {
156
+ flushWriters(done, retiredSpanWriter, retiredEvalWriter)
157
+ })
158
+ function onWriterDestroyed () {
159
+ if (--remainingWriters === 0) unregisterRetiredFlusher?.()
160
+ }
161
+ for (const writer of retiredWriters) writer.destroy(onWriterDestroyed)
162
+ }
163
+
122
164
  // since LLMObs traces can extend between services and be the same trace,
123
165
  // we need to propagate the parent id, mlApp, session id, and sampling rate/decision.
124
166
  function handleLLMObsInjection ({ carrier }) {
@@ -192,15 +234,36 @@ function handleLLMObsInjection ({ carrier }) {
192
234
  if (tags !== existing) writeDatadogTags(carrier, tags)
193
235
  }
194
236
 
195
- function handleFlush () {
196
- let err = ''
197
- try {
198
- spanWriter.flush()
199
- evalWriter.flush()
200
- } catch (e) {
201
- err = 'writer_flush_error'
202
- log.warn('Failed to flush LLMObs spans and evaluation metrics:', e.message)
237
+ /**
238
+ * Flushes the specified LLMObs writers and joins deliveries active at the boundary.
239
+ * @param {Function} [done]
240
+ * @param {LLMObsSpanWriter | null} [currentSpanWriter]
241
+ * @param {LLMObsEvalMetricsWriter | null} [currentEvalWriter]
242
+ * @returns {boolean} `true` when a writer throws synchronously.
243
+ */
244
+ function flushWriters (done, currentSpanWriter = spanWriter, currentEvalWriter = evalWriter) {
245
+ let failed = false
246
+ const deliveryTracker = createServerlessDeliveryTracker()
247
+ const flush = writer => {
248
+ try {
249
+ if (deliveryTracker && writer) deliveryTracker.track(complete => writer.flush(complete))
250
+ // Non-serverless flushes retain the existing writer behavior.
251
+ else writer?.flush()
252
+ } catch (error) {
253
+ failed = true
254
+ log.warn('Failed to flush LLMObs writer:', error.message)
255
+ }
203
256
  }
257
+
258
+ flush(currentSpanWriter)
259
+ flush(currentEvalWriter)
260
+ deliveryTracker?.waitForIdle(done)
261
+ if (!deliveryTracker) done?.()
262
+ return failed
263
+ }
264
+
265
+ function handleFlush () {
266
+ const err = flushWriters() ? 'writer_flush_error' : ''
204
267
  telemetry.recordUserFlush(err)
205
268
  }
206
269