fix(otel): preserve explicit console exporters

This commit is contained in:
Yucheng He 2026-09-05 08:49:30 -07:00
parent 093a9912c8
commit 3b2635674c
3 changed files with 51 additions and 11 deletions

View file

@ -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)

View file

@ -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

View file

@ -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