mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
29765dc8c7
commit
e8cea3e735
4 changed files with 105 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue