From e7ce773008501d435c22368c2471650de0cf387a Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 19:45:47 +0000 Subject: [PATCH] fix(otel): emit litellm_request span when caller parent span already ended Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/opentelemetry.py | 12 +++++++----- .../test_litellm/integrations/test_opentelemetry.py | 12 +++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 99394c48792..dfcc278ccd9 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1213,9 +1213,12 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): ctx = None # Decide whether to create a primary span - # Always create if no parent span exists (backward compatibility) + # Always create if no parent span exists (backward compatibility), or if the + # parent already ended, since writes to it would be dropped by the SDK # OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled - should_create_primary_span: Final = parent_span is None or get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN") + should_create_primary_span: Final = ( + parent_span is None or not is_recording(parent_span) or get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN") + ) if should_create_primary_span: # Create a new litellm_request span @@ -1231,9 +1234,8 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): from opentelemetry.trace import Status, StatusCode span = None - if is_recording(parent_span): - parent_span.set_status(Status(StatusCode.OK)) - self.set_attributes(parent_span, kwargs, response_obj) + parent_span.set_status(Status(StatusCode.OK)) + self.set_attributes(parent_span, kwargs, response_obj) # Raw-request as direct child of parent_span self._maybe_log_raw_request(kwargs, response_obj, start_time, end_time, parent_span) diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 99fafa73fd9..45f3337983a 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -6047,9 +6047,15 @@ class TestEndedParentSpanNotWritten(unittest.TestCase): finally: otel_context.detach(token) - exported_server_spans = [s for s in span_exporter.get_finished_spans() if s.name == "POST /chat"] - self.assertEqual(len(exported_server_spans), 1) - self.assertNotIn("gen_ai.request.model", exported_server_spans[0].attributes or {}) + exported = span_exporter.get_finished_spans() + server_spans = [s for s in exported if s.name == "POST /chat"] + self.assertEqual(len(server_spans), 1) + self.assertNotIn("gen_ai.request.model", server_spans[0].attributes or {}) + + litellm_spans = [s for s in exported if s.name == "litellm_request"] + self.assertEqual(len(litellm_spans), 1, "attributes must land on a fresh child span, not be dropped") + self.assertIn("gen_ai.request.model", litellm_spans[0].attributes or {}) + self.assertEqual(litellm_spans[0].parent.span_id, server_spans[0].context.span_id) def test_safe_set_attribute_skips_ended_span(self): tracer_provider = TracerProvider()