From e8cea3e7353054e6c5b970a2415aded6757ee315 Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Sat, 5 Sep 2026 02:16:10 -0700 Subject: [PATCH] fix(otel v2): anchor destinations off the published provider, refuse headerless tenant transports set_tracer_provider keeps the first provider it is handed, so a process whose OTel global was claimed before the proxy published (auto-instrumentation, a legacy logger) had no fan-out on the global and auth anchored no destination. Auth now reads the fan-out off the registered logger's own provider. A destination whose protocol maps to a headerless exporter kind is no longer buildable: the console fallback would drop the tenant's credentials and print the spans to stdout while the operator's exporter stood down for them. --- litellm/integrations/otel/logger.py | 14 ++++ .../integrations/otel/plumbing/providers.py | 13 +++- litellm/proxy/auth/user_api_key_auth.py | 5 +- .../otel/test_otel_v2_destinations.py | 76 +++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 0d4b91fa049..36b186cdb96 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -16,9 +16,11 @@ from opentelemetry.trace import ( Span, Tracer, get_current_span, + get_tracer_provider, set_span_in_context, use_span, ) +from opentelemetry.trace import TracerProvider as ApiTracerProvider import litellm from litellm._logging import verbose_logger @@ -917,6 +919,18 @@ def seed_request_identity(user_api_key_dict: object, model: str | None = None) - logger.seed_request_identity(user_api_key_dict, model=model) +def fan_out_provider() -> ApiTracerProvider: + """The provider :func:`publish_global_otel_v2_provider` gave the tenant fan-out. + + That is the registered logger's own provider, which stays the carrier even when + the OTel global was claimed before the proxy published (auto-instrumentation, a + legacy logger): ``set_tracer_provider`` keeps the first provider it was given, so + reading the global there would find no fan-out and drop every destination. + """ + logger: Final = _registered_v2_logger() + return logger.tracer_provider if logger is not None else get_tracer_provider() + + @contextmanager def phase_span(name: str) -> "Iterator[Span | None]": logger: Final = _registered_v2_logger() diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 8422f1963f4..350566d1985 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -541,10 +541,19 @@ class TenantFanOutSpanProcessor(SpanProcessor): def _destination_processor(destination: "OtelDestination") -> SpanProcessor | None: - """A batching OTLP processor aimed at ``destination``, or ``None`` if unbuildable.""" + """A batching OTLP processor aimed at ``destination``, or ``None`` if unbuildable. + + A protocol that resolves to a headerless exporter is unbuildable too: the + console fallback would swallow the tenant's credentials and print its spans to + the proxy's stdout while the operator's exporter stands down for them. + """ + kind: Final = destination.protocol or "otlp_http" + if exporter_transport(kind) == "headerless": + verbose_logger.debug("OTel V2 fan-out: no OTLP transport for protocol %r at %s", kind, destination.endpoint) + return None try: spec: Final = ExporterSpec( - kind=destination.protocol or "otlp_http", + kind=kind, endpoint=destination.endpoint, headers=destination.header_string(), owner=None, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 680d994bee6..ba92d710cc8 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2862,13 +2862,16 @@ def _seed_request_destinations(user_api_key_dict: UserAPIKeyAuth) -> None: is set. """ try: + from litellm.integrations.otel.logger import fan_out_provider from litellm.integrations.otel.plumbing.context import set_request_destinations from litellm.integrations.otel.plumbing.providers import deliverable_destinations from litellm.proxy.litellm_pre_call_utils import ( resolve_tenant_otel_destinations, ) - set_request_destinations(deliverable_destinations(resolve_tenant_otel_destinations(user_api_key_dict))) + set_request_destinations( + deliverable_destinations(resolve_tenant_otel_destinations(user_api_key_dict), fan_out_provider()) + ) except Exception as exc: # noqa: BLE001 # telemetry routing is best-effort and must never break authentication verbose_proxy_logger.debug("OTel V2: tenant destination resolution failed: %s", exc) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py index e2d358b5e31..d0e01fcba89 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -566,6 +566,17 @@ class TestFanOut: assert deliverable_destinations((LANGFUSE_DEST,), provider) == () + def test_a_protocol_with_no_otlp_transport_is_not_deliverable(self): + """An unknown exporter kind falls back to the console exporter, which ignores the + tenant's credentials and prints its spans to the proxy's stdout. Treating that as + deliverable would stand the operator's exporter down for spans nobody stores.""" + typo = LANGFUSE_DEST.model_copy(update={"protocol": "consle"}) + fan_out = TenantFanOutSpanProcessor() + try: + assert fan_out.deliverable((typo, LANGFUSE_DEST)) == (LANGFUSE_DEST,) + finally: + fan_out.shutdown() + def test_a_closed_fan_out_anchors_nothing(self): fan_out = TenantFanOutSpanProcessor(processor_factory=lambda _d: SimpleSpanProcessor(InMemorySpanExporter())) provider = TracerProvider() @@ -652,6 +663,71 @@ class TestProviderWiring: kinds = [type(p).__name__ for p in logger._tracer_provider._active_span_processor._span_processors] assert kinds.count("TenantFanOutSpanProcessor") == 1 + def test_anchoring_reads_the_fan_out_off_the_registered_logger_not_the_otel_global(self, monkeypatch): + """``set_tracer_provider`` keeps the first provider it was handed. When + auto-instrumentation or a legacy logger claimed it before the proxy published, + the OTel global carries no fan-out, so reading it there would refuse every + destination while the registered logger's provider would have delivered them.""" + from litellm.integrations.otel.logger import fan_out_provider + from litellm.proxy import proxy_server + + config = OpenTelemetryV2Config(exporters=[ExporterSpec(kind="in_memory", owner=ExporterOwner.LANGFUSE_OTEL)]) + logger = OpenTelemetryV2(config=config, callback_name="langfuse_otel") + publish_global_otel_v2_provider([], lambda _p: None, registered=logger) + monkeypatch.setattr(proxy_server, "open_telemetry_logger", logger) + claimed_first = TracerProvider() + + assert fan_out_provider() is logger.tracer_provider + assert deliverable_destinations((LANGFUSE_DEST,), claimed_first) == () + assert deliverable_destinations((LANGFUSE_DEST,), fan_out_provider()) == (LANGFUSE_DEST,) + + def test_without_a_registered_logger_anchoring_falls_back_to_the_otel_global(self, monkeypatch): + from opentelemetry import trace + + from litellm.integrations.otel.logger import fan_out_provider + from litellm.proxy import proxy_server + + monkeypatch.setattr(proxy_server, "open_telemetry_logger", None) + + assert fan_out_provider() is trace.get_tracer_provider() + + def test_auth_seeds_the_request_with_destinations_the_registered_logger_can_deliver( + self, monkeypatch, allow_test_hosts + ): + from litellm.proxy import proxy_server + from litellm.proxy.auth.user_api_key_auth import _seed_request_destinations + + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + is_otel_v2_enabled.cache_clear() + config = OpenTelemetryV2Config(exporters=[ExporterSpec(kind="in_memory", owner=ExporterOwner.LANGFUSE_OTEL)]) + logger = OpenTelemetryV2(config=config, callback_name="langfuse_otel") + publish_global_otel_v2_provider([], lambda _p: None, registered=logger) + monkeypatch.setattr(proxy_server, "open_telemetry_logger", logger) + auth = UserAPIKeyAuth( + team_metadata={ + "logging": [ + { + "callback_name": "langfuse_otel", + "callback_type": "success", + "callback_vars": { + "langfuse_public_key": "pk-team", + "langfuse_secret_key": "sk-team", + "langfuse_host": "http://team.local", + }, + } + ] + } + ) + expected = resolve_tenant_otel_destinations(auth) + assert expected, "the fixture must resolve to a destination for the test to mean anything" + + def run(): + _seed_request_destinations(auth) + return request_destinations() + + assert deliverable_destinations(expected, TracerProvider()) == () + assert in_fresh_context(run) == expected + class TestRouting: def test_an_overridden_backend_is_not_detached_onto_a_second_provider(self):