dd-trace 6.11.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 (55) hide show
  1. package/LICENSE-3rdparty.csv +5 -0
  2. package/index.d.ts +20 -3
  3. package/package.json +18 -17
  4. package/packages/datadog-instrumentations/src/cucumber.js +4 -4
  5. package/packages/datadog-instrumentations/src/mocha/utils.js +5 -2
  6. package/packages/datadog-instrumentations/src/playwright-reporter.js +149 -1
  7. package/packages/datadog-instrumentations/src/playwright.js +33 -33
  8. package/packages/datadog-instrumentations/src/vitest-main-no-worker-init.js +6 -8
  9. package/packages/datadog-plugin-playwright/src/index.js +4 -0
  10. package/packages/dd-trace/src/appsec/reporter.js +2 -0
  11. package/packages/dd-trace/src/appsec/telemetry/index.js +13 -0
  12. package/packages/dd-trace/src/appsec/telemetry/rasp.js +16 -0
  13. package/packages/dd-trace/src/appsec/telemetry/waf.js +24 -0
  14. package/packages/dd-trace/src/carrier.js +107 -48
  15. package/packages/dd-trace/src/ci-visibility/early-flake-detection/get-known-tests.js +5 -5
  16. package/packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js +2 -0
  17. package/packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js +5 -3
  18. package/packages/dd-trace/src/ci-visibility/exporters/agentless/writer.js +4 -2
  19. package/packages/dd-trace/src/ci-visibility/exporters/git/git_metadata.js +6 -4
  20. package/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +5 -3
  21. package/packages/dd-trace/src/ci-visibility/requests/get-library-configuration.js +4 -2
  22. package/packages/dd-trace/src/ci-visibility/requests/upload-coverage-report.js +4 -2
  23. package/packages/dd-trace/src/ci-visibility/requests/upload-test-screenshot.js +4 -2
  24. package/packages/dd-trace/src/ci-visibility/test-management/get-test-management-tests.js +5 -5
  25. package/packages/dd-trace/src/debugger/constants.js +1 -0
  26. package/packages/dd-trace/src/debugger/devtools_client/condition.js +1 -1
  27. package/packages/dd-trace/src/debugger/devtools_client/index.js +3 -10
  28. package/packages/dd-trace/src/debugger/index.js +4 -2
  29. package/packages/dd-trace/src/debugger/inspect-segment.js +154 -0
  30. package/packages/dd-trace/src/evp_proxy/constants.js +8 -0
  31. package/packages/dd-trace/src/evp_proxy/direct.js +64 -0
  32. package/packages/dd-trace/src/evp_proxy/discovery.js +133 -0
  33. package/packages/dd-trace/src/evp_proxy/path.js +22 -0
  34. package/packages/dd-trace/src/exporters/common/request.js +5 -2
  35. package/packages/dd-trace/src/llmobs/experiments/client.js +6 -2
  36. package/packages/dd-trace/src/llmobs/experiments/dataset.js +249 -11
  37. package/packages/dd-trace/src/llmobs/experiments/experiment.js +23 -10
  38. package/packages/dd-trace/src/llmobs/experiments/index.js +18 -6
  39. package/packages/dd-trace/src/llmobs/experiments/noop.js +64 -4
  40. package/packages/dd-trace/src/llmobs/experiments/util.js +60 -4
  41. package/packages/dd-trace/src/openfeature/constants/constants.js +0 -18
  42. package/packages/dd-trace/src/openfeature/index.js +7 -4
  43. package/packages/dd-trace/src/openfeature/writers/base.js +153 -11
  44. package/packages/dd-trace/src/openfeature/writers/exposures.js +62 -14
  45. package/packages/dd-trace/src/openfeature/writers/util.js +97 -15
  46. package/packages/dd-trace/src/opentracing/propagation/text_map.js +22 -13
  47. package/packages/dd-trace/src/telemetry/metrics.js +62 -8
  48. package/packages/dd-trace/src/telemetry/send-data.js +1 -0
  49. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  50. package/vendor/dist/https-proxy-agent/LICENSE +22 -0
  51. package/vendor/dist/https-proxy-agent/index.js +4 -0
  52. package/vendor/dist/protobufjs/index.js +1 -1
  53. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  54. package/vendor/dist/proxy-from-env/LICENSE +20 -0
  55. package/vendor/dist/proxy-from-env/index.js +1 -0
