fix(datadog_llm_obs): keep the guardrail audit record under message redaction (#39702)

* fix(datadog_llm_obs): keep the guardrail audit record under message redaction

Redaction nulled `guardrail_information` on the span whole, so an operator
running `turn_off_message_logging` (or a caller sending
`x-litellm-enable-message-redaction`) lost the record of which guardrails ran,
what they returned, and what they masked. Four of the record's fields can quote
the prompt; the rest report what the guardrail decided without reproducing it.

Replace only those four, the way
`_sanitize_guardrail_information_for_spend_logs` already does for spend logs,
and declare the field list once in `litellm/types/utils.py` so both readers
share it.

* fix(datadog_llm_obs): keep a lone guardrail record, and test through the span

Review round 1.

A guardrail that writes the metadata key itself leaves a single record where
the type says list, which Prometheus already normalizes at
`_guardrail_overhead_seconds`. Redaction dropped that shape and the latency
extraction raised on it, so the span was lost outright. Normalize once and use
it in both places.

The new tests now drive `create_llm_obs_payload` instead of reading the module's
private helpers and the record's declared field names.
This commit is contained in:
yucheng-berri 2026-09-04 18:24:16 -07:00 committed by GitHub
parent 41c8c2f410
commit e2741b5643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 228 additions and 19 deletions

View file

@ -19,7 +19,7 @@ import httpx
import litellm
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import REDACTED_BY_LITELLM
from litellm.constants import REDACTED_BY_LITELLM, REDACTED_BY_LITELM_STRING
from litellm.integrations.custom_batch_logger import CustomBatchLogger
from litellm.integrations.datadog.datadog_handler import (
get_datadog_base_url_from_env,
@ -46,9 +46,10 @@ 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 (
AUDIT_GUARDRAIL_FIELDS,
PROMPT_CARRYING_GUARDRAIL_FIELDS,
PROMPT_QUOTING_ROUTING_DECISION_FIELDS,
CallTypes,
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
StandardLoggingPayloadErrorInformation,
)
@ -60,6 +61,8 @@ _SAFE_REDACTED_MESSAGE_ROLES: Final = frozenset(
{"agent", "assistant", "developer", "function", "model", "system", "tool", "user"}
)
_CLASSIFIED_GUARDRAIL_FIELDS: Final = AUDIT_GUARDRAIL_FIELDS | PROMPT_CARRYING_GUARDRAIL_FIELDS
_PROMPT_CARRYING_METADATA_FIELDS: Final = frozenset(
{
"routing_decision",
@ -108,6 +111,49 @@ def _router_span_fields(
)
def _guardrail_entries(guardrail_information: object) -> tuple[Mapping[str, object], ...]:
"""The guardrail records as a sequence, whatever shape the payload carries.
`guardrail_information` is typed as a list, but a guardrail that writes the metadata key itself
can leave a single record there; Prometheus normalizes the same shape at
`_guardrail_overhead_seconds`.
"""
if isinstance(guardrail_information, Mapping):
return (guardrail_information,)
if isinstance(guardrail_information, (list, tuple)):
return tuple(entry for entry in guardrail_information if isinstance(entry, Mapping))
return ()
def _guardrail_entry_without_prompt_carriers(entry: Mapping[str, object]) -> Mapping[str, object]:
"""One guardrail record kept as its audit fields, with the prompt-quoting ones marked redacted.
Built as an allow-list rather than a deny-list: a key neither set classifies is dropped, so a
guardrail that records its own extra detail cannot put the caller's prompt on a redacted span.
"""
return { # mutable-ok: a fresh record built per entry, handed straight to the span serializer
field: REDACTED_BY_LITELM_STRING if field in PROMPT_CARRYING_GUARDRAIL_FIELDS else value
for field, value in entry.items()
if field in _CLASSIFIED_GUARDRAIL_FIELDS
}
def _guardrail_information_without_prompt_carriers(
guardrail_information: object,
) -> tuple[Mapping[str, object], ...] | None:
"""The guardrail records reduced to what a redacted span may carry.
Redaction removes the prompt, not the record that a guardrail ran: the name, mode, status,
timings and masked-entity counts are what an operator reads to answer whether a guardrail
caught anything on a request, and none of them reproduce the prompt. Field-level rather than
dropping the list, which is what `_sanitize_guardrail_information_for_spend_logs` already does
for spend logs.
"""
if guardrail_information is None:
return None
return tuple(_guardrail_entry_without_prompt_carriers(entry) for entry in _guardrail_entries(guardrail_information))
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(
@ -872,7 +918,9 @@ class DataDogLLMObsLogger(CustomBatchLogger):
"cache_key": standard_logging_payload.get("cache_key", "unknown"),
"saved_cache_cost": standard_logging_payload.get("saved_cache_cost", 0),
"guardrail_information": (
None if redact_prompt_text else standard_logging_payload.get("guardrail_information", None)
_guardrail_information_without_prompt_carriers(standard_logging_payload.get("guardrail_information"))
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)),
@ -904,14 +952,12 @@ class DataDogLLMObsLogger(CustomBatchLogger):
latency_metrics["litellm_overhead_time_ms"] = litellm_overhead_ms
# Guardrail overhead latency
guardrail_info: Final[list[StandardLoggingGuardrailInformation] | None] = standard_logging_payload.get(
"guardrail_information"
)
if guardrail_info is not None:
guardrail_info: Final = _guardrail_entries(standard_logging_payload.get("guardrail_information"))
if guardrail_info:
total_duration = 0.0
for info in guardrail_info:
_guardrail_duration_seconds: float | None = info.get("duration")
if _guardrail_duration_seconds is not None:
_guardrail_duration_seconds = info.get("duration")
if isinstance(_guardrail_duration_seconds, (int, float, str)):
total_duration += float(_guardrail_duration_seconds)
if total_duration > 0:

View file

@ -37,6 +37,7 @@ from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsR
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
CallTypes,
CostBreakdown,
StandardLoggingGuardrailInformation,
@ -1073,13 +1074,6 @@ def _sanitize_guardrail_information_for_spend_logs(
return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)]
_PROMPT_CARRYING_GUARDRAIL_FIELDS: Final = (
"guardrail_request",
"guardrail_response",
"match_details",
"classification",
)
_NUMERIC_COMPRESSION_STAT_KEYS: Final = (
"tokens_before",
"tokens_after",
@ -1114,7 +1108,7 @@ def _redact_prompt_fields_in_guardrail_entry(
preserved_stats: Final = _numeric_compression_stats_from_guardrail_response(entry.get("guardrail_response"))
redacted: Final[StandardLoggingGuardrailInformation] = {
**entry,
**{key: REDACTED_BY_LITELM_STRING for key in _PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry},
**{key: REDACTED_BY_LITELM_STRING for key in PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry},
}
if preserved_stats is None:
return redacted

View file

@ -3080,6 +3080,50 @@ class GuardrailMode(TypedDict, total=False):
GuardrailStatus = Literal["success", "guardrail_intervened", "guardrail_failed_to_respond", "not_run"]
# Fields on a guardrail record whose values can quote the caller's prompt: the payload sent to the
# guardrail, the provider response that echoes it back, and the two first-party hooks that inline
# prompt substrings (``block_code_execution`` and ``litellm_content_filter``). Every other field
# reports what the guardrail decided without reproducing the prompt, so redaction replaces these
# four and keeps the rest of the record.
PROMPT_CARRYING_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset(
{
"guardrail_request",
"guardrail_response",
"match_details",
"classification",
}
)
# The rest of the record: what the guardrail is, what it decided, how long it took and what it cost.
# None of these reproduce the prompt, so a redacted record keeps them and stays explainable.
# `test_every_guardrail_field_is_classified` fails if a field is added to the record without being
# placed in one set or the other, so a new field is dropped from redacted records rather than
# shipped unexamined.
AUDIT_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset(
{
"guardrail_name",
"guardrail_provider",
"guardrail_mode",
"guardrail_status",
"start_time",
"end_time",
"duration",
"masked_entity_count",
"guardrail_id",
"policy_template",
"detection_method",
"confidence_score",
"patterns_checked",
"alert_recipients",
"risk_score",
"violation_categories",
"guardrail_action",
"guardrail_usage",
"guardrail_cost",
"guardrail_cost_in_spend",
}
)
class StandardLoggingGuardrailInformation(TypedDict, total=False):
guardrail_name: str | None

View file

@ -12,7 +12,7 @@ spelling of prompt-cache counts (`prompt_tokens_details.cached_tokens`).
import json
import os
from datetime import datetime, timedelta
from typing import Any
from typing import Any, Final
from unittest.mock import patch
import pytest
@ -20,6 +20,8 @@ import pytest
import litellm
from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import StandardLoggingGuardrailInformation
TOOL_DEFINITION: dict[str, Any] = {
"type": "function",
@ -631,10 +633,133 @@ def test_redaction_drops_every_prompt_carrying_metadata_record(logger: DataDogLL
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 redacted["meta"]["metadata"]["guardrail_information"] == [
{"guardrail_name": "g", "guardrail_request": "REDACTED_BY_LITELM"}
] # the record survives; only the field quoting the prompt is replaced
assert unredacted["meta"]["metadata"]["guardrail_information"] is not None
_AUDIT_RECORD: Final[StandardLoggingGuardrailInformation] = StandardLoggingGuardrailInformation(
guardrail_name="bedrock-pii",
guardrail_provider="bedrock",
guardrail_mode=GuardrailEventHooks.pre_call,
guardrail_status="guardrail_intervened",
guardrail_response={"action": "MASK", "match": "alice@acme.com"},
match_details=[{"pattern": "email", "match": "alice@acme.com"}],
classification="the user asked for alice@acme.com",
masked_entity_count={"EMAIL": 2},
violation_categories=["pii"],
duration=0.01,
)
def _payload_with_guardrail_record(guardrail_information: object) -> dict[str, Any]:
payload = build_payload()
payload["standard_logging_object"]["guardrail_information"] = guardrail_information
return payload
def test_redaction_keeps_the_guardrail_audit_record(logger: DataDogLLMObsLogger) -> None:
"""Redaction removes the prompt, not the operator's record that a guardrail intervened."""
redacted = _span_json(
_redacting_logger(turn_off_message_logging=True),
_payload_with_guardrail_record([dict(_AUDIT_RECORD)]),
)
record = redacted["meta"]["metadata"]["guardrail_information"][0]
for field in ("guardrail_request", "guardrail_response", "match_details", "classification"):
assert record.get(field, "REDACTED_BY_LITELM") == "REDACTED_BY_LITELM"
assert record["guardrail_name"] == "bedrock-pii"
assert record["guardrail_provider"] == "bedrock"
assert record["guardrail_mode"] == "pre_call"
assert record["guardrail_status"] == "guardrail_intervened"
assert record["masked_entity_count"] == {"EMAIL": 2}
assert record["violation_categories"] == ["pii"]
assert record["duration"] == 0.01
assert "alice@acme.com" not in safe_dumps(redacted["meta"]["metadata"])
def test_a_caller_supplied_redaction_header_cannot_blank_the_guardrail_record(
logger: DataDogLLMObsLogger,
) -> None:
"""Any key may redact its own prompts with the header; none may erase what a guardrail caught."""
payload = _payload_with_guardrail_record([dict(_AUDIT_RECORD)])
payload["litellm_params"] = {"metadata": {"headers": {"x-litellm-enable-message-redaction": "true"}}}
span = _span_json(logger, payload)
record = span["meta"]["metadata"]["guardrail_information"][0]
assert span["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert record["guardrail_status"] == "guardrail_intervened"
assert record["masked_entity_count"] == {"EMAIL": 2}
assert "alice@acme.com" not in safe_dumps(span["meta"]["metadata"])
def test_a_guardrails_own_extra_field_never_reaches_a_redacted_span(logger: DataDogLLMObsLogger) -> None:
"""A guardrail may record whatever it likes; only classified fields survive redaction."""
span = _span_json(
_redacting_logger(turn_off_message_logging=True),
_payload_with_guardrail_record([{**_AUDIT_RECORD, "matched_text": "the caller asked about alice@acme.com"}]),
)
record = span["meta"]["metadata"]["guardrail_information"][0]
assert "matched_text" not in record
assert record["guardrail_status"] == "guardrail_intervened"
assert "alice@acme.com" not in safe_dumps(span["meta"]["metadata"])
def test_a_lone_guardrail_record_survives_redaction(logger: DataDogLLMObsLogger) -> None:
"""A guardrail that writes the metadata key itself leaves one record, not a list of them."""
span = _span_json(
_redacting_logger(turn_off_message_logging=True),
_payload_with_guardrail_record(dict(_AUDIT_RECORD)),
)
metadata = span["meta"]["metadata"]
assert metadata["guardrail_information"] == [
{
"guardrail_name": "bedrock-pii",
"guardrail_provider": "bedrock",
"guardrail_mode": "pre_call",
"guardrail_status": "guardrail_intervened",
"guardrail_response": "REDACTED_BY_LITELM",
"match_details": "REDACTED_BY_LITELM",
"classification": "REDACTED_BY_LITELM",
"masked_entity_count": {"EMAIL": 2},
"violation_categories": ["pii"],
"duration": 0.01,
}
]
assert metadata["latency_metrics"]["guardrail_overhead_time_ms"] == 10.0
@pytest.mark.parametrize("guardrail_information", [None, [], 5, "abc", [None, "x"], {}])
def test_odd_guardrail_shapes_still_produce_a_span(
guardrail_information: object,
) -> None:
"""The redacted branch replaced an expression that could not fail, so it must not start failing."""
span = _span_json(
_redacting_logger(turn_off_message_logging=True),
_payload_with_guardrail_record(guardrail_information),
)
assert span["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
assert span["meta"]["metadata"]["guardrail_information"] in (None, [], [{}])
def test_a_redacted_span_carries_every_declared_guardrail_field() -> None:
"""A field added to the record without a redaction decision would be dropped, so it fails here."""
declared = dict.fromkeys(StandardLoggingGuardrailInformation.__annotations__, "alice@acme.com")
payload = _payload_with_guardrail_record([{**declared, "duration": 0.01}])
span = _span_json(_redacting_logger(turn_off_message_logging=True), payload)
record = span["meta"]["metadata"]["guardrail_information"][0]
assert set(record) == set(declared)
for field in ("guardrail_request", "guardrail_response", "match_details", "classification"):
assert record[field] == "REDACTED_BY_LITELM"
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(