Skip to content

Commit d2e70ef

Browse files
committed
add create/deletion methods, add tests
1 parent 3a7c8a4 commit d2e70ef

File tree

8 files changed

+102
-53
lines changed

8 files changed

+102
-53
lines changed

ddtrace/llmobs/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@
88

99
from ._experiment import Dataset
1010
from ._experiment import DatasetRecord
11-
from ._experiment import Experiment
1211
from ._llmobs import LLMObs
1312
from ._llmobs import LLMObsSpan
1413

1514

16-
__all__ = ["LLMObs", "LLMObsSpan", "Experiment", "Dataset", "DatasetRecord"]
15+
__all__ = ["LLMObs", "LLMObsSpan", "Dataset", "DatasetRecord"]

ddtrace/llmobs/_constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@
4545
EVP_SUBDOMAIN_HEADER_NAME = "X-Datadog-EVP-Subdomain"
4646
SPAN_SUBDOMAIN_NAME = "llmobs-intake"
4747
EVAL_SUBDOMAIN_NAME = "api"
48+
EXP_SUBDOMAIN_NAME = "api"
4849
AGENTLESS_SPAN_BASE_URL = "https://{}".format(SPAN_SUBDOMAIN_NAME)
4950
AGENTLESS_EVAL_BASE_URL = "https://{}".format(EVAL_SUBDOMAIN_NAME)
51+
AGENTLESS_EXP_BASE_URL = "https://{}".format(EXP_SUBDOMAIN_NAME)
5052

5153
EVP_PAYLOAD_SIZE_LIMIT = 5 << 20 # 5MB (actual limit is 5.1MB)
5254
EVP_EVENT_SIZE_LIMIT = (1 << 20) - 1024 # 999KB (actual limit is 1MB)

ddtrace/llmobs/_experiment.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,11 @@ class DatasetRecord(TypedDict):
1818

1919

2020
class Dataset:
21-
_name: str
21+
name: str
2222
_id: str
2323
_data: List[DatasetRecord]
2424

25-
def __init__(self, name: str, id: str, data: List[DatasetRecord]) -> None:
26-
self._name = name
27-
self._id = id
28-
self._data = data
29-
30-
def __str__(self) -> str:
31-
return f"Dataset(name={self._name}, id={self._id}, data={self._data})"
32-
33-
34-
class Experiment:
35-
def __init__(self, name: str, dataset: Dataset, description: str = "") -> None:
25+
def __init__(self, name: str, dataset_id: str, data: List[DatasetRecord]) -> None:
3626
self.name = name
37-
self._dataset = dataset
38-
self._experiment_id: Optional[str] = None
39-
self._project_id: Optional[str] = None
40-
41-
def run(self):
42-
pass
27+
self._id = dataset_id
28+
self._data = data

ddtrace/llmobs/_llmobs.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from typing import TypedDict
1515
from typing import Union
1616
from typing import cast
17-
from urllib.parse import quote
1817