@@ -2,16 +2,30 @@
2
2
 
3
3
  const { randomUUID } = require('node:crypto')
4
4
 
5
- // Dataset record: { input, expectedOutput?, metadata?, id }.
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? }.
6
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 = []) {
9
22
  if (id != null && (typeof id !== 'string' || id.length === 0)) {
10
23
  throw new Error('record id must be a non-empty string')
11
24
  }
12
25
  this.input = input
13
26
  this.expectedOutput = expectedOutput ?? null
14
27
  this.metadata = metadata ?? {}
28
+ this.tags = validateTagsList(tags)
15
29
  this.id = id ?? randomUUID()
16
30
  }
17
31
  }
@@ -21,12 +35,14 @@ function versionFromMutationResult (result) {
21
35
  }
22
36
 
23
37
  function serializedRecord (record) {
24
- return {
38
+ const output = {
25
39
  id: record.id,
26
40
  input: record.input,
27
41
  expected_output: record.expectedOutput ?? null,
28
42
  metadata: record.metadata ?? {},
29
43
  }
44
+ if (record.tags.length > 0) output.tags = record.tags
45
+ return output
30
46
  }
31
47
 
32
48
  function serializedRecordUpdate (update) {
@@ -36,9 +52,57 @@ function serializedRecordUpdate (update) {
36
52
  output.expected_output = update.expectedOutput
37
53
  }
38
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
59
+ }
60
+
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
39
66
  return output
40
67
  }
41
68
 
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
+
42
106
  function valuesAreEqual (left, right) {
43
107
  return JSON.stringify(left) === JSON.stringify(right)
44
108
  }
