11import atexit
2+ import json
23from typing import Any
34from typing import Dict
45from typing import List
56from typing import Optional
67from typing import Union
8+ from urllib .parse import quote
79
810
911# TypedDict was added to typing in python 3.8
2426from ddtrace .internal .utils .retry import fibonacci_backoff_with_jitter
2527from ddtrace .llmobs import _telemetry as telemetry
2628from ddtrace .llmobs ._constants import AGENTLESS_EVAL_BASE_URL
29+ from ddtrace .llmobs ._constants import AGENTLESS_EXP_BASE_URL
2730from ddtrace .llmobs ._constants import AGENTLESS_SPAN_BASE_URL
2831from ddtrace .llmobs ._constants import DROPPED_IO_COLLECTION_ERROR
2932from ddtrace .llmobs ._constants import DROPPED_VALUE_TEXT
3336from ddtrace .llmobs ._constants import EVP_PAYLOAD_SIZE_LIMIT
3437from ddtrace .llmobs ._constants import EVP_PROXY_AGENT_BASE_PATH
3538from ddtrace .llmobs ._constants import EVP_SUBDOMAIN_HEADER_NAME
39+ from ddtrace .llmobs ._constants import EXP_SUBDOMAIN_NAME
3640from ddtrace .llmobs ._constants import SPAN_ENDPOINT
3741from ddtrace .llmobs ._constants import SPAN_SUBDOMAIN_NAME
42+ from ddtrace .llmobs ._experiment import Dataset
43+ from ddtrace .llmobs ._experiment import DatasetRecord
44+ from ddtrace .llmobs ._experiment import JSONType
3845from ddtrace .llmobs ._utils import safe_json
3946from ddtrace .settings ._agent import config as agent_config
4047
@@ -110,6 +117,7 @@ def __init__(
110117 is_agentless : bool ,
111118 _site : str = "" ,
112119 _api_key : str = "" ,
120+ _app_key : str = "" ,
113121 _override_url : str = "" ,
114122 ) -> None :
115123 super (BaseLLMObsWriter , self ).__init__ (interval = interval )
@@ -119,6 +127,7 @@ def __init__(
119127 self ._timeout : float = timeout
120128 self ._api_key : str = _api_key or config ._dd_api_key
121129 self ._site : str = _site or config ._dd_site
130+ self ._app_key : str = _app_key
122131 self ._override_url : str = _override_url
123132
124133 self ._agentless : bool = is_agentless
@@ -264,6 +273,97 @@ def _data(self, events: List[LLMObsEvaluationMetricEvent]) -> Dict[str, Any]:
264273 return {"data" : {"type" : "evaluation_metric" , "attributes" : {"metrics" : events }}}
265274
266275
276+ class LLMObsExperimentsClient (BaseLLMObsWriter ):
277+ EVP_SUBDOMAIN_HEADER_VALUE = EXP_SUBDOMAIN_NAME
278+ AGENTLESS_BASE_URL = AGENTLESS_EXP_BASE_URL
279+ ENDPOINT = ""
280+
281+ def request (self , method : str , path : str , body : JSONType = None ) -> Response :
282+ headers = {
283+ "Content-Type" : "application/json" ,
284+ "DD-API-KEY" : self ._api_key ,
285+ "DD-APPLICATION-KEY" : self ._app_key ,
286+ }
287+ if not self ._agentless :
288+ headers [EVP_SUBDOMAIN_HEADER_NAME ] = self .EVP_SUBDOMAIN_HEADER_VALUE
289+
290+ encoded_body = json .dumps (body ).encode ("utf-8" ) if body else b""
291+ conn = get_connection (self ._intake )
292+ try :
293+ url = self ._intake + self ._endpoint + path
294+ logger .debug ("requesting %s" , url )
295+ conn .request (method , url , encoded_body , headers )
296+ resp = conn .getresponse ()
297+ return Response .from_http_response (resp )
298+ finally :
299+ conn .close ()
300+
301+ def dataset_delete (self , dataset_id : str ) -> None :
302+ path = "/api/unstable/llm-obs/v1/datasets/delete"
303+ resp = self .request (
304+ "POST" ,
305+ path ,
306+ body = {
307+ "data" : {
308+ "type" : "datasets" ,
309+ "attributes" : {
310+ "type" : "soft" ,
311+ "dataset_ids" : [dataset_id ],
312+ },
313+ },
314+ },
315+ )
316+ if resp .status != 200 :
317+ raise ValueError (f"Failed to delete dataset { id } : { resp .get_json ()} " )
318+ return None
319+
320+ def dataset_create (self , name : str , description : str ) -> Dataset :
321+ path = "/api/unstable/llm-obs/v1/datasets"
322+ body : JSONType = {
323+ "data" : {
324+ "type" : "datasets" ,
325+ "attributes" : {"name" : name , "description" : description },
326+ }
327+ }
328+ resp = self .request ("POST" , path , body )
329+ if resp .status != 200 :
330+ raise ValueError (f"Failed to create dataset { name } : { resp .status } { resp .get_json ()} " )
331+ response_data = resp .get_json ()
332+ dataset_id = response_data ["data" ]["id" ]
333+ return Dataset (name , dataset_id , [])
334+
335+ def dataset_pull (self , name : str ) -> Dataset :
336+ path = f"/api/unstable/llm-obs/v1/datasets?filter[name]={ quote (name )} "
337+ resp = self .request ("GET" , path )
338+ if resp .status != 200 :
339+ raise ValueError (f"Failed to pull dataset { name } : { resp .status } { resp .get_json ()} " )
340+
341+ response_data = resp .get_json ()
342+ data = response_data ["data" ]
343+ if not data :
344+ raise ValueError (f"Dataset '{ name } ' not found" )
345+
346+ dataset_id = data [0 ]["id" ]
347+ url = f"/api/unstable/llm-obs/v1/datasets/{ dataset_id } /records"
348+ resp = self .request ("GET" , url )
349+ if resp .status == 404 :
350+ raise ValueError (f"Dataset '{ name } ' not found" )
351+ records_data = resp .get_json ()
352+
353+ class_records : List [DatasetRecord ] = []
354+ for record in records_data .get ("data" , []):
355+ attrs = record .get ("attributes" , {})
356+ class_records .append (
357+ {
358+ "record_id" : record ["id" ],
359+ "input" : attrs ["input" ],
360+ "expected_output" : attrs ["expected_output" ],
361+ "metadata" : attrs .get ("metadata" , {}),
362+ }
363+ )
364+ return Dataset (name , dataset_id , class_records )
365+
366+
267367class LLMObsSpanWriter (BaseLLMObsWriter ):
268368 """Writes span events to the LLMObs Span Endpoint."""
269369
0 commit comments