dd-trace 5.121.0 → 5.122.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 (137) hide show
  1. package/README.md +0 -17
  2. package/index.d.ts +108 -3
  3. package/initialize.mjs +28 -17
  4. package/openfeature.js +2 -2
  5. package/package.json +11 -8
  6. package/packages/datadog-esbuild/index.js +0 -34
  7. package/packages/datadog-instrumentations/src/anthropic.js +210 -15
  8. package/packages/datadog-instrumentations/src/aws-sdk.js +9 -1
  9. package/packages/datadog-instrumentations/src/browser-bunyan.js +11 -0
  10. package/packages/datadog-instrumentations/src/bunyan.js +3 -16
  11. package/packages/datadog-instrumentations/src/cucumber.js +26 -11
  12. package/packages/datadog-instrumentations/src/cypress-config.js +182 -16
  13. package/packages/datadog-instrumentations/src/cypress-legacy-finalizers.js +30 -0
  14. package/packages/datadog-instrumentations/src/cypress.js +2 -0
  15. package/packages/datadog-instrumentations/src/fastify.js +2 -0
  16. package/packages/datadog-instrumentations/src/helpers/bunyan.js +22 -0
  17. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -1
  18. package/packages/datadog-instrumentations/src/helpers/pool-acquire.js +9 -4
  19. package/packages/datadog-instrumentations/src/helpers/register.js +10 -8
  20. package/packages/datadog-instrumentations/src/jest.js +25 -17
  21. package/packages/datadog-instrumentations/src/mariadb.js +278 -21
  22. package/packages/datadog-instrumentations/src/mocha/main.js +539 -55
  23. package/packages/datadog-instrumentations/src/mocha/utils.js +130 -17
  24. package/packages/datadog-instrumentations/src/playwright-reporter.js +99 -0
  25. package/packages/datadog-instrumentations/src/playwright.js +149 -2
  26. package/packages/datadog-instrumentations/src/router.js +2 -0
  27. package/packages/datadog-instrumentations/src/vitest-main.js +11 -3
  28. package/packages/datadog-plugin-aws-sdk/src/base.js +1 -0
  29. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +163 -44
  30. package/packages/datadog-plugin-aws-sdk/src/services/kinesis.js +2 -8
  31. package/packages/datadog-plugin-azure-functions/src/index.js +1 -1
  32. package/packages/datadog-plugin-browser-bunyan/src/index.js +9 -0
  33. package/packages/datadog-plugin-bunyan/src/index.js +5 -8
  34. package/packages/datadog-plugin-cucumber/src/index.js +11 -0
  35. package/packages/datadog-plugin-cypress/src/after-run.js +5 -1
  36. package/packages/datadog-plugin-cypress/src/after-spec.js +5 -1
  37. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +81 -29
  38. package/packages/datadog-plugin-cypress/src/finalization.js +76 -0
  39. package/packages/datadog-plugin-cypress/src/index.js +12 -6
  40. package/packages/datadog-plugin-cypress/src/plugin.js +16 -3
  41. package/packages/datadog-plugin-dd-trace-api/src/index.js +4 -0
  42. package/packages/datadog-plugin-electron/src/index.js +2 -0
  43. package/packages/datadog-plugin-fs/src/index.js +1 -1
  44. package/packages/datadog-plugin-jest/src/index.js +6 -0
  45. package/packages/datadog-plugin-mocha/src/index.js +56 -1
  46. package/packages/datadog-plugin-nats/src/index.js +1 -1
  47. package/packages/datadog-plugin-openai/src/tracing.js +41 -30
  48. package/packages/datadog-plugin-openai-agents/src/integration.js +1 -0
  49. package/packages/datadog-plugin-playwright/src/index.js +42 -13
  50. package/packages/datadog-plugin-vitest/src/index.js +6 -0
  51. package/packages/datadog-webpack/index.js +0 -11
  52. package/packages/dd-trace/index.js +0 -1
  53. package/packages/dd-trace/src/agent/info.js +6 -5
  54. package/packages/dd-trace/src/aiguard/client.js +100 -1
  55. package/packages/dd-trace/src/aiguard/errors.js +41 -0
  56. package/packages/dd-trace/src/aiguard/evaluation.js +357 -0
  57. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +70 -0
  58. package/packages/dd-trace/src/aiguard/integrations/index.js +4 -0
  59. package/packages/dd-trace/src/aiguard/messages/anthropic.js +423 -0
  60. package/packages/dd-trace/src/aiguard/messages/openai.js +1 -24
  61. package/packages/dd-trace/src/aiguard/messages/utils.js +28 -0
  62. package/packages/dd-trace/src/aiguard/messages/vercel-ai.js +1 -11
  63. package/packages/dd-trace/src/aiguard/sdk.js +22 -278
  64. package/packages/dd-trace/src/appsec/iast/taint-tracking/rewriter.js +9 -12
  65. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +1 -0
  66. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +27 -3
  67. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +22 -6
  68. package/packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js +21 -5
  69. package/packages/dd-trace/src/ci-visibility/exporters/agentless/request-tracker.js +164 -0
  70. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +22 -6
  71. package/packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js +365 -32
  72. package/packages/dd-trace/src/ci-visibility/exporters/request.js +201 -0
  73. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/index.js +5 -3
  74. package/packages/dd-trace/src/ci-visibility/exporters/test-worker/writer.js +9 -5
  75. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +2 -1
  76. package/packages/dd-trace/src/ci-visibility/requests/rate-limit.js +39 -0
  77. package/packages/dd-trace/src/ci-visibility/requests/request.js +3 -6
  78. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +6 -2
  79. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -0
  80. package/packages/dd-trace/src/config/index.js +13 -2
  81. package/packages/dd-trace/src/config/major-overrides.js +7 -0
  82. package/packages/dd-trace/src/config/supported-configurations.json +14 -0
  83. package/packages/dd-trace/src/datastreams/index.js +2 -0
  84. package/packages/dd-trace/src/datastreams/manager.js +9 -2
  85. package/packages/dd-trace/src/datastreams/processor.js +15 -13
  86. package/packages/dd-trace/src/datastreams/size.js +5 -0
  87. package/packages/dd-trace/src/debugger/devtools_client/index.js +3 -2
  88. package/packages/dd-trace/src/exporters/agent/writer.js +49 -9
  89. package/packages/dd-trace/src/exporters/common/buffering-exporter.js +6 -4
  90. package/packages/dd-trace/src/exporters/common/writer.js +11 -6
  91. package/packages/dd-trace/src/llmobs/experiments/client.js +163 -5
  92. package/packages/dd-trace/src/llmobs/experiments/dataset.js +241 -87
  93. package/packages/dd-trace/src/llmobs/experiments/experiment.js +280 -20
  94. package/packages/dd-trace/src/llmobs/experiments/index.js +58 -59
  95. package/packages/dd-trace/src/llmobs/experiments/noop.js +71 -3
  96. package/packages/dd-trace/src/llmobs/experiments/result.js +1 -0
  97. package/packages/dd-trace/src/llmobs/experiments/util.js +41 -5
  98. package/packages/dd-trace/src/llmobs/plugins/ai/util.js +12 -3
  99. package/packages/dd-trace/src/llmobs/plugins/base.js +4 -2
  100. package/packages/dd-trace/src/noop/proxy.js +3 -4
  101. package/packages/dd-trace/src/openfeature/flagging_provider.js +18 -2
  102. package/packages/dd-trace/src/openfeature/remote_config.js +1 -1
  103. package/packages/dd-trace/src/opentelemetry/context_manager.js +7 -7
  104. package/packages/dd-trace/src/opentelemetry/logs/batch_log_processor.js +2 -1
  105. package/packages/dd-trace/src/opentelemetry/logs/logger.js +67 -11
  106. package/packages/dd-trace/src/opentelemetry/logs/otlp_transformer.js +14 -3
  107. package/packages/dd-trace/src/opentelemetry/metrics/index.js +25 -12
  108. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_exporter.js +2 -5
  109. package/packages/dd-trace/src/opentelemetry/metrics/otlp_span_stats_transformer.js +96 -43
  110. package/packages/dd-trace/src/opentracing/tracer.js +5 -2
  111. package/packages/dd-trace/src/otel-thread-ctx.js +387 -0
  112. package/packages/dd-trace/src/plugin_manager.js +3 -3
  113. package/packages/dd-trace/src/plugins/ci_plugin.js +6 -1
  114. package/packages/dd-trace/src/plugins/index.js +1 -0
  115. package/packages/dd-trace/src/plugins/util/jest.js +4 -3
  116. package/packages/dd-trace/src/plugins/util/test.js +9 -9
  117. package/packages/dd-trace/src/plugins/util/web.js +3 -0
  118. package/packages/dd-trace/src/profiling/profiler.js +1 -1
  119. package/packages/dd-trace/src/profiling/profilers/space.js +1 -3
  120. package/packages/dd-trace/src/profiling/profilers/wall.js +37 -90
  121. package/packages/dd-trace/src/profiling/webspan-utils.js +45 -0
  122. package/packages/dd-trace/src/proxy.js +38 -29
  123. package/packages/dd-trace/src/ritm.js +9 -2
  124. package/packages/dd-trace/src/serverless.js +37 -0
  125. package/packages/dd-trace/src/span_format.js +1 -0
  126. package/packages/dd-trace/src/span_stats.js +30 -3
  127. package/packages/dd-trace/src/storage-channels.js +86 -0
  128. package/packages/dd-trace/src/tracer.js +9 -2
  129. package/packages/dd-trace/src/tracer_metadata.js +14 -1
  130. package/packages/dd-trace/src/web-tags-cache.js +132 -0
  131. package/vendor/dist/@datadog/openfeature-node-server/LICENSE +201 -0
  132. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -0
  133. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +0 -65
  134. package/packages/datadog-webpack/src/optional-peer-loader.js +0 -17
  135. package/packages/dd-trace/src/feature-registry.js +0 -29
  136. package/packages/dd-trace/src/openfeature/register.js +0 -35
  137. package/packages/dd-trace/src/openfeature/require-provider.js +0 -18
