Skip to content

Commit 868370b

Browse files
feat(llmobs): add experiments dataset operations (#9458)
* feat(llmobs): add experiments dataset operations * feat(llmobs): add CSV dataset creation * chore(llmobs): use csv parser for dataset import * chore(llmobs): add csv parser license * feat(llmobs): include dataset version in experiments * fix(llmobs): address dataset review feedback * create jsontype to replace any * remove dataset operations from llmobs * remove csv dataset func * noops produce warn logs instead of error throwing * use vcr testing instead * Update packages/dd-trace/src/llmobs/experiments/index.js Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com> * fix(llmobs): address experiments ci failures * test(llmobs): cover experiments noop accessors * Apply suggestion from @sabrenner Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com> * Apply suggestion from @sabrenner Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com> * fix(llmobs): simplify dataset records query route * test(llmobs): remove handmade experiment cassettes * test(llmobs): isolate experiment fetch mocks --------- Co-authored-by: Sam Brenner <106700075+sabrenner@users.noreply.github.com>
1 parent a71b285 commit 868370b

11 files changed

Lines changed: 546 additions & 409 deletions

File tree

index.d.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3734,16 +3734,36 @@ declare namespace tracer {
37343734
flush (): void
37353735
}
37363736

3737+
/** JSON-serializable value accepted by LLMObs Experiments. */
3738+
type JSONType = string | number | boolean | null | JSONType[] | { [key: string]: JSONType }
3739+
37373740
/**
37383741
* A task run over each dataset record during an experiment.
37393742
*/
3740-
type ExperimentTask = (input: any, config: Record<string, any>) => any | Promise<any>
3743+
type ExperimentTask = (
3744+
input: JSONType,
3745+
config: Record<string, JSONType>
3746+
) => JSONType | Promise<JSONType>
37413747

37423748
/**
37433749
* Scores a single task output. The return type selects the metric:
37443750
* `boolean` -> boolean, `number` -> score, anything else -> categorical.
37453751
*/
3746-
type ExperimentEvaluator = (input: any, output: any, expectedOutput: any) => any | Promise<any>
3752+
type ExperimentEvaluator = (
3753+
input: JSONType,
3754+
output: JSONType,
3755+
expectedOutput: JSONType
3756+
) => JSONType | Promise<JSONType>
3757+
3758+
interface CreateDatasetOptions {
3759+
description?: string
3760+
records?: Array<{
3761+
id?: string,
3762+
inputData: JSONType,
3763+
expectedOutput?: JSONType,
3764+
metadata?: Record<string, JSONType>
3765+
}>
3766+
}
37473767

37483768
interface ExperimentOptions {
37493769
name: string
@@ -3752,11 +3772,13 @@ declare namespace tracer {
37523772
/** Evaluators keyed by metric label. */
37533773
evaluators?: Record<string, ExperimentEvaluator>
37543774
description?: string
3755-
config?: Record<string, any>
3775+
config?: Record<string, JSONType>
37563776
tags?: Record<string, string>
37573777
}
37583778

37593779
interface PullDatasetOptions {
3780+
/** Dataset version to pull. Defaults to latest. */
3781+
version?: number
37603782
/** Wait until at least this many records are readable (absorbs write lag). */
37613783
expectedRecordCount?: number
37623784
/** Maximum total time to wait, in ms. Default 30000. */
@@ -3769,13 +3791,13 @@ declare namespace tracer {
37693791
traceId: string
37703792
startNs: number
37713793
durationNs: number
3772-
input: any
3773-
output: any
3774-
expectedOutput: any
3794+
input: JSONType
3795+
output: JSONType
3796+
expectedOutput: JSONType
37753797
readonly isError: boolean
37763798
errorType: string | null
37773799
errorMessage: string | null
3778-
evaluations: Record<string, any>
3800+
evaluations: Record<string, JSONType>
37793801
evaluationErrors: Record<string, string>
37803802
}
37813803

@@ -3794,13 +3816,20 @@ declare namespace tracer {
37943816
}
37953817

37963818
interface Dataset {
3797-
addRecord (input: any, expectedOutput?: any, metadata?: Record<string, any>): Dataset
3819+
addRecord (input: JSONType, expectedOutput?: JSONType, metadata?: Record<string, JSONType>): Dataset
37983820
/** Creates the dataset remotely if needed and pushes any unpushed records. */
37993821
push (): Promise<DatasetPushResult>
38003822
name (): string
38013823
id (): string | null
38023824
projectId (): string | null
3803-
records (): Array<{ input: any, expectedOutput: any, metadata: Record<string, any> }>
3825+
version (): number | null
3826+
latestVersion (): number | null
3827+
records (): Array<{
3828+
id: string | null,
3829+
input: JSONType,
3830+
expectedOutput: JSONType,
3831+
metadata: Record<string, JSONType>
3832+
}>
38043833
/** Dashboard URL for the dataset, or null until pushed. */
38053834
url (): string | null
38063835
}
@@ -3815,6 +3844,7 @@ declare namespace tracer {
38153844
interface Experiments {
38163845
/** Create a local dataset buffer; pushed on the first experiment run. */
38173846
createDataset (name: string, description?: string): Dataset
3847+
createDataset (name: string, options?: CreateDatasetOptions): Dataset
38183848
/** Pull an existing dataset (with records) by name. */
38193849
pullDataset (name: string, options?: PullDatasetOptions): Promise<Dataset>
38203850
/** Build an experiment to run over a dataset. */

index.d.v5.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3931,16 +3931,36 @@ declare namespace tracer {
39313931
flush (): void
39323932
}
39333933

3934+
/** JSON-serializable value accepted by LLMObs Experiments. */
3935+
type JSONType = string | number | boolean | null | JSONType[] | { [key: string]: JSONType }
3936+
39343937
/**
39353938
* A task run over each dataset record during an experiment.
39363939
*/
3937-
type ExperimentTask = (input: any, config: Record<string, any>) => any | Promise<any>
3940+
type ExperimentTask = (
3941+
input: JSONType,
3942+
config: Record<string, JSONType>
3943+
) => JSONType | Promise<JSONType>
39383944

39393945
/**
39403946
* Scores a single task output. The return type selects the metric:
39413947
* `boolean` -> boolean, `number` -> score, anything else -> categorical.
39423948
*/
3943-
type ExperimentEvaluator = (input: any, output: any, expectedOutput: any) => any | Promise<any>
3949+
type ExperimentEvaluator = (
3950+
input: JSONType,
3951+
output: JSONType,
3952+
expectedOutput: JSONType
3953+
) => JSONType | Promise<JSONType>
3954+
3955+
interface CreateDatasetOptions {
3956+
description?: string
3957+
records?: Array<{
3958+
id?: string,
3959+
inputData: JSONType,
3960+
expectedOutput?: JSONType,
3961+
metadata?: Record<string, JSONType>
3962+
}>
3963+
}
39443964

39453965
interface ExperimentOptions {
39463966
name: string
@@ -3949,11 +3969,13 @@ declare namespace tracer {
39493969
/** Evaluators keyed by metric label. */
39503970
evaluators?: Record<string, ExperimentEvaluator>
39513971
description?: string
3952-
config?: Record<string, any>
3972+
config?: Record<string, JSONType>
39533973
tags?: Record<string, string>
39543974
}
39553975

39563976
interface PullDatasetOptions {
3977+
/** Dataset version to pull. Defaults to latest. */
3978+
version?: number
39573979
/** Wait until at least this many records are readable (absorbs write lag). */
39583980
expectedRecordCount?: number
39593981
/** Maximum total time to wait, in ms. Default 30000. */
@@ -3966,13 +3988,13 @@ declare namespace tracer {
39663988
traceId: string
39673989
startNs: number
39683990
durationNs: number
3969-
input: any
3970-
output: any
3971-
expectedOutput: any
3991+
input: JSONType
3992+
output: JSONType
3993+
expectedOutput: JSONType
39723994
readonly isError: boolean
39733995
errorType: string | null
39743996
errorMessage: string | null
3975-
evaluations: Record<string, any>
3997+
evaluations: Record<string, JSONType>
39763998
evaluationErrors: Record<string, string>
39773999
}
39784000

@@ -3991,13 +4013,20 @@ declare namespace tracer {
39914013
}
39924014

39934015
interface Dataset {
3994-
addRecord (input: any, expectedOutput?: any, metadata?: Record<string, any>): Dataset
4016+
addRecord (input: JSONType, expectedOutput?: JSONType, metadata?: Record<string, JSONType>): Dataset
39954017
/** Creates the dataset remotely if needed and pushes any unpushed records. */
39964018
push (): Promise<DatasetPushResult>
39974019
name (): string
39984020
id (): string | null
39994021
projectId (): string | null
4000-
records (): Array<{ input: any, expectedOutput: any, metadata: Record<string, any> }>
4022+
version (): number | null
4023+
latestVersion (): number | null
4024+
records (): Array<{
4025+
id: string | null,
4026+
input: JSONType,
4027+
expectedOutput: JSONType,
4028+
metadata: Record<string, JSONType>
4029+
}>
40014030
/** Dashboard URL for the dataset, or null until pushed. */
40024031
url (): string | null
40034032
}
@@ -4012,6 +4041,7 @@ declare namespace tracer {
40124041
interface Experiments {
40134042
/** Create a local dataset buffer; pushed on the first experiment run. */
40144043
createDataset (name: string, description?: string): Dataset
4044+
createDataset (name: string, options?: CreateDatasetOptions): Dataset
40154045
/** Pull an existing dataset (with records) by name. */
40164046
pullDataset (name: string, options?: PullDatasetOptions): Promise<Dataset>
40174047
/** Build an experiment to run over a dataset. */

packages/dd-trace/src/llmobs/experiments/client.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ function apiHost (site) {
1414
}
1515

1616
// Web-app host for dashboard URLs. Single-level sites (datadoghq.com,
17-
// ddog-gov.com) are served from the `app.` subdomain; regional sites
18-
// (us3.datadoghq.com, ap1.datadoghq.com) are used as-is.
17+
// ddog-gov.com) are served from the `app.` subdomain; staging uses
18+
// dd.datad0g.com; regional sites (us3.datadoghq.com, ap1.datadoghq.com)
19+
// are used as-is.
1920
function appHost (site) {
21+
if (site === 'datad0g.com') return 'dd.datad0g.com'
2022
return site.split('.').length === 2 ? `app.${site}` : site
2123
}
2224

packages/dd-trace/src/llmobs/experiments/dataset.js

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,35 @@
22

33
const { API_BASE_PATH } = require('./client')
44

5-
// Immutable dataset record: { input, expectedOutput?, metadata? }.
5+
// Dataset record: { input, expectedOutput?, metadata?, id? }.
6+
// `id` may be user-provided before push or filled from the backend-created record.
67
class DatasetRecord {
7-
constructor (input, expectedOutput = null, metadata = {}) {
8+
constructor (input, expectedOutput = null, metadata = {}, id = null) {
89
this.input = input
910
this.expectedOutput = expectedOutput ?? null
1011
this.metadata = metadata ?? {}
12+
this.id = id ?? null
1113
}
1214
}
1315

16+
function createdRecordsFromResponse (response) {
17+
if (Array.isArray(response?.records)) return response.records
18+
if (Array.isArray(response?.data)) return response.data
19+
return []
20+
}
21+
22+
function recordIdFromCreatedRecord (record) {
23+
return String(record?.id ?? record?.attributes?.id ?? '')
24+
}
25+
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)
32+
}
33+
1434
// A local buffer of dataset records, created remotely and pushed on first run
1535
// (or eagerly via push()). Pushes are incremental.
1636
class Dataset {
@@ -22,6 +42,8 @@ class Dataset {
2242
#id
2343
#projectId
2444
#pushedCount
45+
#version
46+
#latestVersion
2547

2648
constructor (client, name, description = '') {
2749
this.#client = client
@@ -32,16 +54,20 @@ class Dataset {
3254
this.#id = null
3355
this.#projectId = null
3456
this.#pushedCount = 0
57+
this.#version = null
58+
this.#latestVersion = null
3559
}
3660

3761
// Build a Dataset that already exists remotely (used by pullDataset).
38-
static fromExisting (client, name, description, id, projectId, records, recordIds) {
62+
static fromExisting (client, name, description, id, projectId, records, recordIds, version, latestVersion) {
3963
const dataset = new Dataset(client, name, description)
4064
dataset.#id = id
4165
dataset.#projectId = projectId
4266
dataset.#records.push(...records)
4367
dataset.#recordIds.push(...recordIds)
4468
dataset.#pushedCount = records.length
69+
dataset.#version = version ?? null
70+
dataset.#latestVersion = latestVersion ?? version ?? null
4571
return dataset
4672
}
4773

@@ -74,6 +100,14 @@ class Dataset {
74100
return this.#projectId
75101
}
76102

103+
version () {
104+
return this.#version
105+
}
106+
107+
latestVersion () {
108+
return this.#latestVersion
109+
}
110+
77111
// Dashboard URL for this dataset, or null until pushed/pulled.
78112
url () {
79113
if (this.#id === null) return null
@@ -100,14 +134,22 @@ class Dataset {
100134
throw new Error(`Failed to create dataset '${this.#name}': ${err.message}`)
101135
}
102136
this.#id = response?.data?.id ?? null
137+
if (this.#id === null) {
138+
throw new Error(`Failed to create dataset '${this.#name}': backend response is missing dataset id`)
139+
}
103140
this.#projectId = projectId
141+
this.#version = response?.data?.attributes?.current_version ?? this.#version
142+
this.#latestVersion = response?.data?.attributes?.current_version ?? this.#latestVersion
104143
}
105144

106145
if (this.#pushedCount >= this.#records.length) return { pushedCount: 0, totalCount: 0 }
107146

108147
const pending = this.#records.slice(this.#pushedCount)
109148
const records = pending.map((rec) => {
110149
const out = { input: rec.input }
150+
if (rec.id != null) {
151+
out.id = rec.id
152+
}
111153
if (rec.expectedOutput !== null && rec.expectedOutput !== undefined) {
112154
out.expected_output = rec.expectedOutput
113155
}
@@ -128,20 +170,30 @@ class Dataset {
128170
throw new Error(`Failed to push records to dataset '${this.#name}': ${err.message}`)
129171
}
130172

131-
// The append-records response returns created records under a top-level
132-
// `records` field, not the usual `data` envelope.
133-
const created = response?.records
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 {
183+
this.#version = pushedVersion
184+
this.#latestVersion = Math.max(Number(this.#latestVersion ?? pushedVersion), pushedVersion)
185+
}
186+
134187
let pushedCount = 0
135-
if (Array.isArray(created)) {
136-
for (const node of created) {
137-
const recordId = String(node?.id ?? '')
138-
if (recordId !== '') pushedCount++
139-
this.#recordIds.push(recordId)
188+
for (const [index, node] of created.entries()) {
189+
const recordId = recordIdFromCreatedRecord(node)
190+
if (recordId !== '') {
191+
pushedCount++
192+
pending[index].id = recordId
140193
}
141-
for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
142-
} else {
143-
for (let i = 0; i < pending.length; i++) this.#recordIds.push('')
194+
this.#recordIds.push(recordId)
144195
}
196+
for (let i = created.length; i < pending.length; i++) this.#recordIds.push('')
145197

146198
// Advance by the snapshotted pending count, not the live records length,
147199
// so records added while this push was in flight aren't skipped by the next push.

packages/dd-trace/src/llmobs/experiments/experiment.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class Experiment {
149149
description: this.#description,
150150
ensure_unique: true,
151151
}
152+
const datasetVersion = this.#dataset.version()
153+
if (datasetVersion !== null) attributes.dataset_version = datasetVersion
152154
if (Object.keys(this.#config).length > 0) attributes.config = this.#config
153155

154156
let created

0 commit comments

Comments
 (0)