Skip to content

Commit 9e4907f

Browse files
mehulsonowalclaudeBridgeAR
authored
feat(llmobs): control-plane HTTP client for experiments (#9158)
* spike(llmobs): control-plane HTTP client for experiments Initial spike toward unifying the standalone Node LLM Experiments SDK into dd-trace under llmobs. Adds a fetch-based control-plane client (no new dependencies, same approach as src/aiguard/client.js) that sources DD_API_KEY / DD_APP_KEY / site from tracer config, resolves the api.<site> host, and implements get-or-create project as the first working call. Includes a mocha/sinon spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(llmobs): datasets & experiments via tracer.llmobs.experiments Ports the standalone Node LLM Experiments SDK into dd-trace as an llmobs submodule, exposed as tracer.llmobs.experiments. No new runtime dependency — all backend calls go through the global fetch client from the spike, sourcing DD_API_KEY / DD_APP_KEY / site from tracer config. - dataset.js / experiment.js / result.js: Dataset + DatasetRecord, Experiment.run() orchestration with inline span/metric builders, ExperimentResult/Row. - index.js: Experiments facade (createDataset, pullDataset with exponential backoff, experiment()) + createExperiments gating. - noop.js: NoopExperiments for when llmobs is disabled or keys are missing; wired into both the LLMObs SDK and its noop. - index.d.ts: llmobs.Experiments / Dataset / Experiment / ExperimentResult types. - tests: mocha/sinon specs for client, dataset+experiment run, and the facade (23 passing); plus a manual end-to-end example script. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(llmobs): cover experiments error paths and branches Raises patch coverage on the experiments module to ~99.7% lines: adds tests for dataset getters + DatasetRecord instances, non-array push response padding, dataset/records/experiment create failures, the failed-status path when posting events errors, categorical stringify branches, experiment getters, the client `site` getter, the facade createDataset/experiment factories, the no-op operations, and pullDataset list-error and expected-count-not-met errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(llmobs): generate experiment span/trace ids with the tracer's own id module Reuse the same root-span convention as opentracing/span.js (a single random 64-bit span id, reused as the trace id's low 64 bits with a start-time-derived high part) instead of a bespoke 32-char hex id shared between span and trace. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(llmobs): mark an experiment failed when any row or evaluation errors A task/evaluator error is isolated per row (the run doesn't abort), but the experiment status still reported "completed" even when rows failed. Track whether any row hit an error and report the experiment as "failed" overall in that case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(llmobs): don't drop records pushed mid-flight, report push status Dataset#push() advanced #pushedCount to the live records length after awaiting the records POST, so a record added while that POST was in flight would be silently skipped by the next push. Advance by the snapshotted pending count instead. Also read created records from the append-records response's top-level `records` field (it doesn't use the usual `data` envelope), and resolve push()/ensureCreatedAndPushed() with { pushedCount, totalCount } so callers can confirm a push landed fully. * chore(llmobs): mirror the experiments type surface into index.d.v5.ts Datasets & Experiments interfaces were added to index.d.ts (v6) but not yet to the frozen v5 type surface, so v5 consumers had no types for tracer.llmobs.experiments. Mirror the same interfaces (Dataset, Experiment, Experiments, DatasetPushResult, etc.) into index.d.v5.ts. * fix(llmobs): follow pagination when pulling dataset records pullDataset() only fetched the first page of a dataset's records endpoint, so a dataset with more records than fit on one page returned a silently truncated Dataset, and polling for an expectedRecordCount beyond the first page reread the same page until the wait budget expired. Follow the response's meta.after cursor via page[cursor] until the last page. * fix(llmobs): throw an actionable error when no project name is configured createExperiments() already falls back to config.service when llmobs.mlApp isn't set, but if neither is set it silently sent an empty project name to the backend and failed with a confusing "Failed to create or get project" error. Gate on it up front and return a NoopExperiments (consistent with the disabled/missing-keys gates) that tells the user exactly which env var or tracer.init() option to set before retrying. * fix(llmobs): classify evaluator metrics to match dd-trace-py Object-valued evaluator results were stringified into a single opaque categorical value instead of using metric_type "json", making them unreadable in the UI. Mirror dd-trace-py's _generate_metric_from_evaluation classification: dicts (plain objects) become json, everything else non-primitive (including arrays) falls through to categorical with a lowercased string representation. * fix(llmobs): propagate the underlying error when pulling dataset records fails pullDataset() previously swallowed a records-fetch error into a successful empty Dataset whenever expectedRecordCount wasn't set, masking real API/network failures as an empty dataset. * chore(llmobs): tighten comments in the experiments module Drop the stale "Spike:" label, orphaned W1/W2 workaround tags, and version-specific (v0.1) wording; keep the substance each comment was actually conveying. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Bridgewater <ruben@bridgewater.de>
1 parent 6f1b5ad commit 9e4907f

14 files changed

Lines changed: 1797 additions & 0 deletions

File tree

index.d.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3568,6 +3568,12 @@ declare namespace tracer {
35683568
*/
35693569
enabled: boolean,
35703570

3571+
/**
3572+
* Datasets & Experiments API. Requires LLM Observability to be enabled and
3573+
* `DD_API_KEY` / `DD_APP_KEY` to be set.
3574+
*/
3575+
experiments: Experiments,
3576+
35713577
/**
35723578
* Enable LLM Observability tracing.
35733579
*
@@ -3721,6 +3727,93 @@ declare namespace tracer {
37213727
flush (): void
37223728
}
37233729

3730+
/**
3731+
* A task run over each dataset record during an experiment.
3732+
*/
3733+
type ExperimentTask = (input: any, config: Record<string, any>) => any | Promise<any>
3734+
3735+
/**
3736+
* Scores a single task output. The return type selects the metric:
3737+
* `boolean` -> boolean, `number` -> score, anything else -> categorical.
3738+
*/
3739+
type ExperimentEvaluator = (input: any, output: any, expectedOutput: any) => any | Promise<any>
3740+
3741+
interface ExperimentOptions {
3742+
name: string
3743+
dataset: Dataset
3744+
task: ExperimentTask
3745+
/** Evaluators keyed by metric label. */
3746+
evaluators?: Record<string, ExperimentEvaluator>
3747+
description?: string
3748+
config?: Record<string, any>
3749+
tags?: Record<string, string>
3750+
}
3751+
3752+
interface PullDatasetOptions {
3753+
/** Wait until at least this many records are readable (absorbs write lag). */
3754+
expectedRecordCount?: number
3755+
/** Maximum total time to wait, in ms. Default 30000. */
3756+
maxWaitMs?: number
3757+
}
3758+
3759+
interface ExperimentResultRow {
3760+
index: number
3761+
spanId: string
3762+
traceId: string
3763+
startNs: number
3764+
durationNs: number
3765+
input: any
3766+
output: any
3767+
expectedOutput: any
3768+
readonly isError: boolean
3769+
errorType: string | null
3770+
errorMessage: string | null
3771+
evaluations: Record<string, any>
3772+
evaluationErrors: Record<string, string>
3773+
}
3774+
3775+
interface ExperimentResult {
3776+
experimentId: string
3777+
rows: ExperimentResultRow[]
3778+
/** Dashboard URL for the experiment. */
3779+
url: string
3780+
}
3781+
3782+
interface DatasetPushResult {
3783+
/** Number of records from this push that were confirmed with a record id. */
3784+
pushedCount: number
3785+
/** Number of records attempted in this push. */
3786+
totalCount: number
3787+
}
3788+
3789+
interface Dataset {
3790+
addRecord (input: any, expectedOutput?: any, metadata?: Record<string, any>): Dataset
3791+
/** Creates the dataset remotely if needed and pushes any unpushed records. */
3792+
push (): Promise<DatasetPushResult>
3793+
name (): string
3794+
id (): string | null
3795+
projectId (): string | null
3796+
records (): Array<{ input: any, expectedOutput: any, metadata: Record<string, any> }>
3797+
/** Dashboard URL for the dataset, or null until pushed. */
3798+
url (): string | null
3799+
}
3800+
3801+
interface Experiment {
3802+
name (): string
3803+
experimentId (): string | null
3804+
url (): string | null
3805+
run (): Promise<ExperimentResult>
3806+
}
3807+
3808+
interface Experiments {
3809+
/** Create a local dataset buffer; pushed on the first experiment run. */
3810+
createDataset (name: string, description?: string): Dataset
3811+
/** Pull an existing dataset (with records) by name. */
3812+
pullDataset (name: string, options?: PullDatasetOptions): Promise<Dataset>
3813+
/** Build an experiment to run over a dataset. */
3814+
experiment (options: ExperimentOptions): Experiment
3815+
}
3816+
37243817
interface LLMObservabilitySpan {
37253818
/**
37263819
* The span kind

index.d.v5.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3774,6 +3774,12 @@ declare namespace tracer {
37743774
*/
37753775
enabled: boolean,
37763776

3777+
/**
3778+
* Datasets & Experiments API. Requires LLM Observability to be enabled and
3779+
* `DD_API_KEY` / `DD_APP_KEY` to be set.
3780+
*/
3781+
experiments: Experiments,
3782+
37773783
/**
37783784
* Enable LLM Observability tracing.
37793785
*
@@ -3918,6 +3924,93 @@ declare namespace tracer {
39183924
flush (): void
39193925
}
39203926

3927+
/**
3928+
* A task run over each dataset record during an experiment.
3929+
*/
3930+
type ExperimentTask = (input: any, config: Record<string, any>) => any | Promise<any>
3931+
3932+
/**
3933+
* Scores a single task output. The return type selects the metric:
3934+
* `boolean` -> boolean, `number` -> score, anything else -> categorical.
3935+
*/
3936+
type ExperimentEvaluator = (input: any, output: any, expectedOutput: any) => any | Promise<any>
3937+
3938+
interface ExperimentOptions {
3939+
name: string
3940+
dataset: Dataset
3941+
task: ExperimentTask
3942+
/** Evaluators keyed by metric label. */
3943+
evaluators?: Record<string, ExperimentEvaluator>
3944+
description?: string
3945+
config?: Record<string, any>
3946+
tags?: Record<string, string>
3947+
}
3948+
3949+
interface PullDatasetOptions {
3950+
/** Wait until at least this many records are readable (absorbs write lag). */
3951+
expectedRecordCount?: number
3952+
/** Maximum total time to wait, in ms. Default 30000. */
3953+
maxWaitMs?: number
3954+
}
3955+
3956+
interface ExperimentResultRow {
3957+
index: number
3958+
spanId: string
3959+
traceId: string
3960+
startNs: number
3961+
durationNs: number
3962+
input: any
3963+
output: any
3964+
expectedOutput: any
3965+
readonly isError: boolean
3966+
errorType: string | null
3967+
errorMessage: string | null
3968+
evaluations: Record<string, any>
3969+
evaluationErrors: Record<string, string>
3970+
}
3971+
3972+
interface ExperimentResult {
3973+
experimentId: string
3974+
rows: ExperimentResultRow[]
3975+
/** Dashboard URL for the experiment. */
3976+
url: string
3977+
}
3978+
3979+
interface DatasetPushResult {
3980+
/** Number of records from this push that were confirmed with a record id. */
3981+
pushedCount: number
3982+
/** Number of records attempted in this push. */
3983+
totalCount: number
3984+
}
3985+
3986+
interface Dataset {
3987+
addRecord (input: any, expectedOutput?: any, metadata?: Record<string, any>): Dataset
3988+
/** Creates the dataset remotely if needed and pushes any unpushed records. */
3989+
push (): Promise<DatasetPushResult>
3990+
name (): string
3991+
id (): string | null
3992+
projectId (): string | null
3993+
records (): Array<{ input: any, expectedOutput: any, metadata: Record<string, any> }>
3994+
/** Dashboard URL for the dataset, or null until pushed. */
3995+
url (): string | null
3996+
}
3997+
3998+
interface Experiment {
3999+
name (): string
4000+
experimentId (): string | null
4001+
url (): string | null
4002+
run (): Promise<ExperimentResult>
4003+
}
4004+
4005+
interface Experiments {
4006+
/** Create a local dataset buffer; pushed on the first experiment run. */
4007+
createDataset (name: string, description?: string): Dataset
4008+
/** Pull an existing dataset (with records) by name. */
4009+
pullDataset (name: string, options?: PullDatasetOptions): Promise<Dataset>
4010+
/** Build an experiment to run over a dataset. */
4011+
experiment (options: ExperimentOptions): Experiment
4012+
}
4013+
39214014
interface LLMObservabilitySpan {
39224015
/**
39234016
* The span kind
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use strict'
2+
3+
// Control-plane HTTP client for LLM Obs Experiments. Uses the global `fetch`,
4+
// so this module adds no new dependency; credentials and site come from config.
5+
6+
const API_BASE_PATH = '/api/v2/llm-obs/v1'
7+
8+
// Control-plane host for a Datadog site, e.g.
9+
// datadoghq.com -> api.datadoghq.com
10+
// us3.datadoghq.com -> api.us3.datadoghq.com
11+
// datad0g.com (staging)-> api.datad0g.com
12+
function apiHost (site) {
13+
return `api.${site}`
14+
}
15+
16+
// 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.
19+
function appHost (site) {
20+
return site.split('.').length === 2 ? `app.${site}` : site
21+
}
22+
23+
class ExperimentsClient {
24+
#apiKey
25+
#appKey
26+
#site
27+
#projectName
28+
#timeout
29+
#cachedProjectId
30+
31+
constructor ({ apiKey, appKey, site, projectName, timeout = 30_000 } = {}) {
32+
this.#apiKey = apiKey
33+
this.#appKey = appKey
34+
this.#site = site
35+
this.#projectName = projectName
36+
this.#timeout = timeout
37+
this.#cachedProjectId = null
38+
}
39+
40+
// Whether the client has everything it needs to talk to the control plane.
41+
get configured () {
42+
return Boolean(this.#apiKey && this.#appKey && this.#site)
43+
}
44+
45+
get site () {
46+
return this.#site
47+
}
48+
49+
// Dashboard URL base for the configured site, e.g. https://app.datadoghq.com
50+
get appBase () {
51+
return `https://${appHost(this.#site)}`
52+
}
53+
54+
// Resolve the configured project's id (get-or-create), cached.
55+
ensureProjectId () {
56+
return this.getOrCreateProject(this.#projectName)
57+
}
58+
59+
// Low-level request. Builds https://api.<site><path>, attaches both keys, and
60+
// returns the parsed JSON body. Throws with status + body on a non-2xx.
61+
async request (method, path, body) {
62+
const url = `https://${apiHost(this.#site)}${path}`
63+
const headers = {
64+
'DD-API-KEY': this.#apiKey,
65+
'DD-APPLICATION-KEY': this.#appKey,
66+
}
67+
68+
let payload
69+
if (body !== undefined) {
70+
payload = JSON.stringify(body)
71+
headers['Content-Type'] = 'application/json'
72+
}
73+
74+
let response
75+
try {
76+
response = await fetch(url, {
77+
method,
78+
headers,
79+
body: payload,
80+
signal: AbortSignal.timeout(this.#timeout),
81+
})
82+
} catch (err) {
83+
throw new Error(`${method} ${path} failed: ${err.message}`)
84+
}
85+
86+
const text = await response.text()
87+
if (!response.ok) {
88+
throw new Error(`${method} ${path} failed: HTTP ${response.status} ${text}`)
89+
}
90+
return text ? JSON.parse(text) : {}
91+
}
92+
93+
// Resolve the project id for `name`, creating it if absent. The create
94+
// endpoint is get-or-create on name, so repeated calls return the same id.
95+
// Cached after the first resolution.
96+
async getOrCreateProject (name) {
97+
if (this.#cachedProjectId) return this.#cachedProjectId
98+
99+
let response
100+
try {
101+
response = await this.request('POST', `${API_BASE_PATH}/projects`, {
102+
data: { type: 'projects', attributes: { name } },
103+
})
104+
} catch (err) {
105+
throw new Error(`Failed to create or get project '${name}': ${err.message}`)
106+
}
107+
108+
this.#cachedProjectId = response?.data?.id ?? null
109+
return this.#cachedProjectId
110+
}
111+
}
112+
113+
module.exports = { ExperimentsClient, apiHost, appHost, API_BASE_PATH }

0 commit comments

Comments
 (0)