mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
988196911a
commit
35520adb4f
2 changed files with 66 additions and 7 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue