Skip to content
Merged
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
47 changes: 42 additions & 5 deletions litellm/integrations/opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,37 @@ def _normalize_team_metadata_keys(value: Any) -> List[str]:
return [str(item).strip() for item in value if str(item).strip()]


_FREEZE_MAX_DEPTH = 16

HashableScope = Union[
str,
int,
float,
bool,
bytes,
None,
tuple["HashableScope", ...],
frozenset["HashableScope"],
]


def _freeze_for_dedupe(value: object, _depth: int = 0) -> HashableScope:
if _depth >= _FREEZE_MAX_DEPTH:
return repr(value)
if isinstance(value, (list, tuple)):
return tuple(_freeze_for_dedupe(item, _depth + 1) for item in value)
if isinstance(value, set):
return frozenset(_freeze_for_dedupe(item, _depth + 1) for item in value)
if isinstance(value, dict):
return frozenset(
(_freeze_for_dedupe(key, _depth + 1), _freeze_for_dedupe(item, _depth + 1))
for key, item in value.items()
)
if isinstance(value, (str, int, float, bytes)) or value is None:
return value
return repr(value)


@dataclass
class OpenTelemetryConfig:
exporter: Union[str, SpanExporter] = "console"
Expand Down Expand Up @@ -1073,10 +1104,12 @@ def _emit_once(self, kwargs: dict, *scope: object) -> bool:
can be re-read with mutated entries between calls, so dedupe
must be at entry granularity. Scope: the entry's stable identity.

``scope`` parts can be any hashable identity. The marker is stored
in ``kwargs["litellm_params"]["metadata"]["_otel_internal"]`` so it
is request-local (kwargs is shared across the sync/async callbacks
and lifecycle hooks for one request).
``scope`` parts may include unhashable containers (list, dict, set);
they are normalized into a hashable shape via ``_freeze_for_dedupe``
before keying the marker dict. The marker is stored in
``kwargs["litellm_params"]["metadata"]["_otel_internal"]`` so it is
request-local (kwargs is shared across the sync/async callbacks and
lifecycle hooks for one request).
"""
litellm_params = kwargs.get("litellm_params")
if not isinstance(litellm_params, dict):
Expand All @@ -1098,7 +1131,11 @@ 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)
dedupe_key = (
self.__class__.__name__,
id(self),
*(_freeze_for_dedupe(part) for part in scope),
)
if spans_logged.get(dedupe_key) is True:
return False

Expand Down
1 change: 1 addition & 0 deletions tests/code_coverage_tests/recursive_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"_resolve", # OCI: $ref resolver bounded by `resolving_stack` cycle guard.
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
]


Expand Down
103 changes: 103 additions & 0 deletions tests/test_litellm/integrations/test_opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4690,6 +4690,109 @@ def test_emit_once_handles_missing_litellm_params(self):
self.assertTrue(otel._emit_once(kwargs, "success"))
self.assertFalse(otel._emit_once(kwargs, "success"))

def test_emit_once_accepts_list_valued_scope_part(self):
"""Regression for LIT-3428 / LIT-3764: a list-valued ``guardrail_mode``
(the shape Presidio expands to with ``output_parse_pii: true``) must
not raise ``TypeError: unhashable type: 'list'`` when building the
dedupe key. Pre-fix, this call crashed inside ``dict.get``."""
otel = OpenTelemetry()
kwargs = self._build_kwargs()
self.assertTrue(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call", "post_call"])
)
self.assertFalse(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call", "post_call"]),
"Same list scope must dedupe to False on the second call",
)

def test_emit_once_distinct_list_scopes_dont_collide(self):
"""Two different list-valued scopes on the same handler/kwargs must
each emit exactly once. Catches a regression where every list collapses
to the same key (e.g. ``str(list)`` collisions on near-identical input)."""
otel = OpenTelemetry()
kwargs = self._build_kwargs()
self.assertTrue(otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call"]))
self.assertTrue(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call", "post_call"]),
"Distinct list scopes must produce distinct dedupe keys",
)
self.assertFalse(otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call"]))
self.assertFalse(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, ["pre_call", "post_call"])
)

def test_emit_once_accepts_dict_and_set_scope_parts(self):
"""``guardrail_mode`` can also arrive as a ``GuardrailMode`` TypedDict
(i.e. a plain dict at runtime). Sets are not produced today but flow
through the same normalization. Both must hash without raising."""
otel = OpenTelemetry()
kwargs = self._build_kwargs()
self.assertTrue(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, {"tags": ["pre", "post"]})
)
self.assertFalse(
otel._emit_once(kwargs, "guardrail", "pii", 1.0, {"tags": ["pre", "post"]})
)
self.assertTrue(otel._emit_once(kwargs, "guardrail", "pii", 1.0, {"a", "b"}))

def test_emit_once_handles_self_referential_scope_without_recursion_error(self):
"""``_freeze_for_dedupe`` caps recursion at ``_FREEZE_MAX_DEPTH`` and
falls back to ``repr`` past the cap, so a self-referential container
in scope must not crash ``_emit_once``. ``guardrail_mode`` cannot
construct such input today, but the cap is the bound that justifies
recursion on the logging hot path."""
otel = OpenTelemetry()
kwargs = self._build_kwargs()
cyclic: list = []
cyclic.append(cyclic)
self.assertTrue(otel._emit_once(kwargs, "guardrail", "pii", 1.0, cyclic))
self.assertFalse(otel._emit_once(kwargs, "guardrail", "pii", 1.0, cyclic))

def test_create_guardrail_span_does_not_raise_on_list_mode(self):
"""End-to-end regression for LIT-3428: ``_create_guardrail_span``
must produce exactly one span (not raise ``TypeError``) when the
guardrail entry's ``guardrail_mode`` is a list."""
span_exporter = InMemorySpanExporter()
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))

otel = OpenTelemetry(tracer_provider=tracer_provider)
otel.tracer = tracer_provider.get_tracer(__name__)

kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"litellm_params": {"custom_llm_provider": "openai", "metadata": {}},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
"hidden_params": {},
"guardrail_information": [
{
"guardrail_name": "presidio-pii",
"guardrail_mode": ["pre_call", "post_call"],
"guardrail_response": "ok",
"start_time": 1.0,
"end_time": 2.0,
}
],
},
}

otel._create_guardrail_span(kwargs=kwargs, context=None)
otel._create_guardrail_span(kwargs=kwargs, context=None)

guardrail_spans = [
s for s in span_exporter.get_finished_spans() if s.name == "guardrail"
]
self.assertEqual(
len(guardrail_spans),
1,
"List-valued guardrail_mode must emit exactly one guardrail span "
"across repeated lifecycle entrypoints",
)

def test_handle_success_emits_single_litellm_request_span_on_double_call(self):
"""Sync + async callback paths firing for the same kwargs must
result in exactly one litellm_request span."""
Expand Down
Loading