@@ -63,21 +127,25 @@ class Dataset {
63
127
  #newRecordsById
64
128
  #updatedRecordsById
65
129
  #deletedRecordIds
130
+ #pendingTagOperations
66
131
  #id
67
132
  #projectId
68
133
  #version
69
134
  #latestVersion
70
135
  #pushPromise
136
+ #filterTags
71
137
 
72
- constructor (client, name, description = '') {
138
+ constructor (client, name, description = '', filterTags = []) {
73
139
  this.#client = client
74
140
  this.#name = name
75
141
  this.#description = description
142
+ this.#filterTags = validateTagsList(filterTags)
76
143
  this.#records = []
77
144
  this.#recordsById = new Map()
78
145
  this.#newRecordsById = new Map()
79
146
  this.#updatedRecordsById = new Map()
80
147
  this.#deletedRecordIds = new Set()
148
+ this.#pendingTagOperations = new Map()
81
149
  this.#id = null
82
150
  this.#projectId = null
83
151
  this.#version = null
@@ -86,8 +154,8 @@ class Dataset {
86
154
  }
87
155
 
88
156
  // Build a Dataset that already exists remotely (used by pullDataset).
89
- static fromExisting (client, name, description, id, projectId, records, version, latestVersion) {
90
- 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)
91
159
  dataset.#id = id
92
160
  dataset.#projectId = projectId
93
161
  dataset.#version = version ?? null
@@ -97,14 +165,70 @@ class Dataset {
97
165
  }
98
166
 
99
167
  // Append a record. Accepts a DatasetRecord or (input, expectedOutput?, metadata?).
100
- addRecord (recordOrInput, expectedOutput, metadata) {
168
+ addRecord (recordOrInput, expectedOutput, metadata, tags) {
101
169
  const record = recordOrInput instanceof DatasetRecord
102
170
  ? recordOrInput
103
- : new DatasetRecord(recordOrInput, expectedOutput, metadata)
171
+ : new DatasetRecord(recordOrInput, expectedOutput, metadata, null, tags)
104
172
  this.#addRecord(record)
105
173
  return this
106
174
  }
107
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
+
108
232
  /**
109
233
  * Update an existing dataset record. New records are updated in place and are sent with their insert.
110
234
  * @param {number} index Dataset record index.
@@ -130,6 +254,9 @@ class Dataset {
130
254
 
131
255
  const update = this.#updatedRecordsById.get(record.id) ?? { id: record.id }
132
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
+ }
133
260
  this.#updatedRecordsById.set(record.id, update)
134
261
  return this
135
262
  }
@@ -144,13 +271,47 @@ class Dataset {
144
271
  this.#records.splice(index, 1)
145
272
  this.#recordsById.delete(record.id)
146
273
 
147
- if (this.#newRecordsById.delete(record.id)) return this
274
+ if (this.#newRecordsById.delete(record.id)) {
275
+ this.#pendingTagOperations.delete(record.id)
276
+ return this
277
+ }
148
278
 
149
279
  this.#updatedRecordsById.delete(record.id)
280
+ this.#pendingTagOperations.delete(record.id)
150
281
  this.#deletedRecordIds.add(record.id)
151
282
  return this
152
283
  }
153
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
+
154
315
  name () {
155
316
  return this.#name
156
317
  }
@@ -183,6 +344,14 @@ class Dataset {
183
344
  return this.#latestVersion
184
345
  }
185
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
+
186
355
  // Dashboard URL for this dataset, or null until pushed/pulled.
187
356
  url () {
188
357
  if (this.#id === null) return null
@@ -228,10 +397,13 @@ class Dataset {
228
397
  const pending = this.#pendingBatch()
229
398
  if (pending.totalCount === 0) return { pushedCount: 0, totalCount: 0 }
230
399
 
400
+ this.#detachCommittedTagOperations(pending)
401
+
231
402
  let result
232
403
  try {
233
404
  result = await this.#client.batchUpdateDatasetRecords(projectId, this.#id, pending.attributes)
234
405
  } catch (err) {
406
+ this.#restoreFailedTagOperations(pending)
235
407
  throw new Error(`Failed to push changes to dataset '${this.#name}': ${err.message}`)
236
408
  }
237
409
 
@@ -252,6 +424,9 @@ class Dataset {
252
424
  const updateRecords = []
253
425
  const updatePayloads = new Map()
254
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
255
430
  const payload = serializedRecordUpdate(update)
256
431
  updateRecords.push(payload)
257
432
  updatePayloads.set(recordId, payload)
@@ -277,6 +452,58 @@ class Dataset {
277
452
  }
278
453
  }
279
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
490
+
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
+ */
280
507
  #clearCommittedChanges (pending) {
281
508
  for (const [recordId, payload] of pending.insertPayloads) {
282
509
  const current = this.#newRecordsById.get(recordId)
@@ -286,19 +513,29 @@ class Dataset {
286
513
  }
287
514
  if (valuesAreEqual(serializedRecord(current), payload)) {
288
515
  this.#newRecordsById.delete(recordId)
516
+ this.#pendingTagOperations.delete(recordId)
289
517
  continue
290
518
  }
291
519
 
292
520
  this.#newRecordsById.delete(recordId)
293
521
  const update = this.#updatedRecordsById.get(recordId) ??
294
522
  updateFromInsertedRecord(recordId, current, payload)
523
+ const queuedOperations = this.#pendingTagOperations.get(recordId)
524
+ if (queuedOperations) update.tagOperations = queuedOperations
295
525
  this.#updatedRecordsById.set(recordId, update)
296
526
  }
297
527
 
298
528
  for (const [recordId, payload] of pending.updatePayloads) {
299
529
  const current = this.#updatedRecordsById.get(recordId)
300
- if (current && valuesAreEqual(serializedRecordUpdate(current), payload)) {
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)) {
301
537
  this.#updatedRecordsById.delete(recordId)
538
+ this.#pendingTagOperations.delete(recordId)
302
539
  }
303
540
  }
304
541
 
@@ -341,7 +578,8 @@ class Dataset {
341
578
  record.input,
342
579
  record.expectedOutput,
343
580
  record.metadata,
344
- record.id
581
+ record.id,
582
+ record.tags
345
583
  )
346
584
  }
347
585
  if (this.#recordsById.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
@@ -13,6 +13,7 @@ const {
13
13
  normalizeEvaluators,
14
14
  mergeTags,
15
15
  normalizeJsonMetricValue,
16
+ recordTagsToObject,
16
17
  sleep,
17
18
  stringify,
18
19
  timestampMs,
@@ -20,7 +21,7 @@ const {
20
21
  } = require('./util')
21
22
 
22
23
  // One span per experiment row (LLM Obs experiment span wire format).
23
- function toSpan (row, metadata, ids, spanName, userTags) {
24
+ function toSpan (row, metadata, ids, spanName, userTags, recordTags) {
24
25
  const meta = {
25
26
  input: row.input ?? null,
26
27
  output: row.output ?? null,
@@ -33,6 +34,20 @@ function toSpan (row, metadata, ids, spanName, userTags) {
33
34
  meta.error = { type: row.errorType ?? '', message: row.errorMessage ?? '', stack: row.errorStack ?? '' }
34
35
  }
35
36
 
37
+ const tags = buildTags({
38
+ ...userTags,
39
+ ...recordTagsToObject(recordTags),
40
+ }, {
41
+ experiment_id: ids.experimentId,
42
+ run_id: ids.runId,
43
+ run_iteration: ids.runIteration,
44
+ project_id: ids.projectId,
45
+ dataset_id: ids.datasetId,
46
+ dataset_record_id: ids.datasetRecordId,
47
+ dataset_name: ids.datasetName,
48
+ experiment_name: ids.experimentName,
49
+ })
50
+
36
51
  return {
37
52
  span_id: row.spanId,
38
53
  trace_id: row.traceId,
@@ -43,13 +58,7 @@ function toSpan (row, metadata, ids, spanName, userTags) {
43
58
  duration: row.durationNs,
44
59
  status: row.isError ? 'error' : 'ok',
45
60
  meta,
46
- tags: buildTags(userTags, {
47
- experiment_id: ids.experimentId,
48
- run_id: ids.runId,
49
- run_iteration: ids.runIteration,
50
- dataset_id: ids.datasetId,
51
- dataset_record_id: ids.datasetRecordId,
52
- }),
61
+ tags,
53
62
  }
54
63
  }
55
64
 
@@ -152,6 +161,8 @@ class Experiment {
152
161
  this.#evaluators = normalizeEvaluators(options.evaluators, 'row')
153
162
  this.#summaryEvaluators = normalizeEvaluators(options.summaryEvaluators, 'summary')
154
163
  this.#config = { ...options.config }
164
+ const filterTags = this.#dataset.filterTags?.() ?? []
165
+ if (filterTags.length > 0) this.#config.filtered_record_tags = filterTags
155
166
  this.#tags = { ...options.tags }
156
167
  this.#metadata = { ...options.metadata }
157
168
  this.#projectId = null
@@ -454,9 +465,11 @@ class Experiment {
454
465
  projectId,
455
466
  datasetId,
456
467
  datasetRecordId,
468
+ datasetName: this.#dataset.name(),
469
+ experimentName: this.#name,
457
470
  runId,
458
471
  runIteration,
459
- }, this.#task.name || this.#name, this.#tags))
472
+ }, this.#task.name || this.#name, this.#tags, record.tags))
460
473
  }
461
474
  }
462
475
 
@@ -521,7 +534,7 @@ class Experiment {
521
534
  dataset_name: this.#dataset.name(),
522
535
  experiment_name: this.#name,
523
536
  }
524
- const tags = mergeTags(this.#tags, autoTags)
537
+ const tags = mergeTags(this.#tags, { ...recordTagsToObject(record.tags), ...autoTags })
525
538
 
526
539
  const execute = () => this.#runWithRetries(
527
540
  () => this.#task(record.input, this.#config, record.metadata),
@@ -4,6 +4,7 @@ const log = require('../../log')
4
4
  const { ExperimentsClient } = require('./client')
5
5
  const { Dataset, DatasetRecord } = require('./dataset')
6
6
  const { Experiment, ExternalExperiment } = require('./experiment')
7
+ const { validateTagsList } = require('./util')
7
8
  const NoopExperiments = require('./noop')
8
9
 
9
10
  // Poll `attempt` with exponential backoff until it returns true or the time
@@ -66,7 +67,9 @@ class Experiments {
66
67
  if (recordIds.has(record.id)) throw new Error(`Duplicate record id '${record.id}'`)
67
68
  recordIds.add(record.id)
68
69
  }
69
- dataset.addRecord(new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id))
70
+ dataset.addRecord(
71
+ new DatasetRecord(record.inputData, record.expectedOutput, record.metadata, record.id, record.tags)
72
+ )
70
73
  }
71
74
  }
72
75
  return dataset
@@ -74,14 +77,16 @@ class Experiments {
74
77
 
75
78
  // Pull an existing dataset by name (with its records). Polls with exponential
76
79
  // backoff to absorb read-after-write lag; pass `expectedRecordCount` to also
77
- // wait until that many records are readable.
80
+ // wait until that many records are readable. Pass `tags` to filter records by
81
+ // dataset record tags.
78
82
  async pullDataset (name, options = {}) {
79
- const { expectedRecordCount, maxWaitMs = 30_000, version } = options
83
+ const { expectedRecordCount, maxWaitMs = 30_000, tags, version } = options
84
+ const filterTags = validateTagsList(tags)
80
85
  const projectId = await this.#client.ensureProjectId()
81
86
 
82
87
  let pulledDataset = null
83
88
  let records = []
84
- let datasetVersion = version ?? null
89
+ const datasetVersion = version ?? null
85
90
  let latestVersion = null
86
91
  let lastError = ''
87
92
 
@@ -93,7 +98,6 @@ class Experiments {
93
98
  if (dataset.name() === name) {
94
99
  pulledDataset = dataset
95
100
  latestVersion = dataset.latestVersion()
96
- datasetVersion = version ?? latestVersion
97
101
  break
98
102
  }
99
103
  }
@@ -107,6 +111,7 @@ class Experiments {
107
111
  // eslint-disable-next-line no-await-in-loop
108
112
  const page = await this.#client.listDatasetRecords(projectId, pulledDataset.id(), {
109
113
  cursor,
114
+ tags: filterTags,
110
115
  version: datasetVersion,
111
116
  })
112
117
  for (const record of page.records) recs.push(record)
@@ -139,6 +144,12 @@ class Experiments {
139
144
  )
140
145
  }
141
146
 
147
+ for (const record of records) {
148
+ if (record.id === null || record.id === undefined || record.id === '') {
149
+ throw new Error(`Failed to pull dataset '${name}': backend returned a record without an id`)
150
+ }
151
+ }
152
+
142
153
  return Dataset.fromExisting(
143
154
  this.#client,
144
155
  name,
@@ -147,7 +158,8 @@ class Experiments {
147
158
  projectId,
148
159
  records,
149
160
  datasetVersion,
150
- latestVersion
161
+ latestVersion,
162
+ filterTags
151
163
  )
152
164
  }
153
165
 
@@ -11,20 +11,29 @@ class NoopDataset {
11
11
  #name
12
12
  #description
13
13
  #records
14
+ #filterTags
14
15
 
15
16
  constructor (name = '', options = {}) {
16
17
  this.#name = name
17
18
  this.#description = typeof options === 'string' ? options : (options.description ?? '')
19
+ this.#filterTags = typeof options === 'string' ? [] : [...(options.filterTags ?? [])]
18
20
  this.#records = (typeof options === 'string' ? [] : (options.records ?? [])).map(record => ({
19
21
  id: record.id ?? null,
20
22
  input: record.inputData,
21
23
  expectedOutput: record.expectedOutput ?? null,
22
24
  metadata: record.metadata ?? {},
25
+ tags: [...(record.tags ?? [])],
23
26
  }))
24
27
  }
25
28
 
26
- addRecord (input, expectedOutput, metadata) {
27
- this.#records.push({ id: null, input, expectedOutput: expectedOutput ?? null, metadata: metadata ?? {} })
29
+ addRecord (input, expectedOutput, metadata, tags) {
30
+ this.#records.push({
31
+ id: null,
32
+ input,
33
+ expectedOutput: expectedOutput ?? null,
34
+ metadata: metadata ?? {},
35
+ tags: [...(tags ?? [])],
36
+ })
28
37
  return this
29
38
  }
30
39
 
@@ -46,6 +55,49 @@ class NoopDataset {
46
55
  return Promise.resolve({ pushedCount: 0, totalCount: 0 })
47
56
  }
48
57
 
58
+ /**
59
+ * Add tags to a dataset record.
60
+ * @param {number} index Dataset record index.
61
+ * @param {string[]} tags Tags in key:value format.
62
+ * @returns {NoopDataset} This dataset for chaining.
63
+ */
64
+ addTags (index, tags) {
65
+ const record = this.#records[index]
66
+ if (!record) return this
67
+ const normalizedTags = tags ?? []
68
+ record.tags = [...new Set([...(record.tags ?? []), ...normalizedTags])].sort()
69
+ return this
70
+ }
71
+
72
+ /**
73
+ * Remove tags from a dataset record.
74
+ * @param {number} index Dataset record index.
75
+ * @param {string[]} tags Tags in key:value format.
76
+ * @returns {NoopDataset} This dataset for chaining.
77
+ */
78
+ removeTags (index, tags) {
79
+ const record = this.#records[index]
80
+ if (!record) return this
81
+ const normalizedTags = tags ?? []
82
+ const removed = new Set(normalizedTags)
83
+ record.tags = (record.tags ?? []).filter(tag => !removed.has(tag)).sort()
84
+ return this
85
+ }
86
+
87
+ /**
88
+ * Replace all tags on a dataset record.
89
+ * @param {number} index Dataset record index.
90
+ * @param {string[]} tags Tags in key:value format.
91
+ * @returns {NoopDataset} This dataset for chaining.
92
+ */
93
+ replaceTags (index, tags) {
94
+ const record = this.#records[index]
95
+ if (!record) return this
96
+ const normalizedTags = tags ?? []
97
+ record.tags = [...normalizedTags]
98
+ return this
99
+ }
100
+
49
101
  name () {
50
102
  return this.#name
51
103
  }
@@ -70,6 +122,14 @@ class NoopDataset {
70
122
  return null
71
123
  }
72
124
 
125
+ /**
126
+ * Return the tags used to filter this dataset.
127
+ * @returns {string[]} Dataset record filter tags.
128
+ */
129
+ filterTags () {
130
+ return [...this.#filterTags]
131
+ }
132
+
73
133
  records () {
74
134
  return [...this.#records]
75
135
  }
@@ -156,9 +216,9 @@ class NoopExperiments {
156
216
  return new NoopDataset(name, options)
157
217
  }
158
218
 
159
- pullDataset (name) {
219
+ pullDataset (name, options = {}) {
160
220
  this.#warn()
161
- return Promise.resolve(new NoopDataset(name))
221
+ return Promise.resolve(new NoopDataset(name, { filterTags: options.tags }))
162
222
  }
163
223
 
164
224
  experiment (options = {}) {