diff --git a/docs/my-website/docs/observability/opentelemetry_integration.md b/docs/my-website/docs/observability/opentelemetry_integration.md index 2b3cf1313b..aa59d0aa3f 100644 --- a/docs/my-website/docs/observability/opentelemetry_integration.md +++ b/docs/my-website/docs/observability/opentelemetry_integration.md @@ -12,7 +12,9 @@ OpenTelemetry is a CNCF standard for observability. It connects to any observabi From v1.81.0, the request/response will be set as attributes on the parent "Received Proxy Server Request" span by default. This allows you to see the request/response in the parent span in your observability tool. -To use the older behavior with nested "litellm_request" spans, set the following environment variable: +**Note:** When making multiple LLM calls within an external OTEL span context, the last call's attributes will overwrite previous calls' attributes on the parent span. + +To use the older behavior with nested "litellm_request" spans (which creates separate spans for each call), set the following environment variable: ```shell USE_OTEL_LITELLM_REQUEST_SPAN=true diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index 93dce578fe..dd34c7be70 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -48,6 +48,7 @@ else: LITELLM_TRACER_NAME = os.getenv("OTEL_TRACER_NAME", "litellm") LITELLM_METER_NAME = os.getenv("LITELLM_METER_NAME", "litellm") LITELLM_LOGGER_NAME = os.getenv("LITELLM_LOGGER_NAME", "litellm") +LITELLM_PROXY_REQUEST_SPAN_NAME = "Received Proxy Server Request" # Remove the hardcoded LITELLM_RESOURCE dictionary - we'll create it properly later RAW_REQUEST_SPAN_NAME = "raw_gen_ai_request" LITELLM_REQUEST_SPAN_NAME = "litellm_request" @@ -233,14 +234,16 @@ class OpenTelemetry(CustomLogger): trace.set_tracer_provider(tracer_provider) else: # Tracer provider explicitly provided (e.g., for testing) + # Do NOT call set_tracer_provider - the caller is responsible for managing global state + # If they want it to be global, they've already set it before passing it to us verbose_logger.debug( "OpenTelemetry: Using provided TracerProvider: %s", type(tracer_provider).__name__, ) - trace.set_tracer_provider(tracer_provider) - # grab our tracer - self.tracer = trace.get_tracer(LITELLM_TRACER_NAME) + # Grab our tracer from the TracerProvider (not from global context) + # This ensures we use the provided TracerProvider (e.g., for testing) + self.tracer = tracer_provider.get_tracer(LITELLM_TRACER_NAME) self.span_kind = SpanKind def _init_metrics(self, meter_provider): @@ -514,6 +517,7 @@ class OpenTelemetry(CustomLogger): response: LLMResponseTypes, ): from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + from opentelemetry.trace import Status, StatusCode litellm_logging_obj = data.get("litellm_logging_obj") @@ -527,6 +531,7 @@ class OpenTelemetry(CustomLogger): # 3. Guardrail span self._create_guardrail_span(kwargs=kwargs, context=ctx) + return response ######################################################### @@ -557,9 +562,9 @@ class OpenTelemetry(CustomLogger): def _get_dynamic_otel_headers_from_kwargs(self, kwargs) -> Optional[dict]: """Extract dynamic headers from kwargs if available.""" - standard_callback_dynamic_params: Optional[StandardCallbackDynamicParams] = ( - kwargs.get("standard_callback_dynamic_params") - ) + standard_callback_dynamic_params: Optional[ + StandardCallbackDynamicParams + ] = kwargs.get("standard_callback_dynamic_params") if not standard_callback_dynamic_params: return None @@ -607,18 +612,35 @@ class OpenTelemetry(CustomLogger): ) ctx, parent_span = self._get_span_context(kwargs) - if get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN"): - primary_span_parent = None - else: - primary_span_parent = parent_span - - # 1. Primary span - span = self._start_primary_span( - kwargs, response_obj, start_time, end_time, ctx, primary_span_parent + # Decide whether to create a primary span + # Always create if no parent span exists (backward compatibility) + # OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled + should_create_primary_span = parent_span is None or get_secret_bool( + "USE_OTEL_LITELLM_REQUEST_SPAN" ) - # 2. Raw‐request sub-span (if enabled) - self._maybe_log_raw_request(kwargs, response_obj, start_time, end_time, span) + if should_create_primary_span: + # Create a new litellm_request span + span = self._start_primary_span( + kwargs, response_obj, start_time, end_time, ctx + ) + # Raw-request sub-span (if enabled) - child of litellm_request span + self._maybe_log_raw_request( + kwargs, response_obj, start_time, end_time, span + ) + else: + # Do not create primary span (keep hierarchy shallow when parent exists) + from opentelemetry.trace import Status, StatusCode + + span = None + # Only set attributes if the span is still recording (not closed) + # Note: parent_span is guaranteed to be not None here + 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 + ) # 3. Guardrail span self._create_guardrail_span(kwargs=kwargs, context=ctx) @@ -628,12 +650,18 @@ class OpenTelemetry(CustomLogger): # 5. Semantic logs. if self.config.enable_events: - self._emit_semantic_logs(kwargs, response_obj, span) + log_span = span if span is not None else parent_span + if log_span is not None: + self._emit_semantic_logs(kwargs, response_obj, log_span) - # 6. End parent span (only if it wasn't reused as the primary span) - # If parent_span was reused as the primary span, it was already ended in _start_primary_span - if parent_span is not None and parent_span is not span: - parent_span.end(end_time=self._to_ns(datetime.now())) + # 6. Do NOT end parent span - it should be managed by its creator + # External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM + # However, proxy-created spans should be closed here + if ( + parent_span is not None + and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME + ): + parent_span.end(end_time=self._to_ns(end_time)) def _start_primary_span( self, @@ -642,16 +670,19 @@ class OpenTelemetry(CustomLogger): start_time, end_time, context, - parent_span: Optional[Span] = None, ): from opentelemetry.trace import Status, StatusCode otel_tracer: Tracer = self.get_tracer_to_use_for_request(kwargs) - span = parent_span or otel_tracer.start_span( + + # Always create a new span + # The parent relationship is preserved through the context parameter + span = otel_tracer.start_span( name=self._get_span_name(kwargs), start_time=self._to_ns(start_time), context=context, ) + span.set_status(Status(StatusCode.OK)) self.set_attributes(span, kwargs, response_obj) span.end(end_time=self._to_ns(end_time)) @@ -764,10 +795,10 @@ class OpenTelemetry(CustomLogger): return float(val) # isinstance(val, str) - parse datetime string (with or without microseconds) try: - return datetime.strptime(val, '%Y-%m-%d %H:%M:%S.%f').timestamp() + return datetime.strptime(val, "%Y-%m-%d %H:%M:%S.%f").timestamp() except ValueError: try: - return datetime.strptime(val, '%Y-%m-%d %H:%M:%S').timestamp() + return datetime.strptime(val, "%Y-%m-%d %H:%M:%S").timestamp() except ValueError: return None @@ -775,23 +806,23 @@ class OpenTelemetry(CustomLogger): """Record Time to First Token (TTFT) metric for streaming requests.""" optional_params = kwargs.get("optional_params", {}) is_streaming = optional_params.get("stream", False) - + if not (self._time_to_first_token_histogram and is_streaming): return - + # Use api_call_start_time for precision (matches Prometheus implementation) # This excludes LiteLLM overhead and measures pure LLM API latency api_call_start_time = kwargs.get("api_call_start_time", None) completion_start_time = kwargs.get("completion_start_time", None) - + if api_call_start_time is not None and completion_start_time is not None: # Convert to timestamps if needed (handles datetime, float, and string) api_call_start_ts = self._to_timestamp(api_call_start_time) completion_start_ts = self._to_timestamp(completion_start_time) - + if api_call_start_ts is None or completion_start_ts is None: return # Skip recording if conversion failed - + time_to_first_token_seconds = completion_start_ts - api_call_start_ts self._time_to_first_token_histogram.record( time_to_first_token_seconds, attributes=common_attrs @@ -806,38 +837,40 @@ class OpenTelemetry(CustomLogger): common_attrs: dict, ): """Record Time Per Output Token (TPOT) metric. - + Calculated as: generation_time / completion_tokens - For streaming: uses end_time - completion_start_time (time to generate all tokens after first) - For non-streaming: uses end_time - api_call_start_time (total generation time) """ if not self._time_per_output_token_histogram: return - + # Get completion tokens from response_obj completion_tokens = None if response_obj and (usage := response_obj.get("usage")): completion_tokens = usage.get("completion_tokens") - + if completion_tokens is None or completion_tokens <= 0: return - + # Calculate generation time completion_start_time = kwargs.get("completion_start_time", None) api_call_start_time = kwargs.get("api_call_start_time", None) - + # Convert end_time to timestamp (handles datetime, float, and string) end_time_ts = self._to_timestamp(end_time) if end_time_ts is None: # Fallback to duration_s if conversion failed generation_time_seconds = duration_s if generation_time_seconds > 0: - time_per_output_token_seconds = generation_time_seconds / completion_tokens + time_per_output_token_seconds = ( + generation_time_seconds / completion_tokens + ) self._time_per_output_token_histogram.record( time_per_output_token_seconds, attributes=common_attrs ) return - + if completion_start_time is not None: # Streaming: use completion_start_time (when first token arrived) # This measures time to generate all tokens after the first one @@ -858,7 +891,7 @@ class OpenTelemetry(CustomLogger): else: # Fallback: use duration_s (already calculated as (end_time - start_time).total_seconds()) generation_time_seconds = duration_s - + if generation_time_seconds > 0: time_per_output_token_seconds = generation_time_seconds / completion_tokens self._time_per_output_token_histogram.record( @@ -872,37 +905,37 @@ class OpenTelemetry(CustomLogger): common_attrs: dict, ): """Record Total Generation Time (response duration) metric. - + Measures pure LLM API generation time: end_time - api_call_start_time This excludes LiteLLM overhead and measures only the LLM provider's response time. Works for both streaming and non-streaming requests. - + Mirrors Prometheus's litellm_llm_api_latency_metric. Uses kwargs.get("end_time") with fallback to parameter for consistency with Prometheus. """ if not self._response_duration_histogram: return - + api_call_start_time = kwargs.get("api_call_start_time", None) if api_call_start_time is None: return - + # Use end_time from kwargs if available (matches Prometheus), otherwise use parameter # For streaming: end_time is when the stream completes (final chunk received) # For non-streaming: end_time is when the response is received _end_time = kwargs.get("end_time") or end_time if _end_time is None: _end_time = datetime.now() - + # Convert to timestamps if needed (handles datetime, float, and string) api_call_start_ts = self._to_timestamp(api_call_start_time) end_time_ts = self._to_timestamp(_end_time) - + if api_call_start_ts is None or end_time_ts is None: return # Skip recording if conversion failed - + response_duration_seconds = end_time_ts - api_call_start_ts - + if response_duration_seconds > 0: self._response_duration_histogram.record( response_duration_seconds, attributes=common_attrs @@ -1065,26 +1098,49 @@ class OpenTelemetry(CustomLogger): ) _parent_context, parent_otel_span = self._get_span_context(kwargs) - # Span 1: Requst sent to litellm SDK - otel_tracer: Tracer = self.get_tracer_to_use_for_request(kwargs) - span = otel_tracer.start_span( - name=self._get_span_name(kwargs), - start_time=self._to_ns(start_time), - context=_parent_context, + # Decide whether to create a primary span + # Always create if no parent span exists (backward compatibility) + # OR if USE_OTEL_LITELLM_REQUEST_SPAN is explicitly enabled + should_create_primary_span = parent_otel_span is None or get_secret_bool( + "USE_OTEL_LITELLM_REQUEST_SPAN" ) - span.set_status(Status(StatusCode.ERROR)) - self.set_attributes(span, kwargs, response_obj) - # Record exception information using OTEL standard method - self._record_exception_on_span(span=span, kwargs=kwargs) + if should_create_primary_span: + # Span 1: Request sent to litellm SDK + otel_tracer: Tracer = self.get_tracer_to_use_for_request(kwargs) + span = otel_tracer.start_span( + name=self._get_span_name(kwargs), + start_time=self._to_ns(start_time), + context=_parent_context, + ) + span.set_status(Status(StatusCode.ERROR)) + self.set_attributes(span, kwargs, response_obj) - span.end(end_time=self._to_ns(end_time)) + # Record exception information using OTEL standard method + self._record_exception_on_span(span=span, kwargs=kwargs) + + span.end(end_time=self._to_ns(end_time)) + else: + # When parent span exists and USE_OTEL_LITELLM_REQUEST_SPAN=false, + # record error on parent span (keeps hierarchy shallow) + # Only set attributes if the span is still recording (not closed) + # Note: parent_otel_span is guaranteed to be not None here + if parent_otel_span.is_recording(): + parent_otel_span.set_status(Status(StatusCode.ERROR)) + self.set_attributes(parent_otel_span, kwargs, response_obj) + self._record_exception_on_span(span=parent_otel_span, kwargs=kwargs) # Create span for guardrail information self._create_guardrail_span(kwargs=kwargs, context=_parent_context) - if parent_otel_span is not None: - parent_otel_span.end(end_time=self._to_ns(datetime.now())) + # Do NOT end parent span - it should be managed by its creator + # External spans (from Langfuse, user code, HTTP headers, global context) must not be closed by LiteLLM + # However, proxy-created spans should be closed here + if ( + parent_otel_span is not None + and parent_otel_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME + ): + parent_otel_span.end(end_time=self._to_ns(end_time)) def _record_exception_on_span(self, span: Span, kwargs: dict): """ @@ -1263,7 +1319,9 @@ class OpenTelemetry(CustomLogger): ) return elif self.callback_name == "weave_otel": - from litellm.integrations.weave.weave_otel import set_weave_otel_attributes + from litellm.integrations.weave.weave_otel import ( + set_weave_otel_attributes, + ) set_weave_otel_attributes(span, kwargs, response_obj) return @@ -1994,9 +2052,9 @@ class OpenTelemetry(CustomLogger): """ Create a span for the received proxy server request. """ - + return self.tracer.start_span( - name="Received Proxy Server Request", + name=LITELLM_PROXY_REQUEST_SPAN_NAME, start_time=self._to_ns(start_time), context=self.get_traceparent_from_header(headers=headers), kind=self.span_kind.SERVER, diff --git a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py index c8ceded4cf..3d0682d903 100644 --- a/tests/logging_callback_tests/test_opentelemetry_unit_tests.py +++ b/tests/logging_callback_tests/test_opentelemetry_unit_tests.py @@ -40,11 +40,15 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): @pytest.mark.asyncio async def test_opentelemetry_integration(self): """ - Unit test to confirm the parent otel span is ended. + Unit test to confirm external parent otel spans are NOT ended by LiteLLM. + + External spans (passed via metadata) should be managed by their creators, + not by LiteLLM. This prevents premature closure of spans from Langfuse, + user code, or other external observability tools. """ # Reset all callbacks to ensure clean state litellm.logging_callback_manager._reset_all_callbacks() - + parent_otel_span = MagicMock() litellm.callbacks = ["otel"] @@ -57,8 +61,9 @@ class TestOpentelemetryUnitTests(BaseLoggingCallbackTest): await asyncio.sleep(1) - # Verify span was ended (may be called multiple times due to callback architecture) - parent_otel_span.end.assert_called() + # Verify external span was NOT ended by LiteLLM + # External spans should only be closed by their creators + parent_otel_span.end.assert_not_called() def test_init_tracing_respects_existing_tracer_provider(self): """ diff --git a/tests/test_litellm/integrations/test_opentelemetry.py b/tests/test_litellm/integrations/test_opentelemetry.py index 58fccf5e42..5d648f601f 100644 --- a/tests/test_litellm/integrations/test_opentelemetry.py +++ b/tests/test_litellm/integrations/test_opentelemetry.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch # Adds the grandparent directory to sys.path to allow importing project modules sys.path.insert(0, os.path.abspath("../..")) +from opentelemetry import trace from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider from opentelemetry.sdk._logs.export import InMemoryLogExporter, SimpleLogRecordProcessor from opentelemetry.sdk.metrics import MeterProvider @@ -1318,3 +1319,456 @@ class TestOpenTelemetryProtocolSelection(unittest.TestCase): "http://collector:4317/v1/traces", "logs" ) self.assertEqual(normalized, "http://collector:4317/v1/logs") + + +class TestOpenTelemetryExternalSpan(unittest.TestCase): + """ + Test suite for external span handling in OpenTelemetry integration. + + These tests verify that LiteLLM correctly handles spans created outside + of LiteLLM (e.g., by Langfuse SDK, user application code, or global context) + without closing them prematurely. + + Background: + - External spans can come from: Langfuse SDK, user code, HTTP traceparent headers, global context + - LiteLLM should NEVER close spans it did not create + - Bug: LiteLLM was reusing and closing external spans in _start_primary_span + """ + + HERE = os.path.dirname(__file__) + + def setUp(self): + """Set up common test fixtures""" + self.span_exporter = InMemorySpanExporter() + self.tracer_provider = TracerProvider() + self.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + + # Don't set global tracer provider - instead, get tracers directly from our provider + # This avoids "Overriding of current TracerProvider is not allowed" warnings + + # Clear any existing spans + self.span_exporter.clear() + + def _create_test_kwargs_and_response(self): + """Load test data from JSON files""" + with open( + os.path.join(self.HERE, "open_telemetry", "data", "captured_kwargs.json") + ) as f: + kwargs = json.load(f) + + with open( + os.path.join(self.HERE, "open_telemetry", "data", "captured_response.json") + ) as f: + response_obj = json.load(f) + + return kwargs, response_obj + + def _get_spans_by_name(self, name): + """Get all spans with the given name""" + spans = self.span_exporter.get_finished_spans() + return [s for s in spans if s.name == name] + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False) + def test_external_span_not_closed_with_use_otel_litellm_request_span_false(self): + """ + Test that external spans are not closed when USE_OTEL_LITELLM_REQUEST_SPAN=false (default). + + Expected behavior: + - External span remains open (is_recording = True) + - raw_gen_ai_request spans are direct children of external span (shallow hierarchy) + - No litellm_request span is created + - Multiple completions work correctly + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_parent_span") as parent_span: + parent_ctx = parent_span.get_span_context() + parent_trace_id = parent_ctx.trace_id + parent_span_id = parent_ctx.span_id + + self.assertTrue( + parent_span.is_recording(), + "External span should be recording before completion calls" + ) + + # First completion call + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time, end_time) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span should still be recording after first completion" + ) + + # Second completion call + start_time2 = end_time + end_time2 = start_time2 + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time2, end_time2) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span should still be recording after second completion" + ) + + # After exiting context, verify spans + spans = self.span_exporter.get_finished_spans() + + # All spans should have the same trace_id + for span in spans: + self.assertEqual( + span.context.trace_id, + parent_trace_id, + f"Span {span.name} should have same trace_id as parent" + ) + + # Should have external_parent_span + parent_spans = self._get_spans_by_name("external_parent_span") + self.assertEqual(len(parent_spans), 1, "Should have exactly one external_parent_span") + + # Verify LiteLLM set attributes on external parent span + parent_span_finished = parent_spans[0] + self.assertIsNotNone( + parent_span_finished.attributes, + "Parent span should have attributes set by LiteLLM" + ) + self.assertIn( + "gen_ai.request.model", + parent_span_finished.attributes, + "Parent span should have model attribute from LiteLLM" + ) + + # Should have raw_gen_ai_request spans (if message_logging is on) + raw_spans = self._get_spans_by_name("raw_gen_ai_request") + # Note: May be 0 if message_logging is off, or 2 if on + + # Should NOT have litellm_request spans (USE_OTEL_LITELLM_REQUEST_SPAN=false) + litellm_spans = self._get_spans_by_name("litellm_request") + self.assertEqual( + len(litellm_spans), + 0, + "Should NOT have litellm_request spans when USE_OTEL_LITELLM_REQUEST_SPAN=false" + ) + + # Verify raw_gen_ai_request spans are direct children of external span + for raw_span in raw_spans: + self.assertEqual( + raw_span.parent.span_id if raw_span.parent else None, + parent_span_id, + f"raw_gen_ai_request should be direct child of external_parent_span" + ) + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}, clear=False) + def test_external_span_not_closed_with_use_otel_litellm_request_span_true(self): + """ + Test that external spans are not closed when USE_OTEL_LITELLM_REQUEST_SPAN=true. + + Expected behavior: + - External span remains open (is_recording = True) + - litellm_request spans are created as children of external span + - raw_gen_ai_request spans are children of litellm_request spans + - Correct hierarchy: external_parent → litellm_request → raw_gen_ai_request + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_parent_span") as parent_span: + parent_ctx = parent_span.get_span_context() + parent_trace_id = parent_ctx.trace_id + parent_span_id = parent_ctx.span_id + + # First completion call + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time, end_time) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span should still be recording after first completion" + ) + + # Second completion call + start_time2 = end_time + end_time2 = start_time2 + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time2, end_time2) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span should still be recording after second completion" + ) + + # After exiting context, verify spans + spans = self.span_exporter.get_finished_spans() + + # All spans should have the same trace_id + for span in spans: + self.assertEqual( + span.context.trace_id, + parent_trace_id, + f"Span {span.name} should have same trace_id as parent" + ) + + # Should have litellm_request spans (USE_OTEL_LITELLM_REQUEST_SPAN=true) + litellm_spans = self._get_spans_by_name("litellm_request") + self.assertEqual( + len(litellm_spans), + 2, + "Should have 2 litellm_request spans when USE_OTEL_LITELLM_REQUEST_SPAN=true" + ) + + # Verify litellm_request spans are children of external span + for litellm_span in litellm_spans: + self.assertEqual( + litellm_span.parent.span_id if litellm_span.parent else None, + parent_span_id, + "litellm_request should be child of external_parent_span" + ) + + # Verify raw_gen_ai_request spans (if present) are children of litellm_request + raw_spans = self._get_spans_by_name("raw_gen_ai_request") + if raw_spans: + litellm_span_ids = {s.context.span_id for s in litellm_spans} + for raw_span in raw_spans: + self.assertIn( + raw_span.parent.span_id if raw_span.parent else None, + litellm_span_ids, + "raw_gen_ai_request should be child of litellm_request" + ) + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False) + def test_external_span_with_multiple_completions(self): + """ + Test that multiple completion calls work correctly within external span context. + + Expected behavior: + - Both completion calls succeed + - All spans belong to the same trace + - External span remains open throughout + - No errors or warnings about "ended span" + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_parent_span") as parent_span: + parent_ctx = parent_span.get_span_context() + parent_trace_id = parent_ctx.trace_id + + # Make multiple completion calls + for i in range(3): + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + + # This should not raise any exceptions + otel._handle_success(kwargs, response_obj, start_time, end_time) + + # Verify parent span is still recording after each call + self.assertTrue( + parent_span.is_recording(), + f"External span should still be recording after completion #{i+1}" + ) + + # Verify all spans have the same trace_id + spans = self.span_exporter.get_finished_spans() + for span in spans: + self.assertEqual( + span.context.trace_id, + parent_trace_id, + f"All spans should belong to the same trace" + ) + + # Should have the external parent span + parent_spans = self._get_spans_by_name("external_parent_span") + self.assertEqual(len(parent_spans), 1, "Should have exactly one external_parent_span") + + # Verify LiteLLM set attributes on external parent span + parent_span_finished = parent_spans[0] + self.assertIn( + "gen_ai.request.model", + parent_span_finished.attributes, + "Parent span should have model attribute from LiteLLM" + ) + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False) + def test_external_span_from_global_context(self): + """ + Test external span detection from global context (Priority 3 in _get_span_context). + + This simulates the case where a span is set in the global context + (e.g., by user code or Langfuse SDK) and LiteLLM detects it via + trace.get_current_span(). + + Expected behavior: + - LiteLLM detects the span from global context + - External span is not closed + - Correct parent-child relationship + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span and set it as current using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_global_span") as parent_span: + parent_ctx = parent_span.get_span_context() + parent_trace_id = parent_ctx.trace_id + + # Verify the span is in global context + current_span = trace.get_current_span() + self.assertEqual(current_span, parent_span, "Span should be in global context") + + # Make completion call + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time, end_time) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span from global context should not be closed" + ) + + # Verify trace structure + spans = self.span_exporter.get_finished_spans() + for span in spans: + self.assertEqual( + span.context.trace_id, + parent_trace_id, + "All spans should have the same trace_id" + ) + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False) + def test_external_span_hierarchy_preserved(self): + """ + Test that span hierarchy is correctly preserved with external parent. + + Expected behavior: + - Parent span IDs are correct + - Trace structure matches expected hierarchy + - Span names are correct + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + otel.message_logging = True # Enable message logging to get raw_gen_ai_request spans + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_parent_span") as parent_span: + parent_span_id = parent_span.get_span_context().span_id + + # Make completion call + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + otel._handle_success(kwargs, response_obj, start_time, end_time) + + # Verify hierarchy + spans = self.span_exporter.get_finished_spans() + + # Get spans by name + parent_spans = self._get_spans_by_name("external_parent_span") + raw_spans = self._get_spans_by_name("raw_gen_ai_request") + + self.assertEqual(len(parent_spans), 1, "Should have one parent span") + + # Verify parent-child relationship + if raw_spans: # If message_logging is on + for raw_span in raw_spans: + self.assertEqual( + raw_span.parent.span_id if raw_span.parent else None, + parent_span_id, + "raw_gen_ai_request should be child of external_parent_span" + ) + + @patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "false"}, clear=False) + def test_external_span_not_ended_on_failure(self): + """ + Test that external spans are not closed even on failure. + + Expected behavior: + - When _handle_failure is called with external span context + - External span remains open (is_recording = True) + - Error span is created correctly + - External span status is NOT changed by LiteLLM + """ + # Initialize OpenTelemetry + otel = OpenTelemetry(tracer_provider=self.tracer_provider) + + # Load test data + kwargs, response_obj = self._create_test_kwargs_and_response() + + # Create external parent span using our test TracerProvider + tracer = self.tracer_provider.get_tracer(__name__) + + with tracer.start_as_current_span("external_parent_span") as parent_span: + parent_ctx = parent_span.get_span_context() + parent_trace_id = parent_ctx.trace_id + + # Simulate failure + start_time = datetime.utcnow() + end_time = start_time + timedelta(seconds=1) + + # Create error response object + error_response = {"error": "Test error"} + + # Call _handle_failure + otel._handle_failure(kwargs, error_response, start_time, end_time) + + # Verify parent span is still recording + self.assertTrue( + parent_span.is_recording(), + "External span should still be recording even after failure" + ) + + # Verify trace structure + spans = self.span_exporter.get_finished_spans() + + # All spans should have the same trace_id + for span in spans: + self.assertEqual( + span.context.trace_id, + parent_trace_id, + "All spans should have the same trace_id even on failure" + ) + + # Should have external_parent_span + parent_spans = self._get_spans_by_name("external_parent_span") + self.assertEqual(len(parent_spans), 1, "Should have exactly one external_parent_span") + + # Verify LiteLLM set attributes on external parent span even on failure + parent_span_finished = parent_spans[0] + self.assertIn( + "gen_ai.request.model", + parent_span_finished.attributes, + "Parent span should have model attribute from LiteLLM even on failure" + )