1918
import ddtrace
2019
from ddtrace import config
@@ -75,7 +74,6 @@
7574
from ddtrace.llmobs._context import LLMObsContextProvider
7675
from ddtrace.llmobs._evaluators.runner import EvaluatorRunner
7776
from ddtrace.llmobs._experiment import Dataset
78-
from ddtrace.llmobs._experiment import Experiment
7977
from ddtrace.llmobs._utils import AnnotationContext
8078
from ddtrace.llmobs._utils import LinkTracker
8179
from ddtrace.llmobs._utils import ToolCallTracker
@@ -562,8 +560,13 @@ def enable(
562560
def pull_dataset(cls, name: str) -> Dataset:
563561
return cls._instance._dne_client.dataset_pull(name)
564562

565-
def experiment(self, name: str, dataset: Dataset) -> Experiment:
566-
return Experiment(name, dataset)
563+
@classmethod
564+
def create_dataset(cls, name: str, description: str) -> Dataset:
565+
return cls._instance._dne_client.dataset_create(name, description)
566+
567+
@classmethod
568+
def _delete_dataset(cls, dataset_id: str) -> None:
569+
return cls._instance._dne_client.dataset_delete(dataset_id)
567570

568571
@classmethod
569572
def register_processor(cls, processor: Optional[Callable[[LLMObsSpan], LLMObsSpan]] = None) -> None:

ddtrace/llmobs/_writer.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import atexit
2+
import json
23
from typing import Any
34
from typing import Dict
45
from typing import List
@@ -25,6 +26,7 @@
2526
from ddtrace.internal.utils.retry import fibonacci_backoff_with_jitter
2627
from ddtrace.llmobs import _telemetry as telemetry
2728
from ddtrace.llmobs._constants import AGENTLESS_EVAL_BASE_URL
29+
from ddtrace.llmobs._constants import AGENTLESS_EXP_BASE_URL
2830
from ddtrace.llmobs._constants import AGENTLESS_SPAN_BASE_URL
2931
from ddtrace.llmobs._constants import DROPPED_IO_COLLECTION_ERROR
3032
from ddtrace.llmobs._constants import DROPPED_VALUE_TEXT
@@ -38,6 +40,7 @@
3840
from ddtrace.llmobs._constants import SPAN_SUBDOMAIN_NAME
3941
from ddtrace.llmobs._experiment import Dataset
4042
from ddtrace.llmobs._experiment import DatasetRecord
43+
from ddtrace.llmobs._experiment import JSONType
4144
from ddtrace.llmobs._utils import safe_json
4245
from ddtrace.settings._agent import config as agent_config
4346

@@ -269,58 +272,77 @@ def _data(self, events: List[LLMObsEvaluationMetricEvent]) -> Dict[str, Any]:
269272

270273

271274
class LLMObsExperimentsClient(BaseLLMObsWriter):
275+
AGENTLESS_BASE_URL = AGENTLESS_EXP_BASE_URL
272276

273-
def request(self, method: str, path: str, body: bytes = b"") -> Response:
277+
def request(self, method: str, path: str, body: JSONType = None) -> Response:
274278
headers = {
275279
"Content-Type": "application/json",
276280
"DD-API-KEY": self._api_key,
277281
"DD-APPLICATION-KEY": self._app_key,
278282
}
279-
site = self._site
280-
if site == "datad0g.com":
281-
base = "https://dd.datad0g.com"
282-
else:
283-
base = f"https://api.{site}"
284-
285-
conn = get_connection(base)
283+
body = json.dumps(body).encode("utf-8") if body else b""
284+
conn = get_connection(self._intake)
286285
try:
287-
url = base + path
286+
url = self._intake + path
288287
logger.debug("requesting %s", url)
289288
conn.request(method, url, body, headers)
290289
resp = conn.getresponse()
291-
if resp.status >= 300:
292-
raise ValueError(f"Failed to {method} {path}: {resp.status}")
293290
return Response.from_http_response(resp)
294291
finally:
295292
conn.close()
296293

297-
def dataset_pull(self, name: str) -> Dataset:
294+
def dataset_delete(self, dataset_id: str) -> None:
295+
path = "/api/unstable/llm-obs/v1/datasets/delete"
296+
resp = self.request(
297+
"POST",
298+
path,
299+
body={
300+
"data": {
301+
"type": "datasets",
302+
"attributes": {
303+
"type": "soft",
304+
"dataset_ids": [dataset_id],
305+
},
306+
},
307+
},
308+
)
309+
assert resp.status == 200, f"Failed to delete dataset {id}: {resp.get_json()}"
310+
return None
311+
312+
def dataset_create(self, name: str, description: str) -> Dataset:
313+
path = "/api/unstable/llm-obs/v1/datasets"
314+
body = {
315+
"data": {
316+
"type": "datasets",
317+
"attributes": {"name": name, "description": description},
318+
}
319+
}
320+
resp = self.request("POST", path, body)
321+
response_data = resp.get_json()
322+
dataset_id = response_data["data"]["id"]
323+
return Dataset(name, dataset_id, [])
298324

325+
def dataset_pull(self, name: str) -> Dataset:
299326
path = f"/api/unstable/llm-obs/v1/datasets?filter[name]={quote(name)}"
300327
resp = self.request("GET", path)
301328

302329
response_data = resp.get_json()
303-
datasets = response_data.get("data", [])
304-
305-
if not datasets:
330+
data = response_data["data"]
331+
if not data:
306332
raise ValueError(f"Dataset '{name}' not found")
307333

308-
dataset_id = datasets[0]["id"]
334+
dataset_id = data[0]["id"]
309335
url = f"/api/unstable/llm-obs/v1/datasets/{dataset_id}/records"
310-
try:
311-
resp = self.request("GET", url)
312-
records_data = resp.get_json()
313-
except ValueError as e:
314-
if "404" in str(e):
315-
raise ValueError(f"Dataset '{name}' not found") from e
316-
raise
336+
resp = self.request("GET", url)
337+
if resp.status == 404:
338+
raise ValueError(f"Dataset '{name}' not found")
339+
records_data = resp.get_json()
317340

318341
class_records: List[DatasetRecord] = []
319342
for record in records_data.get("data", []):
320343
attrs = record.get("attributes", {})
321344
input_data = attrs.get("input")
322345
expected_output = attrs.get("expected_output")
323-
324346
class_records.append(
325347
{
326348
"record_id": record.get("id"),
@@ -329,7 +351,6 @@ def dataset_pull(self, name: str) -> Dataset:
329351
**attrs.get("metadata", {}),
330352
}
331353
)
332-
333354
return Dataset(name, dataset_id, class_records)
334355

335356

tests/llmobs/_utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424
cassette_library_dir=os.path.join(os.path.dirname(__file__), "llmobs_cassettes/"),
2525
record_mode="once",
2626
match_on=["path"],
27-
filter_headers=["authorization", "OpenAI-Organization", "api-key", "x-api-key", ("DD-API-KEY", "XXXXXX")],
27+
filter_headers=[
28+
"authorization",
29+
"OpenAI-Organization",
30+
"api-key",
31+
"x-api-key",
32+
("DD-API-KEY", "XXXXXX"),
33+
("DD-APPLICATION-KEY", "XXXXXX"),
34+
],
2835
# Ignore requests to the agent
2936
ignore_localhost=True,
3037
)

