Merge pull request #18553 from BerriAI/litellm_fix_ends_parent_span_in_otel

fix: prevent LiteLLM from closing external OTEL spans
This commit is contained in:
YutaSaito
2026-01-01 11:49:12 +09:00
committed by GitHub
4 changed files with 586 additions and 67 deletions
@@ -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
+120 -62
View File
@@ -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. Rawrequest 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,
@@ -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):
"""
@@ -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"
)