diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 29a29d376dc..d6f80e89c66 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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", 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 28360d4f30a..bea83ff2407 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -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"))