From 3c595f6fd28afb279f59aba38cf087a5cf82c55a Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 26 Feb 2026 06:40:24 -0500 Subject: [PATCH] fix(test): update Phoenix OTEL test for dedicated TracerProvider architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old test assumed ArizePhoenixLogger reused the global TracerProvider. With the nested traces fix, Phoenix now creates its own dedicated provider and produces litellm_proxy_request + litellm_request + raw_gen_ai_request spans independently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../test_otel_logging.py | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/tests/logging_callback_tests/test_otel_logging.py b/tests/logging_callback_tests/test_otel_logging.py index f0511e7d1e..a331c98d4a 100644 --- a/tests/logging_callback_tests/test_otel_logging.py +++ b/tests/logging_callback_tests/test_otel_logging.py @@ -258,57 +258,61 @@ def validate_redacted_message_span_attributes(span): pass @pytest.mark.asyncio -async def test_arize_phoenix_adds_openinference_kind_and_avoids_duplicate_litellm_spans(): +async def test_arize_phoenix_creates_nested_spans_on_dedicated_provider(): """ - Ensure Arize Phoenix spans include OpenInference span kind and do not create - a duplicate litellm_request span when a proxy parent span is already active. + ArizePhoenixLogger creates its own dedicated TracerProvider so it can + coexist with the generic ``otel`` callback. In proxy mode it creates a + ``litellm_proxy_request`` parent span and a ``litellm_request`` child span + on its *own* provider — completely independent of the global provider. + + This test verifies: + 1. Phoenix creates both parent and child spans on its dedicated exporter. + 2. The spans form a proper parent-child hierarchy (same trace ID). + 3. A raw_gen_ai_request sub-span is also produced. """ - from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor - exporter.clear() + phoenix_exporter = InMemorySpanExporter() + litellm.logging_callback_manager._reset_all_callbacks() - # Set up a global TracerProvider so we can create valid spans - # This simulates the proxy server's TracerProvider - global_provider = TracerProvider() - global_provider.add_span_processor(SimpleSpanProcessor(exporter)) - trace.set_tracer_provider(global_provider) + # ArizePhoenixLogger builds its own TracerProvider internally. + # We pass our in-memory exporter so we can inspect spans. + phoenix_logger = ArizePhoenixLogger( + config=OpenTelemetryConfig(exporter=phoenix_exporter), + callback_name="arize_phoenix", + ) - otel_logger = ArizePhoenixLogger(config=OpenTelemetryConfig(exporter=exporter)) - litellm.callbacks = [otel_logger] + litellm.callbacks = [phoenix_logger] litellm.success_callback = [] litellm.failure_callback = [] - tracer = trace.get_tracer(LITELLM_TRACER_NAME) - parent_span = tracer.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME) + # Simulate a proxy request by injecting proxy_server_request as a top-level kwarg. + # This triggers ArizePhoenixLogger._get_phoenix_context to create its own parent span. + await litellm.acompletion( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "ping"}], + mock_response="pong", + proxy_server_request={"url": "/chat/completions", "method": "POST", "headers": {}}, + ) - # Keep parent span active; OpenTelemetry logger will attach attributes and end it. - with trace.use_span(parent_span, end_on_exit=False): - await litellm.acompletion( - model="gpt-3.5-turbo", - messages=[{"role": "user", "content": "ping"}], - mock_response="pong", - ) - - # Flush span processing + # Flush async span processing await asyncio.sleep(1) - if parent_span.is_recording(): - parent_span.end() + spans = phoenix_exporter.get_finished_spans() + span_names = [s.name for s in spans] - spans = exporter.get_finished_spans() + # Phoenix creates its own span names on its dedicated TracerProvider: + # - "litellm_proxy_request" (parent) — created by _get_phoenix_context + # - "litellm_request" (child) — the LLM call span + # - "raw_gen_ai_request" — raw request sub-span + assert "litellm_proxy_request" in span_names, f"Expected proxy parent span, got: {span_names}" + assert LITELLM_REQUEST_SPAN_NAME in span_names, f"Expected request child span, got: {span_names}" + assert RAW_REQUEST_SPAN_NAME in span_names, f"Expected raw request span, got: {span_names}" - span_names = [span.name for span in spans] - assert LITELLM_REQUEST_SPAN_NAME not in span_names - assert span_names.count(LITELLM_PROXY_REQUEST_SPAN_NAME) == 1 - assert span_names.count(RAW_REQUEST_SPAN_NAME) == 1 + # All spans should share the same trace ID (proper hierarchy) + trace_ids = {s.context.trace_id for s in spans} + assert len(trace_ids) == 1, f"Expected single trace, got {len(trace_ids)} traces" - # All spans should belong to the same trace (parent + raw child) - assert len({span.context.trace_id for span in spans}) == 1 - assert len(spans) == 2 - - proxy_span = next(span for span in spans if span.name == LITELLM_PROXY_REQUEST_SPAN_NAME) - assert proxy_span.attributes.get(OISpanAttributes.OPENINFERENCE_SPAN_KIND) == OpenInferenceSpanKindValues.LLM.value - - exporter.clear() + phoenix_exporter.clear()