mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* test: drop the cwd-relative sys.path.insert calls from the test suite
TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.
Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.
Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.
* test: drop the duplicate imports the sys.path sweep exposed to F811
* test(pre-call-utils): restore the os import the new bedrock tests need
849 lines
29 KiB
Python
849 lines
29 KiB
Python
import io
|
|
import os
|
|
|
|
from litellm.integrations.datadog.datadog_handler import (
|
|
get_datadog_source,
|
|
get_datadog_service,
|
|
get_datadog_env,
|
|
get_datadog_pod_name,
|
|
get_datadog_hostname,
|
|
get_datadog_tags,
|
|
)
|
|
|
|
|
|
import asyncio
|
|
import gzip
|
|
import json
|
|
import logging
|
|
import time
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm import completion
|
|
from litellm._logging import verbose_logger
|
|
from litellm.integrations.datadog.datadog import *
|
|
import litellm.integrations.datadog.datadog as datadog_module
|
|
from datetime import datetime, timedelta
|
|
from litellm.types.utils import (
|
|
StandardLoggingPayload,
|
|
StandardLoggingModelInformation,
|
|
StandardLoggingMetadata,
|
|
StandardLoggingHiddenParams,
|
|
LiteLLMCommonStrings,
|
|
)
|
|
from litellm.types.integrations.datadog import DatadogInitParams
|
|
|
|
verbose_logger.setLevel(logging.DEBUG)
|
|
|
|
|
|
def create_standard_logging_payload() -> StandardLoggingPayload:
|
|
return StandardLoggingPayload(
|
|
id="test_id",
|
|
call_type="completion",
|
|
response_cost=0.1,
|
|
response_cost_failure_debug_info=None,
|
|
status="success",
|
|
total_tokens=30,
|
|
prompt_tokens=20,
|
|
completion_tokens=10,
|
|
startTime=1234567890.0,
|
|
endTime=1234567891.0,
|
|
completionStartTime=1234567890.5,
|
|
model_map_information=StandardLoggingModelInformation(
|
|
model_map_key="gpt-4.1-mini", model_map_value=None
|
|
),
|
|
model="gpt-4.1-mini",
|
|
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,
|
|
model_parameters={"stream": True},
|
|
hidden_params=StandardLoggingHiddenParams(
|
|
model_id="model-123",
|
|
cache_key=None,
|
|
api_base="https://api.openai.com",
|
|
response_cost="0.1",
|
|
additional_headers=None,
|
|
),
|
|
)
|
|
|
|
|
|
class _DummySpan:
|
|
def __init__(self, trace_id=None, span_id=None):
|
|
self.trace_id = trace_id
|
|
self.span_id = span_id
|
|
|
|
|
|
class _DummyTracer:
|
|
def __init__(self, current_span=None, current_root_span=None):
|
|
self._current_span = current_span
|
|
self._current_root_span = current_root_span
|
|
|
|
def current_span(self):
|
|
return self._current_span
|
|
|
|
def current_root_span(self):
|
|
return self._current_root_span
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_datadog_logging_payload():
|
|
"""Test creating a DataDog logging payload from a standard logging object"""
|
|
dd_logger = DataDogLogger()
|
|
standard_payload = create_standard_logging_payload()
|
|
|
|
# Create mock kwargs with the standard logging object
|
|
kwargs = {"standard_logging_object": standard_payload}
|
|
|
|
# Test payload creation
|
|
dd_payload = dd_logger.create_datadog_logging_payload(
|
|
kwargs=kwargs,
|
|
response_obj=None,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now(),
|
|
)
|
|
|
|
# Verify payload structure
|
|
assert dd_payload["ddsource"] == os.getenv("DD_SOURCE", "litellm")
|
|
assert dd_payload["service"] == "litellm-server"
|
|
assert dd_payload["status"] == DataDogStatus.INFO
|
|
|
|
# verify the message field == standard_payload
|
|
dict_payload = json.loads(dd_payload["message"])
|
|
assert dict_payload == standard_payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_failure_logging():
|
|
"""Test logging a failure event to DataDog"""
|
|
dd_logger = DataDogLogger()
|
|
standard_payload = create_standard_logging_payload()
|
|
standard_payload["status"] = "failure" # Set status to failure
|
|
standard_payload["error_str"] = "Test error"
|
|
|
|
kwargs = {"standard_logging_object": standard_payload}
|
|
|
|
dd_payload = dd_logger.create_datadog_logging_payload(
|
|
kwargs=kwargs,
|
|
response_obj=None,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now(),
|
|
)
|
|
|
|
assert (
|
|
dd_payload["status"] == DataDogStatus.ERROR
|
|
) # Verify failure maps to warning status
|
|
|
|
# verify the message field == standard_payload
|
|
dict_payload = json.loads(dd_payload["message"])
|
|
assert dict_payload == standard_payload
|
|
|
|
# verify error_str is in the message field
|
|
assert "error_str" in dict_payload
|
|
assert dict_payload["error_str"] == "Test error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_logging_http_request():
|
|
"""
|
|
- Test that the HTTP request is made to Datadog
|
|
- sent to the /api/v2/logs endpoint
|
|
- the payload is batched
|
|
- each element in the payload is a DatadogPayload
|
|
- each element in a DatadogPayload.message contains all the valid fields
|
|
"""
|
|
try:
|
|
from litellm.integrations.datadog.datadog import DataDogLogger
|
|
|
|
os.environ["DD_SITE"] = "https://fake.datadoghq.com"
|
|
os.environ["DD_API_KEY"] = "anything"
|
|
dd_logger = DataDogLogger()
|
|
|
|
litellm.callbacks = [dd_logger]
|
|
|
|
litellm.set_verbose = True
|
|
|
|
# Create a mock for the async_client's post method
|
|
mock_post = AsyncMock()
|
|
mock_post.return_value.status_code = 202
|
|
mock_post.return_value.text = "Accepted"
|
|
dd_logger.async_client.post = mock_post
|
|
|
|
# Make the completion call
|
|
for _ in range(5):
|
|
response = await litellm.acompletion(
|
|
model="gpt-4.1-mini",
|
|
messages=[{"role": "user", "content": "what llm are u"}],
|
|
max_tokens=10,
|
|
temperature=0.2,
|
|
mock_response="Accepted",
|
|
)
|
|
print(response)
|
|
|
|
# Wait for 5 seconds
|
|
await asyncio.sleep(6)
|
|
|
|
# Assert that the mock was called
|
|
assert mock_post.called, "HTTP request was not made"
|
|
|
|
# Get the arguments of the last call
|
|
args, kwargs = mock_post.call_args
|
|
|
|
print("CAll args and kwargs", args, kwargs)
|
|
|
|
# Print the request body
|
|
|
|
# You can add more specific assertions here if needed
|
|
# For example, checking if the URL is correct
|
|
assert kwargs["url"].endswith("/api/v2/logs"), "Incorrect DataDog endpoint"
|
|
|
|
body = kwargs["data"]
|
|
|
|
# use gzip to unzip the body
|
|
with gzip.open(io.BytesIO(body), "rb") as f:
|
|
body = f.read().decode("utf-8")
|
|
print(body)
|
|
|
|
# body is string parse it to dict
|
|
body = json.loads(body)
|
|
print(body)
|
|
|
|
assert len(body) == 5 # 5 logs should be sent to DataDog
|
|
|
|
# Assert that the first element in body has the expected fields and shape
|
|
assert isinstance(body[0], dict), "First element in body should be a dictionary"
|
|
|
|
# Get the expected fields and their types from DatadogPayload
|
|
expected_fields = DatadogPayload.__annotations__
|
|
required_fields = {
|
|
"ddsource": str,
|
|
"ddtags": str,
|
|
"hostname": str,
|
|
"message": str,
|
|
"service": str,
|
|
"status": str,
|
|
}
|
|
optional_fields = set(expected_fields.keys()) - set(required_fields.keys())
|
|
|
|
# Assert that all elements in body have the required fields with correct types
|
|
for log in body:
|
|
assert isinstance(log, dict), "Each log should be a dictionary"
|
|
for field, expected_type in required_fields.items():
|
|
assert field in log, f"Field '{field}' is missing from the log"
|
|
assert isinstance(
|
|
log[field], expected_type
|
|
), f"Field '{field}' has incorrect type. Expected {expected_type}, got {type(log[field])}"
|
|
|
|
for optional_field in optional_fields:
|
|
if optional_field in log:
|
|
assert isinstance(
|
|
log[optional_field], str
|
|
), f"Optional field '{optional_field}' must be a string"
|
|
|
|
unexpected_fields = set(log.keys()) - set(expected_fields.keys())
|
|
assert (
|
|
not unexpected_fields
|
|
), f"Log contains unexpected fields: {unexpected_fields}"
|
|
|
|
# Parse the 'message' field as JSON and check its structure
|
|
message = json.loads(body[0]["message"])
|
|
print("logged message", json.dumps(message, indent=4))
|
|
|
|
expected_message_fields = StandardLoggingPayload.__annotations__.keys()
|
|
|
|
for field in expected_message_fields:
|
|
assert field in message, f"Field '{field}' is missing from the message"
|
|
|
|
# Check specific fields
|
|
assert message["call_type"] == "acompletion"
|
|
assert message["model"] == "gpt-4.1-mini"
|
|
assert isinstance(message["model_parameters"], dict)
|
|
assert "temperature" in message["model_parameters"]
|
|
assert "max_tokens" in message["model_parameters"]
|
|
assert isinstance(message["response"], dict)
|
|
assert isinstance(message["metadata"], dict)
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Test failed with exception: {str(e)}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_trace_context_uses_current_span(monkeypatch):
|
|
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
|
|
monkeypatch.setenv("DD_API_KEY", "anything")
|
|
tracer = _DummyTracer(current_span=_DummySpan(trace_id=123, span_id=456))
|
|
monkeypatch.setattr(datadog_module, "tracer", tracer)
|
|
|
|
dd_logger = DataDogLogger()
|
|
payload = DatadogPayload(
|
|
ddsource="litellm",
|
|
ddtags="env:test",
|
|
hostname="host",
|
|
message="{}",
|
|
service="svc",
|
|
status="info",
|
|
)
|
|
|
|
dd_logger._add_trace_context_to_payload(payload)
|
|
assert payload["dd.trace_id"] == "123"
|
|
assert payload["dd.span_id"] == "456"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_trace_context_falls_back_to_root_span(monkeypatch):
|
|
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
|
|
monkeypatch.setenv("DD_API_KEY", "anything")
|
|
tracer = _DummyTracer(
|
|
current_span=None,
|
|
current_root_span=_DummySpan(trace_id=789, span_id=None),
|
|
)
|
|
monkeypatch.setattr(datadog_module, "tracer", tracer)
|
|
|
|
dd_logger = DataDogLogger()
|
|
payload = DatadogPayload(
|
|
ddsource="litellm",
|
|
ddtags="env:test",
|
|
hostname="host",
|
|
message="{}",
|
|
service="svc",
|
|
status="info",
|
|
)
|
|
|
|
dd_logger._add_trace_context_to_payload(payload)
|
|
assert payload["dd.trace_id"] == "789"
|
|
assert "dd.span_id" not in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_trace_context_handles_missing_tracer(monkeypatch):
|
|
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
|
|
monkeypatch.setenv("DD_API_KEY", "anything")
|
|
monkeypatch.setattr(datadog_module, "tracer", object())
|
|
|
|
dd_logger = DataDogLogger()
|
|
payload = DatadogPayload(
|
|
ddsource="litellm",
|
|
ddtags="env:test",
|
|
hostname="host",
|
|
message="{}",
|
|
service="svc",
|
|
status="info",
|
|
)
|
|
|
|
dd_logger._add_trace_context_to_payload(payload)
|
|
assert "dd.trace_id" not in payload
|
|
assert "dd.span_id" not in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_trace_context_ignores_span_without_trace_id(monkeypatch):
|
|
monkeypatch.setenv("DD_SITE", "https://fake.datadoghq.com")
|
|
monkeypatch.setenv("DD_API_KEY", "anything")
|
|
tracer = _DummyTracer(current_span=_DummySpan(trace_id=None, span_id=555))
|
|
monkeypatch.setattr(datadog_module, "tracer", tracer)
|
|
|
|
dd_logger = DataDogLogger()
|
|
payload = DatadogPayload(
|
|
ddsource="litellm",
|
|
ddtags="env:test",
|
|
hostname="host",
|
|
message="{}",
|
|
service="svc",
|
|
status="info",
|
|
)
|
|
|
|
dd_logger._add_trace_context_to_payload(payload)
|
|
assert "dd.trace_id" not in payload
|
|
assert "dd.span_id" not in payload
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_log_redis_failures():
|
|
"""
|
|
Test that poorly configured Redis is logged as Warning on DataDog
|
|
"""
|
|
try:
|
|
from litellm.caching.caching import Cache
|
|
from litellm.integrations.datadog.datadog import DataDogLogger
|
|
|
|
litellm.cache = Cache(
|
|
type="redis", host="badhost", port="6379", password="badpassword"
|
|
)
|
|
|
|
os.environ["DD_SITE"] = "https://fake.datadoghq.com"
|
|
os.environ["DD_API_KEY"] = "anything"
|
|
dd_logger = DataDogLogger()
|
|
|
|
litellm.callbacks = [dd_logger]
|
|
litellm.service_callback = ["datadog"]
|
|
|
|
litellm.set_verbose = True
|
|
|
|
# Create a mock for the async_client's post method
|
|
mock_post = AsyncMock()
|
|
mock_post.return_value.status_code = 202
|
|
mock_post.return_value.text = "Accepted"
|
|
dd_logger.async_client.post = mock_post
|
|
|
|
# Make the completion call
|
|
for _ in range(3):
|
|
response = await litellm.acompletion(
|
|
model="gpt-4.1-mini",
|
|
messages=[{"role": "user", "content": "what llm are u"}],
|
|
max_tokens=10,
|
|
temperature=0.2,
|
|
mock_response="Accepted",
|
|
)
|
|
print(response)
|
|
|
|
# Wait for 5 seconds
|
|
await asyncio.sleep(6)
|
|
|
|
# Assert that the mock was called
|
|
assert mock_post.called, "HTTP request was not made"
|
|
|
|
# Get the arguments of the last call
|
|
args, kwargs = mock_post.call_args
|
|
print("CAll args and kwargs", args, kwargs)
|
|
|
|
# For example, checking if the URL is correct
|
|
assert kwargs["url"].endswith("/api/v2/logs"), "Incorrect DataDog endpoint"
|
|
|
|
body = kwargs["data"]
|
|
|
|
# use gzip to unzip the body
|
|
with gzip.open(io.BytesIO(body), "rb") as f:
|
|
body = f.read().decode("utf-8")
|
|
print(body)
|
|
|
|
# body is string parse it to dict
|
|
body = json.loads(body)
|
|
print(body)
|
|
|
|
failure_events = [log for log in body if log["status"] == "warning"]
|
|
assert len(failure_events) > 0, "No failure events logged"
|
|
|
|
print("ALL FAILURE/WARN EVENTS", failure_events)
|
|
|
|
for event in failure_events:
|
|
message = json.loads(event["message"])
|
|
assert (
|
|
event["status"] == "warning"
|
|
), f"Event status is not 'warning': {event['status']}"
|
|
assert (
|
|
message["service"] == "redis"
|
|
), f"Service is not 'redis': {message['service']}"
|
|
assert "error" in message, "No 'error' field in the message"
|
|
assert message["error"], "Error field is empty"
|
|
except Exception as e:
|
|
pytest.fail(f"Test failed with exception: {str(e)}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skip(reason="local-only test, to test if everything works fine.")
|
|
async def test_datadog_logging():
|
|
try:
|
|
litellm.success_callback = ["datadog"]
|
|
litellm.set_verbose = True
|
|
response = await litellm.acompletion(
|
|
model="gpt-4.1-mini",
|
|
messages=[{"role": "user", "content": "what llm are u"}],
|
|
max_tokens=10,
|
|
temperature=0.2,
|
|
)
|
|
print(response)
|
|
|
|
await asyncio.sleep(5)
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_payload_environment_variables():
|
|
"""Test that DataDog payload correctly includes environment variables in the payload structure"""
|
|
try:
|
|
# Set test environment variables
|
|
test_env = {
|
|
"DD_ENV": "test-env",
|
|
"DD_SERVICE": "test-service",
|
|
"DD_VERSION": "1.0.0",
|
|
"DD_SOURCE": "test-source",
|
|
"DD_API_KEY": "fake-key",
|
|
"DD_SITE": "datadoghq.com",
|
|
}
|
|
|
|
with patch.dict(os.environ, test_env):
|
|
dd_logger = DataDogLogger()
|
|
standard_payload = create_standard_logging_payload()
|
|
|
|
# Create the payload
|
|
dd_payload = dd_logger.create_datadog_logging_payload(
|
|
kwargs={"standard_logging_object": standard_payload},
|
|
response_obj=None,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now(),
|
|
)
|
|
|
|
print("dd payload=", json.dumps(dd_payload, indent=2))
|
|
|
|
# Verify payload structure and environment variables
|
|
assert (
|
|
dd_payload["ddsource"] == "test-source"
|
|
), "Incorrect source in payload"
|
|
assert (
|
|
dd_payload["service"] == "test-service"
|
|
), "Incorrect service in payload"
|
|
|
|
assert (
|
|
"env:test-env,service:test-service,version:1.0.0,HOSTNAME:"
|
|
in dd_payload["ddtags"]
|
|
), "Incorrect tags in payload"
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Test failed with exception: {str(e)}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_payload_content_truncation():
|
|
"""
|
|
Test that DataDog payload correctly truncates long content
|
|
|
|
DataDog has a limit of 1MB for the logged payload size.
|
|
"""
|
|
dd_logger = DataDogLogger()
|
|
|
|
# Create a standard payload with very long content
|
|
standard_payload = create_standard_logging_payload()
|
|
long_content = "x" * 80_000 # Create string longer than MAX_STR_LENGTH (10_000)
|
|
|
|
# Modify payload with long content
|
|
standard_payload["error_str"] = long_content
|
|
standard_payload["messages"] = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": long_content,
|
|
"detail": "low",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
standard_payload["response"] = {"choices": [{"message": {"content": long_content}}]}
|
|
|
|
# Create the payload
|
|
dd_payload = dd_logger.create_datadog_logging_payload(
|
|
kwargs={"standard_logging_object": standard_payload},
|
|
response_obj=None,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now(),
|
|
)
|
|
|
|
print("dd_payload", json.dumps(dd_payload, indent=2))
|
|
|
|
# Parse the message back to dict to verify truncation
|
|
message_dict = json.loads(dd_payload["message"])
|
|
|
|
# Verify truncation of fields
|
|
assert len(message_dict["error_str"]) < 10_100, "error_str not truncated correctly"
|
|
assert (
|
|
len(str(message_dict["messages"])) < 10_100
|
|
), "messages not truncated correctly"
|
|
assert (
|
|
len(str(message_dict["response"])) < 10_100
|
|
), "response not truncated correctly"
|
|
|
|
|
|
def test_datadog_static_methods():
|
|
"""Test the static helper methods in DataDogLogger class"""
|
|
|
|
# Test with default environment variables
|
|
assert get_datadog_source() == "litellm"
|
|
assert get_datadog_service() == "litellm-server"
|
|
assert get_datadog_hostname() is not None
|
|
assert get_datadog_env() == "unknown"
|
|
assert get_datadog_pod_name() == "unknown"
|
|
|
|
# Test tags format with default values
|
|
assert "env:unknown,service:litellm-server,version:unknown,HOSTNAME:" in ",".join(
|
|
get_datadog_tags()
|
|
)
|
|
|
|
# Test with custom environment variables
|
|
test_env = {
|
|
"DD_SOURCE": "custom-source",
|
|
"DD_SERVICE": "custom-service",
|
|
"HOSTNAME": "test-host",
|
|
"DD_ENV": "production",
|
|
"DD_VERSION": "1.0.0",
|
|
"POD_NAME": "pod-123",
|
|
}
|
|
|
|
with patch.dict(os.environ, test_env):
|
|
assert get_datadog_source() == "custom-source"
|
|
print("DataDogLogger._get_datadog_source()", get_datadog_source())
|
|
assert get_datadog_service() == "custom-service"
|
|
print("DataDogLogger._get_datadog_service()", get_datadog_service())
|
|
assert get_datadog_hostname() == "test-host"
|
|
print(
|
|
"DataDogLogger._get_datadog_hostname()",
|
|
get_datadog_hostname(),
|
|
)
|
|
assert get_datadog_env() == "production"
|
|
print("DataDogLogger._get_datadog_env()", get_datadog_env())
|
|
assert get_datadog_pod_name() == "pod-123"
|
|
print(
|
|
"DataDogLogger._get_datadog_pod_name()",
|
|
get_datadog_pod_name(),
|
|
)
|
|
|
|
# Test tags format with custom values
|
|
expected_custom_tags = "env:production,service:custom-service,version:1.0.0,HOSTNAME:test-host,POD_NAME:pod-123"
|
|
print("DataDogLogger._get_datadog_tags()", get_datadog_tags())
|
|
assert ",".join(get_datadog_tags()) == expected_custom_tags
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_non_serializable_messages():
|
|
"""Test logging events with non-JSON-serializable messages"""
|
|
dd_logger = DataDogLogger()
|
|
|
|
# Create payload with non-serializable content
|
|
standard_payload = create_standard_logging_payload()
|
|
non_serializable_obj = datetime.now() # datetime objects aren't JSON serializable
|
|
standard_payload["messages"] = [{"role": "user", "content": non_serializable_obj}]
|
|
standard_payload["response"] = {
|
|
"choices": [{"message": {"content": non_serializable_obj}}]
|
|
}
|
|
|
|
kwargs = {"standard_logging_object": standard_payload}
|
|
|
|
# Test payload creation
|
|
dd_payload = dd_logger.create_datadog_logging_payload(
|
|
kwargs=kwargs,
|
|
response_obj=None,
|
|
start_time=datetime.now(),
|
|
end_time=datetime.now(),
|
|
)
|
|
|
|
# Verify payload can be serialized
|
|
assert dd_payload["status"] == DataDogStatus.INFO
|
|
|
|
# Verify the message can be parsed back to dict
|
|
dict_payload = json.loads(dd_payload["message"])
|
|
|
|
# Check that the non-serializable objects were converted to strings
|
|
assert isinstance(dict_payload["messages"][0]["content"], str)
|
|
assert isinstance(dict_payload["response"]["choices"][0]["message"]["content"], str)
|
|
|
|
|
|
def test_get_datadog_tags():
|
|
"""Test the _get_datadog_tags static method with various inputs"""
|
|
# Test with no standard_logging_object and default env vars
|
|
base_tags = get_datadog_tags()
|
|
assert any("env:" in t for t in base_tags)
|
|
assert any("service:" in t for t in base_tags)
|
|
assert any("version:" in t for t in base_tags)
|
|
assert any("POD_NAME:" in t for t in base_tags)
|
|
assert any("HOSTNAME:" in t for t in base_tags)
|
|
|
|
# Test with custom env vars
|
|
test_env = {
|
|
"DD_ENV": "production",
|
|
"DD_SERVICE": "custom-service",
|
|
"DD_VERSION": "1.0.0",
|
|
"HOSTNAME": "test-host",
|
|
"POD_NAME": "pod-123",
|
|
}
|
|
with patch.dict(os.environ, test_env):
|
|
custom_tags = get_datadog_tags()
|
|
assert "env:production" in custom_tags
|
|
assert "service:custom-service" in custom_tags
|
|
assert "version:1.0.0" in custom_tags
|
|
assert "HOSTNAME:test-host" in custom_tags
|
|
assert "POD_NAME:pod-123" in custom_tags
|
|
|
|
# Test with standard_logging_object containing request_tags
|
|
standard_logging_obj = create_standard_logging_payload()
|
|
standard_logging_obj["request_tags"] = ["tag1", "tag2"]
|
|
|
|
tags_with_request = get_datadog_tags(standard_logging_obj)
|
|
assert "request_tag:tag1" in tags_with_request
|
|
assert "request_tag:tag2" in tags_with_request
|
|
|
|
# Test with empty request_tags
|
|
standard_logging_obj["request_tags"] = []
|
|
tags_empty_request = get_datadog_tags(standard_logging_obj)
|
|
assert not any(t.startswith("request_tag:") for t in tags_empty_request)
|
|
|
|
# Test with None request_tags
|
|
standard_logging_obj["request_tags"] = None
|
|
tags_none_request = get_datadog_tags(standard_logging_obj)
|
|
assert not any(t.startswith("request_tag:") for t in tags_none_request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_datadog_message_redaction():
|
|
"""
|
|
Test that DataDog logger correctly initializes with turn_off_message_logging=True
|
|
from litellm.datadog_params
|
|
"""
|
|
try:
|
|
# Test using litellm.datadog_params pattern
|
|
litellm.datadog_params = DatadogInitParams(turn_off_message_logging=True)
|
|
|
|
os.environ["DD_SITE"] = "https://fake.datadoghq.com"
|
|
os.environ["DD_API_KEY"] = "anything"
|
|
|
|
# Mock the periodic flush to avoid async issues
|
|
with patch("asyncio.create_task"):
|
|
dd_logger = DataDogLogger()
|
|
|
|
# Verify that turn_off_message_logging was set correctly from litellm.datadog_params
|
|
assert hasattr(
|
|
dd_logger, "turn_off_message_logging"
|
|
), "DataDogLogger should have turn_off_message_logging attribute"
|
|
assert (
|
|
dd_logger.turn_off_message_logging is True
|
|
), f"Expected turn_off_message_logging=True, got {dd_logger.turn_off_message_logging}"
|
|
|
|
# Test the redaction method inherited from CustomLogger
|
|
model_call_details = {
|
|
"standard_logging_object": {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "This is sensitive information that should be redacted",
|
|
}
|
|
],
|
|
"response": {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"content": "This is a sensitive response that should be redacted"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
}
|
|
}
|
|
|
|
# Apply redaction using the inherited method
|
|
redacted_details = (
|
|
dd_logger.redact_standard_logging_payload_from_model_call_details(
|
|
model_call_details
|
|
)
|
|
)
|
|
redacted_str = "redacted-by-litellm"
|
|
|
|
# Verify that messages are redacted
|
|
redacted_standard_obj = redacted_details["standard_logging_object"]
|
|
assert (
|
|
redacted_standard_obj["messages"][0]["content"] == redacted_str
|
|
), f"Messages not redacted. Got: {redacted_standard_obj['messages'][0]['content']}"
|
|
|
|
# Verify that response is redacted
|
|
assert (
|
|
redacted_standard_obj["response"]["choices"][0]["message"]["content"]
|
|
== redacted_str
|
|
), f"Response not redacted. Got: {redacted_standard_obj['response']['choices'][0]['message']['content']}"
|
|
|
|
print("✅ DataDog message redaction test passed")
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Test failed with exception: {str(e)}")
|
|
finally:
|
|
# Clean up
|
|
litellm.datadog_params = None
|
|
litellm.callbacks = []
|
|
|
|
|
|
def test_datadog_agent_configuration():
|
|
"""
|
|
Test that DataDog logger correctly configures agent endpoint when LITELLM_DD_AGENT_HOST is set.
|
|
|
|
Note: We use LITELLM_DD_AGENT_HOST instead of DD_AGENT_HOST to avoid conflicts
|
|
with ddtrace which automatically sets DD_AGENT_HOST for APM tracing.
|
|
"""
|
|
test_env = {
|
|
"LITELLM_DD_AGENT_HOST": "localhost",
|
|
"LITELLM_DD_AGENT_PORT": "10518",
|
|
}
|
|
|
|
# Remove DD_SITE and DD_API_KEY to verify they're not required for agent mode
|
|
env_to_remove = ["DD_SITE", "DD_API_KEY"]
|
|
|
|
with patch.dict(os.environ, test_env, clear=False):
|
|
for key in env_to_remove:
|
|
os.environ.pop(key, None)
|
|
|
|
with patch("asyncio.create_task"):
|
|
dd_logger = DataDogLogger()
|
|
|
|
# Verify agent endpoint is configured correctly
|
|
assert (
|
|
dd_logger.intake_url == "http://localhost:10518/api/v2/logs"
|
|
), f"Expected agent URL, got {dd_logger.intake_url}"
|
|
|
|
# Verify DD_API_KEY is optional (can be None)
|
|
assert dd_logger.DD_API_KEY is None or isinstance(dd_logger.DD_API_KEY, str)
|
|
|
|
|
|
def test_datadog_ignores_ddtrace_agent_host():
|
|
"""
|
|
Regression test: Ensure DD_AGENT_HOST set by ddtrace doesn't interfere with LiteLLM logging.
|
|
|
|
When users have ddtrace installed for APM tracing, it automatically sets DD_AGENT_HOST.
|
|
LiteLLM should ignore DD_AGENT_HOST and only use LITELLM_DD_AGENT_HOST for agent mode.
|
|
|
|
This prevents the 404 error when ddtrace's DD_AGENT_HOST points to an APM endpoint
|
|
that doesn't support /api/v2/logs.
|
|
|
|
Regression test for: https://github.com/BerriAI/litellm/issues/16379
|
|
"""
|
|
test_env = {
|
|
# User's explicit config for LiteLLM logging (direct API)
|
|
"DD_API_KEY": "fake-api-key",
|
|
"DD_SITE": "us5.datadoghq.com",
|
|
# ddtrace automatically sets these for APM tracing
|
|
"DD_AGENT_HOST": "10.176.100.40",
|
|
"DD_AGENT_PORT": "8126",
|
|
}
|
|
|
|
with patch.dict(os.environ, test_env, clear=False):
|
|
with patch("asyncio.create_task"):
|
|
dd_logger = DataDogLogger()
|
|
|
|
# Verify direct API endpoint is used (DD_AGENT_HOST should be ignored)
|
|
expected_url = "https://http-intake.logs.us5.datadoghq.com/api/v2/logs"
|
|
assert dd_logger.intake_url == expected_url, (
|
|
f"Expected direct API URL '{expected_url}', got '{dd_logger.intake_url}'. "
|
|
"DD_AGENT_HOST (set by ddtrace) should be ignored - only LITELLM_DD_AGENT_HOST should trigger agent mode."
|
|
)
|
|
|
|
# Verify API key is set correctly
|
|
assert dd_logger.DD_API_KEY == "fake-api-key"
|