Files
litellm/tests/test_litellm/integrations/datadog/test_datadog_llm_observability.py
T

1123 lines
44 KiB
Python

import asyncio
import os
import sys
from datetime import datetime, timedelta, timezone
from typing import Optional
from unittest.mock import MagicMock, Mock, patch
import pytest
# Adds the grandparent directory to sys.path to allow importing project modules
sys.path.insert(0, os.path.abspath("../.."))
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger
from litellm.types.integrations.datadog_llm_obs import (
DatadogLLMObsInitParams,
)
from litellm.types.utils import (
StandardLoggingGuardrailInformation,
StandardLoggingHiddenParams,
StandardLoggingMetadata,
StandardLoggingModelInformation,
StandardLoggingPayload,
StandardLoggingPayloadErrorInformation,
)
def create_standard_logging_payload_with_cache() -> StandardLoggingPayload:
"""Create a real StandardLoggingPayload object for testing"""
return StandardLoggingPayload(
id="test-request-id-456",
call_type="completion",
response_cost=0.05,
response_cost_failure_debug_info=None,
status="success",
total_tokens=30,
prompt_tokens=10,
completion_tokens=20,
startTime=1234567890.0,
endTime=1234567891.0,
completionStartTime=1234567890.5,
model_map_information=StandardLoggingModelInformation(
model_map_key="gpt-4", model_map_value=None
),
model="gpt-4",
model_id="model-123",
model_group="openai-gpt",
api_base="https://api.openai.com",
metadata=StandardLoggingMetadata(
user_api_key_hash="test_hash",
user_api_key_org_id=None,
user_api_key_alias="test_alias",
user_api_key_team_id="test_team",
user_api_key_user_id="test_user",
user_api_key_team_alias="test_team_alias",
spend_logs_metadata=None,
requester_ip_address="127.0.0.1",
requester_metadata=None,
),
cache_hit=True,
cache_key="test-cache-key-789",
saved_cache_cost=0.02,
request_tags=[],
end_user=None,
requester_ip_address="127.0.0.1",
messages=[{"role": "user", "content": "Hello, world!"}],
response={"choices": [{"message": {"content": "Hi there!"}}]},
error_str=None,
model_parameters={"stream": True},
hidden_params=StandardLoggingHiddenParams(
model_id="model-123",
cache_key="test-cache-key-789",
api_base="https://api.openai.com",
response_cost="0.05",
additional_headers=None,
),
trace_id="test-trace-id-123",
custom_llm_provider="openai",
)
def create_standard_logging_payload_with_failure() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object for failure testing"""
return StandardLoggingPayload(
id="test-request-id-failure-789",
call_type="completion",
response_cost=0.0,
response_cost_failure_debug_info=None,
status="failure",
total_tokens=0,
prompt_tokens=10,
completion_tokens=0,
startTime=1234567890.0,
endTime=1234567891.0,
completionStartTime=1234567890.5,
model_map_information=StandardLoggingModelInformation(
model_map_key="gpt-4", model_map_value=None
),
model="gpt-4",
model_id="model-123",
model_group="openai-gpt",
api_base="https://api.openai.com",
metadata=StandardLoggingMetadata(
user_api_key_hash="test_hash",
user_api_key_org_id=None,
user_api_key_alias="test_alias",
user_api_key_team_id="test_team",
user_api_key_user_id="test_user",
user_api_key_team_alias="test_team_alias",
spend_logs_metadata=None,
requester_ip_address="127.0.0.1",
requester_metadata=None,
),
cache_hit=False,
cache_key=None,
saved_cache_cost=0.0,
request_tags=[],
end_user=None,
requester_ip_address="127.0.0.1",
messages=[{"role": "user", "content": "Hello, world!"}],
response=None,
error_str="RateLimitError: You exceeded your current quota",
error_information=StandardLoggingPayloadErrorInformation(
error_code="rate_limit_exceeded",
error_class="RateLimitError",
llm_provider="openai",
traceback="Traceback (most recent call last):\n File test.py, line 1\n RateLimitError: You exceeded your current quota",
error_message="RateLimitError: You exceeded your current quota",
),
model_parameters={"stream": False},
hidden_params=StandardLoggingHiddenParams(
model_id="model-123",
cache_key=None,
api_base="https://api.openai.com",
response_cost="0.0",
additional_headers=None,
),
trace_id="test-trace-id-failure-456",
custom_llm_provider="openai",
)
class TestDataDogLLMObsLogger:
"""Test suite for DataDog LLM Observability Logger"""
@pytest.fixture
def mock_env_vars(self):
"""Mock environment variables for DataDog"""
with patch.dict(
os.environ, {"DD_API_KEY": "test_api_key", "DD_SITE": "us5.datadoghq.com"}
):
yield
@pytest.fixture
def mock_response_obj(self):
"""Create a mock response object"""
mock_response = Mock()
mock_response.__getitem__ = Mock(
return_value={
"choices": [
{
"message": Mock(
json=Mock(
return_value={"role": "assistant", "content": "Hello!"}
)
)
}
]
}
)
return mock_response
def test_cost_and_trace_id_integration(self, mock_env_vars, mock_response_obj):
"""Test that total_cost is passed and trace_id from standard payload is used"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_cache()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {
"metadata": {"trace_id": "old-trace-id-should-be-ignored"}
},
}
start_time = datetime.now()
end_time = datetime.now()
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Test 1: Verify total_cost is correctly extracted from response_cost
assert payload["metrics"].get("total_cost") == 0.05
# Test 2: Verify trace_id comes from standard_logging_payload, not metadata
assert payload["trace_id"] == "test-trace-id-123"
# Test 3: Verify saved_cache_cost is in metadata
metadata = payload["meta"]["metadata"]
assert metadata["saved_cache_cost"] == 0.02
assert metadata["cache_hit"] is True
assert metadata["cache_key"] == "test-cache-key-789"
# Test 4: Verify is_streamed_request is in metadata
assert metadata["is_streamed_request"] is True
def test_cache_metadata_fields(self, mock_env_vars, mock_response_obj):
"""Test that cache-related metadata fields are correctly tracked"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_cache()
# Test the _get_dd_llm_obs_payload_metadata method directly
metadata = logger._get_dd_llm_obs_payload_metadata(standard_payload)
# Verify all cache-related fields are present
assert metadata["cache_hit"] is True
assert metadata["cache_key"] == "test-cache-key-789"
assert metadata["saved_cache_cost"] == 0.02
assert metadata["id"] == "test-request-id-456"
assert metadata["trace_id"] == "test-trace-id-123"
assert metadata["model_name"] == "gpt-4"
assert metadata["model_provider"] == "openai"
def test_get_time_to_first_token_seconds(self, mock_env_vars):
"""Test the _get_time_to_first_token_seconds method for streaming calls"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Test streaming case (completion_start_time available)
streaming_payload = create_standard_logging_payload_with_cache()
# Modify times for testing: start=1000, completion_start=1002, end=1005
streaming_payload["startTime"] = 1000.0
streaming_payload["completionStartTime"] = 1002.0
streaming_payload["endTime"] = 1005.0
# Test streaming case: should use completion_start_time - start_time
time_to_first_token = logger._get_time_to_first_token_seconds(
streaming_payload
)
assert time_to_first_token == 2.0 # 1002.0 - 1000.0 = 2.0 seconds
def test_datadog_span_kind_mapping(self, mock_env_vars):
"""Test that call_type values are correctly mapped to DataDog span kinds"""
from litellm.types.utils import CallTypes
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Test embedding operations
assert logger._get_datadog_span_kind(CallTypes.embedding.value, "123") == "embedding"
assert logger._get_datadog_span_kind(CallTypes.aembedding.value, "123") == "embedding"
# Test LLM completion operations
assert logger._get_datadog_span_kind(CallTypes.completion.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.acompletion.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.text_completion.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.generate_content.value, None) == "llm"
assert (
logger._get_datadog_span_kind(CallTypes.anthropic_messages.value, None) == "llm"
)
assert logger._get_datadog_span_kind(CallTypes.responses.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.aresponses.value, None) == "llm"
# Test tool operations
assert logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, "123") == "tool"
# Test retrieval operations
assert (
logger._get_datadog_span_kind(CallTypes.get_assistants.value, "123") == "retrieval"
)
assert (
logger._get_datadog_span_kind(CallTypes.file_retrieve.value, "123") == "retrieval"
)
assert (
logger._get_datadog_span_kind(CallTypes.retrieve_batch.value, "123") == "retrieval"
)
# Test task operations
assert logger._get_datadog_span_kind(CallTypes.create_batch.value, "123") == "task"
assert logger._get_datadog_span_kind(CallTypes.image_generation.value, "123") == "task"
assert logger._get_datadog_span_kind(CallTypes.moderation.value, "123") == "task"
assert logger._get_datadog_span_kind(CallTypes.transcription.value, "123") == "task"
# Test default fallback
assert logger._get_datadog_span_kind("unknown_call_type", None) == "llm"
assert logger._get_datadog_span_kind(None, None) == "llm"
def test_datadog_span_kind_defaults_without_parent(self, mock_env_vars):
"""Test that non-llm kinds fallback to llm when no parent span is provided"""
from litellm.types.utils import CallTypes
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Tool/task/retrieval span kinds should fallback to llm when parent_id missing
assert logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.create_batch.value, None) == "llm"
assert logger._get_datadog_span_kind(CallTypes.get_assistants.value, None) == "llm"
@pytest.mark.asyncio
async def test_async_log_failure_event(self, mock_env_vars):
"""Test that async_log_failure_event correctly processes failure payloads according to DD LLM Obs API spec"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Ensure log_queue starts empty
logger.log_queue = []
standard_failure_payload = create_standard_logging_payload_with_failure()
kwargs = {
"standard_logging_object": standard_failure_payload,
"model": "gpt-4",
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now() + timedelta(seconds=2)
# Mock async_send_batch to prevent actual network calls
with patch.object(logger, "async_send_batch") as mock_send_batch:
# Call the method under test
await logger.async_log_failure_event(kwargs, None, start_time, end_time)
# Verify payload was added to queue
assert len(logger.log_queue) == 1
# Verify the payload has correct failure characteristics according to DD LLM Obs API spec
payload = logger.log_queue[0]
assert payload["trace_id"] == "test-trace-id-failure-456"
assert (
payload["meta"]["metadata"]["id"] == "test-request-id-failure-789"
)
assert payload["status"] == "error"
# Verify error information follows DD LLM Obs API spec
assert (
payload["meta"]["error"]["message"]
== "RateLimitError: You exceeded your current quota"
)
assert payload["meta"]["error"]["type"] == "RateLimitError"
assert (
payload["meta"]["error"]["stack"]
== "Traceback (most recent call last):\n File test.py, line 1\n RateLimitError: You exceeded your current quota"
)
assert payload["metrics"]["total_cost"] == 0.0
assert payload["metrics"]["total_tokens"] == 0
assert payload["metrics"]["output_tokens"] == 0
# Verify batch sending not triggered (queue size < batch_size)
mock_send_batch.assert_not_called()
class TestDataDogLLMObsLoggerForRedaction(DataDogLLMObsLogger):
"""Test suite for DataDog LLM Observability Logger"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.logged_standard_logging_payload: Optional[StandardLoggingPayload] = None
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self.logged_standard_logging_payload = kwargs.get("standard_logging_object")
class TestS3Logger(CustomLogger):
"""Test suite for S3 Logger"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.logged_standard_logging_payload: Optional[StandardLoggingPayload] = None
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
self.logged_standard_logging_payload = kwargs.get("standard_logging_object")
@pytest.mark.asyncio
async def test_dd_llms_obs_redaction(mock_env_vars):
# init DD with turn_off_message_logging=True
litellm._turn_on_debug()
from litellm.types.utils import LiteLLMCommonStrings
litellm.datadog_llm_observability_params = DatadogLLMObsInitParams(
turn_off_message_logging=True
)
dd_llms_obs_logger = TestDataDogLLMObsLoggerForRedaction()
test_s3_logger = TestS3Logger()
litellm.callbacks = [dd_llms_obs_logger, test_s3_logger]
# call litellm
await litellm.acompletion(
model="gpt-4o",
mock_response="Hi there!",
messages=[{"role": "user", "content": "Hello, world!"}],
)
# sleep 1 second for logging to complete
await asyncio.sleep(1)
#################
# test validation
# 1. both loggers logged a standard_logging_payload
# 2. DD LLM Obs standard_logging_payload has messages and response redacted
# 3. S3 standard_logging_payload does not have messages and response redacted
assert dd_llms_obs_logger.logged_standard_logging_payload is not None
assert test_s3_logger.logged_standard_logging_payload is not None
assert (
dd_llms_obs_logger.logged_standard_logging_payload["messages"][0]["content"]
== "redacted-by-litellm"
)
assert (
dd_llms_obs_logger.logged_standard_logging_payload["response"]["choices"][0][
"message"
]["content"]
== "redacted-by-litellm"
)
assert test_s3_logger.logged_standard_logging_payload["messages"] == [
{"role": "user", "content": "Hello, world!"}
]
assert (
test_s3_logger.logged_standard_logging_payload["response"]["choices"][0][
"message"
]["content"]
== "Hi there!"
)
@pytest.fixture
def mock_env_vars():
"""Mock environment variables for DataDog"""
with patch.dict(
os.environ, {"DD_API_KEY": "test_api_key", "DD_SITE": "us5.datadoghq.com"}
):
yield
@pytest.mark.asyncio
async def test_create_llm_obs_payload(mock_env_vars):
datadog_llm_obs_logger = DataDogLLMObsLogger()
standard_logging_payload = create_standard_logging_payload_with_cache()
payload = datadog_llm_obs_logger.create_llm_obs_payload(
kwargs={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"standard_logging_object": standard_logging_payload,
},
start_time=datetime.now(),
end_time=datetime.now() + timedelta(seconds=1),
)
assert payload["name"] == "litellm_llm_call"
assert payload["meta"]["kind"] == "llm"
assert payload["meta"]["input"]["messages"] == [
{"role": "user", "content": "Hello, world!"}
]
assert payload["meta"]["output"]["messages"][0]["content"] == "Hi there!"
assert payload["metrics"]["input_tokens"] == 10
assert payload["metrics"]["output_tokens"] == 20
assert payload["metrics"]["total_tokens"] == 30
def create_standard_logging_payload_with_latency_metrics() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object with latency metrics for testing"""
guardrail_info = StandardLoggingGuardrailInformation(
guardrail_name="test_guardrail",
guardrail_status="success",
start_time=1234567890.0,
end_time=1234567890.5,
duration=0.5, # 500ms
guardrail_request={"input": "test input message", "user_id": "test_user"},
guardrail_response={
"output": "filtered output",
"flagged": False,
"score": 0.1,
},
)
hidden_params = StandardLoggingHiddenParams(
model_id="model-123",
cache_key="test-cache-key",
api_base="https://api.openai.com",
response_cost="0.05",
litellm_overhead_time_ms=150.0, # 150ms
additional_headers=None,
)
return StandardLoggingPayload(
id="test-request-id-latency",
call_type="completion",
response_cost=0.05,
response_cost_failure_debug_info=None,
status="success",
total_tokens=30,
prompt_tokens=10,
completion_tokens=20,
startTime=1234567890.0,
endTime=1234567892.0,
completionStartTime=1234567890.8, # 800ms after start
response_time=2.0,
model_map_information=StandardLoggingModelInformation(
model_map_key="gpt-4", model_map_value=None
),
model="gpt-4",
model_id="model-123",
model_group="openai-gpt",
api_base="https://api.openai.com",
metadata=StandardLoggingMetadata(
user_api_key_hash="test_hash",
user_api_key_org_id=None,
user_api_key_alias="test_alias",
user_api_key_team_id="test_team",
user_api_key_user_id="test_user",
user_api_key_team_alias="test_team_alias",
spend_logs_metadata=None,
requester_ip_address="127.0.0.1",
requester_metadata=None,
),
cache_hit=False,
cache_key=None,
saved_cache_cost=0.0,
request_tags=[],
end_user=None,
requester_ip_address="127.0.0.1",
messages=[{"role": "user", "content": "Hello, world!"}],
response={"choices": [{"message": {"content": "Hi there!"}}]},
error_str=None,
error_information=None,
model_parameters={"stream": True},
hidden_params=hidden_params,
guardrail_information=[ guardrail_info ],
trace_id="test-trace-id-latency",
custom_llm_provider="openai",
)
def test_latency_metrics_in_metadata(mock_env_vars):
"""Test that time to first token, litellm overhead, and guardrail overhead are included in metadata"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_latency_metrics()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
# Test the metadata generation directly
metadata = logger._get_dd_llm_obs_payload_metadata(standard_payload)
latency_metadata = metadata.get("latency_metrics", {})
# Verify time to first token is included (800ms)
assert "time_to_first_token_ms" in latency_metadata
assert (
abs(latency_metadata["time_to_first_token_ms"] - 800.0) < 0.001
) # 0.8 seconds * 1000 with tolerance for floating-point precision
# Verify litellm overhead is included (150ms)
assert "litellm_overhead_time_ms" in latency_metadata
assert latency_metadata["litellm_overhead_time_ms"] == 150.0
# Verify guardrail overhead is included (500ms)
assert "guardrail_overhead_time_ms" in latency_metadata
assert (
latency_metadata["guardrail_overhead_time_ms"] == 500.0
) # 0.5 seconds * 1000
# Verify these metrics are also included in the full payload
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
payload_metadata_latency = payload["meta"]["metadata"]["latency_metrics"]
assert abs(payload_metadata_latency["time_to_first_token_ms"] - 800.0) < 0.001
assert payload_metadata_latency["litellm_overhead_time_ms"] == 150.0
assert payload_metadata_latency["guardrail_overhead_time_ms"] == 500.0
def test_latency_metrics_edge_cases(mock_env_vars):
"""Test latency metrics with edge cases (missing fields, zero values, etc.)"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Test case 1: No latency metrics present
standard_payload = create_standard_logging_payload_with_cache()
metadata = logger._get_dd_llm_obs_payload_metadata(standard_payload)
# Should not have latency fields if data is missing/zero
assert "time_to_first_token_ms" not in metadata # Will be 0, so not included
assert (
"litellm_overhead_time_ms" not in metadata
) # Not present in hidden_params
assert "guardrail_overhead_time_ms" not in metadata # No guardrail_information
# Test case 2: Zero time to first token should not be included
standard_payload = create_standard_logging_payload_with_cache()
standard_payload["startTime"] = 1000.0
standard_payload["completionStartTime"] = 1000.0 # Same time = 0 difference
metadata = logger._get_dd_llm_obs_payload_metadata(standard_payload)
assert "time_to_first_token_ms" not in metadata
# Test case 3: Missing guardrail duration should not crash
standard_payload = create_standard_logging_payload_with_cache()
standard_payload["guardrail_information"] = [StandardLoggingGuardrailInformation(
guardrail_name="test",
guardrail_status="success",
# duration is missing
)]
metadata = logger._get_dd_llm_obs_payload_metadata(standard_payload)
assert "guardrail_overhead_time_ms" not in metadata
def test_guardrail_information_in_metadata(mock_env_vars):
"""Test that guardrail_information is included in metadata with input/output fields"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Create a standard payload with guardrail information
standard_payload = create_standard_logging_payload_with_latency_metrics()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
# Create the payload and verify guardrail_information is in metadata
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
metadata = payload["meta"]["metadata"]
# Verify guardrail_information is present in metadata
assert "guardrail_information" in metadata
assert metadata["guardrail_information"] is not None
# Verify the guardrail information structure
guardrail_info = metadata["guardrail_information"]
assert guardrail_info[0]["guardrail_name"] == "test_guardrail"
assert guardrail_info[0]["guardrail_status"] == "success"
assert guardrail_info[0]["duration"] == 0.5
# Verify input/output fields are present
assert "guardrail_request" in guardrail_info[0]
assert "guardrail_response" in guardrail_info[0]
# Validate the input/output content
assert guardrail_info[0]["guardrail_request"]["input"] == "test input message"
assert guardrail_info[0]["guardrail_request"]["user_id"] == "test_user"
assert guardrail_info[0]["guardrail_response"]["output"] == "filtered output"
assert guardrail_info[0]["guardrail_response"]["flagged"] is False
assert guardrail_info[0]["guardrail_response"]["score"] == 0.1
def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object with tool calls for testing"""
return {
"id": "test-request-id-tool-calls",
"trace_id": "test-trace-id-tool-calls",
"call_type": "completion",
"stream": None,
"response_cost": 0.05,
"response_cost_failure_debug_info": None,
"status": "success",
"custom_llm_provider": "openai",
"total_tokens": 50,
"prompt_tokens": 20,
"completion_tokens": 30,
"startTime": 1234567890.0,
"endTime": 1234567891.0,
"completionStartTime": 1234567890.5,
"response_time": 1.0,
"model_map_information": {
"model_map_key": "gpt-4",
"model_map_value": None
},
"model": "gpt-4",
"model_id": "model-123",
"model_group": "openai-gpt",
"api_base": "https://api.openai.com",
"metadata": {
"user_api_key_hash": "test_hash",
"user_api_key_org_id": None,
"user_api_key_alias": "test_alias",
"user_api_key_team_id": "test_team",
"user_api_key_user_id": "test_user",
"user_api_key_team_alias": "test_team_alias",
"user_api_key_user_email": None,
"user_api_key_end_user_id": None,
"user_api_key_request_route": None,
"spend_logs_metadata": None,
"requester_ip_address": "127.0.0.1",
"requester_metadata": None,
"requester_custom_headers": None,
"prompt_management_metadata": None,
"mcp_tool_call_metadata": None,
"vector_store_request_metadata": None,
"applied_guardrails": None,
"usage_object": None,
"cold_storage_object_key": None,
},
"cache_hit": False,
"cache_key": None,
"saved_cache_cost": 0.0,
"request_tags": [],
"end_user": None,
"requester_ip_address": "127.0.0.1",
"messages": [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "I'll check the weather for you.",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "NYC"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": '{"temperature": 72, "condition": "sunny"}',
},
],
"response": {
"choices": [
{
"message": {
"role": "assistant",
"content": "It's 72°F and sunny in NYC!",
"tool_calls": [
{
"id": "call_456",
"type": "function",
"function": {
"name": "format_response",
"arguments": '{"temp": 72, "condition": "sunny"}',
},
}
],
}
}
]
},
"error_str": None,
"error_information": None,
"model_parameters": {"temperature": 0.7},
"hidden_params": {
"model_id": "model-123",
"cache_key": None,
"api_base": "https://api.openai.com",
"response_cost": "0.05",
"litellm_overhead_time_ms": None,
"additional_headers": None,
"batch_models": None,
"litellm_model_name": None,
"usage_object": None,
},
"guardrail_information": None,
"standard_built_in_tools_params": None,
} # type: ignore
class TestDataDogLLMObsLoggerToolCalls:
"""Simple test suite for DataDog LLM Observability Logger tool call handling"""
@pytest.fixture
def mock_env_vars(self):
"""Mock environment variables for DataDog"""
with patch.dict(
os.environ, {"DD_API_KEY": "test_api_key", "DD_SITE": "us5.datadoghq.com"}
):
yield
def test_tool_call_span_kind_mapping(self, mock_env_vars):
"""Test that tool call operations are correctly mapped to 'tool' span kind"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
# Test MCP tool call mapping
from litellm.types.utils import CallTypes
assert (
logger._get_datadog_span_kind(CallTypes.call_mcp_tool.value, "123") == "tool"
)
def test_tool_call_payload_creation(self, mock_env_vars):
"""Test that tool call payloads are created correctly"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_tool_calls()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Verify basic payload structure
assert payload.get("name") == "litellm_llm_call"
assert payload.get("status") == "ok"
assert (
payload.get("meta", {}).get("kind") == "llm"
) # Regular completion, not tool call
# Verify metrics
metrics = payload.get("metrics", {})
assert metrics.get("input_tokens") == 20
assert metrics.get("output_tokens") == 30
assert metrics.get("total_tokens") == 50
def test_tool_call_messages_preserved(self, mock_env_vars):
"""Test that tool call messages are preserved in the payload"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_tool_calls()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Verify input messages include tool calls
meta = payload.get("meta", {})
input_meta = meta.get("input", {})
input_messages = input_meta.get("messages", [])
assert len(input_messages) == 3
# Check assistant message has tool calls
assistant_msg = input_messages[1]
assert assistant_msg.get("role") == "assistant"
assert "tool_calls" in assistant_msg
tool_calls = assistant_msg.get("tool_calls", [])
assert len(tool_calls) == 1
tool_call = tool_calls[0]
function_info = tool_call.get("function", {})
assert function_info.get("name") == "get_weather"
# Check tool message
tool_msg = input_messages[2]
assert tool_msg.get("role") == "tool"
assert tool_msg.get("tool_call_id") == "call_123"
def test_tool_call_response_handling(self, mock_env_vars):
"""Test that tool calls in response are handled correctly"""
with patch(
"litellm.integrations.datadog.datadog_llm_obs.get_async_httpx_client"
), patch("asyncio.create_task"):
logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_tool_calls()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
payload = logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Verify output messages include tool calls
meta = payload.get("meta", {})
output_meta = meta.get("output", {})
output_messages = output_meta.get("messages", [])
assert len(output_messages) == 1
output_msg = output_messages[0]
assert output_msg.get("role") == "assistant"
assert "tool_calls" in output_msg
output_tool_calls = output_msg.get("tool_calls", [])
assert len(output_tool_calls) == 1
output_function_info = output_tool_calls[0].get("function", {})
assert output_function_info.get("name") == "format_response"
def create_standard_logging_payload_with_spend_metrics() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object with spend metrics for testing"""
from datetime import datetime, timezone
# Create a budget reset time 10 days from now (using "10d" format)
budget_reset_at = datetime.now(timezone.utc) + timedelta(days=10)
return {
"id": "test-request-id-spend",
"trace_id": "test-trace-id-spend",
"call_type": "completion",
"stream": None,
"response_cost": 0.15,
"response_cost_failure_debug_info": None,
"status": "success",
"custom_llm_provider": "openai",
"total_tokens": 30,
"prompt_tokens": 10,
"completion_tokens": 20,
"startTime": 1234567890.0,
"endTime": 1234567891.0,
"completionStartTime": 1234567890.5,
"response_time": 1.0,
"model_map_information": {
"model_map_key": "gpt-4",
"model_map_value": None
},
"model": "gpt-4",
"model_id": "model-123",
"model_group": "openai-gpt",
"api_base": "https://api.openai.com",
"metadata": {
"user_api_key_hash": "test_hash",
"user_api_key_org_id": None,
"user_api_key_alias": "test_alias",
"user_api_key_team_id": "test_team",
"user_api_key_user_id": "test_user",
"user_api_key_team_alias": "test_team_alias",
"user_api_key_user_email": None,
"user_api_key_end_user_id": None,
"user_api_key_request_route": None,
"user_api_key_spend": 0.67,
"user_api_key_max_budget": 10.0, # $10 max budget
"user_api_key_budget_reset_at": budget_reset_at.isoformat(), # ISO format: 2025-09-26T...
"spend_logs_metadata": None,
"requester_ip_address": "127.0.0.1",
"requester_metadata": None,
"requester_custom_headers": None,
"prompt_management_metadata": None,
"mcp_tool_call_metadata": None,
"vector_store_request_metadata": None,
"applied_guardrails": None,
"usage_object": None,
"cold_storage_object_key": None,
},
"cache_hit": False,
"cache_key": None,
"saved_cache_cost": 0.0,
"request_tags": [],
"end_user": None,
"requester_ip_address": "127.0.0.1",
"messages": [{"role": "user", "content": "Hello, world!"}],
"response": {"choices": [{"message": {"content": "Hi there!"}}]},
"error_str": None,
"error_information": None,
"model_parameters": {"stream": False},
"hidden_params": {
"model_id": "model-123",
"cache_key": None,
"api_base": "https://api.openai.com",
"response_cost": "0.15",
"litellm_overhead_time_ms": None,
"additional_headers": None,
"batch_models": None,
"litellm_model_name": None,
"usage_object": None,
},
"guardrail_information": None,
"standard_built_in_tools_params": None,
} # type: ignore
@pytest.mark.asyncio
async def test_datadog_llm_obs_spend_metrics(mock_env_vars):
"""Test that budget metrics are properly extracted and logged"""
datadog_llm_obs_logger = DataDogLLMObsLogger()
# Create a standard logging payload with spend metrics
payload = create_standard_logging_payload_with_spend_metrics()
# Show the budget reset time in ISO format
budget_reset_iso = payload["metadata"]["user_api_key_budget_reset_at"]
print(f"Budget reset time (ISO format): {budget_reset_iso}")
from datetime import datetime, timezone
print(f"Current time: {datetime.now(timezone.utc).isoformat()}")
# Test the _get_spend_metrics method
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
# Verify budget metrics are present
assert "user_api_key_max_budget" in spend_metrics
assert spend_metrics["user_api_key_max_budget"] == 10.0
assert "user_api_key_budget_reset_at" in spend_metrics
# The budget reset should be a datetime string in ISO format
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
assert isinstance(budget_reset, str)
print(f"Budget reset datetime: {budget_reset}")
# Should be close to 10 days from now
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days
print(f"Spend metrics: {spend_metrics}")
@pytest.mark.asyncio
async def test_datadog_llm_obs_spend_metrics_no_budget(mock_env_vars):
"""Test that spend metrics work when no budget is set"""
datadog_llm_obs_logger = DataDogLLMObsLogger()
# Create a standard logging payload without budget metadata
payload = create_standard_logging_payload_with_spend_metrics()
# Remove budget-related metadata to test no-budget scenario
payload["metadata"].pop("user_api_key_max_budget", None)
payload["metadata"].pop("user_api_key_budget_reset_at", None)
# Test the _get_spend_metrics method
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
# Verify only response cost is present
assert "response_cost" in spend_metrics
assert spend_metrics["response_cost"] == 0.15
# Budget metrics should not be present
assert "user_api_key_max_budget" not in spend_metrics
assert "user_api_key_budget_reset_at" not in spend_metrics
print(f"Spend metrics (no budget): {spend_metrics}")
@pytest.mark.asyncio
async def test_spend_metrics_in_datadog_payload(mock_env_vars):
"""Test that spend metrics are correctly included in DataDog LLM Observability payloads"""
from datetime import datetime
datadog_llm_obs_logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_spend_metrics()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
payload = datadog_llm_obs_logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Verify basic payload structure
assert payload.get("name") == "litellm_llm_call"
assert payload.get("status") == "ok"
# Verify spend metrics are included in metadata
meta = payload.get("meta", {})
assert meta is not None, "Meta section should exist in payload"
metadata = meta.get("metadata", {})
assert metadata is not None, "Metadata section should exist in meta"
spend_metrics = metadata.get("spend_metrics", {})
assert spend_metrics, "Spend metrics should exist in metadata"
# Check that all metrics are present
assert "response_cost" in spend_metrics
assert "user_api_key_spend" in spend_metrics
assert "user_api_key_max_budget" in spend_metrics
assert "user_api_key_budget_reset_at" in spend_metrics
# Verify the values are correct
assert spend_metrics["response_cost"] == 0.15 # response_cost
assert spend_metrics["user_api_key_spend"] == 0.67 # lol
assert spend_metrics["user_api_key_max_budget"] == 10.0 # max budget
# Verify budget reset is a datetime string in ISO format
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
assert isinstance(budget_reset, str)
print(f"Budget reset in payload: {budget_reset}") # In StandardLoggingUserAPIKeyMetadata
user_api_key_budget_reset_at: Optional[str] = None
# In DDLLMObsSpendMetrics
user_api_key_budget_reset_at: str
# Should be close to 10 days from now
from datetime import datetime, timezone
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days