refactor(logging): build failure redaction values in one shot

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-29 09:25:33 +00:00
parent 935f51a8b6
commit 63f91e7177
10 changed files with 80 additions and 35 deletions

View file

@ -67,6 +67,22 @@ _BASE64_INLINE_PATTERN: Final = re.compile(
)
def _redacted_failure_error_fields(standard_logging_object: Mapping[str, object]) -> dict[str, object]:
from litellm.litellm_core_utils.redact_messages import redact_error_information
fields: Final[dict[str, object]] = {} # mutable-ok: merged into the standard_logging_object copy below
if standard_logging_object.get("error_str"):
fields["error_str"] = REDACTED_BY_LITELLM
error_information: Final = standard_logging_object.get("error_information")
if isinstance(error_information, Mapping):
fields["error_information"] = redact_error_information(
cast( # cast-ok: same TypedDict shape as the input mapping
StandardLoggingPayloadErrorInformation, error_information
)
)
return fields
class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callback#callback-class
# Class variables or attributes
server_fulfilled_tool_names: ClassVar[frozenset[str]] = frozenset()
@ -905,7 +921,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
This method handles two features:
1. turn_off_message_logging: When True, redacts messages and responses (unless the callback
redacts them itself, see `redacts_messages_itself`)
redacts them itself, see `redacts_messages_itself`), and redacts `error_str`,
`error_information`'s message/traceback and `traceback_exception` independent of
`redacts_messages_itself`
2. standard_logging_payload_excluded_fields: Removes specified fields entirely
Return a modified copy of the provided logging payload.
@ -966,15 +984,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
standard_logging_object_copy["response"] = model_response_dict
if turn_off_message_logging:
if standard_logging_object_copy.get("error_str"):
standard_logging_object_copy["error_str"] = REDACTED_BY_LITELLM
error_information: Final = standard_logging_object_copy.get("error_information")
if isinstance(error_information, Mapping):
from litellm.litellm_core_utils.redact_messages import redact_error_information
standard_logging_object_copy["error_information"] = redact_error_information(
cast(StandardLoggingPayloadErrorInformation, dict(error_information))
)
standard_logging_object_copy.update(_redacted_failure_error_fields(standard_logging_object_copy))
params: Final = model_call_details.get("litellm_params")
request: Final = params.get("proxy_server_request") if isinstance(params, dict) else None

View file

@ -94,7 +94,7 @@ class MlflowLogger(CustomLogger):
span.add_event(
SpanEvent(
name="exception",
attributes={
attributes={ # mutable-ok: mlflow SpanEvent expects a plain dict of attributes
"exception.type": type(exception).__name__,
"exception.message": REDACTED_BY_LITELLM,
"exception.stacktrace": REDACTED_BY_LITELLM,

View file

@ -2244,7 +2244,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
if redact:
span.record_exception(
exception,
attributes={
attributes={ # mutable-ok: record_exception accepts a dict of event attributes
"exception.message": REDACTED_BY_LITELLM,
"exception.stacktrace": REDACTED_BY_LITELLM,
},
@ -3601,13 +3601,13 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
should_redact_message_logging,
)
redact: Final = should_redact_message_logging({})
redact: Final = should_redact_message_logging({}) # mutable-ok: read-only probe for the global flag
error_information: Final = StandardLoggingPayloadSetup.get_error_information(original_exception=exception)
error_information["error_code"] = str(status_code)
self._record_exception_on_span(
span=span,
kwargs={
"standard_logging_object": {
kwargs={ # mutable-ok: _record_exception_on_span reads this kwargs dict
"standard_logging_object": { # mutable-ok: standard_logging_object shape the recorder expects
"error_information": redact_error_information(error_information) if redact else error_information
}
},

View file

@ -97,6 +97,7 @@ if TYPE_CHECKING:
LITELLM_TRACER_NAME: Final = "litellm"
_published_v2_provider: ApiTracerProvider | None = None
_GLOBAL_REDACTION_PROBE: Final[dict[str, object]] = {} # mutable-ok: read-only global-redaction probe
def _span_error_from_exception(
@ -753,7 +754,9 @@ class OpenTelemetryV2(CustomLogger):
if is_recordable_span(span):
stamp_error(
span,
_span_error_from_exception(exc, redact_content=should_redact_message_logging({})),
_span_error_from_exception(
exc, redact_content=should_redact_message_logging(_GLOBAL_REDACTION_PROBE)
),
record_event=False,
set_status=False,
)
@ -798,7 +801,9 @@ class OpenTelemetryV2(CustomLogger):
stamp_error(
span,
_span_error_from_exception(
exception, status_code=status_code, redact_content=should_redact_message_logging({})
exception,
status_code=status_code,
redact_content=should_redact_message_logging(_GLOBAL_REDACTION_PROBE),
),
record_event=not already_stamped,
)

View file

@ -11,6 +11,7 @@ import asyncio
import copy
import inspect
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, cast
import litellm
@ -186,6 +187,13 @@ def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[
return _redact_standard_logging_object(payload)
_REDACTED_ERROR_FIELDS: Final = ("error_message", "traceback")
def _is_non_empty_str(value: object) -> bool:
return isinstance(value, str) and bool(value)
def redact_error_information(
error_information: StandardLoggingPayloadErrorInformation,
) -> StandardLoggingPayloadErrorInformation:
@ -194,12 +202,17 @@ def redact_error_information(
quote the prompt (``error_message``, ``traceback``) replaced by ``REDACTED_BY_LITELLM``
when they are non-empty strings. Every other field is carried over unchanged.
"""
redacted: Final = dict(error_information)
for field in ("error_message", "traceback"):
value: Final = redacted.get(field)
if isinstance(value, str) and value:
redacted[field] = REDACTED_BY_LITELLM
return cast(StandardLoggingPayloadErrorInformation, redacted)
redacted_fields: Final = MappingProxyType(
{
field: REDACTED_BY_LITELLM
for field in _REDACTED_ERROR_FIELDS
if _is_non_empty_str(error_information.get(field))
}
)
return cast( # cast-ok: same TypedDict shape as the input, two fields narrowed to the sentinel
StandardLoggingPayloadErrorInformation,
{**error_information, **redacted_fields}, # mutable-ok: callers pop/update keys on the fresh payload dict
)
def should_redact_failed_request(request_data: Mapping[str, object]) -> bool:
@ -209,19 +222,32 @@ def should_redact_failed_request(request_data: Mapping[str, object]) -> bool:
``litellm_metadata`` is included only when present so
``get_metadata_variable_name_from_kwargs`` resolves ``metadata`` for chat routes.
"""
litellm_params: Final[dict[str, object]] = {"metadata": request_data.get("metadata")}
if "litellm_metadata" in request_data:
litellm_params["litellm_metadata"] = request_data.get("litellm_metadata")
return should_redact_message_logging(
litellm_params: Final = MappingProxyType(
{
key: request_data.get(key)
for key in ("metadata", "litellm_metadata")
if key == "metadata" or key in request_data
}
)
return should_redact_message_logging(
{ # mutable-ok: the model_call_details shape the decision helper reads
"litellm_params": litellm_params,
"standard_callback_dynamic_params": {
"standard_callback_dynamic_params": { # mutable-ok: dynamic-params slot the helper reads
"turn_off_message_logging": request_data.get("turn_off_message_logging")
},
}
)
def maybe_redact_error_information(
error_information: StandardLoggingPayloadErrorInformation,
request_data: Mapping[str, object],
) -> StandardLoggingPayloadErrorInformation:
if should_redact_failed_request(request_data):
return redact_error_information(error_information)
return error_information
def _redact_standard_logging_object(payload: Mapping[str, object]) -> dict[str, object]:
standard_logging_object: Final = copy.deepcopy(without_classifier_audit(payload))
redacted_str: Final = REDACTED_BY_LITELLM
@ -252,7 +278,9 @@ def _redact_standard_logging_object(payload: Mapping[str, object]) -> dict[str,
error_information: Final = standard_logging_object.get("error_information")
if isinstance(error_information, Mapping):
standard_logging_object["error_information"] = redact_error_information(
cast(StandardLoggingPayloadErrorInformation, dict(error_information))
cast( # cast-ok: same TypedDict shape as the input mapping
StandardLoggingPayloadErrorInformation, error_information
)
)
return standard_logging_object

View file

@ -16,10 +16,7 @@ from litellm.litellm_core_utils.core_helpers import (
)
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
from litellm.litellm_core_utils.redact_messages import (
redact_error_information,
should_redact_failed_request,
)
from litellm.litellm_core_utils.redact_messages import maybe_redact_error_information
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import (
get_key_object,
@ -175,8 +172,9 @@ class _ProxyDBLogger(CustomLogger):
original_exception=original_exception,
traceback_str=traceback_str,
)
if should_redact_failed_request(request_data):
_error_information = redact_error_information(_error_information)
_error_information = maybe_redact_error_information(
error_information=_error_information, request_data=request_data
)
if should_suppress_spend_log_tracebacks():
# Drop the traceback key entirely so the per-row Metadata pane in
# the UI (which renders the JSON blob verbatim) doesn't show a

View file

@ -3239,6 +3239,7 @@ def test_provisional_close_then_payload_close_does_not_duplicate():
llm_spans = [s for s in exporter.get_finished_spans() if s.name.startswith("chat")]
assert len(llm_spans) == 1
def test_async_post_call_failure_hook_redacts_error_text_when_gated():
"""With message redaction on, the proxy-level failure span must not carry the
prompt through error.message / the exception event, while error.type and the

View file

@ -6763,6 +6763,7 @@ class TestOpenTelemetryNonInferenceUsage(unittest.TestCase):
self._time_per_output_token_calls("aget_responses", response_obj=self.BACKGROUND_RESPONSE_OBJ), 1
)
SECRET_PROMPT = "secret-prompt-marker"

View file

@ -9030,6 +9030,7 @@ def test_signoz_dispatch_requires_an_endpoint(monkeypatch):
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
class _CapturingFailureLogger(CustomLogger):
def __init__(self, **kwargs):
super().__init__(**kwargs)

View file

@ -1041,6 +1041,7 @@ def test_perform_redaction_drops_the_served_output_texts_from_the_callback_kwarg
perform_redaction(details, None)
assert SERVED_OUTPUT_TEXTS_KEY not in details
def _error_information(**overrides):
info = {
"error_code": "400",