tests/llmobs/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,9 @@ def llmobs(
273273
llmobs_service.enable(_tracer=tracer, **llmobs_enable_opts)
274274
llmobs_service._instance._llmobs_span_writer = llmobs_span_writer
275275
llmobs_service._instance._llmobs_span_writer.start()
276+
llmobs_service._instance._dne_client._intake = "http://localhost:9126/vcr/datadog"
276277
yield llmobs_service
278+
tracer.shutdown()
277279
llmobs_service.disable()
278280

279281

tests/llmobs/test_experiments.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1+
import os
12

3+
import pytest
24

3-
def test_dataset_pull(llmobs):
4-
dataset = llmobs.pull_dataset(name="kyle-test")
5-
assert dataset._id == "929531d1-3cd2-473d-ab4e-2423b40c5db5"
5+
6+
@pytest.fixture
7+
def test_dataset(llmobs):
8+
ds = llmobs.create_dataset(name="test-dataset", description="A test dataset")
9+
10+
# When recording the requests, we need to wait for the dataset to be queryable.
11+
if os.environ.get("RECORD_REQUESTS"):
12+
import time
13+
14+
time.sleep(1)
15+
16+
yield ds
17+
18+
llmobs._delete_dataset(dataset_id=ds._id)
19+
20+
21+
def test_dataset_create_delete(llmobs):
22+
dataset = llmobs.create_dataset(name="test-dataset-2", description="A second test dataset")
23+
assert dataset._id is not None
24+
llmobs._delete_dataset(dataset_id=dataset._id)
25+
26+
27+
def test_dataset_pull_non_existent(llmobs):
28+
with pytest.raises(ValueError):
29+
llmobs.pull_dataset(name="test-dataset-non-existent")
30+
31+
32+
def test_dataset_pull(llmobs, test_dataset):
33+
dataset = llmobs.pull_dataset(name=test_dataset.name)
34+
assert dataset._id is not None

0 commit comments

Comments
 (0)