Skip to content

Commit 60bf567

Browse files
chore(llmobs): projects support for dne (#14517)
adds support for projects 1. if a project name is provided in pull_dataset or create_dataset* methods, the dataset will be searched for or created under the specified project 2. otherwise, the default project is used, or the project name from the `LLMObs.enable` call or the env var is used with this snippet ``` LLMObs.enable(api_key=os.getenv("DD_API_KEY"), app_key=os.getenv("DD_APPLICATION_KEY"), project_name="Onboarding", ml_app="Onboarding-ML-App") dataset_name = "weatheraus-2025090900011" dataset = LLMObs.create_dataset_from_csv(csv_path="/Users/gary.huang/Downloads/weatherAUS.csv", dataset_name=dataset_name, project_name="big-data-gh" , input_data_columns=["Date", "input", "Evaporation", "Sunshine"], expected_output_columns=["RainTomorrow"]) print(dataset.as_dataframe()) print(dataset.url) time.sleep(30) try: pds = LLMObs.pull_dataset(dataset_name=dataset_name) except Exception as e: print(e) pds = LLMObs.pull_dataset(dataset_name=dataset_name, project_name="big-data-gh") print(pds.as_dataframe()) print(pds.url) ``` we get the following output ``` python test-project.py 3.15.0.dev13+gcb1068a93 input_data expected_output input_data Date RainTomorrow Sunshine Evaporation input 0 2008-12-01 No NA NA Albury 1 2008-12-02 No NA NA Albury 2 2008-12-03 No NA NA Albury 3 2008-12-04 No NA NA Albury 4 2008-12-05 No NA NA Albury ... ... ... ... ... ... 145455 2017-06-21 No NA NA Uluru 145456 2017-06-22 No NA NA Uluru 145457 2017-06-23 No NA NA Uluru 145458 2017-06-24 No NA NA Uluru 145459 2017-06-25 NA NA NA Uluru [145460 rows x 5 columns] https://app.datadoghq.com/llm/datasets/7f81325e-dce1-49c7-b6bf-c75947490891 Dataset 'weatheraus-202509090928' not found in project Onboarding input_data expected_output input_data Date RainTomorrow Sunshine Evaporation input 0 2008-12-05 No NA NA Albury 1 2008-12-08 No NA NA Albury 2 2008-12-10 No NA NA Albury 3 2008-12-09 Yes NA NA Albury 4 2008-12-12 Yes NA NA Albury ... ... ... ... ... ... 145455 2016-08-25 No NA NA Uluru 145456 2016-11-20 No NA NA Uluru 145457 2016-12-26 Yes NA NA Uluru 145458 2016-12-31 No NA NA Uluru 145459 2017-04-29 No NA NA Uluru [145460 rows x 5 columns] https://app.datadoghq.com/llm/datasets/7f81325e-dce1-49c7-b6bf-c75947490891 ``` the first pull hits an exception because the dataset wasn't found in the default project from the enable call, and the 2nd pull succeeds, all as expected an alternate approach would be to initialize default project in `enable`, but that messes with the tests everywhere that don't have cassettes ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Co-authored-by: Sam Brenner <[email protected]>
1 parent abfd0cb commit 60bf567

File tree

156 files changed

+4841
-4618
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

156 files changed

+4841
-4618
lines changed

ddtrace/llmobs/_experiment.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
DatasetRecordInputType = Dict[str, NonNoneJSONType]
4545

4646

47+
class Project(TypedDict):
48+
name: str
49+
_id: str
50+
51+
4752
class DatasetRecordRaw(TypedDict):
4853
input_data: DatasetRecordInputType
4954
expected_output: JSONType
@@ -106,13 +111,15 @@ class Dataset:
106111
def __init__(
107112
self,
108113
name: str,
114+
project: Project,
109115
dataset_id: str,
110116
records: List[DatasetRecord],
111117
description: str,
112118
version: int,
113119
_dne_client: "LLMObsExperimentsClient",
114120
) -> None:
115121
self.name = name
122+
self.project = project
116123
self.description = description
117124
self._id = dataset_id
118125
self._version = version
@@ -335,8 +342,8 @@ def run(
335342
)
336343
return []
337344

338-
project_id = self._llmobs_instance._dne_client.project_create_or_get(self._project_name)
339-
self._project_id = project_id
345+
project = self._llmobs_instance._dne_client.project_create_or_get(self._project_name)
346+
self._project_id = project.get("_id", "")
340347

341348
experiment_id, experiment_run_name = self._llmobs_instance._dne_client.experiment_create(
342349
self.name,
@@ -416,6 +423,7 @@ def _run_task(self, jobs: int, raise_errors: bool = False, sample_size: Optional
416423
subset_name = "[Test subset of {} records] {}".format(sample_size, self._dataset.name)
417424
subset_dataset = Dataset(
418425
name=subset_name,
426+
project=self._dataset.project,
419427
dataset_id=self._dataset._id,
420428
records=subset_records,
421429
description=self._dataset.description,

ddtrace/llmobs/_llmobs.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
from ddtrace.llmobs._experiment import Experiment
9393
from ddtrace.llmobs._experiment import ExperimentConfigType
9494
from ddtrace.llmobs._experiment import JSONType
95+
from ddtrace.llmobs._experiment import Project
9596
from ddtrace.llmobs._utils import AnnotationContext
9697
from ddtrace.llmobs._utils import LinkTracker
9798
from ddtrace.llmobs._utils import _get_ml_app
@@ -212,6 +213,7 @@ def __init__(
212213
interval=float(os.getenv("_DD_LLMOBS_WRITER_INTERVAL", 1.0)),
213214
timeout=float(os.getenv("_DD_LLMOBS_WRITER_TIMEOUT", 5.0)),
214215
_app_key=self._app_key,
216+
_default_project=Project(name=self._project_name, _id=""),
215217
is_agentless=True, # agent proxy doesn't seem to work for experiments
216218
)
217219

@@ -645,15 +647,21 @@ def enable(
645647
)
646648

647649
@classmethod
648-
def pull_dataset(cls, name: str) -> Dataset:
649-
ds = cls._instance._dne_client.dataset_get_with_records(name)
650+
def pull_dataset(cls, dataset_name: str, project_name: Optional[str] = None) -> Dataset:
651+
ds = cls._instance._dne_client.dataset_get_with_records(dataset_name, (project_name or cls._project_name))
650652
return ds
651653

652654
@classmethod
653-
def create_dataset(cls, name: str, description: str = "", records: Optional[List[DatasetRecord]] = None) -> Dataset:
655+
def create_dataset(
656+
cls,
657+
dataset_name: str,
658+
project_name: Optional[str] = None,
659+
description: str = "",
660+
records: Optional[List[DatasetRecord]] = None,
661+
) -> Dataset:
654662
if records is None:
655663
records = []
656-
ds = cls._instance._dne_client.dataset_create(name, description)
664+
ds = cls._instance._dne_client.dataset_create(dataset_name, project_name, description)
657665
for r in records:
658666
ds.append(r)
659667
if len(records) > 0:
@@ -669,19 +677,20 @@ def create_dataset_from_csv(
669677
expected_output_columns: Optional[List[str]] = None,
670678
metadata_columns: Optional[List[str]] = None,
671679
csv_delimiter: str = ",",
672-
description="",
680+
description: str = "",
681+
project_name: Optional[str] = None,
673682
) -> Dataset:
674683
if expected_output_columns is None:
675684
expected_output_columns = []
676685
if metadata_columns is None:
677686
metadata_columns = []
678-
ds = cls._instance._dne_client.dataset_create(dataset_name, description)
679687

680688
# Store the original field size limit to restore it later
681689
original_field_size_limit = csv.field_size_limit()
682690

683691
csv.field_size_limit(EXPERIMENT_CSV_FIELD_MAX_SIZE) # 10mb
684692

693+
records = []
685694
try:
686695
with open(csv_path, mode="r") as csvfile:
687696
content = csvfile.readline().strip()
@@ -708,7 +717,7 @@ def create_dataset_from_csv(
708717
raise ValueError(f"Metadata columns not found in CSV header: {missing_metadata_columns}")
709718

710719
for row in rows:
711-
ds.append(
720+
records.append(
712721
DatasetRecord(
713722
input_data={col: row[col] for col in input_data_columns},
714723
expected_output={col: row[col] for col in expected_output_columns},
@@ -721,6 +730,9 @@ def create_dataset_from_csv(
721730
# Always restore the original field size limit
722731
csv.field_size_limit(original_field_size_limit)
723732

733+
ds = cls._instance._dne_client.dataset_create(dataset_name, project_name, description)
734+
for r in records:
735+
ds.append(r)
724736
if len(ds) > 0:
725737
cls._instance._dne_client.dataset_bulk_upload(ds._id, ds._records)
726738
return ds

ddtrace/llmobs/_writer.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from ddtrace.llmobs._experiment import DatasetRecord
5050
from ddtrace.llmobs._experiment import DatasetRecordRaw
5151
from ddtrace.llmobs._experiment import JSONType
52+
from ddtrace.llmobs._experiment import Project
5253
from ddtrace.llmobs._experiment import UpdatableDatasetRecord
5354
from ddtrace.llmobs._utils import safe_json
5455
from ddtrace.settings._agent import config as agent_config
@@ -141,6 +142,7 @@ def __init__(
141142
_api_key: str = "",
142143
_app_key: str = "",
143144
_override_url: str = "",
145+
_default_project: Project = Project(name="", _id=""),
144146
) -> None:
145147
super(BaseLLMObsWriter, self).__init__(interval=interval)
146148
self._lock = forksafe.RLock()
@@ -151,6 +153,7 @@ def __init__(
151153
self._site: str = _site or config._dd_site
152154
self._app_key: str = _app_key
153155
self._override_url: str = _override_url or os.environ.get("DD_LLMOBS_OVERRIDE_ORIGIN", "")
156+
self._default_project: Project = _default_project
154157

155158
self._agentless: bool = is_agentless
156159
self._intake: str = self._override_url or (
@@ -371,23 +374,32 @@ def dataset_delete(self, dataset_id: str) -> None:
371374
raise ValueError(f"Failed to delete dataset {id}: {resp.get_json()}")
372375
return None
373376

374-
def dataset_create(self, name: str, description: str) -> Dataset:
375-
path = "/api/unstable/llm-obs/v1/datasets"
377+
def dataset_create(
378+
self,
379+
dataset_name: str,
380+
project_name: Optional[str],
381+
description: str,
382+
) -> Dataset:
383+
project = self.project_create_or_get(project_name)
384+
project_id = project.get("_id")
385+
logger.debug("getting records with project ID %s for %s", project_id, project_name)
386+
387+
path = f"/api/unstable/llm-obs/v1/{project_id}/datasets"
376388
body: JSONType = {
377389
"data": {
378390
"type": "datasets",
379-
"attributes": {"name": name, "description": description},
391+
"attributes": {"name": dataset_name, "description": description},
380392
}
381393
}
382394
resp = self.request("POST", path, body)
383395
if resp.status != 200:
384-
raise ValueError(f"Failed to create dataset {name}: {resp.status} {resp.get_json()}")
396+
raise ValueError(f"Failed to create dataset {dataset_name}: {resp.status} {resp.get_json()}")
385397
response_data = resp.get_json()
386398
dataset_id = response_data["data"]["id"]
387399
if dataset_id is None or dataset_id == "":
388400
raise ValueError(f"unexpected dataset state, invalid ID (is None: {dataset_id is None})")
389401
curr_version = response_data["data"]["attributes"]["current_version"]
390-
return Dataset(name, dataset_id, [], description, curr_version, _dne_client=self)
402+
return Dataset(dataset_name, project, dataset_id, [], description, curr_version, _dne_client=self)
391403

392404
@staticmethod
393405
def _get_record_json(record: Union[UpdatableDatasetRecord, DatasetRecordRaw], is_update: bool) -> JSONType:
@@ -445,16 +457,22 @@ def dataset_batch_update(
445457
new_record_ids: List[str] = [r["id"] for r in data] if data else []
446458
return new_version, new_record_ids
447459

448-
def dataset_get_with_records(self, name: str) -> Dataset:
449-
path = f"/api/unstable/llm-obs/v1/datasets?filter[name]={quote(name)}"
460+
def dataset_get_with_records(self, dataset_name: str, project_name: Optional[str] = None) -> Dataset:
461+
project = self.project_create_or_get(project_name)
462+
project_id = project.get("_id")
463+
logger.debug("getting records with project ID %s for %s", project_id, project_name)
464+
465+
path = f"/api/unstable/llm-obs/v1/{project_id}/datasets?filter[name]={quote(dataset_name)}"
450466
resp = self.request("GET", path)
451467
if resp.status != 200:
452-
raise ValueError(f"Failed to pull dataset {name}: {resp.status}")
468+
raise ValueError(
469+
f"Failed to pull dataset {dataset_name} from project {project_name} (id={project_id}): {resp.status}"
470+
)
453471

454472
response_data = resp.get_json()
455473
data = response_data["data"]
456474
if not data:
457-
raise ValueError(f"Dataset '{name}' not found")
475+
raise ValueError(f"Dataset '{dataset_name}' not found in project {project_name}")
458476

459477
curr_version = data[0]["attributes"]["current_version"]
460478
dataset_description = data[0]["attributes"].get("description", "")
@@ -469,7 +487,8 @@ def dataset_get_with_records(self, name: str) -> Dataset:
469487
resp = self.request("GET", list_path, timeout=self.LIST_RECORDS_TIMEOUT)
470488
if resp.status != 200:
471489
raise ValueError(
472-
f"Failed to pull {page_num}th page of dataset records {name}: {resp.status} {resp.get_json()}"
490+
f"Failed to pull dataset records for {dataset_name}, page={page_num}: "
491+
f"{resp.status} {resp.get_json()}"
473492
)
474493
records_data = resp.get_json()
475494

@@ -490,7 +509,9 @@ def dataset_get_with_records(self, name: str) -> Dataset:
490509
list_path = f"{list_base_path}?page[cursor]={next_cursor}"
491510
logger.debug("next list records request path %s", list_path)
492511
page_num += 1
493-
return Dataset(name, dataset_id, class_records, dataset_description, curr_version, _dne_client=self)
512+
return Dataset(
513+
dataset_name, project, dataset_id, class_records, dataset_description, curr_version, _dne_client=self
514+
)
494515

495516
def dataset_bulk_upload(self, dataset_id: str, records: List[DatasetRecord]):
496517
with tempfile.NamedTemporaryFile(suffix=".csv") as tmp:
@@ -543,17 +564,42 @@ def dataset_bulk_upload(self, dataset_id: str, records: List[DatasetRecord]):
543564
raise ValueError(f"Failed to upload dataset from file: {resp.status} {resp.get_json()}")
544565
logger.debug("successfully uploaded with code %d", resp.status)
545566

546-
def project_create_or_get(self, name: str) -> str:
567+
def project_create_or_get(self, name: Optional[str] = None) -> Project:
568+
default_project_name = self._default_project["name"]
569+
project_name = default_project_name
570+
571+
if not name:
572+
if self._default_project.get("_id"):
573+
# default project already initialized, use it
574+
return self._default_project
575+
else:
576+
project_name = name
577+
547578
path = "/api/unstable/llm-obs/v1/projects"
548579
resp = self.request(
549580
"POST",
550581
path,
551-
body={"data": {"type": "projects", "attributes": {"name": name, "description": ""}}},
582+
body={"data": {"type": "projects", "attributes": {"name": project_name, "description": ""}}},
552583
)
553584
if resp.status != 200:
554-
raise ValueError(f"Failed to create project {name}: {resp.status} {resp.get_json()}")
585+
raise ValueError(f"Failed to create project {project_name}: {resp.status} {resp.get_json()}")
555586
response_data = resp.get_json()
556-
return response_data["data"]["id"]
587+
project_id = response_data["data"]["id"]
588+
589+
if not project_id:
590+
logger.error(
591+
"got empty project ID for project %s in response, code=%d, resp=%s",
592+
project_name,
593+
resp.status,
594+
resp.get_json(),
595+
)
596+
597+
project = Project(name=project_name, _id=project_id)
598+
# after the initial GET of the project ID, store it
599+
if project_name == default_project_name:
600+
self._default_project = project
601+
602+
return project
557603

558604
def experiment_create(
559605
self,

tests/llmobs/llmobs_cassettes/datadog/datadog_api_intake_llm-obs_v2_eval-metric_post_1218a393.yaml

Lines changed: 0 additions & 47 deletions
This file was deleted.

tests/llmobs/llmobs_cassettes/datadog/datadog_api_intake_llm-obs_v2_eval-metric_post_2d529580.yaml

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)