mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-15 00:22:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user