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.
This commit is contained in:
Yucheng He 2026-09-05 02:16:10 -07:00
parent 29765dc8c7
commit e8cea3e735
4 changed files with 105 additions and 3 deletions

View file

@ -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()

View file

@ -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,

View file

@ -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)

View file

@ -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):