fix: serialize guardrail_response to JSON in OTEL traces (#28362)

* 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) <noreply@anthropic.com>

* test: cover dict-serialization and None-skip for guardrail_response

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) <noreply@anthropic.com>

---------

Co-authored-by: Yassin Kortam <yassinkortam@g.ucla.edu>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yassin Kortam 2026-05-20 17:34:36 -07:00 committed by GitHub
parent 988196911a
commit 35520adb4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 66 additions and 7 deletions

View file

@ -1611,11 +1611,11 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger):
"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)

View file

@ -66,7 +66,7 @@ class TestOpenTelemetryGuardrails(unittest.TestCase):
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})
@ -87,6 +87,65 @@ class TestOpenTelemetryGuardrails(unittest.TestCase):
# 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
@ -1169,7 +1228,7 @@ class TestOpenTelemetry(unittest.TestCase):
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})