From 775a0c6e212ed092008192ecb722348d24664cf5 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 16 Jul 2026 08:45:29 +0000 Subject: [PATCH] fix(otel/v2): guard deferred langfuse span against duplicate emit without call id The deferred close path is deduped by popping the carrier keyed on the call id, so a real call with no litellm_call_id could emit a span from both the success and failure hooks. Skip the deferred span when there is no call id to dedup on, and correct the noop test docstring that no longer described the behavior. Addresses the Greptile P2 on logger.py. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/logger.py | 2 +- .../integrations/otel/test_otel_v2_logger.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 6cfdf7ba80a..7e03fd91ec6 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -394,7 +394,7 @@ class OpenTelemetryV2(CustomLogger): # it (named provisionally) so it isn't leaked as an open span. carrier.span.end(end_time=to_ns(end_time)) return None - if carrier is None and call.is_no_upstream_call: + if carrier is None and (call.is_no_upstream_call or call_id is None): return None data = LLMCallSpanData.from_standard_logging_payload( payload, diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 7dbf599e6cf..774fc136259 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -284,7 +284,8 @@ def test_sync_log_event_is_noop(): def test_missing_standard_logging_object_is_noop(): - """No carrier (``pre_call`` never ran) → the callback emits nothing.""" + """With no standard_logging_object (no payload) and no carrier, there is + nothing to record, so the callback emits nothing.""" logger, exporter = _logger() asyncio.run( logger.async_log_success_event({"litellm_params": {}}, None, None, None) @@ -327,6 +328,18 @@ def test_dynamic_callback_emits_deferred_span_without_pre_call(): assert span.status.status_code is StatusCode.UNSET +def test_no_carrier_without_call_id_does_not_double_emit(): + """The deferred path is deduped by popping the carrier keyed on the call id. + Without a call id there is nothing to dedup on, so both the success and failure + hooks would each emit a span for the same event; a call with no ``litellm_call_id`` + must therefore produce no deferred span rather than a duplicated one.""" + logger, exporter = _logger() + kwargs = _kwargs(_payload(litellm_call_id=None)) + asyncio.run(logger.async_log_success_event(kwargs, None, None, None)) + asyncio.run(logger.async_log_failure_event(kwargs, None, None, None)) + assert exporter.get_finished_spans() == () + + def test_real_llm_failure_still_emitted(): """A genuine LLM failure: ``pre_call`` ran (the call was attempted), so the CLIENT span is opened at the boundary and closed ERROR."""