diff --git a/litellm/integrations/arize/arize_phoenix.py b/litellm/integrations/arize/arize_phoenix.py index 1b038c098f..6720a93044 100644 --- a/litellm/integrations/arize/arize_phoenix.py +++ b/litellm/integrations/arize/arize_phoenix.py @@ -136,78 +136,137 @@ class ArizePhoenixLogger(OpenTelemetry): # type: ignore return None + def _get_phoenix_context(self, kwargs): + """ + Build a trace context for Phoenix's dedicated TracerProvider. + + The base ``_get_span_context`` returns parent spans from the global + TracerProvider (the ``otel`` callback). Those spans live on a + *different* TracerProvider, so they won't appear in Phoenix — using + them as parents just creates broken links. + + Instead we: + 1. Honour an incoming ``traceparent`` HTTP header (distributed tracing). + 2. In proxy mode, create our *own* parent span on Phoenix's tracer + so the hierarchy is visible end-to-end inside Phoenix. + 3. In SDK (non-proxy) mode, just return (None, None) for a root span. + """ + from opentelemetry import trace + + litellm_params = kwargs.get("litellm_params", {}) or {} + proxy_server_request = litellm_params.get("proxy_server_request", {}) or {} + headers = proxy_server_request.get("headers", {}) or {} + + # Propagate distributed trace context if the caller sent a traceparent + traceparent_ctx = ( + self.get_traceparent_from_header(headers=headers) + if headers.get("traceparent") + else None + ) + + is_proxy_mode = bool(proxy_server_request) + + if is_proxy_mode: + # Create a parent span on Phoenix's own tracer so both parent + # and child are exported to Phoenix. + start_time_val = kwargs.get("start_time", kwargs.get("api_call_start_time")) + parent_span = self.tracer.start_span( + name="litellm_proxy_request", + start_time=self._to_ns(start_time_val) if start_time_val is not None else None, + context=traceparent_ctx, + kind=self.span_kind.SERVER, + ) + ctx = trace.set_span_in_context(parent_span) + return ctx, parent_span + + # SDK mode — no parent span needed + return traceparent_ctx, None + def _handle_success(self, kwargs, response_obj, start_time, end_time): """ - Override to prevent creating duplicate litellm_request spans when a proxy parent span exists. - - ArizePhoenixLogger should reuse the proxy parent span instead of creating a new litellm_request span, - to maintain a shallow span hierarchy as expected by Arize Phoenix. + Override to always create spans on ArizePhoenixLogger's dedicated TracerProvider. + + The base class's ``_get_span_context`` would find the parent span created by + the ``otel`` callback on the *global* TracerProvider. That span is invisible + in Phoenix (different exporter pipeline), so we ignore it and build our own + hierarchy via ``_get_phoenix_context``. """ from opentelemetry.trace import Status, StatusCode - from litellm.secret_managers.main import get_secret_bool - from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME - + verbose_logger.debug( "ArizePhoenixLogger: Logging kwargs: %s, OTEL config settings=%s", kwargs, self.config, ) - ctx, parent_span = self._get_span_context(kwargs) - # ArizePhoenixLogger NEVER creates a litellm_request span when a proxy parent span exists - # This is different from the base OpenTelemetry behavior which respects USE_OTEL_LITELLM_REQUEST_SPAN - should_create_primary_span = parent_span is None or ( - parent_span.name != LITELLM_PROXY_REQUEST_SPAN_NAME - and get_secret_bool("USE_OTEL_LITELLM_REQUEST_SPAN") + ctx, parent_span = self._get_phoenix_context(kwargs) + + # Create litellm_request span (child of our parent when in proxy mode) + span = self.tracer.start_span( + name=self._get_span_name(kwargs), + start_time=self._to_ns(start_time), + context=ctx, ) + span.set_status(Status(StatusCode.OK)) + self.set_attributes(span, kwargs, response_obj) - 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 - ) - # Ensure proxy-request parent span is annotated with the actual operation kind - if ( - parent_span is not None - and parent_span.name == LITELLM_PROXY_REQUEST_SPAN_NAME - ): - self.set_attributes(parent_span, kwargs, response_obj) - else: - # Do not create primary span (keep hierarchy shallow when parent exists) - span = None - # Only set attributes if the span is still recording (not closed) - # Note: parent_span is guaranteed to be not None here - if parent_span.is_recording(): - 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 - ) + # Raw-request sub-span (if enabled) — must be created before + # ending the parent span so the hierarchy is valid. + self._maybe_log_raw_request( + kwargs, response_obj, start_time, end_time, span + ) + span.end(end_time=self._to_ns(end_time)) - # 3. Guardrail span + # Guardrail span self._create_guardrail_span(kwargs=kwargs, context=ctx) - # 4. Metrics & cost recording + # Annotate and close our proxy parent span + if parent_span is not None: + parent_span.set_status(Status(StatusCode.OK)) + self.set_attributes(parent_span, kwargs, response_obj) + parent_span.end(end_time=self._to_ns(end_time)) + + # Metrics & cost recording self._record_metrics(kwargs, response_obj, start_time, end_time) - # 5. Semantic logs. + # Semantic logs if self.config.enable_events: - 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) + self._emit_semantic_logs(kwargs, response_obj, span) - # 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 - ): + def _handle_failure(self, kwargs, response_obj, start_time, end_time): + """ + Override to always create failure spans on ArizePhoenixLogger's dedicated + TracerProvider. Mirrors ``_handle_success`` but sets ERROR status. + """ + from opentelemetry.trace import Status, StatusCode + + verbose_logger.debug( + "ArizePhoenixLogger: Failure - Logging kwargs: %s, OTEL config settings=%s", + kwargs, + self.config, + ) + + ctx, parent_span = self._get_phoenix_context(kwargs) + + # Create litellm_request span (child of our parent when in proxy mode) + span = self.tracer.start_span( + name=self._get_span_name(kwargs), + start_time=self._to_ns(start_time), + context=ctx, + ) + span.set_status(Status(StatusCode.ERROR)) + self.set_attributes(span, kwargs, response_obj) + self._record_exception_on_span(span=span, kwargs=kwargs) + span.end(end_time=self._to_ns(end_time)) + + # Guardrail span + self._create_guardrail_span(kwargs=kwargs, context=ctx) + + # Annotate and close our proxy parent span + if parent_span is not None: + parent_span.set_status(Status(StatusCode.ERROR)) + self.set_attributes(parent_span, kwargs, response_obj) + self._record_exception_on_span(span=parent_span, kwargs=kwargs) parent_span.end(end_time=self._to_ns(end_time)) @staticmethod diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 0601e7e845..e450b233c7 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3834,6 +3834,12 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 ) ) _in_memory_loggers.append(otel_logger) + + # Auto-initialize Arize Phoenix if Phoenix env vars are configured + # This allows users to get nested traces in both OTEL and Phoenix + # by only specifying "otel" in callbacks + _maybe_auto_initialize_arize_phoenix(_in_memory_loggers) + return otel_logger # type: ignore elif logging_integration == "galileo": @@ -3887,7 +3893,8 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 headers=f"Authorization={os.getenv('LOGFIRE_TOKEN')}", ) for callback in _in_memory_loggers: - if isinstance(callback, OpenTelemetry): + # Use exact type check to avoid matching ArizePhoenixLogger (subclass) + if type(callback) is OpenTelemetry: return callback # type: ignore _otel_logger = OpenTelemetry(config=otel_config) _in_memory_loggers.append(_otel_logger) @@ -4147,6 +4154,57 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915 return None +def _maybe_auto_initialize_arize_phoenix(_in_memory_loggers: list) -> None: + """ + Auto-initialize ArizePhoenixLogger when Phoenix env vars are detected. + + Called during ``otel`` callback setup so that users get nested traces in + both their OTEL collector *and* Arize Phoenix by only listing ``"otel"`` + in ``callbacks``. If no Phoenix env vars are set, this is a no-op. + """ + phoenix_env_vars = ( + "PHOENIX_API_KEY", + "PHOENIX_COLLECTOR_HTTP_ENDPOINT", + "PHOENIX_COLLECTOR_ENDPOINT", + ) + if not any(os.environ.get(v) for v in phoenix_env_vars): + return + + # Already registered — nothing to do + if any( + isinstance(cb, ArizePhoenixLogger) and cb.callback_name == "arize_phoenix" + for cb in _in_memory_loggers + ): + return + + try: + from litellm.integrations.opentelemetry import OpenTelemetryConfig + + arize_phoenix_config = ArizePhoenixLogger.get_arize_phoenix_config() + otel_config = OpenTelemetryConfig( + exporter=arize_phoenix_config.protocol, + endpoint=arize_phoenix_config.endpoint, + headers=arize_phoenix_config.otlp_auth_headers, + ) + phoenix_logger = ArizePhoenixLogger( + config=otel_config, callback_name="arize_phoenix" + ) + _in_memory_loggers.append(phoenix_logger) + + # Register as a litellm callback so it receives success/failure events + litellm.logging_callback_manager.add_litellm_callback(phoenix_logger) + + verbose_logger.info( + "Auto-initialized Arize Phoenix logger alongside otel " + "(endpoint=%s)", + arize_phoenix_config.endpoint, + ) + except Exception as e: + verbose_logger.warning( + "Failed to auto-initialize Arize Phoenix logger: %s", str(e) + ) + + def get_custom_logger_compatible_class( # noqa: PLR0915 logging_integration: _custom_logger_compatible_callbacks_literal, ) -> Optional[CustomLogger]: @@ -4249,7 +4307,8 @@ def get_custom_logger_compatible_class( # noqa: PLR0915 from litellm.integrations.opentelemetry import OpenTelemetry for callback in _in_memory_loggers: - if isinstance(callback, OpenTelemetry): + # Use exact type check to avoid matching ArizePhoenixLogger (subclass) + if type(callback) is OpenTelemetry: return callback elif logging_integration == "arize": if "ARIZE_API_KEY" not in os.environ: @@ -4266,7 +4325,8 @@ def get_custom_logger_compatible_class( # noqa: PLR0915 from litellm.integrations.opentelemetry import OpenTelemetry for callback in _in_memory_loggers: - if isinstance(callback, OpenTelemetry): + # Use exact type check to avoid matching ArizePhoenixLogger (subclass) + if type(callback) is OpenTelemetry: return callback # type: ignore elif logging_integration == "dynamic_rate_limiter": diff --git a/tests/test_litellm/integrations/arize/test_arize_otel_coexistence.py b/tests/test_litellm/integrations/arize/test_arize_otel_coexistence.py index a0dcf1c091..329902d4a4 100644 --- a/tests/test_litellm/integrations/arize/test_arize_otel_coexistence.py +++ b/tests/test_litellm/integrations/arize/test_arize_otel_coexistence.py @@ -6,8 +6,11 @@ Covers the three root-cause fixes: 1. ArizePhoenixLogger / ArizeLogger create *dedicated* TracerProviders. 2. The ``otel`` dedup check does NOT match Arize subclasses. 3. Arize loggers do NOT overwrite ``proxy_server.open_telemetry_logger``. +4. Phoenix creates nested spans (parent + child) in proxy mode. +5. Auto-initialization of Phoenix when env vars are detected. """ +import os import unittest from unittest.mock import patch @@ -165,5 +168,46 @@ class TestProxyLoggerNotOverwritten(unittest.TestCase): assert proxy_server.open_telemetry_logger is None +class TestPhoenixAutoInitWithOtelOnly(unittest.TestCase): + """When only 'otel' is configured but Phoenix env vars are set, + ArizePhoenixLogger should be auto-initialized and receive spans.""" + + def setUp(self): + """Save original callbacks to restore after each test.""" + import litellm + self._original_callbacks = litellm.callbacks[:] + + def tearDown(self): + """Restore original callbacks to prevent global state leakage.""" + import litellm + litellm.callbacks = self._original_callbacks + + @patch.dict(os.environ, { + "PHOENIX_COLLECTOR_HTTP_ENDPOINT": "http://localhost:6006/v1/traces", + }, clear=False) + def test_auto_init_creates_phoenix_logger(self): + from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger + from litellm.litellm_core_utils.litellm_logging import _maybe_auto_initialize_arize_phoenix + + _in_memory_loggers = [] + _maybe_auto_initialize_arize_phoenix(_in_memory_loggers) + + phoenix_loggers = [cb for cb in _in_memory_loggers if isinstance(cb, ArizePhoenixLogger)] + assert len(phoenix_loggers) == 1, "Phoenix logger should be auto-initialized when env vars are set" + + def test_no_auto_init_without_env_vars(self): + from litellm.integrations.arize.arize_phoenix import ArizePhoenixLogger + from litellm.litellm_core_utils.litellm_logging import _maybe_auto_initialize_arize_phoenix + + env_keys = ["PHOENIX_API_KEY", "PHOENIX_COLLECTOR_HTTP_ENDPOINT", "PHOENIX_COLLECTOR_ENDPOINT"] + with patch.dict(os.environ, {k: "" for k in env_keys}, clear=False): + for k in env_keys: + os.environ.pop(k, None) + _in_memory_loggers = [] + _maybe_auto_initialize_arize_phoenix(_in_memory_loggers) + phoenix_loggers = [cb for cb in _in_memory_loggers if isinstance(cb, ArizePhoenixLogger)] + assert len(phoenix_loggers) == 0 + + if __name__ == "__main__": unittest.main()