857 lines
35 KiB
Python
857 lines
35 KiB
Python
|
|
"""
|
||
|
|
Implements logging integration with Datadog's LLM Observability Service
|
||
|
|
|
||
|
|
|
||
|
|
API Reference: https://docs.datadoghq.com/llm_observability/setup/api/?tab=example#api-standards
|
||
|
|
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from litellm._uuid import uuid
|
||
|
|
from datetime import datetime
|
||
|
|
from typing import Any, Dict, List, Literal, Optional, Union
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
import litellm
|
||
|
|
from litellm._logging import verbose_logger
|
||
|
|
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||
|
|
from litellm.integrations.datadog.datadog_mock_client import (
|
||
|
|
should_use_datadog_mock,
|
||
|
|
create_mock_datadog_client,
|
||
|
|
)
|
||
|
|
from litellm.integrations.datadog.datadog_handler import (
|
||
|
|
get_datadog_service,
|
||
|
|
get_datadog_tags,
|
||
|
|
get_datadog_base_url_from_env,
|
||
|
|
)
|
||
|
|
from litellm.litellm_core_utils.dd_tracing import tracer
|
||
|
|
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||
|
|
handle_any_messages_to_chat_completion_str_messages_conversion,
|
||
|
|
)
|
||
|
|
from litellm.llms.custom_httpx.http_handler import (
|
||
|
|
get_async_httpx_client,
|
||
|
|
httpxSpecialProvider,
|
||
|
|
)
|
||
|
|
from litellm.types.integrations.datadog_llm_obs import *
|
||
|
|
from litellm.types.utils import (
|
||
|
|
CallTypes,
|
||
|
|
StandardLoggingGuardrailInformation,
|
||
|
|
StandardLoggingPayload,
|
||
|
|
StandardLoggingPayloadErrorInformation,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class DataDogLLMObsLogger(CustomBatchLogger):
|
||
|
|
def __init__(self, **kwargs):
|
||
|
|
try:
|
||
|
|
verbose_logger.debug("DataDogLLMObs: Initializing logger")
|
||
|
|
|
||
|
|
self.is_mock_mode = should_use_datadog_mock()
|
||
|
|
|
||
|
|
if self.is_mock_mode:
|
||
|
|
create_mock_datadog_client()
|
||
|
|
verbose_logger.debug(
|
||
|
|
"[DATADOG MOCK] DataDogLLMObs logger initialized in mock mode"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Configure DataDog endpoint (Agent or Direct API)
|
||
|
|
# Use LITELLM_DD_AGENT_HOST to avoid conflicts with ddtrace's DD_AGENT_HOST
|
||
|
|
# Check for agent mode FIRST - agent mode doesn't require DD_API_KEY or DD_SITE
|
||
|
|
dd_agent_host = os.getenv("LITELLM_DD_AGENT_HOST")
|
||
|
|
|
||
|
|
self.async_client = get_async_httpx_client(
|
||
|
|
llm_provider=httpxSpecialProvider.LoggingCallback
|
||
|
|
)
|
||
|
|
self.DD_API_KEY = os.getenv("DD_API_KEY")
|
||
|
|
|
||
|
|
if dd_agent_host:
|
||
|
|
self._configure_dd_agent(dd_agent_host=dd_agent_host)
|
||
|
|
else:
|
||
|
|
# Only require DD_API_KEY and DD_SITE for direct API mode
|
||
|
|
if os.getenv("DD_API_KEY", None) is None:
|
||
|
|
raise Exception("DD_API_KEY is not set, set 'DD_API_KEY=<>'")
|
||
|
|
if os.getenv("DD_SITE", None) is None:
|
||
|
|
raise Exception(
|
||
|
|
"DD_SITE is not set, set 'DD_SITE=<>', example sit = `us5.datadoghq.com`"
|
||
|
|
)
|
||
|
|
self._configure_dd_direct_api()
|
||
|
|
|
||
|
|
# Optional override for testing
|
||
|
|
dd_base_url = get_datadog_base_url_from_env()
|
||
|
|
if dd_base_url:
|
||
|
|
self.intake_url = f"{dd_base_url}/api/intake/llm-obs/v1/trace/spans"
|
||
|
|
|
||
|
|
asyncio.create_task(self.periodic_flush())
|
||
|
|
self.flush_lock = asyncio.Lock()
|
||
|
|
self.log_queue: List[LLMObsPayload] = []
|
||
|
|
|
||
|
|
#########################################################
|
||
|
|
# Handle datadog_llm_observability_params set as litellm.datadog_llm_observability_params
|
||
|
|
#########################################################
|
||
|
|
dict_datadog_llm_obs_params = self._get_datadog_llm_obs_params()
|
||
|
|
kwargs.update(dict_datadog_llm_obs_params)
|
||
|
|
CustomBatchLogger.__init__(self, **kwargs, flush_lock=self.flush_lock)
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(f"DataDogLLMObs: Error initializing - {str(e)}")
|
||
|
|
raise e
|
||
|
|
|
||
|
|
def _configure_dd_agent(self, dd_agent_host: str):
|
||
|
|
"""
|
||
|
|
Configure the Datadog logger to send traces to the Agent.
|
||
|
|
"""
|
||
|
|
# When using the Agent, LLM Observability Intake does NOT require the API Key
|
||
|
|
# Reference: https://docs.datadoghq.com/llm_observability/setup/sdk/#agent-setup
|
||
|
|
|
||
|
|
# Use specific port for LLM Obs (Trace Agent) to avoid conflict with Logs Agent (10518)
|
||
|
|
agent_port = os.getenv("LITELLM_DD_LLM_OBS_PORT", "8126")
|
||
|
|
self.DD_SITE = "localhost" # Not used for URL construction in agent mode
|
||
|
|
self.intake_url = (
|
||
|
|
f"http://{dd_agent_host}:{agent_port}/api/intake/llm-obs/v1/trace/spans"
|
||
|
|
)
|
||
|
|
verbose_logger.debug(f"DataDogLLMObs: Using DD Agent at {self.intake_url}")
|
||
|
|
|
||
|
|
def _configure_dd_direct_api(self):
|
||
|
|
"""
|
||
|
|
Configure the Datadog logger to send traces directly to the Datadog API.
|
||
|
|
"""
|
||
|
|
if not self.DD_API_KEY:
|
||
|
|
raise Exception("DD_API_KEY is not set, set 'DD_API_KEY=<>'")
|
||
|
|
|
||
|
|
self.DD_SITE = os.getenv("DD_SITE")
|
||
|
|
if not self.DD_SITE:
|
||
|
|
raise Exception(
|
||
|
|
"DD_SITE is not set, set 'DD_SITE=<>', example site = `us5.datadoghq.com`"
|
||
|
|
)
|
||
|
|
|
||
|
|
self.intake_url = (
|
||
|
|
f"https://api.{self.DD_SITE}/api/intake/llm-obs/v1/trace/spans"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _get_datadog_llm_obs_params(self) -> Dict:
|
||
|
|
"""
|
||
|
|
Get the datadog_llm_observability_params from litellm.datadog_llm_observability_params
|
||
|
|
|
||
|
|
These are params specific to initializing the DataDogLLMObsLogger e.g. turn_off_message_logging
|
||
|
|
"""
|
||
|
|
dict_datadog_llm_obs_params: Dict = {}
|
||
|
|
if litellm.datadog_llm_observability_params is not None:
|
||
|
|
if isinstance(
|
||
|
|
litellm.datadog_llm_observability_params, DatadogLLMObsInitParams
|
||
|
|
):
|
||
|
|
dict_datadog_llm_obs_params = (
|
||
|
|
litellm.datadog_llm_observability_params.model_dump()
|
||
|
|
)
|
||
|
|
elif isinstance(litellm.datadog_llm_observability_params, Dict):
|
||
|
|
# only allow params that are of DatadogLLMObsInitParams
|
||
|
|
dict_datadog_llm_obs_params = DatadogLLMObsInitParams(
|
||
|
|
**litellm.datadog_llm_observability_params
|
||
|
|
).model_dump()
|
||
|
|
return dict_datadog_llm_obs_params
|
||
|
|
|
||
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||
|
|
try:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Logging success event for model {kwargs.get('model', 'unknown')}"
|
||
|
|
)
|
||
|
|
payload = self.create_llm_obs_payload(kwargs, start_time, end_time)
|
||
|
|
verbose_logger.debug(f"DataDogLLMObs: Payload: {payload}")
|
||
|
|
self.log_queue.append(payload)
|
||
|
|
|
||
|
|
if len(self.log_queue) >= self.batch_size:
|
||
|
|
await self.async_send_batch()
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(
|
||
|
|
f"DataDogLLMObs: Error logging success event - {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||
|
|
try:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Logging failure event for model {kwargs.get('model', 'unknown')}"
|
||
|
|
)
|
||
|
|
payload = self.create_llm_obs_payload(kwargs, start_time, end_time)
|
||
|
|
verbose_logger.debug(f"DataDogLLMObs: Payload: {payload}")
|
||
|
|
self.log_queue.append(payload)
|
||
|
|
|
||
|
|
if len(self.log_queue) >= self.batch_size:
|
||
|
|
await self.async_send_batch()
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(
|
||
|
|
f"DataDogLLMObs: Error logging failure event - {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
async def async_send_batch(self):
|
||
|
|
try:
|
||
|
|
if not self.log_queue:
|
||
|
|
return
|
||
|
|
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Flushing {len(self.log_queue)} events"
|
||
|
|
)
|
||
|
|
|
||
|
|
if self.is_mock_mode:
|
||
|
|
verbose_logger.debug(
|
||
|
|
"[DATADOG MOCK] Mock mode enabled - API calls will be intercepted"
|
||
|
|
)
|
||
|
|
|
||
|
|
# Prepare the payload
|
||
|
|
payload = {
|
||
|
|
"data": DDIntakePayload(
|
||
|
|
type="span",
|
||
|
|
attributes=DDSpanAttributes(
|
||
|
|
ml_app=get_datadog_service(),
|
||
|
|
tags=[get_datadog_tags()],
|
||
|
|
spans=self.log_queue,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
# serialize datetime objects - for budget reset time in spend metrics
|
||
|
|
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||
|
|
|
||
|
|
try:
|
||
|
|
verbose_logger.debug("payload %s", safe_dumps(payload))
|
||
|
|
except Exception as debug_error:
|
||
|
|
verbose_logger.debug(
|
||
|
|
"payload serialization failed: %s", str(debug_error)
|
||
|
|
)
|
||
|
|
|
||
|
|
json_payload = safe_dumps(payload)
|
||
|
|
|
||
|
|
headers = {"Content-Type": "application/json"}
|
||
|
|
if self.DD_API_KEY:
|
||
|
|
headers["DD-API-KEY"] = self.DD_API_KEY
|
||
|
|
|
||
|
|
response = await self.async_client.post(
|
||
|
|
url=self.intake_url,
|
||
|
|
content=json_payload,
|
||
|
|
headers=headers,
|
||
|
|
)
|
||
|
|
|
||
|
|
if response.status_code != 202:
|
||
|
|
raise Exception(
|
||
|
|
f"DataDogLLMObs: Unexpected response - status_code: {response.status_code}, text: {response.text}"
|
||
|
|
)
|
||
|
|
|
||
|
|
if self.is_mock_mode:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"[DATADOG MOCK] Batch of {len(self.log_queue)} events successfully mocked"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Successfully sent batch - status_code: {response.status_code}"
|
||
|
|
)
|
||
|
|
self.log_queue.clear()
|
||
|
|
except httpx.HTTPStatusError as e:
|
||
|
|
verbose_logger.exception(
|
||
|
|
f"DataDogLLMObs: Error sending batch - {e.response.text}"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.exception(f"DataDogLLMObs: Error sending batch - {str(e)}")
|
||
|
|
|
||
|
|
def create_llm_obs_payload(
|
||
|
|
self, kwargs: Dict, start_time: datetime, end_time: datetime
|
||
|
|
) -> LLMObsPayload:
|
||
|
|
standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get(
|
||
|
|
"standard_logging_object"
|
||
|
|
)
|
||
|
|
if standard_logging_payload is None:
|
||
|
|
raise Exception("DataDogLLMObs: standard_logging_object is not set")
|
||
|
|
|
||
|
|
messages = standard_logging_payload["messages"]
|
||
|
|
messages = self._ensure_string_content(messages=messages)
|
||
|
|
|
||
|
|
metadata = kwargs.get("litellm_params", {}).get("metadata", {})
|
||
|
|
|
||
|
|
input_meta = InputMeta(
|
||
|
|
messages=handle_any_messages_to_chat_completion_str_messages_conversion(
|
||
|
|
messages
|
||
|
|
)
|
||
|
|
)
|
||
|
|
output_meta = OutputMeta(
|
||
|
|
messages=self._get_response_messages(
|
||
|
|
standard_logging_payload=standard_logging_payload,
|
||
|
|
call_type=standard_logging_payload.get("call_type"),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
error_info = self._assemble_error_info(standard_logging_payload)
|
||
|
|
|
||
|
|
metadata_parent_id: Optional[str] = None
|
||
|
|
if isinstance(metadata, dict):
|
||
|
|
metadata_parent_id = metadata.get("parent_id")
|
||
|
|
|
||
|
|
meta = Meta(
|
||
|
|
kind=self._get_datadog_span_kind(
|
||
|
|
standard_logging_payload.get("call_type"), metadata_parent_id
|
||
|
|
),
|
||
|
|
input=input_meta,
|
||
|
|
output=output_meta,
|
||
|
|
metadata=self._get_dd_llm_obs_payload_metadata(standard_logging_payload),
|
||
|
|
error=error_info,
|
||
|
|
)
|
||
|
|
|
||
|
|
# Calculate metrics (you may need to adjust these based on available data)
|
||
|
|
metrics = LLMMetrics(
|
||
|
|
input_tokens=float(standard_logging_payload.get("prompt_tokens", 0)),
|
||
|
|
output_tokens=float(standard_logging_payload.get("completion_tokens", 0)),
|
||
|
|
total_tokens=float(standard_logging_payload.get("total_tokens", 0)),
|
||
|
|
total_cost=float(standard_logging_payload.get("response_cost", 0)),
|
||
|
|
time_to_first_token=self._get_time_to_first_token_seconds(
|
||
|
|
standard_logging_payload
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
payload: LLMObsPayload = LLMObsPayload(
|
||
|
|
parent_id=metadata_parent_id if metadata_parent_id else "undefined",
|
||
|
|
trace_id=standard_logging_payload.get("trace_id", str(uuid.uuid4())),
|
||
|
|
span_id=metadata.get("span_id", str(uuid.uuid4())),
|
||
|
|
name=metadata.get("name", "litellm_llm_call"),
|
||
|
|
meta=meta,
|
||
|
|
start_ns=int(start_time.timestamp() * 1e9),
|
||
|
|
duration=int((end_time - start_time).total_seconds() * 1e9),
|
||
|
|
metrics=metrics,
|
||
|
|
status="error" if error_info else "ok",
|
||
|
|
tags=[get_datadog_tags(standard_logging_object=standard_logging_payload)],
|
||
|
|
)
|
||
|
|
|
||
|
|
apm_trace_id = self._get_apm_trace_id()
|
||
|
|
if apm_trace_id is not None:
|
||
|
|
payload["apm_id"] = apm_trace_id
|
||
|
|
|
||
|
|
return payload
|
||
|
|
|
||
|
|
def _get_apm_trace_id(self) -> Optional[str]:
|
||
|
|
"""Retrieve the current APM trace ID if available."""
|
||
|
|
try:
|
||
|
|
current_span_fn = getattr(tracer, "current_span", None)
|
||
|
|
if callable(current_span_fn):
|
||
|
|
current_span = current_span_fn()
|
||
|
|
if current_span is not None:
|
||
|
|
trace_id = getattr(current_span, "trace_id", None)
|
||
|
|
if trace_id is not None:
|
||
|
|
return str(trace_id)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _assemble_error_info(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> Optional[DDLLMObsError]:
|
||
|
|
"""
|
||
|
|
Assemble error information for failure cases according to DD LLM Obs API spec
|
||
|
|
"""
|
||
|
|
# Handle error information for failure cases according to DD LLM Obs API spec
|
||
|
|
error_info: Optional[DDLLMObsError] = None
|
||
|
|
|
||
|
|
if standard_logging_payload.get("status") == "failure":
|
||
|
|
# Try to get structured error information first
|
||
|
|
error_information: Optional[
|
||
|
|
StandardLoggingPayloadErrorInformation
|
||
|
|
] = standard_logging_payload.get("error_information")
|
||
|
|
|
||
|
|
if error_information:
|
||
|
|
error_info = DDLLMObsError(
|
||
|
|
message=error_information.get("error_message")
|
||
|
|
or standard_logging_payload.get("error_str")
|
||
|
|
or "Unknown error",
|
||
|
|
type=error_information.get("error_class"),
|
||
|
|
stack=error_information.get("traceback"),
|
||
|
|
)
|
||
|
|
return error_info
|
||
|
|
|
||
|
|
def _get_time_to_first_token_seconds(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> float:
|
||
|
|
"""
|
||
|
|
Get the time to first token in seconds
|
||
|
|
|
||
|
|
CompletionStartTime - StartTime = Time to first token
|
||
|
|
|
||
|
|
For non streaming calls, CompletionStartTime is time we get the response back
|
||
|
|
"""
|
||
|
|
start_time: Optional[float] = standard_logging_payload.get("startTime")
|
||
|
|
completion_start_time: Optional[float] = standard_logging_payload.get(
|
||
|
|
"completionStartTime"
|
||
|
|
)
|
||
|
|
end_time: Optional[float] = standard_logging_payload.get("endTime")
|
||
|
|
|
||
|
|
if completion_start_time is not None and start_time is not None:
|
||
|
|
return completion_start_time - start_time
|
||
|
|
elif end_time is not None and start_time is not None:
|
||
|
|
return end_time - start_time
|
||
|
|
else:
|
||
|
|
return 0.0
|
||
|
|
|
||
|
|
def _get_response_messages(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload, call_type: Optional[str]
|
||
|
|
) -> List[Any]:
|
||
|
|
"""
|
||
|
|
Get the messages from the response object
|
||
|
|
|
||
|
|
for now this handles logging /chat/completions responses
|
||
|
|
"""
|
||
|
|
|
||
|
|
response_obj = standard_logging_payload.get("response")
|
||
|
|
if response_obj is None:
|
||
|
|
return []
|
||
|
|
|
||
|
|
# edge case: handle response_obj is a string representation of a dict
|
||
|
|
if isinstance(response_obj, str):
|
||
|
|
try:
|
||
|
|
import ast
|
||
|
|
|
||
|
|
response_obj = ast.literal_eval(response_obj)
|
||
|
|
except (ValueError, SyntaxError):
|
||
|
|
try:
|
||
|
|
# fallback to json parsing
|
||
|
|
response_obj = json.loads(str(response_obj))
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return []
|
||
|
|
|
||
|
|
if call_type in [
|
||
|
|
CallTypes.completion.value,
|
||
|
|
CallTypes.acompletion.value,
|
||
|
|
CallTypes.text_completion.value,
|
||
|
|
CallTypes.atext_completion.value,
|
||
|
|
CallTypes.generate_content.value,
|
||
|
|
CallTypes.agenerate_content.value,
|
||
|
|
CallTypes.generate_content_stream.value,
|
||
|
|
CallTypes.agenerate_content_stream.value,
|
||
|
|
CallTypes.anthropic_messages.value,
|
||
|
|
]:
|
||
|
|
try:
|
||
|
|
# Safely extract message from response_obj, handle failure cases
|
||
|
|
if isinstance(response_obj, dict) and "choices" in response_obj:
|
||
|
|
choices = response_obj["choices"]
|
||
|
|
if choices and len(choices) > 0 and "message" in choices[0]:
|
||
|
|
return [choices[0]["message"]]
|
||
|
|
return []
|
||
|
|
except (KeyError, IndexError, TypeError):
|
||
|
|
# In case of any error accessing the response structure, return empty list
|
||
|
|
return []
|
||
|
|
return []
|
||
|
|
|
||
|
|
def _get_datadog_span_kind(
|
||
|
|
self, call_type: Optional[str], parent_id: Optional[str] = None
|
||
|
|
) -> Literal["llm", "tool", "task", "embedding", "retrieval"]:
|
||
|
|
"""
|
||
|
|
Map liteLLM call_type to appropriate DataDog LLM Observability span kind.
|
||
|
|
|
||
|
|
Available DataDog span kinds: "llm", "tool", "task", "embedding", "retrieval"
|
||
|
|
see: https://docs.datadoghq.com/ja/llm_observability/terms/
|
||
|
|
"""
|
||
|
|
# Non llm/workflow/agent kinds cannot be root spans, so fallback to "llm" when parent metadata is missing
|
||
|
|
if call_type is None or parent_id is None:
|
||
|
|
return "llm"
|
||
|
|
|
||
|
|
# Embedding operations
|
||
|
|
if call_type in [CallTypes.embedding.value, CallTypes.aembedding.value]:
|
||
|
|
return "embedding"
|
||
|
|
|
||
|
|
# LLM completion operations
|
||
|
|
if call_type in [
|
||
|
|
CallTypes.completion.value,
|
||
|
|
CallTypes.acompletion.value,
|
||
|
|
CallTypes.text_completion.value,
|
||
|
|
CallTypes.atext_completion.value,
|
||
|
|
CallTypes.generate_content.value,
|
||
|
|
CallTypes.agenerate_content.value,
|
||
|
|
CallTypes.generate_content_stream.value,
|
||
|
|
CallTypes.agenerate_content_stream.value,
|
||
|
|
CallTypes.anthropic_messages.value,
|
||
|
|
CallTypes.responses.value,
|
||
|
|
CallTypes.aresponses.value,
|
||
|
|
]:
|
||
|
|
return "llm"
|
||
|
|
|
||
|
|
# Tool operations
|
||
|
|
if call_type in [CallTypes.call_mcp_tool.value]:
|
||
|
|
return "tool"
|
||
|
|
|
||
|
|
# Retrieval operations
|
||
|
|
if call_type in [
|
||
|
|
CallTypes.get_assistants.value,
|
||
|
|
CallTypes.aget_assistants.value,
|
||
|
|
CallTypes.get_thread.value,
|
||
|
|
CallTypes.aget_thread.value,
|
||
|
|
CallTypes.get_messages.value,
|
||
|
|
CallTypes.aget_messages.value,
|
||
|
|
CallTypes.afile_retrieve.value,
|
||
|
|
CallTypes.file_retrieve.value,
|
||
|
|
CallTypes.afile_list.value,
|
||
|
|
CallTypes.file_list.value,
|
||
|
|
CallTypes.afile_content.value,
|
||
|
|
CallTypes.file_content.value,
|
||
|
|
CallTypes.retrieve_batch.value,
|
||
|
|
CallTypes.aretrieve_batch.value,
|
||
|
|
CallTypes.retrieve_fine_tuning_job.value,
|
||
|
|
CallTypes.aretrieve_fine_tuning_job.value,
|
||
|
|
CallTypes.alist_input_items.value,
|
||
|
|
]:
|
||
|
|
return "retrieval"
|
||
|
|
|
||
|
|
# Task operations (batch, fine-tuning, file operations, etc.)
|
||
|
|
if call_type in [
|
||
|
|
CallTypes.create_batch.value,
|
||
|
|
CallTypes.acreate_batch.value,
|
||
|
|
CallTypes.create_fine_tuning_job.value,
|
||
|
|
CallTypes.acreate_fine_tuning_job.value,
|
||
|
|
CallTypes.cancel_fine_tuning_job.value,
|
||
|
|
CallTypes.acancel_fine_tuning_job.value,
|
||
|
|
CallTypes.list_fine_tuning_jobs.value,
|
||
|
|
CallTypes.alist_fine_tuning_jobs.value,
|
||
|
|
CallTypes.create_assistants.value,
|
||
|
|
CallTypes.acreate_assistants.value,
|
||
|
|
CallTypes.delete_assistant.value,
|
||
|
|
CallTypes.adelete_assistant.value,
|
||
|
|
CallTypes.create_thread.value,
|
||
|
|
CallTypes.acreate_thread.value,
|
||
|
|
CallTypes.add_message.value,
|
||
|
|
CallTypes.a_add_message.value,
|
||
|
|
CallTypes.run_thread.value,
|
||
|
|
CallTypes.arun_thread.value,
|
||
|
|
CallTypes.run_thread_stream.value,
|
||
|
|
CallTypes.arun_thread_stream.value,
|
||
|
|
CallTypes.file_delete.value,
|
||
|
|
CallTypes.afile_delete.value,
|
||
|
|
CallTypes.create_file.value,
|
||
|
|
CallTypes.acreate_file.value,
|
||
|
|
CallTypes.image_generation.value,
|
||
|
|
CallTypes.aimage_generation.value,
|
||
|
|
CallTypes.image_edit.value,
|
||
|
|
CallTypes.aimage_edit.value,
|
||
|
|
CallTypes.moderation.value,
|
||
|
|
CallTypes.amoderation.value,
|
||
|
|
CallTypes.transcription.value,
|
||
|
|
CallTypes.atranscription.value,
|
||
|
|
CallTypes.speech.value,
|
||
|
|
CallTypes.aspeech.value,
|
||
|
|
CallTypes.rerank.value,
|
||
|
|
CallTypes.arerank.value,
|
||
|
|
]:
|
||
|
|
return "task"
|
||
|
|
|
||
|
|
# Default fallback for unknown or passthrough operations
|
||
|
|
return "llm"
|
||
|
|
|
||
|
|
def _ensure_string_content(
|
||
|
|
self, messages: Optional[Union[str, List[Any], Dict[Any, Any]]]
|
||
|
|
) -> List[Any]:
|
||
|
|
if messages is None:
|
||
|
|
return []
|
||
|
|
if isinstance(messages, str):
|
||
|
|
return [messages]
|
||
|
|
elif isinstance(messages, list):
|
||
|
|
return [message for message in messages]
|
||
|
|
elif isinstance(messages, dict):
|
||
|
|
return [str(messages.get("content", ""))]
|
||
|
|
return []
|
||
|
|
|
||
|
|
def _get_dd_llm_obs_payload_metadata(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""
|
||
|
|
Fields to track in DD LLM Observability metadata from litellm standard logging payload
|
||
|
|
"""
|
||
|
|
_metadata: Dict[str, Any] = {
|
||
|
|
"model_name": standard_logging_payload.get("model", "unknown"),
|
||
|
|
"model_provider": standard_logging_payload.get(
|
||
|
|
"custom_llm_provider", "unknown"
|
||
|
|
),
|
||
|
|
"id": standard_logging_payload.get("id", "unknown"),
|
||
|
|
"trace_id": standard_logging_payload.get("trace_id", "unknown"),
|
||
|
|
"cache_hit": standard_logging_payload.get("cache_hit", "unknown"),
|
||
|
|
"cache_key": standard_logging_payload.get("cache_key", "unknown"),
|
||
|
|
"saved_cache_cost": standard_logging_payload.get("saved_cache_cost", 0),
|
||
|
|
"guardrail_information": standard_logging_payload.get(
|
||
|
|
"guardrail_information", None
|
||
|
|
),
|
||
|
|
"is_streamed_request": self._get_stream_value_from_payload(
|
||
|
|
standard_logging_payload
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
#########################################################
|
||
|
|
# Add latency metrics to metadata
|
||
|
|
#########################################################
|
||
|
|
latency_metrics = self._get_latency_metrics(standard_logging_payload)
|
||
|
|
_metadata.update({"latency_metrics": dict(latency_metrics)})
|
||
|
|
|
||
|
|
#########################################################
|
||
|
|
# Add spend metrics to metadata
|
||
|
|
#########################################################
|
||
|
|
spend_metrics = self._get_spend_metrics(standard_logging_payload)
|
||
|
|
_metadata.update({"spend_metrics": dict(spend_metrics)})
|
||
|
|
|
||
|
|
## extract tool calls and add to metadata
|
||
|
|
tool_call_metadata = self._extract_tool_call_metadata(standard_logging_payload)
|
||
|
|
_metadata.update(tool_call_metadata)
|
||
|
|
|
||
|
|
_standard_logging_metadata: dict = (
|
||
|
|
dict(standard_logging_payload.get("metadata", {})) or {}
|
||
|
|
)
|
||
|
|
_metadata.update(_standard_logging_metadata)
|
||
|
|
return _metadata
|
||
|
|
|
||
|
|
def _get_latency_metrics(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> DDLLMObsLatencyMetrics:
|
||
|
|
"""
|
||
|
|
Get the latency metrics from the standard logging payload
|
||
|
|
"""
|
||
|
|
latency_metrics: DDLLMObsLatencyMetrics = DDLLMObsLatencyMetrics()
|
||
|
|
# Add latency metrics to metadata
|
||
|
|
# Time to first token (convert from seconds to milliseconds for consistency)
|
||
|
|
time_to_first_token_seconds = self._get_time_to_first_token_seconds(
|
||
|
|
standard_logging_payload
|
||
|
|
)
|
||
|
|
if time_to_first_token_seconds > 0:
|
||
|
|
latency_metrics["time_to_first_token_ms"] = (
|
||
|
|
time_to_first_token_seconds * 1000
|
||
|
|
)
|
||
|
|
|
||
|
|
# LiteLLM overhead time
|
||
|
|
hidden_params = standard_logging_payload.get("hidden_params", {})
|
||
|
|
litellm_overhead_ms = hidden_params.get("litellm_overhead_time_ms")
|
||
|
|
if litellm_overhead_ms is not None:
|
||
|
|
latency_metrics["litellm_overhead_time_ms"] = litellm_overhead_ms
|
||
|
|
|
||
|
|
# Guardrail overhead latency
|
||
|
|
guardrail_info: Optional[
|
||
|
|
list[StandardLoggingGuardrailInformation]
|
||
|
|
] = standard_logging_payload.get("guardrail_information")
|
||
|
|
if guardrail_info is not None:
|
||
|
|
total_duration = 0.0
|
||
|
|
for info in guardrail_info:
|
||
|
|
_guardrail_duration_seconds: Optional[float] = info.get("duration")
|
||
|
|
if _guardrail_duration_seconds is not None:
|
||
|
|
total_duration += float(_guardrail_duration_seconds)
|
||
|
|
|
||
|
|
if total_duration > 0:
|
||
|
|
# Convert from seconds to milliseconds for consistency
|
||
|
|
latency_metrics["guardrail_overhead_time_ms"] = total_duration * 1000
|
||
|
|
|
||
|
|
return latency_metrics
|
||
|
|
|
||
|
|
def _get_stream_value_from_payload(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> bool:
|
||
|
|
"""
|
||
|
|
Extract the stream value from standard logging payload.
|
||
|
|
|
||
|
|
The stream field in StandardLoggingPayload is only set to True for completed streaming responses.
|
||
|
|
For non-streaming requests, it's None. The original stream parameter is in model_parameters.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
bool: True if this was a streaming request, False otherwise
|
||
|
|
"""
|
||
|
|
# Check top-level stream field first (only True for completed streaming)
|
||
|
|
stream_value = standard_logging_payload.get("stream")
|
||
|
|
if stream_value is True:
|
||
|
|
return True
|
||
|
|
|
||
|
|
# Fallback to model_parameters.stream for original request parameters
|
||
|
|
model_params = standard_logging_payload.get("model_parameters", {})
|
||
|
|
if isinstance(model_params, dict):
|
||
|
|
stream_value = model_params.get("stream")
|
||
|
|
if stream_value is True:
|
||
|
|
return True
|
||
|
|
|
||
|
|
# Default to False for non-streaming requests
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _get_spend_metrics(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> DDLLMObsSpendMetrics:
|
||
|
|
"""
|
||
|
|
Get the spend metrics from the standard logging payload
|
||
|
|
"""
|
||
|
|
spend_metrics: DDLLMObsSpendMetrics = DDLLMObsSpendMetrics()
|
||
|
|
|
||
|
|
# send response cost
|
||
|
|
spend_metrics["response_cost"] = standard_logging_payload.get(
|
||
|
|
"response_cost", 0.0
|
||
|
|
)
|
||
|
|
|
||
|
|
# Get budget information from metadata
|
||
|
|
metadata = standard_logging_payload.get("metadata", {})
|
||
|
|
|
||
|
|
# API key max budget
|
||
|
|
user_api_key_max_budget = metadata.get("user_api_key_max_budget")
|
||
|
|
if user_api_key_max_budget is not None:
|
||
|
|
spend_metrics["user_api_key_max_budget"] = float(user_api_key_max_budget)
|
||
|
|
|
||
|
|
# API key spend
|
||
|
|
user_api_key_spend = metadata.get("user_api_key_spend")
|
||
|
|
if user_api_key_spend is not None:
|
||
|
|
try:
|
||
|
|
spend_metrics["user_api_key_spend"] = float(user_api_key_spend)
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"Invalid user_api_key_spend value: {user_api_key_spend}"
|
||
|
|
)
|
||
|
|
|
||
|
|
# API key budget reset datetime
|
||
|
|
user_api_key_budget_reset_at = metadata.get("user_api_key_budget_reset_at")
|
||
|
|
if user_api_key_budget_reset_at is not None:
|
||
|
|
try:
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
budget_reset_at = None
|
||
|
|
if isinstance(user_api_key_budget_reset_at, str):
|
||
|
|
# Handle ISO format strings that might have 'Z' suffix
|
||
|
|
iso_string = user_api_key_budget_reset_at.replace("Z", "+00:00")
|
||
|
|
budget_reset_at = datetime.fromisoformat(iso_string)
|
||
|
|
elif isinstance(user_api_key_budget_reset_at, datetime):
|
||
|
|
budget_reset_at = user_api_key_budget_reset_at
|
||
|
|
|
||
|
|
if budget_reset_at is not None:
|
||
|
|
# Preserve timezone info if already present
|
||
|
|
if budget_reset_at.tzinfo is None:
|
||
|
|
budget_reset_at = budget_reset_at.replace(tzinfo=timezone.utc)
|
||
|
|
|
||
|
|
# Convert to ISO string format for JSON serialization
|
||
|
|
# This prevents circular reference issues and ensures proper timezone representation
|
||
|
|
iso_string = budget_reset_at.isoformat()
|
||
|
|
spend_metrics["user_api_key_budget_reset_at"] = iso_string
|
||
|
|
|
||
|
|
# Debug logging to verify the conversion
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"Converted budget_reset_at to ISO format: {iso_string}"
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.debug(f"Error processing budget reset datetime: {e}")
|
||
|
|
verbose_logger.debug(f"Original value: {user_api_key_budget_reset_at}")
|
||
|
|
|
||
|
|
return spend_metrics
|
||
|
|
|
||
|
|
def _process_input_messages_preserving_tool_calls(
|
||
|
|
self, messages: List[Any]
|
||
|
|
) -> List[Dict[str, Any]]:
|
||
|
|
"""
|
||
|
|
Process input messages while preserving tool_calls and tool message types.
|
||
|
|
|
||
|
|
This bypasses the lossy string conversion when tool calls are present,
|
||
|
|
allowing complex nested tool_calls objects to be preserved for Datadog.
|
||
|
|
"""
|
||
|
|
processed = []
|
||
|
|
for msg in messages:
|
||
|
|
if isinstance(msg, dict):
|
||
|
|
# Preserve messages with tool_calls or tool role as-is
|
||
|
|
if "tool_calls" in msg or msg.get("role") == "tool":
|
||
|
|
processed.append(msg)
|
||
|
|
else:
|
||
|
|
# For regular messages, still apply string conversion
|
||
|
|
converted = (
|
||
|
|
handle_any_messages_to_chat_completion_str_messages_conversion(
|
||
|
|
[msg]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
processed.extend(converted)
|
||
|
|
else:
|
||
|
|
# For non-dict messages, apply string conversion
|
||
|
|
converted = (
|
||
|
|
handle_any_messages_to_chat_completion_str_messages_conversion(
|
||
|
|
[msg]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
processed.extend(converted)
|
||
|
|
return processed
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _tool_calls_kv_pair(tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
|
|
"""
|
||
|
|
Extract tool call information into key-value pairs for Datadog metadata.
|
||
|
|
|
||
|
|
Similar to OpenTelemetry's implementation but adapted for Datadog's format.
|
||
|
|
"""
|
||
|
|
kv_pairs: Dict[str, Any] = {}
|
||
|
|
for idx, tool_call in enumerate(tool_calls):
|
||
|
|
try:
|
||
|
|
# Extract tool call ID
|
||
|
|
tool_id = tool_call.get("id")
|
||
|
|
if tool_id:
|
||
|
|
kv_pairs[f"tool_calls.{idx}.id"] = tool_id
|
||
|
|
|
||
|
|
# Extract tool call type
|
||
|
|
tool_type = tool_call.get("type")
|
||
|
|
if tool_type:
|
||
|
|
kv_pairs[f"tool_calls.{idx}.type"] = tool_type
|
||
|
|
|
||
|
|
# Extract function information
|
||
|
|
function = tool_call.get("function")
|
||
|
|
if function:
|
||
|
|
function_name = function.get("name")
|
||
|
|
if function_name:
|
||
|
|
kv_pairs[f"tool_calls.{idx}.function.name"] = function_name
|
||
|
|
|
||
|
|
function_arguments = function.get("arguments")
|
||
|
|
if function_arguments:
|
||
|
|
# Store arguments as JSON string for Datadog
|
||
|
|
if isinstance(function_arguments, str):
|
||
|
|
kv_pairs[
|
||
|
|
f"tool_calls.{idx}.function.arguments"
|
||
|
|
] = function_arguments
|
||
|
|
else:
|
||
|
|
import json
|
||
|
|
|
||
|
|
kv_pairs[
|
||
|
|
f"tool_calls.{idx}.function.arguments"
|
||
|
|
] = json.dumps(function_arguments)
|
||
|
|
except (KeyError, TypeError, ValueError) as e:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Error processing tool call {idx}: {str(e)}"
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
|
||
|
|
return kv_pairs
|
||
|
|
|
||
|
|
def _extract_tool_call_metadata(
|
||
|
|
self, standard_logging_payload: StandardLoggingPayload
|
||
|
|
) -> Dict[str, Any]:
|
||
|
|
"""
|
||
|
|
Extract tool call information from both input messages and response for Datadog metadata.
|
||
|
|
"""
|
||
|
|
tool_call_metadata: Dict[str, Any] = {}
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Extract tool calls from input messages
|
||
|
|
messages = standard_logging_payload.get("messages", [])
|
||
|
|
if messages and isinstance(messages, list):
|
||
|
|
for message in messages:
|
||
|
|
if isinstance(message, dict) and "tool_calls" in message:
|
||
|
|
tool_calls = message.get("tool_calls")
|
||
|
|
if tool_calls:
|
||
|
|
input_tool_calls_kv = self._tool_calls_kv_pair(tool_calls)
|
||
|
|
# Prefix with "input_" to distinguish from response tool calls
|
||
|
|
for key, value in input_tool_calls_kv.items():
|
||
|
|
tool_call_metadata[f"input_{key}"] = value
|
||
|
|
|
||
|
|
# Extract tool calls from response
|
||
|
|
response_obj = standard_logging_payload.get("response")
|
||
|
|
if response_obj and isinstance(response_obj, dict):
|
||
|
|
choices = response_obj.get("choices", [])
|
||
|
|
for choice in choices:
|
||
|
|
if isinstance(choice, dict):
|
||
|
|
message = choice.get("message")
|
||
|
|
if message and isinstance(message, dict):
|
||
|
|
tool_calls = message.get("tool_calls")
|
||
|
|
if tool_calls:
|
||
|
|
response_tool_calls_kv = self._tool_calls_kv_pair(
|
||
|
|
tool_calls
|
||
|
|
)
|
||
|
|
# Prefix with "output_" to distinguish from input tool calls
|
||
|
|
for key, value in response_tool_calls_kv.items():
|
||
|
|
tool_call_metadata[f"output_{key}"] = value
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
verbose_logger.debug(
|
||
|
|
f"DataDogLLMObs: Error extracting tool call metadata: {str(e)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
return tool_call_metadata
|