feat(datadog_llm_obs): cost tag dimensions, router decision fields, reasoning token metric, redaction gating (#39402)

* feat(datadog_llm_obs): cost tag dimensions, router decision fields, reasoning token metric, redaction gating

* test(datadog_llm_obs): satisfy test quality gate

* fix: forward integer parent_id as its string form

* fix(datadog): sanitize redacted message roles

* fix(datadog): keep the A2A agent role on redacted spans

* fix(datadog): merge current staging budget

* style(datadog): format redaction tests

* fix(datadog): handle malformed redacted roles

* test(datadog): put the test quality suppression on the reported line

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng-berri 2026-09-02 19:46:09 -07:00 committed by GitHub
parent 64e45a069d
commit 291e84e565
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 577 additions and 41 deletions

View file

@ -57,7 +57,7 @@
"limit": 5601
},
"reportMissingTypeArgument": {
"limit": 15290
"limit": 15288
},
"reportMissingTypeStubs": {
"limit": 40
@ -105,7 +105,7 @@
"limit": 109
},
"reportUnknownMemberType": {
"limit": 38332
"limit": 38324
},
"reportUnknownParameterType": {
"limit": 19625
@ -123,7 +123,7 @@
"limit": 4
},
"reportUnnecessaryIsInstance": {
"limit": 823
"limit": 819
},
"reportUntypedBaseClass": {
"limit": 0

View file

@ -19,11 +19,13 @@ import httpx
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REDACTED_BY_LITELLM
from litellm.integrations.custom_batch_logger import CustomBatchLogger
from litellm.integrations.datadog.datadog_handler import (
get_datadog_base_url_from_env,
get_datadog_service,
get_datadog_tags,
normalize_datadog_tag_value,
)
from litellm.integrations.datadog.datadog_mock_client import (
create_mock_datadog_client,
@ -34,6 +36,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
handle_any_messages_to_chat_completion_str_messages_conversion,
)
from litellm.litellm_core_utils.redact_messages import should_redact_message_logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.llms.custom_httpx.http_handler import (
@ -43,6 +46,7 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.proxy.spend_tracking.savings import extract_cache_creation_tokens, extract_cache_read_tokens
from litellm.types.integrations.datadog_llm_obs import *
from litellm.types.utils import (
PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
CallTypes,
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
@ -52,6 +56,120 @@ from litellm.types.utils import (
_EMPTY_MAPPING: Final[Mapping[str, Any]] = MappingProxyType({})
_EMPTY_MESSAGE: Final[Message] = {"role": "", "content": ""}
_MAX_PARSED_TOOL_ARGUMENT_CHARS: Final = 256 * 1024
_SAFE_REDACTED_MESSAGE_ROLES: Final = frozenset(
{"agent", "assistant", "developer", "function", "model", "system", "tool", "user"}
)
_PROMPT_CARRYING_METADATA_FIELDS: Final = frozenset(
{
"routing_decision",
"requester_metadata",
"prompt_management_metadata",
"mcp_tool_call_metadata",
"vector_store_request_metadata",
}
)
_ROUTER_SPAN_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
{
"tier": "router_tier",
"cause": "router_cause",
"score": "router_score",
"escalated": "router_escalated",
"signals": "router_signals",
"routed_model": "routed_model",
}
)
_ROUTER_DIMENSIONS: Final[tuple[str, ...]] = ("router_tier", "router_cause", "router_escalated", "routed_model")
_COST_DIMENSIONS: Final[tuple[str, ...]] = ("team", "user", "key_alias", "model_group", *_ROUTER_DIMENSIONS)
def _metadata_of(standard_logging_payload: StandardLoggingPayload) -> Mapping[str, Any]:
metadata: Final = standard_logging_payload.get("metadata")
return metadata or _EMPTY_MAPPING
def _router_span_fields(
standard_logging_payload: StandardLoggingPayload, redact_prompt_text: bool
) -> Mapping[str, object]:
"""Flatten the auto-router decision, omitting prompt-quoting fields when redaction is enabled."""
routing_decision: Final = _mapping_field(_metadata_of(standard_logging_payload), "routing_decision")
if not routing_decision:
return _EMPTY_MAPPING
escalated: Final = bool(routing_decision.get("escalated") or routing_decision.get("context_escalated"))
return MappingProxyType(
{
_ROUTER_SPAN_FIELDS[record_field]: value
for record_field, value in (*routing_decision.items(), ("escalated", escalated))
if record_field in _ROUTER_SPAN_FIELDS
and value is not None
and not (redact_prompt_text and record_field in PROMPT_QUOTING_ROUTING_DECISION_FIELDS)
}
)
def _metadata_without_prompt_carriers(standard_logging_metadata: Mapping[str, Any]) -> Mapping[str, Any]:
"""The metadata minus the records that quote prompts, tool arguments, tool results, or retrieved text."""
return MappingProxyType(
{
field: value
for field, value in standard_logging_metadata.items()
if field not in _PROMPT_CARRYING_METADATA_FIELDS
}
)
def _redact_messages(messages: Sequence[Message]) -> tuple[Message, ...]:
"""Each message's shape with its content replaced and tool payloads dropped; no message is invented."""
return tuple(
{
"role": role if isinstance(role, str) and role in _SAFE_REDACTED_MESSAGE_ROLES else "",
"content": REDACTED_BY_LITELLM,
}
for message in messages
for role in (message.get("role", ""),)
)
def _cost_dimension_tags(
standard_logging_payload: StandardLoggingPayload, router_fields: Mapping[str, object]
) -> tuple[str, ...]:
"""The dimensions LLM Obs breaks token and cost metrics down by, as span tags."""
metadata: Final = _metadata_of(standard_logging_payload)
dimensions: Final = (
("user", metadata.get("user_api_key_user_id")),
("key_alias", metadata.get("user_api_key_alias")),
("model_group", standard_logging_payload.get("model_group")),
*((dimension, router_fields.get(dimension)) for dimension in _ROUTER_DIMENSIONS),
)
return tuple(
f"{key}:{normalized}"
for key, value in dimensions
if value is not None and (normalized := normalize_datadog_tag_value(value)) != ""
)
def _declared_cost_tags(span_tags: Sequence[str]) -> tuple[str, ...]:
"""Declare only cost dimensions carrying a value on this span."""
present: Final = frozenset(key for tag in span_tags if (key := tag.partition(":")[0]) and tag.partition(":")[2])
return tuple(dimension for dimension in _COST_DIMENSIONS if dimension in present)
def _reasoning_output_tokens(usage_object: Mapping[str, Any] | None) -> float:
"""The provider's reasoning-token count, from either the chat or the responses spelling."""
if usage_object is None:
return 0.0
return next(
(
float(reasoning_tokens)
for details_field in ("completion_tokens_details", "output_tokens_details")
if isinstance(
reasoning_tokens := _mapping_field(usage_object, details_field).get("reasoning_tokens"), (int, float)
)
and not isinstance(reasoning_tokens, bool)
),
0.0,
)
def _mapping_field(source: Mapping[str, Any], key: str) -> Mapping[str, Any]:
@ -316,12 +434,12 @@ class DataDogLLMObsLogger(CustomBatchLogger):
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()
dict_datadog_llm_obs_params = litellm.datadog_llm_observability_params.model_dump(exclude_unset=True)
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()
).model_dump(exclude_unset=True)
return dict_datadog_llm_obs_params
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
@ -410,25 +528,40 @@ class DataDogLLMObsLogger(CustomBatchLogger):
if standard_logging_payload is None:
raise Exception("DataDogLLMObs: standard_logging_object is not set")
metadata: Final = kwargs.get("litellm_params", {}).get("metadata", {})
raw_metadata: Final = kwargs.get("litellm_params", {}).get("metadata", {})
metadata: Final = raw_metadata if isinstance(raw_metadata, dict) else {}
redact_payload: Final = self._payload_logging_is_off(kwargs)
input_meta: Final = InputMeta(messages=_to_dd_messages(standard_logging_payload["messages"]))
input_messages: Final = _to_dd_messages(standard_logging_payload.get("messages"))
output_messages: Final = self._get_response_messages(
standard_logging_payload=standard_logging_payload,
call_type=standard_logging_payload.get("call_type"),
)
input_meta: Final = InputMeta(messages=_redact_messages(input_messages) if redact_payload else input_messages)
output_meta: Final = OutputMeta(
messages=self._get_response_messages(
standard_logging_payload=standard_logging_payload,
call_type=standard_logging_payload.get("call_type"),
)
messages=_redact_messages(output_messages) if redact_payload else output_messages
)
error_info: Final = self._assemble_error_info(standard_logging_payload)
metadata_parent_id: str | None = None
if isinstance(metadata, dict):
metadata_parent_id = metadata.get("parent_id")
raw_parent_id: Final = metadata.get("parent_id")
metadata_parent_id: Final[str | None] = str(raw_parent_id) if raw_parent_id else None
tool_definitions: Final = _to_dd_tool_definitions(standard_logging_payload.get("model_parameters"))
tool_definitions: Final = (
() if redact_payload else _to_dd_tool_definitions(standard_logging_payload.get("model_parameters"))
)
span_kind: Final = self._get_datadog_span_kind(standard_logging_payload.get("call_type"), metadata_parent_id)
payload_metadata: Final = self._get_dd_llm_obs_payload_metadata(standard_logging_payload)
router_fields: Final = _router_span_fields(standard_logging_payload, redact_prompt_text=redact_payload)
span_tags: Final = [
*get_datadog_tags(standard_logging_object=standard_logging_payload),
*_cost_dimension_tags(standard_logging_payload, router_fields),
]
payload_metadata: Final = self._get_dd_llm_obs_payload_metadata(
standard_logging_payload,
router_fields=router_fields,
cost_tags=_declared_cost_tags(span_tags),
redact_prompt_text=redact_payload,
)
meta: Final[Meta] = {
"kind": span_kind,
@ -451,7 +584,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
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),
tags=span_tags,
)
apm_trace_id: Final = self._get_apm_trace_id()
@ -497,6 +630,13 @@ class DataDogLLMObsLogger(CustomBatchLogger):
)
return error_info
def _payload_logging_is_off(self, kwargs: Mapping[str, Any]) -> bool:
return (
bool(self.turn_off_message_logging)
or self.message_logging is not True
or should_redact_message_logging(dict(kwargs))
)
def _assemble_metrics(self, standard_logging_payload: StandardLoggingPayload) -> LLMMetrics:
"""
Build the span metrics, including the prompt-cache counts LLM Obs charts cache savings from.
@ -513,10 +653,11 @@ class DataDogLLMObsLogger(CustomBatchLogger):
total_cost: Final = float(standard_logging_payload.get("response_cost", 0))
time_to_first_token: Final = self._get_time_to_first_token_seconds(standard_logging_payload)
raw_usage: Final = (standard_logging_payload.get("metadata") or {}).get("usage_object")
raw_usage: Final = _metadata_of(standard_logging_payload).get("usage_object")
usage_object: Final = raw_usage if isinstance(raw_usage, dict) else None
cache_read: Final = float(extract_cache_read_tokens(usage_object))
cache_write: Final = float(extract_cache_creation_tokens(usage_object))
reasoning_output_tokens: Final = _reasoning_output_tokens(usage_object)
metrics: Final[LLMMetrics] = {
"input_tokens": prompt_tokens,
@ -533,6 +674,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
if cache_read or cache_write
else {}
),
**({"reasoning_output_tokens": reasoning_output_tokens} if reasoning_output_tokens else {}),
}
return metrics
@ -707,11 +849,21 @@ class DataDogLLMObsLogger(CustomBatchLogger):
# Default fallback for unknown or passthrough operations
return "llm"
def _get_dd_llm_obs_payload_metadata(self, standard_logging_payload: StandardLoggingPayload) -> dict[str, object]:
def _get_dd_llm_obs_payload_metadata(
self,
standard_logging_payload: StandardLoggingPayload,
router_fields: Mapping[str, object] | None = None,
cost_tags: Sequence[str] = (),
redact_prompt_text: bool = False,
) -> dict[str, object]:
"""
Fields to track in DD LLM Observability metadata from litellm standard logging payload
"""
_metadata: Final[dict[str, object]] = {
raw_metadata: Final = _metadata_of(standard_logging_payload)
standard_logging_metadata: Final = (
_metadata_without_prompt_carriers(raw_metadata) if redact_prompt_text else raw_metadata
)
return {
"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"),
@ -719,26 +871,21 @@ class DataDogLLMObsLogger(CustomBatchLogger):
"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),
"guardrail_information": (
None if redact_prompt_text else standard_logging_payload.get("guardrail_information", None)
),
"is_streamed_request": self._get_stream_value_from_payload(standard_logging_payload),
"latency_metrics": dict(self._get_latency_metrics(standard_logging_payload)),
"spend_metrics": dict(self._get_spend_metrics(standard_logging_payload)),
**standard_logging_metadata,
**(router_fields or _EMPTY_MAPPING),
**(
{"_dd": {**_mapping_field(standard_logging_metadata, "_dd"), "cost_tags": list(cost_tags)}}
if cost_tags
else _EMPTY_MAPPING
),
}
#########################################################
# Add latency metrics to metadata
#########################################################
latency_metrics: Final = self._get_latency_metrics(standard_logging_payload)
_metadata.update({"latency_metrics": dict(latency_metrics)})
#########################################################
# Add spend metrics to metadata
#########################################################
spend_metrics: Final = self._get_spend_metrics(standard_logging_payload)
_metadata.update({"spend_metrics": dict(spend_metrics)})
_standard_logging_metadata: Final[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
@ -808,7 +955,7 @@ class DataDogLLMObsLogger(CustomBatchLogger):
spend_metrics["response_cost"] = standard_logging_payload.get("response_cost", 0.0)
# Get budget information from metadata
metadata: Final = standard_logging_payload.get("metadata", {})
metadata: Final = _metadata_of(standard_logging_payload)
# API key max budget
user_api_key_max_budget: Final = metadata.get("user_api_key_max_budget")

View file

@ -86,6 +86,7 @@ class LLMMetrics(TypedDict, total=False):
cache_read_input_tokens: ReadOnly[float]
cache_write_input_tokens: ReadOnly[float]
non_cached_input_tokens: ReadOnly[float]
reasoning_output_tokens: ReadOnly[float]
class LLMObsPayload(TypedDict, total=False):

View file

@ -17,6 +17,7 @@ from unittest.mock import patch
import pytest
import litellm
from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@ -55,15 +56,22 @@ def build_payload(
response_message: dict[str, Any] | None = None,
usage_object: dict[str, Any] | None = None,
model_parameters: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
model_group: str | None = None,
prompt_tokens: int = 4447,
) -> dict[str, Any]:
standard_logging_metadata: dict[str, Any] = {
**(metadata or {}),
**({"usage_object": usage_object} if usage_object is not None else {}),
}
return {
"standard_logging_object": {
"call_type": "acompletion",
"messages": [{"role": "user", "content": "hi"}] if messages is NOT_GIVEN else messages,
"response": {"choices": [{"message": response_message or {"role": "assistant", "content": "hello"}}]},
"model_parameters": model_parameters or {},
"metadata": {"usage_object": usage_object} if usage_object is not None else {},
"metadata": standard_logging_metadata,
"model_group": model_group,
"prompt_tokens": prompt_tokens,
"completion_tokens": 507,
"total_tokens": prompt_tokens + 507,
@ -244,6 +252,43 @@ def test_no_cache_keys_when_the_provider_reports_no_caching(logger: DataDogLLMOb
assert "non_cached_input_tokens" not in payload["metrics"]
def test_reasoning_tokens_are_reported_as_span_metrics(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, usage_object={"completion_tokens_details": {"reasoning_tokens": 128}})
assert payload["metrics"]["reasoning_output_tokens"] == 128.0
def test_responses_reasoning_tokens_are_reported_as_span_metrics(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, usage_object={"output_tokens_details": {"reasoning_tokens": 64}})
assert payload["metrics"]["reasoning_output_tokens"] == 64.0
def test_zero_reasoning_tokens_are_not_reported(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, usage_object={"completion_tokens_details": {"reasoning_tokens": 0}})
assert "reasoning_output_tokens" not in payload["metrics"]
def test_reasoning_tokens_come_from_the_spelling_that_reports_them(logger: DataDogLLMObsLogger) -> None:
"""A chat-details mapping without the count must not shadow the responses spelling that has it."""
payload = build(
logger,
usage_object={
"completion_tokens_details": {"accepted_prediction_tokens": 5},
"output_tokens_details": {"reasoning_tokens": 64},
},
)
assert payload["metrics"]["reasoning_output_tokens"] == 64.0
def test_boolean_reasoning_tokens_are_not_a_count(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, usage_object={"completion_tokens_details": {"reasoning_tokens": True}})
assert "reasoning_output_tokens" not in payload["metrics"]
def test_tool_definitions_are_sent_on_meta(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, model_parameters={"tools": [TOOL_DEFINITION]})
@ -256,6 +301,340 @@ def test_tool_definitions_are_sent_on_meta(logger: DataDogLLMObsLogger) -> None:
]
def test_cost_tags_include_present_categories_and_dimensions(logger: DataDogLLMObsLogger) -> None:
payload = build(
logger,
metadata={
"user_api_key_user_id": "User 42",
"user_api_key_alias": "Primary Key",
"team_alias": "Platform",
"routing_decision": {
"tier": "premium",
"cause": "high_complexity",
"score": 0.91,
"escalated": True,
"signals": ["long prompt"],
"routed_model": "openai/gpt-5",
},
},
model_group="premium-models",
)
assert payload["tags"][-8:] == [
"team:platform",
"user:user_42",
"key_alias:primary_key",
"model_group:premium-models",
"router_tier:premium",
"router_cause:high_complexity",
"router_escalated:true",
"routed_model:openai/gpt-5",
]
assert payload["meta"]["metadata"]["_dd"]["cost_tags"] == [
"team",
"user",
"key_alias",
"model_group",
"router_tier",
"router_cause",
"router_escalated",
"routed_model",
]
def test_missing_cost_tag_values_are_not_declared(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, metadata={"team_alias": "Platform"})
assert payload["meta"]["metadata"]["_dd"]["cost_tags"] == ["team"]
assert not any(tag.startswith(("user:", "key_alias:", "model_group:")) for tag in payload["tags"])
def test_values_that_normalize_to_empty_are_not_tagged_or_declared(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, metadata={"user_api_key_user_id": "___", "user_api_key_alias": "!!!"}, model_group="tier-1")
assert not any(tag in ("user:", "key_alias:") for tag in payload["tags"])
assert payload["meta"]["metadata"]["_dd"]["cost_tags"] == ["model_group"]
def test_a_valueless_tag_from_the_shared_builder_is_not_declared(logger: DataDogLLMObsLogger) -> None:
"""The team tag comes from the shared builder, which emits it bare when the alias normalizes away."""
payload = build(logger, metadata={"team_alias": "!!!"}, model_group="tier-1")
assert "team:" in payload["tags"]
assert payload["meta"]["metadata"]["_dd"]["cost_tags"] == ["model_group"]
def test_router_fields_are_flattened(logger: DataDogLLMObsLogger) -> None:
payload = build(
logger,
metadata={
"routing_decision": {
"tier": "premium",
"cause": "high_complexity",
"score": 0.91,
"escalated": True,
"signals": ["secret prompt text"],
"routed_model": "openai/gpt-5",
}
},
model_group="premium-models",
)
assert payload["meta"]["metadata"]["router_tier"] == "premium"
assert payload["meta"]["metadata"]["router_cause"] == "high_complexity"
assert payload["meta"]["metadata"]["router_score"] == 0.91
assert payload["meta"]["metadata"]["router_escalated"] is True
assert payload["meta"]["metadata"]["router_signals"] == ["secret prompt text"]
assert payload["meta"]["metadata"]["routed_model"] == "openai/gpt-5"
def test_a_context_escalated_route_reports_as_escalated(logger: DataDogLLMObsLogger) -> None:
"""The router records a size-driven escalation under its own key, and it is still an escalation."""
payload = build(logger, metadata={"routing_decision": {"tier": "premium", "context_escalated": True}})
assert payload["meta"]["metadata"]["router_escalated"] is True
assert "router_escalated:true" in payload["tags"]
def test_a_routed_request_that_did_not_escalate_reports_false(logger: DataDogLLMObsLogger) -> None:
"""Without this the escalation dimension is absent on ordinary traffic, so nothing can group by it."""
payload = build(logger, metadata={"routing_decision": {"tier": "simple", "cause": "heuristic_scorer"}})
assert payload["meta"]["metadata"]["router_escalated"] is False
assert "router_escalated:false" in payload["tags"]
assert "router_escalated" in payload["meta"]["metadata"]["_dd"]["cost_tags"]
def test_a_request_that_never_reached_a_router_has_no_router_fields(logger: DataDogLLMObsLogger) -> None:
payload = build(logger, model_group="premium-models")
assert "router_escalated" not in payload["meta"]["metadata"]
assert not any(tag.startswith("router_") for tag in payload["tags"])
def test_redacted_payload_keeps_metrics_and_removes_sensitive_fields(logger: DataDogLLMObsLogger) -> None:
payload = build_payload(
messages=[{"role": "user", "content": "secret prompt"}],
response_message={"role": "assistant", "content": "secret response"},
usage_object={"prompt_tokens_details": {"cached_tokens": 128}},
metadata={"routing_decision": {"tier": "premium", "signals": ["secret prompt text"]}},
model_parameters={"tools": [TOOL_DEFINITION]},
)
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
redacted_logger = DataDogLLMObsLogger(turn_off_message_logging=True)
redacted_payload = redacted_logger.redact_standard_logging_payload_from_model_call_details(payload)
result = json.loads(
safe_dumps(
redacted_logger.create_llm_obs_payload(
redacted_payload, datetime(2026, 9, 1, 12, 0, 0), datetime(2026, 9, 1, 12, 0, 2)
)
)
)
assert result["meta"]["input"]["messages"][0]["content"] == "redacted-by-litellm"
assert result["meta"]["output"]["messages"][0]["content"] == "redacted-by-litellm"
assert result["meta"]["metadata"]["router_tier"] == "premium"
assert "router_signals" not in result["meta"]["metadata"]
assert "routing_decision" not in result["meta"]["metadata"]
assert "tool_definitions" not in result["meta"]
assert result["metrics"]["cache_read_input_tokens"] == 128.0
assert result["metrics"]["total_cost"] == 0.02
def test_redaction_drops_the_routing_record_carried_in_metadata(logger: DataDogLLMObsLogger) -> None:
"""The whole routing record rides along in metadata, so dropping the flat copy alone leaks the prompt."""
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
redacted_logger = DataDogLLMObsLogger(turn_off_message_logging=True)
result = json.loads(
safe_dumps(
redacted_logger.create_llm_obs_payload(
build_payload(
metadata={
"routing_decision": {
"tier": "premium",
"cause": "keyword_rule",
"signals": ["secret prompt text"],
"matched_keyword": "secret keyword",
"escalation_keyword": "secret escalation",
}
}
),
datetime(2026, 9, 1, 12, 0, 0),
datetime(2026, 9, 1, 12, 0, 2),
)
)
)
assert "routing_decision" not in result["meta"]["metadata"]
assert result["meta"]["metadata"]["router_tier"] == "premium"
assert result["meta"]["metadata"]["router_cause"] == "keyword_rule"
assert "secret" not in safe_dumps(result["meta"]["metadata"])
def test_a_failure_span_redacts_its_messages(logger: DataDogLLMObsLogger) -> None:
"""The redaction hook only runs on success, so the failure span has to redact for itself."""
failed = build_payload(messages=[{"role": "user", "content": "secret prompt"}])
failed["standard_logging_object"]["status"] = "failure"
failed["standard_logging_object"]["response"] = None
failed["standard_logging_object"]["error_information"] = {"error_message": "boom", "error_class": "BadRequestError"}
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
redacted_logger = DataDogLLMObsLogger(turn_off_message_logging=True)
result = json.loads(
safe_dumps(
redacted_logger.create_llm_obs_payload(
failed, datetime(2026, 9, 1, 12, 0, 0), datetime(2026, 9, 1, 12, 0, 2)
)
)
)
assert result["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert result["meta"]["output"]["messages"] == []
assert result["status"] == "error"
def test_excluding_messages_from_the_logging_payload_still_ships_the_span(logger: DataDogLLMObsLogger) -> None:
"""`standard_logging_payload_excluded_fields` deletes the key, and a span with no prompt is still a span."""
payload = build_payload()
del payload["standard_logging_object"]["messages"]
span = json.loads(
safe_dumps(
logger.create_llm_obs_payload(payload, datetime(2026, 9, 1, 12, 0, 0), datetime(2026, 9, 1, 12, 0, 2))
)
)
assert span["meta"]["input"]["messages"] == []
assert span["metrics"]["total_cost"] == 0.02
def test_an_explicit_redaction_setting_survives_the_global_params(logger: DataDogLLMObsLogger) -> None:
"""Global params carry defaults for keys the operator never set, and those must not win."""
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
with patch.object( # test-quality-ok: the ctor reads this module global with no injection seam
litellm, "datadog_llm_observability_params", {}
):
configured_logger = DataDogLLMObsLogger(
turn_off_message_logging=True
) # test-quality-ok: verifies ctor setting
assert configured_logger.turn_off_message_logging is True
def _redacting_logger(
**kwargs: Any,
) -> DataDogLLMObsLogger: # test-quality-ok: shared test factory accepts init variants
with patch.dict(os.environ, {"DD_API_KEY": "k", "DD_SITE": "us5.datadoghq.com"}, clear=True):
with patch("asyncio.create_task"):
return DataDogLLMObsLogger(**kwargs)
def _span_json(logger_under_test: DataDogLLMObsLogger, payload: dict[str, Any]) -> dict[str, Any]:
span = logger_under_test.create_llm_obs_payload(
payload, datetime(2026, 9, 1, 12, 0, 0), datetime(2026, 9, 1, 12, 0, 2)
)
return json.loads(safe_dumps(span))
def test_redaction_keeps_the_conversation_shape_without_its_content() -> None:
"""Roles and message count survive so the trace stays legible; contents and tool payloads do not."""
result = _span_json(
_redacting_logger(turn_off_message_logging=True),
build_payload(
messages=[
{"role": "user", "content": "secret prompt"},
{"role": "assistant", "content": None, "tool_calls": [ASSISTANT_TOOL_CALL]},
],
response_message={"role": "assistant", "content": "secret response"},
),
)
assert result["meta"]["input"]["messages"] == [
{"role": "user", "content": "redacted-by-litellm"},
{"role": "assistant", "content": "redacted-by-litellm"},
]
assert result["meta"]["output"]["messages"] == [{"role": "assistant", "content": "redacted-by-litellm"}]
def test_redaction_drops_unrecognized_and_malformed_message_roles() -> None:
"""Caller-controlled role values must not bypass redaction or crash span creation."""
result = _span_json(
_redacting_logger(turn_off_message_logging=True),
build_payload(
messages=[
{"role": "SECRET-39402", "content": "hello"},
{"role": ["SECRET-39402"], "content": "hello"},
{"role": {"secret": "SECRET-39402"}, "content": "hello"},
{"role": "agent", "content": "hello"},
]
),
)
assert result["meta"]["input"]["messages"] == [
{"role": "", "content": "redacted-by-litellm"},
{"role": "", "content": "redacted-by-litellm"},
{"role": "", "content": "redacted-by-litellm"},
{"role": "agent", "content": "redacted-by-litellm"},
]
assert "SECRET-39402" not in safe_dumps(result)
def test_the_deprecated_message_logging_flag_engages_the_same_redaction() -> None:
"""The platform redacts for `message_logging is not True`, so this callback's own gate must agree."""
result = _span_json(
_redacting_logger(message_logging=False),
build_payload(
messages=[{"role": "user", "content": "secret prompt"}],
model_parameters={"tools": [TOOL_DEFINITION]},
metadata={"routing_decision": {"tier": "premium", "signals": ["secret prompt text"]}},
),
)
assert result["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert "tool_definitions" not in result["meta"]
assert "routing_decision" not in result["meta"]["metadata"]
def test_a_truthy_redaction_setting_redacts_like_the_shared_hook() -> None:
"""The shared hook redacts on truthiness, so a config-provided string must not half-redact the span."""
result = _span_json(
_redacting_logger(turn_off_message_logging="yes"),
build_payload(messages=[{"role": "user", "content": "secret prompt"}]),
)
assert result["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
def test_redaction_drops_every_prompt_carrying_metadata_record(logger: DataDogLLMObsLogger) -> None:
"""Tool arguments, retrieved text, and the guardrail's copy of the request ride in metadata records too."""
sensitive_metadata: dict[str, Any] = {
"requester_metadata": {"note": "secret prompt text"},
"prompt_management_metadata": {"prompt_id": "p1", "prompt_variables": {"topic": "secret"}},
"mcp_tool_call_metadata": {"name": "search", "arguments": {"query": "secret"}},
"vector_store_request_metadata": [{"query": "secret"}],
}
def sensitive_payload() -> dict[str, Any]:
payload = build_payload(metadata=sensitive_metadata)
payload["standard_logging_object"]["guardrail_information"] = [
{"guardrail_name": "g", "guardrail_request": {"messages": [{"content": "secret prompt"}]}}
]
return payload
redacted = _span_json(_redacting_logger(turn_off_message_logging=True), sensitive_payload())
unredacted = _span_json(logger, sensitive_payload())
assert "secret" not in safe_dumps(redacted["meta"]["metadata"])
for record in sensitive_metadata:
assert record not in redacted["meta"]["metadata"]
assert record in unredacted["meta"]["metadata"]
assert redacted["meta"]["metadata"]["guardrail_information"] is None
assert unredacted["meta"]["metadata"]["guardrail_information"] is not None
def test_tool_definitions_accept_the_bare_anthropic_shape(logger: DataDogLLMObsLogger) -> None:
"""The Anthropic surface declares tools unwrapped, with input_schema instead of parameters."""
payload = build(
@ -272,6 +651,15 @@ def test_meta_omits_tool_definitions_when_no_tools_were_offered(logger: DataDogL
assert "tool_definitions" not in build(logger)["meta"]
def test_a_ddtrace_integer_parent_id_is_forwarded_as_its_string(logger: DataDogLLMObsLogger) -> None:
"""ddtrace hands span ids as ints; dropping them detaches the span from its APM trace."""
kwargs = build_payload()
kwargs["litellm_params"]["metadata"]["parent_id"] = 8675309
start = datetime(2026, 9, 1, 12, 0, 0)
span = json.loads(safe_dumps(logger.create_llm_obs_payload(kwargs, start, start + timedelta(seconds=2))))
assert span["parent_id"] == "8675309"
def test_unparseable_tool_arguments_are_preserved_rather_than_dropped(logger: DataDogLLMObsLogger) -> None:
"""A truncated argument string is still the only record of what the model tried to call."""
payload = build(

View file

@ -1,6 +1,6 @@
{
"LIT001": {
"limit": 22334
"limit": 22330
},
"LIT002": {
"limit": 26763
@ -27,7 +27,7 @@
"limit": 0
},
"LIT010": {
"limit": 16480
"limit": 16478
},
"LIT011": {
"limit": 5520