From 3b2635674c216ad3c568a1e8d6929991277e39c8 Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Sat, 5 Sep 2026 08:49:30 -0700 Subject: [PATCH] fix(otel): preserve explicit console exporters --- litellm/integrations/otel/model/config.py | 6 ++- litellm/integrations/otel/presets/utils.py | 10 ++-- .../otel/test_otel_v2_destinations.py | 46 +++++++++++++++++-- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index 9e3064c2bff..d8439045a12 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -253,7 +253,9 @@ class OpenTelemetryV2Config(BaseSettings): if self.endpoint and self.exporter == "console": self.exporter = "otlp_http" # When no explicit destinations are given, fold the single-destination - # shorthand into one spec so the provider always has a destination. + # shorthand into one spec so the provider always has a destination. A spec + # with no fields set is how the presets tell "nothing configured" from an + # operator who asked for the console by name. if not self.exporters: self.exporters = [ ExporterSpec( @@ -261,6 +263,8 @@ class OpenTelemetryV2Config(BaseSettings): endpoint=self.endpoint, headers=self.headers, ) + if self.model_fields_set & {"exporter", "endpoint", "headers"} + else ExporterSpec() ] # Ensure ``genai`` is always present and first. names = list(self.mapper_names) diff --git a/litellm/integrations/otel/presets/utils.py b/litellm/integrations/otel/presets/utils.py index 19bcdd71aff..1270c41e77b 100644 --- a/litellm/integrations/otel/presets/utils.py +++ b/litellm/integrations/otel/presets/utils.py @@ -40,9 +40,9 @@ def credential_gated_exporters( 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 - they did configure survives, whatever its kind, and so does the gated spec this - module appends, which would otherwise eat itself when one preset layers onto - another. + No field set is what says the operator asked for nothing: an exporter they did + configure survives, even ``OTEL_EXPORTER=console`` whose value matches the default, + and so does the gated spec this module appends, which would otherwise eat itself + when one preset layers onto another. """ - return not spec.model_dump(exclude_defaults=True) + return not spec.model_fields_set 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 1787dd68ad8..4cdc1d93e78 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -1103,18 +1103,18 @@ def credential_less_proxy(monkeypatch) -> None: class TestPresetDegradation: - def test_a_credential_less_langfuse_exports_nowhere_instead_of_to_the_console(self, monkeypatch, capsys): + def test_a_credential_less_langfuse_exports_nowhere_instead_of_to_the_console(self, monkeypatch, capfd): """``_normalize`` folds a console exporter in for an empty list, which would print every span on a proxy whose teams bring their own credentials.""" credential_less_proxy(monkeypatch) config = langfuse_preset(allow_missing_credentials=True) provider = build_tracer_provider(config, tenant_overrides=True) - capsys.readouterr() + capfd.readouterr() in_fresh_context(emit, provider) provider.force_flush() - assert capsys.readouterr().out == "" + assert '"name": "chat gpt-4"' not in capfd.readouterr().out assert "langfuse" in config.mapper_names def test_langfuse_still_raises_for_a_global_callback_with_no_credentials(self, monkeypatch): @@ -1188,6 +1188,26 @@ class TestPresetDegradation: assert logger is None + def test_an_explicit_console_exporter_keeps_a_credentialless_preset_on_v2(self, monkeypatch, capfd): + """``OTEL_EXPORTER=console`` reads exactly like the placeholder ``_normalize`` + folds in, but the operator asked for it, so a credential-less New Relic keeps + the V2 logger and its spans reach stdout instead of the legacy path.""" + from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 + + monkeypatch.delenv("NEW_RELIC_LICENSE_KEY", raising=False) + for name in _OTEL_SHORTHAND_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OTEL_EXPORTER", "console") + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + + is_otel_v2_enabled.cache_clear() + logger = in_fresh_context(_maybe_construct_otel_v2, "newrelic", []) + is_otel_v2_enabled.cache_clear() + + assert logger is not None + assert logger.config.exporters[0].kind == "console" + assert not logger.config.exporters[0].requires_headers + 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 @@ -2084,15 +2104,31 @@ class TestCredentialGatedExporters: assert kept[0] == operator_memory - def test_the_synthesized_stdout_placeholder_is_dropped(self): + def test_the_synthesized_stdout_placeholder_is_dropped(self, monkeypatch): from litellm.integrations.otel.presets.utils import credential_gated_exporters - placeholder = ExporterSpec(kind="console", endpoint=None, headers=None) + for name in _OTEL_SHORTHAND_ENV: + monkeypatch.delenv(name, raising=False) + placeholder = OpenTelemetryV2Config().exporters[0] kept = credential_gated_exporters((placeholder,), ExporterOwner.LANGFUSE_OTEL) assert [spec.owner for spec in kept] == [ExporterOwner.LANGFUSE_OTEL] + def test_a_console_exporter_the_operator_named_survives(self, monkeypatch): + """Same kind, endpoint and headers as the placeholder; only the fact that the + operator set ``OTEL_EXPORTER`` tells them apart.""" + from litellm.integrations.otel.presets.utils import credential_gated_exporters + + for name in _OTEL_SHORTHAND_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OTEL_EXPORTER", "console") + operator_console = OpenTelemetryV2Config().exporters[0] + + kept = credential_gated_exporters((operator_console,), ExporterOwner.LANGFUSE_OTEL) + + assert kept[0] is operator_console + class TestTenantHostSsrfGuard: """Anyone who can mint a key can write ``langfuse_host``, so the host it names has