dd-trace 6.10.0 → 6.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE-3rdparty.csv +5 -0
  2. package/README.md +0 -17
  3. package/index.d.ts +128 -6
  4. package/initialize.mjs +28 -17
  5. package/openfeature.js +2 -2
  6. package/package.json +27 -23
  7. package/packages/datadog-esbuild/index.js +0 -34
  8. package/packages/datadog-instrumentations/src/anthropic.js +210 -15
  9. package/packages/datadog-instrumentations/src/aws-sdk.js +9 -1
  10. package/packages/datadog-instrumentations/src/browser-bunyan.js +11 -0
  11. package/packages/datadog-instrumentations/src/bunyan.js +3 -16
  12. package/packages/datadog-instrumentations/src/cucumber.js +30 -15
  13. package/packages/datadog-instrumentations/src/cypress-config.js +182 -16
  14. package/packages/datadog-instrumentations/src/cypress-legacy-finalizers.js +30 -0
  15. package/packages/datadog-instrumentations/src/cypress.js +2 -0
  16. package/packages/datadog-instrumentations/src/fastify.js +2 -0
  17. package/packages/datadog-instrumentations/src/helpers/bunyan.js +22 -0
  18. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -1
  19. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +9 -4
  20. package/packages/datadog-instrumentations/src/helpers/register.js +10 -8
  21. package/packages/datadog-instrumentations/src/jest.js +25 -17
  22. package/packages/datadog-instrumentations/src/mariadb.js +278 -21
  23. package/packages/datadog-instrumentations/src/mocha/main.js +539 -55
  24. package/packages/datadog-instrumentations/src/mocha/utils.js +135 -19
  25. package/packages/datadog-instrumentations/src/playwright-reporter.js +247 -0
  26. package/packages/datadog-instrumentations/src/playwright.js +182 -35
  27. package/packages/datadog-instrumentations/src/router.js +2 -0
  28. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +6 -8
  29. package/packages/datadog-instrumentations/src/vitest-main.js +11 -3
  30. package/packages/datadog-plugin-aws-sdk/src/base.js +1 -0
  31. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +163 -44
  32. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +2 -8
  33. package/packages/datadog-plugin-azure-functions/src/index.js +1 -1
  34. package/packages/datadog-plugin-browser-bunyan/src/index.js +9 -0
  35. package/packages/datadog-plugin-bunyan/src/index.js +5 -8
  36. package/packages/datadog-plugin-cucumber/src/index.js +11 -0
  37. package/packages/datadog-plugin-cypress/src/after-run.js +5 -1
  38. package/packages/datadog-plugin-cypress/src/after-spec.js +5 -1
  39. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +81 -29
  40. package/packages/datadog-plugin-cypress/src/finalization.js +76 -0
  41. package/packages/datadog-plugin-cypress/src/index.js +12 -6
  42. package/packages/datadog-plugin-cypress/src/plugin.js +16 -3
  43. package/packages/datadog-plugin-dd-trace-api/src/index.js +4 -0
  44. package/packages/datadog-plugin-electron/src/index.js +2 -0
  45. package/packages/datadog-plugin-fs/src/index.js +1 -1
  46. package/packages/datadog-plugin-jest/src/index.js +6 -0
  47. package/packages/datadog-plugin-mocha/src/index.js +56 -1
  48. package/packages/datadog-plugin-nats/src/index.js +1 -1
  49. package/packages/datadog-plugin-openai/src/tracing.js +41 -30
  50. package/packages/datadog-plugin-openai-agents/src/integration.js +1 -0
  51. package/packages/datadog-plugin-playwright/src/index.js +46 -13
  52. package/packages/datadog-plugin-vitest/src/index.js +6 -0
  53. package/packages/datadog-webpack/index.js +0 -11
  54. package/packages/dd-trace/index.js +0 -1
  55. package/packages/dd-trace/src/agent/info.js +6 -5
  56. package/packages/dd-trace/src/aiguard/client.js +100 -1
  57. package/packages/dd-trace/src/aiguard/errors.js +41 -0
  58. package/packages/dd-trace/src/aiguard/evaluation.js +357 -0
  59. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +70 -0
  60. package/packages/dd-trace/src/aiguard/integrations/index.js +4 -0
  61. package/packages/dd-trace/src/aiguard/messages/anthropic.js +423 -0
  62. package/packages/dd-trace/src/aiguard/messages/openai.js +1 -24
  63. package/packages/dd-trace/src/aiguard/messages/utils.js +28 -0
  64. package/packages/dd-trace/src/aiguard/messages/vercel-ai.js +1 -11
  65. package/packages/dd-trace/src/aiguard/sdk.js +22 -278
  66. package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter.js +9 -12
  67. package/packages/dd-trace/src/appsec/reporter.js +2 -0
  68. package/packages/dd-trace/src/appsec/telemetry/index.js +13 -0
  69. package/packages/dd-trace/src/appsec/telemetry/rasp.js +16 -0
  70. package/packages/dd-trace/src/appsec/telemetry/waf.js +24 -0
  71. package/packages/dd-trace/src/carrier.js +107 -48
  72. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +1 -0
  73. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +5 -5
  74. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +29 -3
  75. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +27 -9
  76. package/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js +21 -5
  77. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +164 -0
  78. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +26 -8
  79. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +365 -32
  80. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +6 -4
  81. package/packages/dd-trace/src/ci-visibility/exporters/request.js +201 -0
  82. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +5 -3
  83. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -5
  84. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +7 -4
  85. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  86. package/packages/dd-trace/src/ci-visibility/requests/rate-limit.js +39 -0
  87. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -6
  88. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +4 -2
  89. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +10 -4
  90. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +5 -5
  91. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  92. package/packages/dd-trace/src/config/index.js +13 -2
  93. package/packages/dd-trace/src/config/major-overrides.js +7 -0
  94. package/packages/dd-trace/src/config/supported-configurations.json +14 -0
  95. package/packages/dd-trace/src/datastreams/index.js +2 -0
  96. package/packages/dd-trace/src/datastreams/manager.js +9 -2
  97. package/packages/dd-trace/src/datastreams/processor.js +15 -13
  98. package/packages/dd-trace/src/datastreams/size.js +5 -0
  99. package/packages/dd-trace/src/debugger/constants.js +1 -0
  100. package/packages/dd-trace/src/debugger/devtools_client/condition.js +1 -1
  101. package/packages/dd-trace/src/debugger/devtools_client/index.js +5 -11
  102. package/packages/dd-trace/src/debugger/index.js +4 -2
  103. package/packages/dd-trace/src/debugger/inspect-segment.js +154 -0
  104. package/packages/dd-trace/src/evp_proxy/constants.js +8 -0
  105. package/packages/dd-trace/src/evp_proxy/direct.js +64 -0
  106. package/packages/dd-trace/src/evp_proxy/discovery.js +133 -0
  107. package/packages/dd-trace/src/evp_proxy/path.js +22 -0
  108. package/packages/dd-trace/src/exporters/agent/writer.js +49 -9
  109. package/packages/dd-trace/src/exporters/common/buffering-exporter.js +6 -4
  110. package/packages/dd-trace/src/exporters/common/request.js +5 -2
  111. package/packages/dd-trace/src/exporters/common/writer.js +11 -6
  112. package/packages/dd-trace/src/llmobs/experiments/client.js +167 -5
  113. package/packages/dd-trace/src/llmobs/experiments/dataset.js +484 -92
  114. package/packages/dd-trace/src/llmobs/experiments/experiment.js +302 -29
  115. package/packages/dd-trace/src/llmobs/experiments/index.js +75 -64
  116. package/packages/dd-trace/src/llmobs/experiments/noop.js +135 -7
  117. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  118. package/packages/dd-trace/src/llmobs/experiments/util.js +101 -9
  119. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +12 -3
  120. package/packages/dd-trace/src/llmobs/plugins/base.js +4 -2
  121. package/packages/dd-trace/src/noop/proxy.js +3 -4
  122. package/packages/dd-trace/src/openfeature/constants/constants.js +0 -18
  123. package/packages/dd-trace/src/openfeature/flagging_provider.js +18 -2
  124. package/packages/dd-trace/src/openfeature/index.js +7 -4
  125. package/packages/dd-trace/src/openfeature/remote_config.js +1 -1
  126. package/packages/dd-trace/src/openfeature/writers/base.js +153 -11
  127. package/packages/dd-trace/src/openfeature/writers/exposures.js +62 -14
  128. package/packages/dd-trace/src/openfeature/writers/util.js +97 -15
  129. package/packages/dd-trace/src/opentelemetry/context_manager.js +7 -7
  130. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +2 -1
  131. package/packages/dd-trace/src/opentelemetry/logs/logger.js +67 -11
  132. package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +14 -3
  133. package/packages/dd-trace/src/opentelemetry/metrics/index.js +25 -12
  134. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +2 -5
  135. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +96 -43
  136. package/packages/dd-trace/src/opentracing/propagation/text_map.js +22 -13
  137. package/packages/dd-trace/src/opentracing/tracer.js +5 -2
  138. package/packages/dd-trace/src/otel-thread-ctx.js +387 -0
  139. package/packages/dd-trace/src/plugin_manager.js +3 -3
  140. package/packages/dd-trace/src/plugins/ci_plugin.js +6 -1
  141. package/packages/dd-trace/src/plugins/index.js +1 -0
  142. package/packages/dd-trace/src/plugins/util/jest.js +4 -3
  143. package/packages/dd-trace/src/plugins/util/test.js +9 -9
  144. package/packages/dd-trace/src/plugins/util/web.js +3 -0
  145. package/packages/dd-trace/src/profiling/profiler.js +1 -1
  146. package/packages/dd-trace/src/profiling/profilers/space.js +1 -3
  147. package/packages/dd-trace/src/profiling/profilers/wall.js +37 -90
  148. package/packages/dd-trace/src/profiling/webspan-utils.js +45 -0
  149. package/packages/dd-trace/src/proxy.js +38 -29
  150. package/packages/dd-trace/src/ritm.js +9 -2
  151. package/packages/dd-trace/src/serverless.js +37 -0
  152. package/packages/dd-trace/src/span_format.js +1 -0
  153. package/packages/dd-trace/src/span_stats.js +30 -3
  154. package/packages/dd-trace/src/storage-channels.js +86 -0
  155. package/packages/dd-trace/src/telemetry/metrics.js +62 -8
  156. package/packages/dd-trace/src/telemetry/send-data.js +1 -0
  157. package/packages/dd-trace/src/tracer.js +9 -2
  158. package/packages/dd-trace/src/tracer_metadata.js +14 -1
  159. package/packages/dd-trace/src/web-tags-cache.js +132 -0
  160. package/vendor/dist/@datadog/openfeature-node-server/LICENSE +201 -0
  161. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -0
  162. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  163. package/vendor/dist/https-proxy-agent/LICENSE +22 -0
  164. package/vendor/dist/https-proxy-agent/index.js +4 -0
  165. package/vendor/dist/protobufjs/index.js +1 -1
  166. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  167. package/vendor/dist/proxy-from-env/LICENSE +20 -0
  168. package/vendor/dist/proxy-from-env/index.js +1 -0
  169. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +0 -65
  170. package/packages/datadog-webpack/src/optional-peer-loader.js +0 -17
  171. package/packages/dd-trace/src/feature-registry.js +0 -29
  172. package/packages/dd-trace/src/openfeature/register.js +0 -35
  173. package/packages/dd-trace/src/openfeature/require-provider.js +0 -18