@@ -3,6 +3,9 @@
3
3
  // Control-plane HTTP client for LLM Obs Experiments. Uses the global `fetch`,
4
4
  // so this module adds no new dependency; credentials and site come from config.
5
5
 
6
+ const { Dataset, DatasetRecord } = require('./dataset')
7
+ const { ExperimentResult } = require('./result')
8
+
6
9
  const API_BASE_PATH = '/api/v2/llm-obs/v1'
7
10
 
8
11
  // Control-plane host for a Datadog site, e.g.
@@ -22,12 +25,67 @@ function appHost (site) {
22
25
  return site.split('.').length === 2 ? `app.${site}` : site
23
26
  }
24
27
 
28
+ function datasetRecordFromResource (resource) {
29
+ const attrs = resource?.attributes ?? resource ?? {}
30
+ const id = String(resource?.id ?? attrs.id ?? '')
31
+ if (id === '') throw new Error('Dataset record response is missing an id')
32
+ return new DatasetRecord(
33
+ attrs.input ?? null,
34
+ attrs.expected_output ?? null,
35
+ attrs.metadata ?? {},
36
+ id
37
+ )
38
+ }
39
+
40
+ function datasetVersionFromResource (resource) {
41
+ const attrs = resource?.attributes ?? resource ?? {}
42
+ return attrs.valid_from_version ?? attrs.version ?? null
43
+ }
44
+
45
+ function datasetVersionFromResources (resources) {
46
+ const versions = resources
47
+ .map(datasetVersionFromResource)
48
+ .filter(version => version != null)
49
+ .map(Number)
50
+ .filter(Number.isFinite)
51
+ if (versions.length === 0) return null
52
+ return Math.max(...versions)
53
+ }
54
+
55
+ function datasetMutationResultFromResources (resources) {
56
+ return {
57
+ records: resources.map(datasetRecordFromResource),
58
+ version: datasetVersionFromResources(resources),
59
+ }
60
+ }
61
+
62
+ function datasetFromResource (client, projectId, resource) {
63
+ const attrs = resource?.attributes ?? resource ?? {}
64
+ const version = attrs.current_version ?? null
65
+ return Dataset.fromExisting(
66
+ client,
67
+ String(attrs.name ?? ''),
68
+ String(attrs.description ?? ''),
69
+ resource?.id ?? attrs.id ?? null,
70
+ projectId,
71
+ [],
72
+ version,
73
+ version
74
+ )
75
+ }
76
+
77
+ function experimentFromResource (client, resource) {
78
+ const id = resource?.id
79
+ return new ExperimentResult(id, [], id == null ? null : `${client.appBase}/llm/experiments/${id}`)
80
+ }
81
+
25
82
  class ExperimentsClient {
26
83
  #apiKey
27
84
  #appKey
28
85
  #site
29
86
  #projectName
30
87
  #timeout
88
+ apiBase
31
89
  #cachedProjectId
32
90
 
33
91
  constructor ({ apiKey, appKey, site, projectName, timeout = 30_000 } = {}) {
@@ -36,6 +94,7 @@ class ExperimentsClient {
36
94
  this.#site = site
37
95
  this.#projectName = projectName
38
96
  this.#timeout = timeout
97
+ this.apiBase = `https://${apiHost(this.#site)}`
39
98
  this.#cachedProjectId = null
40
99
  }
41
100
 
@@ -61,7 +120,7 @@ class ExperimentsClient {
61
120
  // Low-level request. Builds https://api.<site><path>, attaches both keys, and
62
121
  // returns the parsed JSON body. Throws with status + body on a non-2xx.
63
122
  async request (method, path, body) {
64
- const url = `https://${apiHost(this.#site)}${path}`
123
+ const url = `${this.apiBase}${path}`
65
124
  const headers = {
66
125
  'DD-API-KEY': this.#apiKey,
67
126
  'DD-APPLICATION-KEY': this.#appKey,
@@ -92,6 +151,107 @@ class ExperimentsClient {
92
151
  return text ? JSON.parse(text) : {}
93
152
  }
94
153
 
154
+ jsonApiRequest (method, path, type, attributes) {
155
+ return this.request(method, path, {
156
+ data: { type, attributes },
157
+ })
158
+ }
159
+
160
+ async createProject (name) {
161
+ const response = await this.jsonApiRequest('POST', `${API_BASE_PATH}/projects`, 'projects', { name })
162
+ return response?.data ?? null
163
+ }
164
+
165
+ async createDataset (projectId, attributes) {
166
+ const response = await this.jsonApiRequest('POST', `${API_BASE_PATH}/${projectId}/datasets`, 'datasets', attributes)
167
+ return datasetFromResource(this, projectId, response?.data ?? null)
168
+ }
169
+
170
+ deleteDataset (projectId, datasetId) {
171
+ return this.jsonApiRequest('POST', `${API_BASE_PATH}/${projectId}/datasets/delete`, 'datasets', {
172
+ type: 'soft',
173
+ dataset_ids: [datasetId],
174
+ })
175
+ }
176
+
177
+ async listDatasets (projectId, options = {}) {
178
+ const query = new URLSearchParams()
179
+ if (options.name !== undefined) query.set('filter[name]', options.name)
180
+ const response = await this.request('GET', `${API_BASE_PATH}/${projectId}/datasets?${query.toString()}`)
181
+ const resources = Array.isArray(response?.data) ? response.data : []
182
+ return resources.map(resource => datasetFromResource(this, projectId, resource))
183
+ }
184
+
185
+ async appendDatasetRecords (projectId, datasetId, records) {
186
+ const response = await this.jsonApiRequest(
187
+ 'POST',
188
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records`,
189
+ 'datasets',
190
+ { records }
191
+ )
192
+ // The append-records response has used both a top-level `records` array
193
+ // and JSON:API `data` resources. Accept either so generated/custom record
194
+ // ids are preserved for experiment row tagging.
195
+ const resources = Array.isArray(response?.records)
196
+ ? response.records
197
+ : (Array.isArray(response?.data) ? response.data : [])
198
+ return resources.map(datasetRecordFromResource)
199
+ }
200
+
201
+ async batchUpdateDatasetRecords (projectId, datasetId, attributes) {
202
+ const response = await this.request(
203
+ 'POST',
204
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/batch_update`,
205
+ {
206
+ data: {
207
+ type: 'datasets',
208
+ id: datasetId,
209
+ attributes: {
210
+ insert_records: attributes.insert_records ?? [],
211
+ update_records: attributes.update_records ?? [],
212
+ delete_records: attributes.delete_records ?? [],
213
+ deduplicate: attributes.deduplicate !== false,
214
+ create_new_version: attributes.create_new_version !== false,
215
+ },
216
+ },
217
+ }
218
+ )
219
+ const resources = Array.isArray(response?.records)
220
+ ? response.records
221
+ : (Array.isArray(response?.data) ? response.data : [])
222
+ return datasetMutationResultFromResources(resources)
223
+ }
224
+
225
+ async listDatasetRecords (projectId, datasetId, options = {}) {
226
+ const query = new URLSearchParams()
227
+ if (options.cursor) query.set('page[cursor]', options.cursor)
228
+ if (options.version !== undefined && options.version !== null) query.set('filter[version]', String(options.version))
229
+ const response = await this.request(
230
+ 'GET',
231
+ `${API_BASE_PATH}/${projectId}/datasets/${datasetId}/records?${query.toString()}`
232
+ )
233
+ const records = Array.isArray(response?.data) ? response.data.map(datasetRecordFromResource) : []
234
+ return { records, after: response?.meta?.after ?? '' }
235
+ }
236
+
237
+ async createExperiment (attributes) {
238
+ const response = await this.jsonApiRequest('POST', `${API_BASE_PATH}/experiments`, 'experiments', attributes)
239
+ return experimentFromResource(this, response?.data ?? null)
240
+ }
241
+
242
+ postExperimentEvents (experimentId, attributes) {
243
+ return this.jsonApiRequest(
244
+ 'POST',
245
+ `${API_BASE_PATH}/experiments/${experimentId}/events`,
246
+ 'experiments',
247
+ attributes
248
+ )
249
+ }
250
+
251
+ updateExperiment (experimentId, attributes) {
252
+ return this.jsonApiRequest('PATCH', `${API_BASE_PATH}/experiments/${experimentId}`, 'experiments', attributes)
253
+ }
254
+
95
255
  // Resolve the project id for `name`, creating it if absent. The create
96
256
  // endpoint is get-or-create on name, so repeated calls return the same id.
97
257
  // Cached after the first resolution.
@@ -100,14 +260,12 @@ class ExperimentsClient {
100
260
 
101
261
  let response
102
262
  try {
103
- response = await this.request('POST', `${API_BASE_PATH}/projects`, {
104
- data: { type: 'projects', attributes: { name } },
105
- })
263
+ response = await this.createProject(name)
106
264
  } catch (err) {
107
265
  throw new Error(`Failed to create or get project '${name}': ${err.message}`)
108
266
  }
109
267
 
110
- this.#cachedProjectId = response?.data?.id ?? null
268
+ this.#cachedProjectId = response?.id ?? null
111
269
  return this.#cachedProjectId
112
270
  }
113
271
  }
@@ -1,73 +1,98 @@
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
+ // Dataset record: { input, expectedOutput?, metadata?, id }.
6
+ // IDs are generated locally unless the caller supplies one.
7
7
  class DatasetRecord {
8
8
  constructor (input, expectedOutput = null, metadata = {}, id = null) {
9
+ if (id != null && (typeof id !== 'string' || id.length === 0)) {
10
+ throw new Error('record id must be a non-empty string')
11
+ }
9
12
  this.input = input
10
13
  this.expectedOutput = expectedOutput ?? null
11
14
  this.metadata = metadata ?? {}
12
- this.id = id ?? null
15
+ this.id = id ?? randomUUID()
13
16
  }
14
17
  }
15
18
 
16
- function createdRecordsFromResponse (response) {
17
- if (Array.isArray(response?.records)) return response.records
18
- if (Array.isArray(response?.data)) return response.data
19
- return []
19
+ function versionFromMutationResult (result) {
20
+ return result?.version ?? null
20
21
  }
21
22
 
22
- function recordIdFromCreatedRecord (record) {
23
- return String(record?.id ?? record?.attributes?.id ?? '')
23
+ function serializedRecord (record) {
24
+ return {
25
+ id: record.id,
26
+ input: record.input,
27
+ expected_output: record.expectedOutput ?? null,
28
+ metadata: record.metadata ?? {},
29
+ }
30
+ }
31
+
32
+ function serializedRecordUpdate (update) {
33
+ const output = { id: update.id }
34
+ if (Object.hasOwn(update, 'input') && update.input !== undefined) output.input = update.input
35
+ if (Object.hasOwn(update, 'expectedOutput') && update.expectedOutput !== undefined) {
36
+ output.expected_output = update.expectedOutput
37
+ }
38
+ if (Object.hasOwn(update, 'metadata') && update.metadata !== undefined) output.metadata = update.metadata
39
+ return output
24
40
  }
25
41
 
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)
42
+ function valuesAreEqual (left, right) {
43
+ return JSON.stringify(left) === JSON.stringify(right)
32
44
  }
33
45
 
34
- // A local buffer of dataset records, created remotely and pushed on first run
35
- // (or eagerly via push()). Pushes are incremental.
46
+ function updateFromInsertedRecord (recordId, record, payload) {
47
+ const update = { id: recordId }
48
+ if (!valuesAreEqual(record.input, payload.input)) update.input = record.input
49
+ if (!valuesAreEqual(record.expectedOutput, payload.expected_output)) {
50
+ update.expectedOutput = record.expectedOutput
51
+ }
52
+ if (!valuesAreEqual(record.metadata, payload.metadata)) update.metadata = record.metadata
53
+ return update
54
+ }
55
+
56
+ // A local dataset model that tracks pending changes and pushes them in one batch.
36
57
  class Dataset {
37
58
  #client
38
59
  #name
39
60
  #description
40
61
  #records
41
- #recordIds
62
+ #recordsById
63
+ #newRecordsById
64
+ #updatedRecordsById
65
+ #deletedRecordIds
42
66
  #id
43
67
  #projectId
44
- #pushedCount
45
68
  #version
46
69
  #latestVersion
70
+ #pushPromise
47
71
 
48
72
  constructor (client, name, description = '') {
49
73
  this.#client = client
50
74
  this.#name = name
51
75
  this.#description = description
52
76
  this.#records = []
53
- this.#recordIds = []
77
+ this.#recordsById = new Map()
78
+ this.#newRecordsById = new Map()
79
+ this.#updatedRecordsById = new Map()
80
+ this.#deletedRecordIds = new Set()
54
81
  this.#id = null
55
82
  this.#projectId = null
56
- this.#pushedCount = 0
57
83
  this.#version = null
58
84
  this.#latestVersion = null
85
+ this.#pushPromise = Promise.resolve()
59
86
  }
60
87
 
61
88
  // Build a Dataset that already exists remotely (used by pullDataset).
62
- static fromExisting (client, name, description, id, projectId, records, recordIds, version, latestVersion) {
89
+ static fromExisting (client, name, description, id, projectId, records, version, latestVersion) {
63
90
  const dataset = new Dataset(client, name, description)
64
91
  dataset.#id = id
65
92
  dataset.#projectId = projectId
66
- dataset.#records.push(...records)
67
- dataset.#recordIds.push(...recordIds)
68
- dataset.#pushedCount = records.length
69
93
  dataset.#version = version ?? null
70
94
  dataset.#latestVersion = latestVersion ?? version ?? null
95
+ for (const record of records) dataset.#addExistingRecord(record)
71
96
  return dataset
72
97
  }
73
98
 
@@ -76,7 +101,53 @@ class Dataset {
76
101
  const record = recordOrInput instanceof DatasetRecord
77
102
  ? recordOrInput
78
103
  : new DatasetRecord(recordOrInput, expectedOutput, metadata)
79
- this.#records.push(record)
104
+ this.#addRecord(record)
105
+ return this
106
+ }
107
+
108
+ /**
109
+ * Update an existing dataset record. New records are updated in place and are sent with their insert.
110
+ * @param {number} index Dataset record index.
111
+ * @param {{input?: unknown, expectedOutput?: unknown, metadata?: object}} fields Fields to update.
112
+ * @returns {Dataset} This dataset for chaining.
113
+ */
114
+ update (index, fields) {
115
+ if (fields == null || typeof fields !== 'object' || Array.isArray(fields)) {
116
+ throw new TypeError('record update must be an object')
117
+ }
118
+ const record = this.#recordAt(index)
119
+ const fieldNames = ['input', 'expectedOutput', 'metadata']
120
+ const providedFields = fieldNames.filter(field => Object.hasOwn(fields, field) && fields[field] !== undefined)
121
+ if (providedFields.length === 0) {
122
+ throw new Error('record update must include input, expectedOutput, or metadata')
123
+ }
124
+
125
+ for (const field of providedFields) {
126
+ record[field] = field === 'metadata' ? (fields[field] ?? {}) : fields[field]
127
+ }
128
+
129
+ if (this.#newRecordsById.has(record.id)) return this
130
+
131
+ const update = this.#updatedRecordsById.get(record.id) ?? { id: record.id }
132
+ for (const field of providedFields) update[field] = record[field]
133
+ this.#updatedRecordsById.set(record.id, update)
134
+ return this
135
+ }
136
+
137
+ /**
138
+ * Delete a dataset record. New records are removed locally without a backend operation.
139
+ * @param {number} index Dataset record index.
140
+ * @returns {Dataset} This dataset for chaining.
141
+ */
142
+ delete (index) {
143
+ const record = this.#recordAt(index)
144
+ this.#records.splice(index, 1)
145
+ this.#recordsById.delete(record.id)
146
+
147
+ if (this.#newRecordsById.delete(record.id)) return this
148
+
149
+ this.#updatedRecordsById.delete(record.id)
150
+ this.#deletedRecordIds.add(record.id)
80
151
  return this
81
152
  }
82
153
 
@@ -84,12 +155,16 @@ class Dataset {
84
155
  return this.#name
85
156
  }
86
157
 
158
+ description () {
159
+ return this.#description
160
+ }
161
+
87
162
  records () {
88
163
  return [...this.#records]
89
164
  }
90
165
 
91
166
  recordIds () {
92
- return [...this.#recordIds]
167
+ return this.#records.map(record => record.id)
93
168
  }
94
169
 
95
170
  id () {
@@ -114,92 +189,171 @@ class Dataset {
114
189
  return `${this.#client.appBase}/llm/datasets/${this.#id}`
115
190
  }
116
191
 
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)
192
+ // Eagerly create the dataset (if needed) and push all pending changes.
193
+ push () {
194
+ return this.#enqueuePush(async () => {
195
+ const projectId = await this.#client.ensureProjectId()
196
+ return this.#ensureCreatedAndPushed(projectId)
197
+ })
121
198
  }
122
199
 
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) {
200
+ // Called by Experiment.run() after it has resolved the project id.
201
+ ensureCreatedAndPushed (projectId) {
202
+ return this.#enqueuePush(() => this.#ensureCreatedAndPushed(projectId))
203
+ }
204
+
205
+ #enqueuePush (push) {
206
+ const next = this.#pushPromise.then(push, push)
207
+ this.#pushPromise = next.then(() => {}, () => {})
208
+ return next
209
+ }
210
+
211
+ async #ensureCreatedAndPushed (projectId) {
127
212
  if (this.#id === null) {
128
213
  let response
129
214
  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
- })
215
+ response = await this.#client.createDataset(projectId, { name: this.#name, description: this.#description })
133
216
  } catch (err) {
134
217
  throw new Error(`Failed to create dataset '${this.#name}': ${err.message}`)
135
218
  }
136
- this.#id = response?.data?.id ?? null
219
+ this.#id = response?.id() ?? null
137
220
  if (this.#id === null) {
138
221
  throw new Error(`Failed to create dataset '${this.#name}': backend response is missing dataset id`)
139
222
  }
140
223
  this.#projectId = projectId
141
- this.#version = response?.data?.attributes?.current_version ?? this.#version
142
- this.#latestVersion = response?.data?.attributes?.current_version ?? this.#latestVersion
224
+ this.#version = response.version() ?? this.#version
225
+ this.#latestVersion = response.latestVersion() ?? this.#latestVersion
143
226
  }
144
227
 
145
- if (this.#pushedCount >= this.#records.length) return { pushedCount: 0, totalCount: 0 }
228
+ const pending = this.#pendingBatch()
229
+ if (pending.totalCount === 0) return { pushedCount: 0, totalCount: 0 }
230
+
231
+ let result
232
+ try {
233
+ result = await this.#client.batchUpdateDatasetRecords(projectId, this.#id, pending.attributes)
234
+ } catch (err) {
235
+ throw new Error(`Failed to push changes to dataset '${this.#name}': ${err.message}`)
236
+ }
146
237
 
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
238
+ this.#updateVersionFromMutationResult(result)
239
+ this.#clearCommittedChanges(pending)
240
+ return { pushedCount: pending.totalCount, totalCount: pending.totalCount }
241
+ }
242
+
243
+ #pendingBatch () {
244
+ const insertRecords = []
245
+ const insertPayloads = new Map()
246
+ for (const [recordId, record] of this.#newRecordsById) {
247
+ const payload = serializedRecord(record)
248
+ insertRecords.push(payload)
249
+ insertPayloads.set(recordId, payload)
250
+ }
251
+
252
+ const updateRecords = []
253
+ const updatePayloads = new Map()
254
+ for (const [recordId, update] of this.#updatedRecordsById) {
255
+ const payload = serializedRecordUpdate(update)
256
+ updateRecords.push(payload)
257
+ updatePayloads.set(recordId, payload)
258
+ }
259
+
260
+ const deleteRecordIds = [...this.#deletedRecordIds]
261
+ return {
262
+ attributes: {
263
+ insert_records: insertRecords,
264
+ update_records: updateRecords,
265
+ delete_records: deleteRecordIds,
266
+ deduplicate: true,
267
+ create_new_version: true,
268
+ },
269
+ deleteRecordIds,
270
+ insertPayloads,
271
+ updatePayloads,
272
+ totalCount: new Set([
273
+ ...insertPayloads.keys(),
274
+ ...updatePayloads.keys(),
275
+ ...deleteRecordIds,
276
+ ]).size,
277
+ }
278
+ }
279
+
280
+ #clearCommittedChanges (pending) {
281
+ for (const [recordId, payload] of pending.insertPayloads) {
282
+ const current = this.#newRecordsById.get(recordId)
283
+ if (!current) {
284
+ this.#deletedRecordIds.add(recordId)
285
+ continue
152
286
  }
153
- if (rec.expectedOutput !== null && rec.expectedOutput !== undefined) {
154
- out.expected_output = rec.expectedOutput
287
+ if (valuesAreEqual(serializedRecord(current), payload)) {
288
+ this.#newRecordsById.delete(recordId)
289
+ continue
155
290
  }
156
- if (rec.metadata && Object.keys(rec.metadata).length > 0) {
157
- out.metadata = rec.metadata
291
+
292
+ this.#newRecordsById.delete(recordId)
293
+ const update = this.#updatedRecordsById.get(recordId) ??
294
+ updateFromInsertedRecord(recordId, current, payload)
295
+ this.#updatedRecordsById.set(recordId, update)
296
+ }
297
+
298
+ for (const [recordId, payload] of pending.updatePayloads) {
299
+ const current = this.#updatedRecordsById.get(recordId)
300
+ if (current && valuesAreEqual(serializedRecordUpdate(current), payload)) {
301
+ this.#updatedRecordsById.delete(recordId)
158
302
  }
159
- return out
160
- })
303
+ }
161
304
 
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}`)
305
+ for (const recordId of pending.deleteRecordIds) {
306
+ if (!this.#recordsById.has(recordId)) this.#deletedRecordIds.delete(recordId)
171
307
  }
308
+ }
172
309
 
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 {
310
+ #updateVersionFromMutationResult (result) {
311
+ const pushedVersion = versionFromMutationResult(result)
312
+ if (pushedVersion !== null) {
183
313
  this.#version = pushedVersion
184
- this.#latestVersion = Math.max(Number(this.#latestVersion ?? pushedVersion), pushedVersion)
314
+ this.#latestVersion = pushedVersion
315
+ return
185
316
  }
186
317
 
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)
318
+ const latestVersion = Number(this.#latestVersion)
319
+ if (Number.isFinite(latestVersion)) {
320
+ this.#version = latestVersion + 1
321
+ this.#latestVersion = this.#version
322
+ } else {
323
+ this.#version = null
195
324
  }
196
- for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
325
+ }
326
+
327
+ #addRecord (record) {
328
+ if (this.#recordsById.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
329
+ this.#records.push(record)
330
+ this.#recordsById.set(record.id, record)
331
+ this.#deletedRecordIds.delete(record.id)
332
+ this.#newRecordsById.set(record.id, record)
333
+ }
197
334
 
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
335
+ #addExistingRecord (record) {
336
+ if (record.id === null || record.id === undefined || record.id === '') {
337
+ throw new Error('Dataset records pulled from the backend must have an id')
338
+ }
339
+ if (!(record instanceof DatasetRecord)) {
340
+ record = new DatasetRecord(
341
+ record.input,
342
+ record.expectedOutput,
343
+ record.metadata,
344
+ record.id
345
+ )
346
+ }
347
+ if (this.#recordsById.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
348
+ this.#records.push(record)
349
+ this.#recordsById.set(record.id, record)
350
+ }
201
351
 
202
- return { pushedCount, totalCount: pending.length }
352
+ #recordAt (index) {
353
+ if (!Number.isInteger(index) || index < 0 || index >= this.#records.length) {
354
+ throw new RangeError(`Dataset record index ${index} is out of range`)
355
+ }
356
+ return this.#records[index]
203
357
  }
204
358
  }
205
359