Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion litellm/integrations/opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,14 @@ def _emit_once(self, kwargs: dict, *scope: object) -> bool:
spans_logged = {}
_otel_internal["spans_logged"] = spans_logged

dedupe_key = (self.__class__.__name__, id(self), *scope)
# Normalise scope parts so they are always hashable. `guardrail_mode`
# can be a list (e.g. ["pre_call", "post_call"]) and a tuple containing
# a list is itself unhashable, causing a TypeError when used as a dict key.
normalized_scope = tuple(
tuple(part) if isinstance(part, list) else part
for part in scope
)
dedupe_key = (self.__class__.__name__, id(self), *normalized_scope)
if spans_logged.get(dedupe_key) is True:
return False

Expand Down
32 changes: 32 additions & 0 deletions tests/test_litellm/integrations/test_opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ 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_mode_as_list_does_not_crash(self, mock_datetime):
"""Regression test for https://github.com/BerriAI/litellm/issues/28486.

When a guardrail is configured with a list ``mode`` (e.g.
``["pre_call", "post_call"]``), ``_create_guardrail_span`` previously
crashed with ``TypeError: unhashable type: 'list'`` inside
``_emit_once``, because the list value was unpacked directly into the
tuple used as a ``dict`` key. Lists are not hashable; only tuples are.

The fix normalises each scope part in ``_emit_once`` to a tuple when
it is a list, so the dedupe key is always hashable.
"""
otel = OpenTelemetry()
otel.tracer = MagicMock()
mock_span = MagicMock()
otel.tracer.start_span.return_value = mock_span

guardrail_info = {
"guardrail_name": "test_guardrail",
"guardrail_mode": ["pre_call", "post_call"], # list-valued mode
"start_time": 1609459200.0,
"end_time": 1609459201.0,
}
kwargs = {
"standard_logging_object": {"guardrail_information": [guardrail_info]}
}

# Must not raise TypeError: unhashable type: 'list'
otel._create_guardrail_span(kwargs=kwargs, context=None)
otel.tracer.start_span.assert_called_once()
Comment on lines +118 to +120

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The new test verifies that start_span is called but doesn't assert that the span was properly ended. The sibling test test_create_guardrail_span_with_valid_info includes mock_span.end.assert_called_once(). Without that assertion here, a future regression where the span is created but never closed would go undetected by this test.

Suggested change
# Must not raise TypeError: unhashable type: 'list'
otel._create_guardrail_span(kwargs=kwargs, context=None)
otel.tracer.start_span.assert_called_once()
# Must not raise TypeError: unhashable type: 'list'
otel._create_guardrail_span(kwargs=kwargs, context=None)
otel.tracer.start_span.assert_called_once()
mock_span.end.assert_called_once()


@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
Expand Down
Loading