From 63f91e71779fa9db431047123767070eb003b3f1 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 29 Sep 2026 09:25:33 +0000 Subject: [PATCH] refactor(logging): build failure redaction values in one shot Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_logger.py | 30 +++++++---- litellm/integrations/mlflow.py | 2 +- litellm/integrations/opentelemetry.py | 8 +-- litellm/integrations/otel/logger.py | 9 +++- litellm/litellm_core_utils/redact_messages.py | 52 ++++++++++++++----- .../proxy/hooks/proxy_track_cost_callback.py | 10 ++-- .../integrations/otel/test_otel_v2_logger.py | 1 + tests/unit/integrations/test_opentelemetry.py | 1 + .../test_litellm_logging.py | 1 + .../test_redact_messages.py | 1 + 10 files changed, 80 insertions(+), 35 deletions(-) diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py index 6f7c0d06724..5f0289f833c 100644 --- a/litellm/integrations/custom_logger.py +++ b/litellm/integrations/custom_logger.py @@ -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 diff --git a/litellm/integrations/mlflow.py b/litellm/integrations/mlflow.py index 49c030ecd99..c3da1aecc8f 100644 --- a/litellm/integrations/mlflow.py +++ b/litellm/integrations/mlflow.py @@ -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, diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index ee6a85c685e..0ff42736310 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -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 } }, diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index f3d2f9b91c4..b974cf6086b 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -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, ) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 6c8e5b9181f..f846bb8ac4f 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -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 diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index a5a995076c1..ef5d1759ca5 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -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 diff --git a/tests/unit/integrations/otel/test_otel_v2_logger.py b/tests/unit/integrations/otel/test_otel_v2_logger.py index 706b0bd982e..1efc7a1053b 100644 --- a/tests/unit/integrations/otel/test_otel_v2_logger.py +++ b/tests/unit/integrations/otel/test_otel_v2_logger.py @@ -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 diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index ff842785a3b..8f6cb5f47ab 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -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" diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 74c431df64d..958ac08d3cf 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -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) diff --git a/tests/unit/litellm_core_utils/test_redact_messages.py b/tests/unit/litellm_core_utils/test_redact_messages.py index 85483da23bb..d51737dc182 100644 --- a/tests/unit/litellm_core_utils/test_redact_messages.py +++ b/tests/unit/litellm_core_utils/test_redact_messages.py @@ -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",