diff --git a/litellm/integrations/otel/presets/utils.py b/litellm/integrations/otel/presets/utils.py index 2d7598b63c3..19bcdd71aff 100644 --- a/litellm/integrations/otel/presets/utils.py +++ b/litellm/integrations/otel/presets/utils.py @@ -32,12 +32,12 @@ def credential_gated_exporters( override filter still recognises which backend this provider speaks for. """ return ( - *(spec for spec in exporters if not _is_unconfigured_placeholder(spec)), + *(spec for spec in exporters if not is_unconfigured_placeholder(spec)), ExporterSpec(owner=owner, requires_headers=True), ) -def _is_unconfigured_placeholder(spec: "ExporterSpec") -> bool: +def is_unconfigured_placeholder(spec: "ExporterSpec") -> bool: """Whether ``spec`` is the one ``_normalize`` folds in when nothing was configured. Every field at its default is what says the operator asked for nothing: an exporter diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c155092fdbd..2b669abcfd7 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4806,8 +4806,9 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom 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. + delivery. Without that carrier, a preset that raises or that ends up with nothing + but its gated exporter and the default console placeholder returns ``None``, so the + caller falls through to the legacy path exactly as before V2 landed. """ from litellm.integrations.otel.model.config import is_otel_v2_enabled @@ -4822,6 +4823,7 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom 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) + carried: Final = serves_a_destination and has_v2_logger for callback in _in_memory_loggers: if ( isinstance(callback, OpenTelemetryV2) @@ -4830,20 +4832,15 @@ 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 and has_v2_logger) + built: Final = preset_fn(allow_missing_credentials=carried) 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 gated: Final = _is_credential_gated(built) - has_operator_exporter: Final = any( - not _is_gated(spec) and bool(spec.model_dump(exclude_defaults=True)) for spec in built.exporters - ) - if _exports_nowhere(built) and not (serves_a_destination and has_v2_logger): + if gated and not carried and not _has_operator_exporter(built): return None - if gated and not has_operator_exporter and not (serves_a_destination and has_v2_logger): - return None - config: Final = _only_the_gated_exporter(built) if gated and serves_a_destination and has_v2_logger else built + config: Final = _only_the_gated_exporter(built) if gated and carried else built if _exports_nowhere(config): verbose_logger.warning( "OTel V2: no operator credentials for '%s'; only key/team destinations will receive its traces", @@ -4864,6 +4861,13 @@ def _is_credential_gated(config: "OpenTelemetryV2Config") -> bool: return any(_is_gated(spec) for spec in config.exporters) +def _has_operator_exporter(config: "OpenTelemetryV2Config") -> bool: + """Whether the operator configured somewhere real to export, beyond the default console placeholder.""" + from litellm.integrations.otel.presets.utils import is_unconfigured_placeholder + + return any(not _is_gated(spec) and not is_unconfigured_placeholder(spec) for spec in config.exporters) + + def _only_the_gated_exporter(config: "OpenTelemetryV2Config") -> "OpenTelemetryV2Config": return config.model_copy( update={"exporters": [spec for spec in config.exporters if _is_gated(spec)]} # mutable-ok: model_copy update 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 d52183638b8..f051c6dfcb0 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -1174,7 +1174,7 @@ class TestPresetDegradation: "https://otlp.nr-data.net", ] - def test_a_credentialless_newrelic_without_a_base_exporter_falls_back(self, monkeypatch, capsys): + def test_a_credentialless_newrelic_without_a_base_exporter_falls_back(self, monkeypatch): from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 monkeypatch.delenv("NEW_RELIC_LICENSE_KEY", raising=False) @@ -1187,7 +1187,6 @@ class TestPresetDegradation: is_otel_v2_enabled.cache_clear() assert logger is None - assert capsys.readouterr().out == "" def test_a_destination_for_one_backend_does_not_degrade_another(self, monkeypatch): from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2