@@ -1,95 +1,331 @@
1
1
  'use strict'
2
2
 
3
- const { API_BASE_PATH } = require('./client')
3
+ const { randomUUID } = require('node:crypto')
4
4
 
5
- // Dataset record: { input, expectedOutput?, metadata?, id? }.
6
- // `id` may be user-provided before push or filled from the backend-created record.
5
+ /** @typedef {{add?: string[], remove?: string[], replace?: string[]}} TagOperations */
6
+ /**
7
+ * @typedef {object} PendingBatch
8
+ * @property {object} attributes
9
+ * @property {string[]} deleteRecordIds
10
+ * @property {Map<string, object>} insertPayloads
11
+ * @property {Map<string, object>} updatePayloads
12
+ * @property {number} totalCount
13
+ * @property {Map<string, TagOperations>} [inFlightTagOperations]
14
+ */
15
+
16
+ const { tagOperationsAreEmpty, validateTagsList } = require('./util')
17
+
18
+ // Dataset record: { input, expectedOutput?, metadata?, id, tags? }.
19
+ // IDs are generated locally unless the caller supplies one.
7
20
  class DatasetRecord {
8
- constructor (input, expectedOutput = null, metadata = {}, id = null) {
21
+ constructor (input, expectedOutput = null, metadata = {}, id = null, tags = []) {
22
+ if (id != null && (typeof id !== 'string' || id.length === 0)) {
23
+ throw new Error('record id must be a non-empty string')
24
+ }
9
25
  this.input = input
10
26
  this.expectedOutput = expectedOutput ?? null
11
27
  this.metadata = metadata ?? {}
12
- this.id = id ?? null
28
+ this.tags = validateTagsList(tags)
29
+ this.id = id ?? randomUUID()
13
30
  }
14
31
  }
15
32
 
16
- function createdRecordsFromResponse (response) {
17
- if (Array.isArray(response?.records)) return response.records
18
- if (Array.isArray(response?.data)) return response.data
19
- return []
33
+ function versionFromMutationResult (result) {
34
+ return result?.version ?? null
35
+ }
36
+
37
+ function serializedRecord (record) {
38
+ const output = {
39
+ id: record.id,
40
+ input: record.input,
41
+ expected_output: record.expectedOutput ?? null,
42
+ metadata: record.metadata ?? {},
43
+ }
44
+ if (record.tags.length > 0) output.tags = record.tags
45
+ return output
20
46
  }
21
47
 
22
- function recordIdFromCreatedRecord (record) {
23
- return String(record?.id ?? record?.attributes?.id ?? '')
48
+ function serializedRecordUpdate (update) {
49
+ const output = { id: update.id }
50
+ if (Object.hasOwn(update, 'input') && update.input !== undefined) output.input = update.input
51
+ if (Object.hasOwn(update, 'expectedOutput') && update.expectedOutput !== undefined) {
52
+ output.expected_output = update.expectedOutput
53
+ }
54
+ if (Object.hasOwn(update, 'metadata') && update.metadata !== undefined) output.metadata = update.metadata
55
+ if (Object.hasOwn(update, 'tagOperations')) {
56
+ output.tag_operations = serializedTagOperations(update.tagOperations)
57
+ }
58
+ return output
24
59
  }
25
60
 
26
- function versionFromCreatedRecords (records) {
27
- const versions = records
28
- .map(record => Number(record?.attributes?.valid_from_version ?? record?.attributes?.version))
29
- .filter(Number.isFinite)
30
- if (versions.length === 0) return null
31
- return Math.max(...versions)
61
+ function serializedTagOperations (operations) {
62
+ const output = {}
63
+ if (Object.hasOwn(operations, 'add')) output.add = operations.add
64
+ if (Object.hasOwn(operations, 'remove')) output.remove = operations.remove
65
+ if (Object.hasOwn(operations, 'replace')) output.set = operations.replace
66
+ return output
32
67
  }
33
68
 
34
- // A local buffer of dataset records, created remotely and pushed on first run
35
- // (or eagerly via push()). Pushes are incremental.
69
+ /**
70
+ * @param {TagOperations} operations
71
+ * @returns {TagOperations}
72
+ */
73
+ function copyTagOperations (operations) {
74
+ return Object.fromEntries(Object.entries(operations).map(([key, tags]) => [key, [...tags]]))
75
+ }
76
+
77
+ function mergeTagOperations (operations, operation, tags) {
78
+ if (operation === 'replace') return { replace: [...tags] }
79
+ if (Object.hasOwn(operations, 'replace')) {
80
+ const replaced = new Set(operations.replace)
81
+ for (const tag of tags) {
82
+ if (operation === 'add') replaced.add(tag)
83
+ else replaced.delete(tag)
84
+ }
85
+ return { replace: [...replaced].sort() }
86
+ }
87
+
88
+ const add = new Set(operations.add)
89
+ const remove = new Set(operations.remove)
90
+ for (const tag of tags) {
91
+ if (operation === 'add') {
92
+ if (remove.has(tag)) remove.delete(tag)
93
+ else add.add(tag)
94
+ } else if (add.has(tag)) {
95
+ add.delete(tag)
96
+ } else {
97
+ remove.add(tag)
98
+ }
99
+ }
100
+ const merged = {}
101
+ if (add.size > 0) merged.add = [...add].sort()
102
+ if (remove.size > 0) merged.remove = [...remove].sort()
103
+ return merged
104
+ }
105
+
106
+ function valuesAreEqual (left, right) {
107
+ return JSON.stringify(left) === JSON.stringify(right)
108
+ }
109
+
110
+ function updateFromInsertedRecord (recordId, record, payload) {
111
+ const update = { id: recordId }
112
+ if (!valuesAreEqual(record.input, payload.input)) update.input = record.input
113
+ if (!valuesAreEqual(record.expectedOutput, payload.expected_output)) {
114
+ update.expectedOutput = record.expectedOutput
115
+ }
116
+ if (!valuesAreEqual(record.metadata, payload.metadata)) update.metadata = record.metadata
117
+ return update
118
+ }
119
+
120
+ // A local dataset model that tracks pending changes and pushes them in one batch.
36
121
  class Dataset {
37
122
  #client
38
123
  #name
39
124
  #description
40
125
  #records
41
- #recordIds
126
+ #recordsById
127
+ #newRecordsById
128
+ #updatedRecordsById
129
+ #deletedRecordIds
130
+ #pendingTagOperations
42
131
  #id
43
132
  #projectId
44
- #pushedCount
45
133
  #version
46
134
  #latestVersion
135
+ #pushPromise
136
+ #filterTags
47
137
 
48
- constructor (client, name, description = '') {
138
+ constructor (client, name, description = '', filterTags = []) {
49
139
  this.#client = client
50
140
  this.#name = name
51
141
  this.#description = description
142
+ this.#filterTags = validateTagsList(filterTags)
52
143
  this.#records = []
53
- this.#recordIds = []
144
+ this.#recordsById = new Map()
145
+ this.#newRecordsById = new Map()
146
+ this.#updatedRecordsById = new Map()
147
+ this.#deletedRecordIds = new Set()
148
+ this.#pendingTagOperations = new Map()
54
149
  this.#id = null
55
150
  this.#projectId = null
56
- this.#pushedCount = 0
57
151
  this.#version = null
58
152
  this.#latestVersion = null
153
+ this.#pushPromise = Promise.resolve()
59
154
  }
60
155
 
61
156
  // Build a Dataset that already exists remotely (used by pullDataset).
62
- static fromExisting (client, name, description, id, projectId, records, recordIds, version, latestVersion) {
63
- const dataset = new Dataset(client, name, description)
157
+ static fromExisting (client, name, description, id, projectId, records, version, latestVersion, filterTags) {
158
+ const dataset = new Dataset(client, name, description, filterTags)
64
159
  dataset.#id = id
65
160
  dataset.#projectId = projectId
66
- dataset.#records.push(...records)
67
- dataset.#recordIds.push(...recordIds)
68
- dataset.#pushedCount = records.length
69
161
  dataset.#version = version ?? null
70
162
  dataset.#latestVersion = latestVersion ?? version ?? null
163
+ for (const record of records) dataset.#addExistingRecord(record)
71
164
  return dataset
72
165
  }
73
166
 
74
167
  // Append a record. Accepts a DatasetRecord or (input, expectedOutput?, metadata?).
75
- addRecord (recordOrInput, expectedOutput, metadata) {
168
+ addRecord (recordOrInput, expectedOutput, metadata, tags) {
76
169
  const record = recordOrInput instanceof DatasetRecord
77
170
  ? recordOrInput
78
- : new DatasetRecord(recordOrInput, expectedOutput, metadata)
79
- this.#records.push(record)
171
+ : new DatasetRecord(recordOrInput, expectedOutput, metadata, null, tags)
172
+ this.#addRecord(record)
80
173
  return this
81
174
  }
82
175
 
176
+ /**
177
+ * Add tags to a dataset record.
178
+ * @param {number} index Dataset record index.
179
+ * @param {string[]} tags Tags in key:value format.
180
+ * @returns {Dataset} This dataset for chaining.
181
+ */
182
+ addTags (index, tags) {
183
+ const validated = validateTagsList(tags)
184
+ const record = this.#recordAt(index)
185
+ const next = new Set(record.tags)
186
+ const added = []
187
+ for (const tag of validated) {
188
+ if (next.has(tag)) continue
189
+ next.add(tag)
190
+ added.push(tag)
191
+ }
192
+ record.tags = [...next].sort()
193
+ this.#queueTagOperation(record.id, 'add', added)
194
+ return this
195
+ }
196
+
197
+ /**
198
+ * Remove tags from a dataset record.
199
+ * @param {number} index Dataset record index.
200
+ * @param {string[]} tags Tags in key:value format.
201
+ * @returns {Dataset} This dataset for chaining.
202
+ */
203
+ removeTags (index, tags) {
204
+ const validated = validateTagsList(tags)
205
+ const record = this.#recordAt(index)
206
+ const next = new Set(record.tags)
207
+ const removed = []
208
+ for (const tag of validated) {
209
+ if (!next.has(tag)) continue
210
+ next.delete(tag)
211
+ removed.push(tag)
212
+ }
213
+ record.tags = [...next].sort()
214
+ this.#queueTagOperation(record.id, 'remove', removed)
215
+ return this
216
+ }
217
+
218
+ /**
219
+ * Replace all tags on a dataset record.
220
+ * @param {number} index Dataset record index.
221
+ * @param {string[]} tags Tags in key:value format.
222
+ * @returns {Dataset} This dataset for chaining.
223
+ */
224
+ replaceTags (index, tags) {
225
+ const validated = validateTagsList(tags)
226
+ const record = this.#recordAt(index)
227
+ record.tags = validated
228
+ this.#queueTagOperation(record.id, 'replace', validated)
229
+ return this
230
+ }
231
+
232
+ /**
233
+ * Update an existing dataset record. New records are updated in place and are sent with their insert.
234
+ * @param {number} index Dataset record index.
235
+ * @param {{input?: unknown, expectedOutput?: unknown, metadata?: object}} fields Fields to update.
236
+ * @returns {Dataset} This dataset for chaining.
237
+ */
238
+ update (index, fields) {
239
+ if (fields == null || typeof fields !== 'object' || Array.isArray(fields)) {
240
+ throw new TypeError('record update must be an object')
241
+ }
242
+ const record = this.#recordAt(index)
243
+ const fieldNames = ['input', 'expectedOutput', 'metadata']
244
+ const providedFields = fieldNames.filter(field => Object.hasOwn(fields, field) && fields[field] !== undefined)
245
+ if (providedFields.length === 0) {
246
+ throw new Error('record update must include input, expectedOutput, or metadata')
247
+ }
248
+
249
+ for (const field of providedFields) {
250
+ record[field] = field === 'metadata' ? (fields[field] ?? {}) : fields[field]
251
+ }
252
+
253
+ if (this.#newRecordsById.has(record.id)) return this
254
+
255
+ const update = this.#updatedRecordsById.get(record.id) ?? { id: record.id }
256
+ for (const field of providedFields) update[field] = record[field]
257
+ if (this.#pendingTagOperations.has(record.id)) {
258
+ update.tagOperations = this.#pendingTagOperations.get(record.id)
259
+ }
260
+ this.#updatedRecordsById.set(record.id, update)
261
+ return this
262
+ }
263
+
264
+ /**
265
+ * Delete a dataset record. New records are removed locally without a backend operation.
266
+ * @param {number} index Dataset record index.
267
+ * @returns {Dataset} This dataset for chaining.
268
+ */
269
+ delete (index) {
270
+ const record = this.#recordAt(index)
271
+ this.#records.splice(index, 1)
272
+ this.#recordsById.delete(record.id)
273
+
274
+ if (this.#newRecordsById.delete(record.id)) {
275
+ this.#pendingTagOperations.delete(record.id)
276
+ return this
277
+ }
278
+
279
+ this.#updatedRecordsById.delete(record.id)
280
+ this.#pendingTagOperations.delete(record.id)
281
+ this.#deletedRecordIds.add(record.id)
282
+ return this
283
+ }
284
+
285
+ /**
286
+ * Queue a tag operation for the next dataset push.
287
+ * @param {string} recordId Dataset record id.
288
+ * @param {'add' | 'remove' | 'replace'} operation Tag operation to queue.
289
+ * @param {string[]} tags Tags in key:value format.
290
+ * @returns {void}
291
+ */
292
+ #queueTagOperation (recordId, operation, tags) {
293
+ if (operation !== 'replace' && tags.length === 0) return
294
+ const operations = mergeTagOperations(this.#pendingTagOperations.get(recordId) ?? {}, operation, tags)
295
+ if (tagOperationsAreEmpty(operations)) {
296
+ this.#pendingTagOperations.delete(recordId)
297
+ const update = this.#updatedRecordsById.get(recordId)
298
+ if (update) {
299
+ const hasRecordUpdate = Object.hasOwn(update, 'input') ||
300
+ Object.hasOwn(update, 'expectedOutput') ||
301
+ Object.hasOwn(update, 'metadata')
302
+ delete update.tagOperations
303
+ if (!hasRecordUpdate) this.#updatedRecordsById.delete(recordId)
304
+ }
305
+ return
306
+ }
307
+ this.#pendingTagOperations.set(recordId, operations)
308
+ if (!this.#newRecordsById.has(recordId)) {
309
+ const update = this.#updatedRecordsById.get(recordId) ?? { id: recordId }
310
+ update.tagOperations = operations
311
+ this.#updatedRecordsById.set(recordId, update)
312
+ }
313
+ }
314
+
83
315
  name () {
84
316
  return this.#name
85
317
  }
86
318
 
319
+ description () {
320
+ return this.#description
321
+ }
322
+
87
323
  records () {
88
324
  return [...this.#records]
89
325
  }
90
326
 
91
327
  recordIds () {
92
- return [...this.#recordIds]
328
+ return this.#records.map(record => record.id)
93
329
  }
94
330
 
95
331
  id () {
@@ -108,98 +344,254 @@ class Dataset {
108
344
  return this.#latestVersion
109
345
  }
110
346
 
347
+ /**
348
+ * Return the tags used to filter this dataset.
349
+ * @returns {string[]} Dataset record filter tags.
350
+ */
351
+ filterTags () {
352
+ return [...this.#filterTags]
353
+ }
354
+
111
355
  // Dashboard URL for this dataset, or null until pushed/pulled.
112
356
  url () {
113
357
  if (this.#id === null) return null
114
358
  return `${this.#client.appBase}/llm/datasets/${this.#id}`
115
359
  }
116
360
 
117
- // Eagerly create the dataset (if needed) and push any unpushed records.
118
- async push () {
119
- const projectId = await this.#client.ensureProjectId()
120
- return this.ensureCreatedAndPushed(projectId)
361
+ // Eagerly create the dataset (if needed) and push all pending changes.
362
+ push () {
363
+ return this.#enqueuePush(async () => {
364
+ const projectId = await this.#client.ensureProjectId()
365
+ return this.#ensureCreatedAndPushed(projectId)
366
+ })
367
+ }
368
+
369
+ // Called by Experiment.run() after it has resolved the project id.
370
+ ensureCreatedAndPushed (projectId) {
371
+ return this.#enqueuePush(() => this.#ensureCreatedAndPushed(projectId))
372
+ }
373
+
374
+ #enqueuePush (push) {
375
+ const next = this.#pushPromise.then(push, push)
376
+ this.#pushPromise = next.then(() => {}, () => {})
377
+ return next
121
378
  }
122
379
 
123
- // Create the remote dataset if needed, then push records added since the last
124
- // push. Idempotent and incremental. Resolves to { pushedCount, totalCount } for
125
- // the records attempted in this call, so callers can confirm the push landed.
126
- async ensureCreatedAndPushed (projectId) {
380
+ async #ensureCreatedAndPushed (projectId) {
127
381
  if (this.#id === null) {
128
382
  let response
129
383
  try {
130
- response = await this.#client.request('POST', `${API_BASE_PATH}/${projectId}/datasets`, {
131
- data: { type: 'datasets', attributes: { name: this.#name, description: this.#description } },
132
- })
384
+ response = await this.#client.createDataset(projectId, { name: this.#name, description: this.#description })
133
385
  } catch (err) {
134
386
  throw new Error(`Failed to create dataset '${this.#name}': ${err.message}`)
135
387
  }
136
- this.#id = response?.data?.id ?? null
388
+ this.#id = response?.id() ?? null
137
389
  if (this.#id === null) {
138
390
  throw new Error(`Failed to create dataset '${this.#name}': backend response is missing dataset id`)
139
391
  }
140
392
  this.#projectId = projectId
141
- this.#version = response?.data?.attributes?.current_version ?? this.#version
142
- this.#latestVersion = response?.data?.attributes?.current_version ?? this.#latestVersion
393
+ this.#version = response.version() ?? this.#version
394
+ this.#latestVersion = response.latestVersion() ?? this.#latestVersion
395
+ }
396
+
397
+ const pending = this.#pendingBatch()
398
+ if (pending.totalCount === 0) return { pushedCount: 0, totalCount: 0 }
399
+
400
+ this.#detachCommittedTagOperations(pending)
401
+
402
+ let result
403
+ try {
404
+ result = await this.#client.batchUpdateDatasetRecords(projectId, this.#id, pending.attributes)
405
+ } catch (err) {
406
+ this.#restoreFailedTagOperations(pending)
407
+ throw new Error(`Failed to push changes to dataset '${this.#name}': ${err.message}`)
143
408
  }
144
409
 
145
- if (this.#pushedCount >= this.#records.length) return { pushedCount: 0, totalCount: 0 }
410
+ this.#updateVersionFromMutationResult(result)
411
+ this.#clearCommittedChanges(pending)
412
+ return { pushedCount: pending.totalCount, totalCount: pending.totalCount }
413
+ }
414
+
415
+ #pendingBatch () {
416
+ const insertRecords = []
417
+ const insertPayloads = new Map()
418
+ for (const [recordId, record] of this.#newRecordsById) {
419
+ const payload = serializedRecord(record)
420
+ insertRecords.push(payload)
421
+ insertPayloads.set(recordId, payload)
422
+ }
423
+
424
+ const updateRecords = []
425
+ const updatePayloads = new Map()
426
+ for (const [recordId, update] of this.#updatedRecordsById) {
427
+ const tagOperations = this.#pendingTagOperations.get(recordId)
428
+ if (tagOperations) update.tagOperations = tagOperations
429
+ else delete update.tagOperations
430
+ const payload = serializedRecordUpdate(update)
431
+ updateRecords.push(payload)
432
+ updatePayloads.set(recordId, payload)
433
+ }
434
+
435
+ const deleteRecordIds = [...this.#deletedRecordIds]
436
+ return {
437
+ attributes: {
438
+ insert_records: insertRecords,
439
+ update_records: updateRecords,
440
+ delete_records: deleteRecordIds,
441
+ deduplicate: true,
442
+ create_new_version: true,
443
+ },
444
+ deleteRecordIds,
445
+ insertPayloads,
446
+ updatePayloads,
447
+ totalCount: new Set([
448
+ ...insertPayloads.keys(),
449
+ ...updatePayloads.keys(),
450
+ ...deleteRecordIds,
451
+ ]).size,
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Detach tag changes sent by this batch so edits made while the request is in flight
457
+ * are queued relative to the response that this batch will commit.
458
+ * @param {PendingBatch} pending
459
+ * @returns {void}
460
+ */
461
+ #detachCommittedTagOperations (pending) {
462
+ pending.inFlightTagOperations = new Map()
463
+ for (const [recordId] of pending.insertPayloads) {
464
+ const operations = this.#pendingTagOperations.get(recordId)
465
+ if (!operations) continue
466
+ pending.inFlightTagOperations.set(recordId, copyTagOperations(operations))
467
+ this.#pendingTagOperations.delete(recordId)
468
+ }
469
+ for (const [recordId, payload] of pending.updatePayloads) {
470
+ const operations = this.#pendingTagOperations.get(recordId)
471
+ if (!operations || !Object.hasOwn(payload, 'tag_operations')) continue
472
+
473
+ pending.inFlightTagOperations.set(recordId, copyTagOperations(operations))
474
+ this.#pendingTagOperations.delete(recordId)
475
+ const update = this.#updatedRecordsById.get(recordId)
476
+ if (update) delete update.tagOperations
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Restore tag changes when a batch request fails, including edits made while it was in flight.
482
+ * @param {PendingBatch} pending
483
+ * @returns {void}
484
+ */
485
+ #restoreFailedTagOperations (pending) {
486
+ if (!pending.inFlightTagOperations) return
487
+ for (const [recordId, operations] of pending.inFlightTagOperations) {
488
+ const record = this.#recordsById.get(recordId)
489
+ if (!record || this.#newRecordsById.has(recordId) || this.#deletedRecordIds.has(recordId)) continue
146
490
 
147
- const pending = this.#records.slice(this.#pushedCount)
148
- const records = pending.map((rec) => {
149
- const out = { input: rec.input }
150
- if (rec.id != null) {
151
- out.id = rec.id
491
+ const update = this.#updatedRecordsById.get(recordId) ?? { id: recordId }
492
+ const queuedOperations = this.#pendingTagOperations.get(recordId)
493
+ const restoredOperations = queuedOperations
494
+ ? { replace: [...record.tags] }
495
+ : copyTagOperations(operations)
496
+ this.#pendingTagOperations.set(recordId, restoredOperations)
497
+ update.tagOperations = restoredOperations
498
+ this.#updatedRecordsById.set(recordId, update)
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Clear the changes represented by a completed batch while retaining concurrent local edits.
504
+ * @param {PendingBatch} pending
505
+ * @returns {void}
506
+ */
507
+ #clearCommittedChanges (pending) {
508
+ for (const [recordId, payload] of pending.insertPayloads) {
509
+ const current = this.#newRecordsById.get(recordId)
510
+ if (!current) {
511
+ this.#deletedRecordIds.add(recordId)
512
+ continue
152
513
  }
153
- if (rec.expectedOutput !== null && rec.expectedOutput !== undefined) {
154
- out.expected_output = rec.expectedOutput
514
+ if (valuesAreEqual(serializedRecord(current), payload)) {
515
+ this.#newRecordsById.delete(recordId)
516
+ this.#pendingTagOperations.delete(recordId)
517
+ continue
155
518
  }
156
- if (rec.metadata && Object.keys(rec.metadata).length > 0) {
157
- out.metadata = rec.metadata
519
+
520
+ this.#newRecordsById.delete(recordId)
521
+ const update = this.#updatedRecordsById.get(recordId) ??
522
+ updateFromInsertedRecord(recordId, current, payload)
523
+ const queuedOperations = this.#pendingTagOperations.get(recordId)
524
+ if (queuedOperations) update.tagOperations = queuedOperations
525
+ this.#updatedRecordsById.set(recordId, update)
526
+ }
527
+
528
+ for (const [recordId, payload] of pending.updatePayloads) {
529
+ const current = this.#updatedRecordsById.get(recordId)
530
+ const queuedOperations = this.#pendingTagOperations.get(recordId)
531
+ if (!current || queuedOperations) continue
532
+
533
+ const comparison = { ...current }
534
+ const committedOperations = pending.inFlightTagOperations?.get(recordId)
535
+ if (committedOperations) comparison.tagOperations = committedOperations
536
+ if (valuesAreEqual(serializedRecordUpdate(comparison), payload)) {
537
+ this.#updatedRecordsById.delete(recordId)
538
+ this.#pendingTagOperations.delete(recordId)
158
539
  }
159
- return out
160
- })
540
+ }
161
541
 
162
- let response
163
- try {
164
- response = await this.#client.request(
165
- 'POST',
166
- `${API_BASE_PATH}/${projectId}/datasets/${this.#id}/records`,
167
- { data: { type: 'datasets', attributes: { records } } }
168
- )
169
- } catch (err) {
170
- throw new Error(`Failed to push records to dataset '${this.#name}': ${err.message}`)
542
+ for (const recordId of pending.deleteRecordIds) {
543
+ if (!this.#recordsById.has(recordId)) this.#deletedRecordIds.delete(recordId)
171
544
  }
545
+ }
172
546
 
173
- // The append-records response has used both a top-level `records` array
174
- // and JSON:API `data` resources. Accept either so generated/custom record
175
- // ids are preserved for experiment row tagging.
176
- const created = createdRecordsFromResponse(response)
177
- const pushedVersion = versionFromCreatedRecords(created)
178
- if (pushedVersion === null) {
179
- // The dataset contents changed, but the backend did not report the new
180
- // version. Avoid pinning later experiments to the pre-append create version.
181
- this.#version = null
182
- } else {
547
+ #updateVersionFromMutationResult (result) {
548
+ const pushedVersion = versionFromMutationResult(result)
549
+ if (pushedVersion !== null) {
183
550
  this.#version = pushedVersion
184
- this.#latestVersion = Math.max(Number(this.#latestVersion ?? pushedVersion), pushedVersion)
551
+ this.#latestVersion = pushedVersion
552
+ return
185
553
  }
186
554
 
187
- let pushedCount = 0
188
- for (const [index, node] of created.entries()) {
189
- const recordId = recordIdFromCreatedRecord(node)
190
- if (recordId !== '') {
191
- pushedCount++
192
- pending[index].id = recordId
193
- }
194
- this.#recordIds.push(recordId)
555
+ const latestVersion = Number(this.#latestVersion)
556
+ if (Number.isFinite(latestVersion)) {
557
+ this.#version = latestVersion + 1
558
+ this.#latestVersion = this.#version
559
+ } else {
560
+ this.#version = null
195
561
  }
196
- for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
562
+ }
563
+
564
+ #addRecord (record) {
565
+ if (this.#recordsById.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
566
+ this.#records.push(record)
567
+ this.#recordsById.set(record.id, record)
568
+ this.#deletedRecordIds.delete(record.id)
569
+ this.#newRecordsById.set(record.id, record)
570
+ }
197
571
 
198
- // Advance by the snapshotted pending count, not the live records length,
199
- // so records added while this push was in flight aren't skipped by the next push.
200
- this.#pushedCount += pending.length
572
+ #addExistingRecord (record) {
573
+ if (record.id === null || record.id === undefined || record.id === '') {
574
+ throw new Error('Dataset records pulled from the backend must have an id')
575
+ }
576
+ if (!(record instanceof DatasetRecord)) {
577
+ record = new DatasetRecord(
578
+ record.input,
579
+ record.expectedOutput,
580
+ record.metadata,
581
+ record.id,
582
+ record.tags
583
+ )
584
+ }
585
+ if (this.#recordsById.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
586
+ this.#records.push(record)
587
+ this.#recordsById.set(record.id, record)
588
+ }
201
589
 
202
- return { pushedCount, totalCount: pending.length }
590
+ #recordAt (index) {
591
+ if (!Number.isInteger(index) || index < 0 || index >= this.#records.length) {
592
+ throw new RangeError(`Dataset record index ${index} is out of range`)
593
+ }
594
+ return this.#records[index]
203
595
  }
204
596
  }
205
597