fix(otel): keep credentialless fallback on base path

This commit is contained in:
Yucheng He 2026-09-05 06:56:39 -07:00
parent c46341a1fd
commit c12da6dc90
2 changed files with 28 additions and 24 deletions

View file

@ -4803,17 +4803,11 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
``callback_name`` — callers should then fall through to the legacy path.
A preset that needs operator credentials it cannot find is allowed to build
anyway, exporting nowhere, only while this request has a key/team destination
for that backend: the exporter-less logger exists to let the fan-out carry those
spans without a second detached copy. With no such destination the preset raises
as it always did and the caller falls through to the legacy path, so the proxy
never publishes a provider that exports nowhere for a backend the operator
configured and no tenant can use.
The degraded preset keeps the operator's generic OTLP collector, so a proxy whose
only v2 backend is that preset still reaches it. Beside another v2 logger the
collector is already that logger's, and a second copy of every model span from
this one would land there too, so only the credential-gated exporter is kept.
only when this request has a key/team destination for that backend and another
V2 logger is already registered to carry the fan-out. The resulting logger keeps
only its credential-gated exporter, while the registered logger owns operator
delivery. Without that carrier, the preset raises as it always did and the
caller falls through to the legacy path.
"""
from litellm.integrations.otel.model.config import is_otel_v2_enabled
@ -4827,6 +4821,7 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
if preset_fn is None:
return None
serves_a_destination: Final = callback_name in destination_backends()
has_v2_logger: Final = any(isinstance(callback, OpenTelemetryV2) for callback in _in_memory_loggers)
for callback in _in_memory_loggers:
if (
isinstance(callback, OpenTelemetryV2)
@ -4835,16 +4830,12 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom
):
return callback
try:
built: Final = preset_fn(allow_missing_credentials=serves_a_destination)
built: Final = preset_fn(allow_missing_credentials=serves_a_destination and has_v2_logger)
except Exception:
# If env vars are missing or the preset raises, defer to the legacy path
# so customers get the same error story they had before V2 landed.
return None
config: Final = (
_only_the_gated_exporter(built)
if _is_credential_gated(built) and any(isinstance(callback, OpenTelemetryV2) for callback in _in_memory_loggers)
else built
)
config: Final = _only_the_gated_exporter(built) if _is_credential_gated(built) else built
if _exports_nowhere(config):
verbose_logger.warning(
"OTel V2: no operator credentials for '%s'; only key/team destinations will receive its traces",

View file

@ -1123,21 +1123,22 @@ class TestPresetDegradation:
with pytest.raises(ValueError, match="LANGFUSE_PUBLIC_KEY"):
langfuse_preset()
def test_a_credential_less_proxy_builds_the_v2_logger_for_a_team_destination(self, monkeypatch):
def test_a_credential_less_proxy_builds_the_gated_logger_beside_a_v2_carrier(self, monkeypatch):
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
credential_less_proxy(monkeypatch)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
carrier = build_otel_v2_logger(OpenTelemetryV2Config(exporter="in_memory"))
def run():
set_request_destinations((LANGFUSE_DEST,))
return _maybe_construct_otel_v2("langfuse_otel", [])
return _maybe_construct_otel_v2("langfuse_otel", [carrier])
is_otel_v2_enabled.cache_clear()
logger = in_fresh_context(run)
is_otel_v2_enabled.cache_clear()
assert logger is not None, "team-only deployments must not fall back to the legacy integration"
assert logger is not None
assert all(spec.requires_headers and not spec.headers for spec in logger.config.exporters)
def test_a_credential_less_proxy_with_no_destinations_falls_back_to_the_legacy_path(self, monkeypatch):
@ -1179,7 +1180,7 @@ class TestPresetDegradation:
credential_less_proxy(monkeypatch)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
loggers = []
loggers = [build_otel_v2_logger(OpenTelemetryV2Config(exporter="in_memory"))]
def with_destination():
set_request_destinations((LANGFUSE_DEST,))
@ -1237,10 +1238,21 @@ class TestPresetDegradation:
assert [spec.endpoint for spec in logger.config.exporters] == [None]
assert all(spec.requires_headers and not spec.headers for spec in logger.config.exporters)
def test_a_degraded_logger_on_its_own_keeps_the_operator_collector(self, monkeypatch):
logger = self._degraded_langfuse_beside([], monkeypatch)
def test_a_credential_less_proxy_with_a_destination_but_no_v2_carrier_falls_back(self, monkeypatch):
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2
assert [spec.endpoint for spec in logger.config.exporters] == ["http://collector.local:4318", None]
credential_less_proxy(monkeypatch)
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
def run():
set_request_destinations((LANGFUSE_DEST,))
return _maybe_construct_otel_v2("langfuse_otel", [])
is_otel_v2_enabled.cache_clear()
logger = in_fresh_context(run)
is_otel_v2_enabled.cache_clear()
assert logger is None
def test_a_credentialed_logger_beside_another_v2_logger_keeps_every_exporter(self, monkeypatch):
"""Only a degraded preset gives the collector up; an operator who configured
@ -1249,6 +1261,7 @@ class TestPresetDegradation:
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-1")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-1")
monkeypatch.setenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.local:4318")
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
collector_logger = build_otel_v2_logger(OpenTelemetryV2Config(exporter="in_memory"))