From d134d0384aa44bd8feb4be5117ee1369e3c811f3 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 20 May 2026 09:00:27 -0700 Subject: [PATCH 1/2] fix: serialize guardrail_response to JSON in OTEL traces Guardrail spans previously set the `guardrail_response` attribute via `safe_set_attribute`, which let dict payloads reach the OTEL exporter as Python repr strings. Downstream log pipelines could not parse those as JSON, breaking metric creation from guardrail traces. Serialize `guardrail_response` with `safe_dumps` before setting the attribute, matching how `masked_entity_count` is already handled. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/integrations/opentelemetry.py | 10 +++++----- tests/test_litellm/integrations/test_opentelemetry.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index a70574952b8..e1a3cecfce5 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1611,11 +1611,11 @@ def _create_guardrail_span( "masked_entity_count", safe_dumps(masked_entity_count) ) - self.safe_set_attribute( - span=guardrail_span, - key="guardrail_response", - value=guardrail_information.get("guardrail_response"), - ) + guardrail_response = guardrail_information.get("guardrail_response") + if guardrail_response is not None: + guardrail_span.set_attribute( + "guardrail_response", safe_dumps(guardrail_response) + ) self._set_team_attributes_from_kwargs(guardrail_span, kwargs) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 6de855262bd..98201248de1 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -66,7 +66,7 @@ def test_create_guardrail_span_with_valid_info(self, mock_datetime): mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail") mock_span.set_attribute.assert_any_call("guardrail_mode", "input") mock_span.set_attribute.assert_any_call( - "guardrail_response", "filtered_content" + "guardrail_response", safe_dumps("filtered_content") ) mock_span.set_attribute.assert_any_call( "masked_entity_count", safe_dumps({"CREDIT_CARD": 2}) @@ -1169,7 +1169,7 @@ def test_create_guardrail_span_with_valid_info(self, mock_datetime): mock_span.set_attribute.assert_any_call("guardrail_name", "test_guardrail") mock_span.set_attribute.assert_any_call("guardrail_mode", "input") mock_span.set_attribute.assert_any_call( - "guardrail_response", "filtered_content" + "guardrail_response", safe_dumps("filtered_content") ) mock_span.set_attribute.assert_any_call( "masked_entity_count", safe_dumps({"CREDIT_CARD": 2}) From e4946b5d7832a6127d29bcf2ab23de701cc68b3f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 20 May 2026 09:04:12 -0700 Subject: [PATCH 2/2] test: cover dict-serialization and None-skip for guardrail_response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile feedback on #28362 — add explicit coverage for the two behavioral guarantees of this fix: - Dict payloads (the OpenAI moderation case in the report) reach the span as a JSON string, not a Python repr. - ``None`` guardrail_response skips the attribute entirely, so no ``"null"`` leaks into traces. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integrations/test_opentelemetry.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 98201248de1..b65e629c890 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -87,6 +87,65 @@ def test_create_guardrail_span_with_no_info(self): # Verify that start_span was never called otel.tracer.start_span.assert_not_called() + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_response_dict_is_json_serialized(self, mock_datetime): + """Dict guardrail_response (e.g. OpenAI moderation result) must reach + the span as a JSON string so downstream pipelines can parse it for + metric extraction — this is the bug the PR fixes.""" + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + moderation_payload = { + "id": "modr-7740", + "model": "omni-moderation-latest", + "results": [{"categories": {"harassment": False}}], + } + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": moderation_payload, + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": {"guardrail_information": [guardrail_info]} + } + + otel._create_guardrail_span(kwargs=kwargs, context=None) + + mock_span.set_attribute.assert_any_call( + "guardrail_response", safe_dumps(moderation_payload) + ) + + @patch("litellm.integrations.opentelemetry.datetime") + def test_guardrail_response_none_is_skipped(self, mock_datetime): + """When guardrail_response is None, the attribute must not be set — + guards against round-tripping ``"null"`` into traces.""" + otel = OpenTelemetry() + otel.tracer = MagicMock() + mock_span = MagicMock() + otel.tracer.start_span.return_value = mock_span + + guardrail_info = { + "guardrail_name": "test_guardrail", + "guardrail_mode": "input", + "guardrail_response": None, + "start_time": 1609459200.0, + "end_time": 1609459201.0, + } + kwargs = { + "standard_logging_object": {"guardrail_information": [guardrail_info]} + } + + otel._create_guardrail_span(kwargs=kwargs, context=None) + + attribute_keys = [ + call.args[0] for call in mock_span.set_attribute.call_args_list + ] + self.assertNotIn("guardrail_response", attribute_keys) + class TestOpenTelemetryTeamAttributesOnChildSpans(unittest.TestCase): """team_id / team_alias must land on every child span of a