From 954646e781dee2148b54038245cf73860f35512b Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 28 Oct 2026 00:55:32 +0000 Subject: [PATCH 1/3] fix(otel): stop printing spans to stdout when no exporter is configured Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../integrations/langfuse/langfuse_otel.py | 22 ++- litellm/integrations/otel/logger.py | 13 +- litellm/integrations/otel/presets/agentops.py | 3 +- litellm/integrations/otel/presets/arize.py | 4 +- litellm/integrations/otel/presets/langfuse.py | 3 +- litellm/integrations/otel/presets/levo.py | 3 +- litellm/integrations/otel/presets/newrelic.py | 4 +- litellm/integrations/otel/presets/phoenix.py | 4 +- litellm/integrations/otel/presets/signoz.py | 4 +- litellm/integrations/otel/presets/utils.py | 6 +- litellm/integrations/otel/presets/weave.py | 3 +- .../integrations/otel/test_otel_v2_logger.py | 66 +++++++++ .../integrations/otel/test_otel_v2_presets.py | 134 +++++++++++++++++- tests/unit/integrations/test_langfuse_otel.py | 40 +++++- 14 files changed, 288 insertions(+), 21 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 34d2c9fce0d..e75e19b0436 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -2,11 +2,14 @@ import base64 import json import os from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace from datetime import datetime from itertools import chain from typing import TYPE_CHECKING, Any, Final, Optional, Protocol, runtime_checkable from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult import litellm from litellm._logging import verbose_logger @@ -61,6 +64,11 @@ class _Gettable(Protocol): def get(self, key: str, default: object = None) -> object: ... +class _DiscardingSpanExporter(SpanExporter): + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + return SpanExportResult.SUCCESS + + def _as_gettable(value: object) -> _Gettable | None: return value if isinstance(value, _Gettable) else None @@ -382,8 +390,18 @@ class LangfuseOtelLogger(OpenTelemetry): secret_key: Final = os.environ.get("LANGFUSE_SECRET_KEY", None) if not public_key or not secret_key: - # If no keys, return default from env (likely logging to console or something else) - return OpenTelemetryConfig.from_env() + config: Final = OpenTelemetryConfig.from_env() + if any( + name in os.environ + for name in ( + "OTEL_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_ENDPOINT", + ) + ): + return config + return replace(config, exporter=_DiscardingSpanExporter()) return LangfuseOtelLogger._build_langfuse_otel_config( public_key=public_key, diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index 5092b248eac..f4c30b75f5d 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -31,7 +31,7 @@ from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.otel.emitter import SpanEmitter, stamp_error from litellm.integrations.otel.mappers import resolve_mappers from litellm.integrations.otel.model.baggage import promoted_baggage -from litellm.integrations.otel.model.config import OpenTelemetryV2Config, excluded_db_systems_from +from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config, excluded_db_systems_from from litellm.integrations.otel.model.metadata import ( LLMCallEvent, RequestIdentity, @@ -83,6 +83,7 @@ from litellm.integrations.otel.plumbing.providers import ( resolve_meter_provider, ) from litellm.integrations.otel.plumbing.routing import TenantTracerCache +from litellm.integrations.otel.presets.utils import is_unconfigured_placeholder from litellm.types.utils import captures_span_content if TYPE_CHECKING: @@ -933,7 +934,15 @@ def select_global_otel_v2_logger( if registered is not None: return registered existing: Final = next((cb for cb in in_memory_loggers if isinstance(cb, OpenTelemetryV2)), None) - return existing if existing is not None else OpenTelemetryV2() + if existing is not None: + return existing + fallback_config: Final = OpenTelemetryV2Config() + config: Final = ( + fallback_config.model_copy(update={"exporters": (ExporterSpec(requires_headers=True),)}) + if all(is_unconfigured_placeholder(spec) for spec in fallback_config.exporters) + else fallback_config + ) + return build_otel_v2_logger(config=config) def publish_global_otel_v2_provider( diff --git a/litellm/integrations/otel/presets/agentops.py b/litellm/integrations/otel/presets/agentops.py index 141162bdfa5..36a1fb69514 100644 --- a/litellm/integrations/otel/presets/agentops.py +++ b/litellm/integrations/otel/presets/agentops.py @@ -25,6 +25,7 @@ from litellm.integrations.otel.model.config import ( OpenTelemetryV2Config, ) from litellm.integrations.otel.plumbing.providers import register_exporter_factory +from litellm.integrations.otel.presets.utils import operator_exporters _AGENTOPS_ENDPOINT: Final = "https://otlp.agentops.ai/v1/traces" _AGENTOPS_AUTH_ENDPOINT: Final = "https://api.agentops.ai/v3/auth/token" @@ -56,7 +57,7 @@ def agentops_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind=_AGENTOPS_EXPORTER_KIND, endpoint=_AGENTOPS_ENDPOINT, diff --git a/litellm/integrations/otel/presets/arize.py b/litellm/integrations/otel/presets/arize.py index 0e2534acfd2..592dc23eabb 100644 --- a/litellm/integrations/otel/presets/arize.py +++ b/litellm/integrations/otel/presets/arize.py @@ -11,7 +11,7 @@ from litellm.integrations.otel.model.config import ( ExporterSpec, OpenTelemetryV2Config, ) -from litellm.integrations.otel.presets.utils import credential_gated_exporters, ensure_mappers +from litellm.integrations.otel.presets.utils import credential_gated_exporters, ensure_mappers, operator_exporters from litellm.types.utils import StandardCallbackDynamicParams #: Arize routes an export to a project by the ``model_id`` resource attribute and @@ -52,7 +52,7 @@ def arize_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind=arize_cfg.protocol or "otlp_grpc", endpoint=arize_cfg.endpoint or "https://otlp.arize.com/v1", diff --git a/litellm/integrations/otel/presets/langfuse.py b/litellm/integrations/otel/presets/langfuse.py index 3ff3b521d29..41ffad2abc9 100644 --- a/litellm/integrations/otel/presets/langfuse.py +++ b/litellm/integrations/otel/presets/langfuse.py @@ -13,6 +13,7 @@ from litellm.integrations.otel.model.config import ( from litellm.integrations.otel.presets.utils import ( credential_gated_exporters, ensure_mappers, + operator_exporters, ) from litellm.types.utils import StandardCallbackDynamicParams @@ -39,7 +40,7 @@ def langfuse_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind=kind, endpoint=cfg.endpoint, diff --git a/litellm/integrations/otel/presets/levo.py b/litellm/integrations/otel/presets/levo.py index c1580cf7a5b..7e20019db08 100644 --- a/litellm/integrations/otel/presets/levo.py +++ b/litellm/integrations/otel/presets/levo.py @@ -8,6 +8,7 @@ from litellm.integrations.otel.model.config import ( ExporterSpec, OpenTelemetryV2Config, ) +from litellm.integrations.otel.presets.utils import operator_exporters def levo_preset( @@ -20,7 +21,7 @@ def levo_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind="otlp_http", endpoint=cfg.endpoint, diff --git a/litellm/integrations/otel/presets/newrelic.py b/litellm/integrations/otel/presets/newrelic.py index 9d8d04d8f0d..25fe149eb46 100644 --- a/litellm/integrations/otel/presets/newrelic.py +++ b/litellm/integrations/otel/presets/newrelic.py @@ -14,7 +14,7 @@ from litellm.integrations.otel.model.config import ( ExporterSpec, OpenTelemetryV2Config, ) -from litellm.integrations.otel.presets.utils import ensure_mappers +from litellm.integrations.otel.presets.utils import ensure_mappers, operator_exporters from litellm.types.utils import StandardCallbackDynamicParams #: Region -> OTLP base endpoint. A fixed table by design: team config picks a @@ -55,7 +55,7 @@ def newrelic_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind="otlp_http", endpoint=endpoint, diff --git a/litellm/integrations/otel/presets/phoenix.py b/litellm/integrations/otel/presets/phoenix.py index f4f34ee7525..a1324573b05 100644 --- a/litellm/integrations/otel/presets/phoenix.py +++ b/litellm/integrations/otel/presets/phoenix.py @@ -15,7 +15,7 @@ from litellm.integrations.otel.model.config import ( ExporterSpec, OpenTelemetryV2Config, ) -from litellm.integrations.otel.presets.utils import ensure_mappers +from litellm.integrations.otel.presets.utils import ensure_mappers, operator_exporters class _PhoenixSettings(BaseSettings): @@ -69,7 +69,7 @@ def phoenix_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind=cfg.protocol if hasattr(cfg, "protocol") else "otlp_http", endpoint=cfg.endpoint, diff --git a/litellm/integrations/otel/presets/signoz.py b/litellm/integrations/otel/presets/signoz.py index 1d55f99cb52..041fb322ad1 100644 --- a/litellm/integrations/otel/presets/signoz.py +++ b/litellm/integrations/otel/presets/signoz.py @@ -12,7 +12,7 @@ from litellm.integrations.otel.model.config import ( ExporterSpec, OpenTelemetryV2Config, ) -from litellm.integrations.otel.presets.utils import ensure_mappers +from litellm.integrations.otel.presets.utils import ensure_mappers, operator_exporters from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.types.utils import StandardCallbackDynamicParams @@ -44,7 +44,7 @@ def signoz_preset( return base.model_copy( update=MappingProxyType( { - "exporters": (*base.exporters, spec), + "exporters": (*operator_exporters(base.exporters), spec), "mapper_names": ensure_mappers(base.mapper_names, "genai"), } ) diff --git a/litellm/integrations/otel/presets/utils.py b/litellm/integrations/otel/presets/utils.py index 1270c41e77b..7a83fb34e58 100644 --- a/litellm/integrations/otel/presets/utils.py +++ b/litellm/integrations/otel/presets/utils.py @@ -19,6 +19,10 @@ def ensure_mappers(mapper_names: Iterable[str], *names: str) -> list[str]: return result +def operator_exporters(exporters: Iterable[ExporterSpec]) -> tuple[ExporterSpec, ...]: + return tuple(spec for spec in exporters if not is_unconfigured_placeholder(spec)) + + def credential_gated_exporters( exporters: "Iterable[ExporterSpec]", owner: "ExporterOwner" ) -> "tuple[ExporterSpec, ...]": @@ -32,7 +36,7 @@ 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)), + *operator_exporters(exporters), ExporterSpec(owner=owner, requires_headers=True), ) diff --git a/litellm/integrations/otel/presets/weave.py b/litellm/integrations/otel/presets/weave.py index a6eb8bb8cdf..b1826b34933 100644 --- a/litellm/integrations/otel/presets/weave.py +++ b/litellm/integrations/otel/presets/weave.py @@ -10,6 +10,7 @@ from litellm.integrations.otel.model.config import ( from litellm.integrations.otel.presets.utils import ( credential_gated_exporters, ensure_mappers, + operator_exporters, ) from litellm.integrations.weave.weave_otel import ( get_weave_authorization_header, @@ -39,7 +40,7 @@ def weave_preset( return base.model_copy( update={ "exporters": [ - *base.exporters, + *operator_exporters(base.exporters), ExporterSpec( kind=weave_cfg.protocol or "otlp_http", endpoint=weave_cfg.endpoint, diff --git a/tests/unit/integrations/otel/test_otel_v2_logger.py b/tests/unit/integrations/otel/test_otel_v2_logger.py index 1f9d3af71d9..a9bfc9c08c5 100644 --- a/tests/unit/integrations/otel/test_otel_v2_logger.py +++ b/tests/unit/integrations/otel/test_otel_v2_logger.py @@ -14,6 +14,7 @@ import os import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from io import StringIO from pathlib import Path from types import MappingProxyType from typing import Final @@ -24,6 +25,7 @@ import pytest pytest.importorskip("opentelemetry") from opentelemetry import trace # noqa: E402 +from opentelemetry.sdk.trace.export import ConsoleSpanExporter # noqa: E402 from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 InMemorySpanExporter, ) @@ -2699,6 +2701,70 @@ def test_select_global_otel_v2_logger_builds_one_when_none_registered(): assert isinstance(chosen, OpenTelemetryV2) +def _unset_otel_exporter_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "OTEL_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_ENDPOINT", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_HEADERS", + "OTEL_EXPORTER_OTLP_HEADERS", + ): + monkeypatch.delenv(name, raising=False) + + +def _capture_console_output(monkeypatch: pytest.MonkeyPatch) -> StringIO: + output: Final = StringIO() + + class _CapturingConsoleSpanExporter(ConsoleSpanExporter): + def __init__(self) -> None: + super().__init__(out=output) + + monkeypatch.setattr(providers, "ConsoleSpanExporter", _CapturingConsoleSpanExporter) + return output + + +def test_select_global_otel_v2_logger_without_exporter_does_not_print( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.integrations.otel.logger import select_global_otel_v2_logger + + _unset_otel_exporter_env(monkeypatch) + console_output: Final = _capture_console_output(monkeypatch) + logger: Final = select_global_otel_v2_logger([]) + with logger.tracer.start_as_current_span("fallback"): + pass + assert console_output.getvalue() == "" + + +def test_select_global_otel_v2_logger_keeps_explicit_console_exporter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.integrations.otel.logger import select_global_otel_v2_logger + + _unset_otel_exporter_env(monkeypatch) + console_output: Final = _capture_console_output(monkeypatch) + monkeypatch.setenv("OTEL_EXPORTER", "console") + logger: Final = select_global_otel_v2_logger([]) + with logger.tracer.start_as_current_span("explicit-console"): + pass + assert "explicit-console" in console_output.getvalue() + + +def test_select_global_otel_v2_logger_fallback_allows_tenant_fan_out(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.integrations.otel.logger import select_global_otel_v2_logger + + _unset_otel_exporter_env(monkeypatch) + logger: Final = select_global_otel_v2_logger([]) + providers.attach_tenant_fan_out(logger._tracer_provider, logger.config) + assert any( + isinstance(processor, providers.TenantFanOutSpanProcessor) + for processor in logger._tracer_provider._active_span_processor._span_processors + ) + + def test_publish_global_otel_v2_provider_sets_selected_logger_provider(monkeypatch): """The startup publish must set the OTel global provider to the *selected* logger's provider (the preset logger that owns every exporter), so the FastAPI diff --git a/tests/unit/integrations/otel/test_otel_v2_presets.py b/tests/unit/integrations/otel/test_otel_v2_presets.py index 570c27a24e1..85f88524d31 100644 --- a/tests/unit/integrations/otel/test_otel_v2_presets.py +++ b/tests/unit/integrations/otel/test_otel_v2_presets.py @@ -3,15 +3,19 @@ event loop: the preset does no network I/O, and a custom exporter mints the JWT lazily on its first export (in the BatchSpanProcessor worker thread).""" import json +from collections.abc import Callable, Mapping +from io import StringIO +from typing import Final import httpx import pytest import requests_mock import respx -from opentelemetry.sdk.trace.export import SpanExportResult +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExportResult +from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config from litellm.integrations.otel.plumbing import providers -from litellm.integrations.otel.model.config import ExporterSpec from litellm.integrations.otel.presets import agentops as agentops_mod from litellm.integrations.otel.presets.agentops import ( _AGENTOPS_AUTH_ENDPOINT, @@ -21,6 +25,132 @@ from litellm.integrations.otel.presets.agentops import ( _fetch_agentops_jwt, agentops_preset, ) +from litellm.integrations.otel.presets.arize import arize_preset +from litellm.integrations.otel.presets.langfuse import langfuse_preset +from litellm.integrations.otel.presets.levo import levo_preset +from litellm.integrations.otel.presets.newrelic import newrelic_preset +from litellm.integrations.otel.presets.phoenix import phoenix_preset +from litellm.integrations.otel.presets.signoz import signoz_preset +from litellm.integrations.otel.presets.weave import weave_preset + +_PRESET_ENV_VARS: Final = ( + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_HOST", + "LANGFUSE_OTEL_HOST", + "ARIZE_SPACE_ID", + "ARIZE_SPACE_KEY", + "ARIZE_API_KEY", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "WANDB_API_KEY", + "WANDB_PROJECT_ID", + "LEVOAI_API_KEY", + "LEVOAI_ORG_ID", + "LEVOAI_WORKSPACE_ID", + "LEVOAI_COLLECTOR_URL", + "PHOENIX_API_KEY", + "PHOENIX_COLLECTOR_ENDPOINT", + "PHOENIX_COLLECTOR_PROJECT_NAME", + "SIGNOZ_INGESTION_ENDPOINT", + "SIGNOZ_INGESTION_KEY", + "NEW_RELIC_LICENSE_KEY", + "NEW_RELIC_REGION", + "NEW_RELIC_AI_MONITORING_RECORD_CONTENT_ENABLED", + "AGENTOPS_API_KEY", + "AGENTOPS_SERVICE_NAME", + "AGENTOPS_ENVIRONMENT", + "OTEL_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_ENDPOINT", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_HEADERS", + "OTEL_EXPORTER_OTLP_HEADERS", +) + +_PRESET_CASES: Final = ( + pytest.param( + langfuse_preset, + {"LANGFUSE_PUBLIC_KEY": "pk", "LANGFUSE_SECRET_KEY": "sk"}, + id="langfuse", + ), + pytest.param(arize_preset, {"ARIZE_SPACE_ID": "space", "ARIZE_API_KEY": "key"}, id="arize"), + pytest.param(weave_preset, {"WANDB_API_KEY": "key", "WANDB_PROJECT_ID": "entity/project"}, id="weave"), + pytest.param( + levo_preset, + { + "LEVOAI_API_KEY": "key", + "LEVOAI_ORG_ID": "org", + "LEVOAI_WORKSPACE_ID": "workspace", + "LEVOAI_COLLECTOR_URL": "http://levo.test/v1/traces", + }, + id="levo", + ), + pytest.param(phoenix_preset, {"PHOENIX_API_KEY": "key"}, id="phoenix"), + pytest.param( + signoz_preset, + {"SIGNOZ_INGESTION_ENDPOINT": "http://signoz.test/v1/traces", "SIGNOZ_INGESTION_KEY": "key"}, + id="signoz", + ), + pytest.param(newrelic_preset, {"NEW_RELIC_LICENSE_KEY": "key"}, id="newrelic"), + pytest.param(agentops_preset, {"AGENTOPS_API_KEY": "key"}, id="agentops"), +) + + +def _clear_preset_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in _PRESET_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _capture_console_output(monkeypatch: pytest.MonkeyPatch) -> StringIO: + output: Final = StringIO() + + class _CapturingConsoleSpanExporter(ConsoleSpanExporter): + def __init__(self) -> None: + super().__init__(out=output) + + monkeypatch.setattr(providers, "ConsoleSpanExporter", _CapturingConsoleSpanExporter) + return output + + +def _provider_for_console_behavior(preset: Callable[[], OpenTelemetryV2Config]) -> TracerProvider: + config: Final = preset() + exporters: Final = tuple( + spec if spec.kind.lower() == "console" else ExporterSpec(kind="in_memory") for spec in config.exporters + ) + safe_config: Final = config.model_copy(update={"exporters": exporters}) + return providers.build_tracer_provider(safe_config) + + +@pytest.mark.parametrize(("preset", "credentials"), _PRESET_CASES) +def test_credentialed_presets_do_not_export_to_default_console( + preset: Callable[[], OpenTelemetryV2Config], + credentials: Mapping[str, str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_preset_env(monkeypatch) + console_output: Final = _capture_console_output(monkeypatch) + for name, value in credentials.items(): + monkeypatch.setenv(name, value) + provider: Final = _provider_for_console_behavior(preset) + with provider.get_tracer("litellm").start_as_current_span("preset") as span: + span.set_attribute("preset", "credentialed") + assert console_output.getvalue() == "" + + +def test_credentialed_preset_keeps_explicit_console_exporter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_preset_env(monkeypatch) + console_output: Final = _capture_console_output(monkeypatch) + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk") + monkeypatch.setenv("OTEL_EXPORTER", "console") + provider: Final = _provider_for_console_behavior(langfuse_preset) + with provider.get_tracer("litellm").start_as_current_span("explicit-console") as span: + span.set_attribute("preset", "console") + assert "explicit-console" in console_output.getvalue() def test_agentops_preset_does_no_network_io(monkeypatch): diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index 5d37a145de6..06282c02247 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -4,14 +4,17 @@ import os from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone +from io import StringIO from typing import Final from unittest.mock import MagicMock, patch import pytest +import opentelemetry.sdk.trace.export as trace_export from openai.types.responses import ResponseFunctionToolCall from opentelemetry import trace from opentelemetry.context import Context from opentelemetry.sdk.trace import SpanLimits, TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter from opentelemetry.trace import Span import litellm @@ -627,8 +630,6 @@ class TestLangfuseOtelKeyDynamicConfig: with self._clean_env(): logger = LangfuseOtelLogger() - assert logger.OTEL_EXPORTER == "console" - tracer = logger.get_tracer_to_use_for_request( {"standard_callback_dynamic_params": self._dynamic_params()} ) @@ -1474,6 +1475,41 @@ def test_langfuse_otel_env_config_headers_carry_v4_ingestion_and_basic_auth() -> assert _decoded_basic_auth(headers["Authorization"]) == "pk-lf-123:sk-lf-123" +def _capture_console_output(monkeypatch: pytest.MonkeyPatch) -> StringIO: + output: Final = StringIO() + + class _CapturingConsoleSpanExporter(ConsoleSpanExporter): + def __init__(self) -> None: + super().__init__(out=output) + + monkeypatch.setattr(trace_export, "ConsoleSpanExporter", _CapturingConsoleSpanExporter) + return output + + +def test_langfuse_otel_without_keys_or_exporter_does_not_print( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with patch.dict(os.environ, LANGFUSE_ENV_ONLY, clear=True): + console_output: Final = _capture_console_output(monkeypatch) + logger: Final = LangfuseOtelLogger(callback_name="langfuse_otel") + with logger.tracer.start_as_current_span("langfuse-default"): + pass + assert logger._tracer_provider.force_flush() + assert console_output.getvalue() == "" + + +def test_langfuse_otel_without_keys_keeps_explicit_console_exporter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with patch.dict(os.environ, {**LANGFUSE_ENV_ONLY, "OTEL_EXPORTER": "console"}, clear=True): + console_output: Final = _capture_console_output(monkeypatch) + logger: Final = LangfuseOtelLogger(callback_name="langfuse_otel") + with logger.tracer.start_as_current_span("langfuse-explicit-console"): + pass + assert logger._tracer_provider.force_flush() + assert "langfuse-explicit-console" in console_output.getvalue() + + def test_langfuse_otel_dynamic_headers_carry_v4_ingestion_and_basic_auth() -> None: with patch.dict(os.environ, LANGFUSE_ENV_ONLY, clear=True): logger: Final = LangfuseOtelLogger() From e8692ab6e1b08def82caac9961ced52d8d439853 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 28 Oct 2026 20:47:41 +0000 Subject: [PATCH 2/3] test(otel): align destination expectations with no-default exporter behavior Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../otel/test_otel_v2_destinations.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index 782442aad22..ab477cab08c 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -3043,7 +3043,7 @@ class TestPresetDegradation: "https://otlp.nr-data.net", ] - def test_a_credentialless_newrelic_without_a_base_exporter_falls_back(self, monkeypatch): + def test_a_credentialless_newrelic_fallback_does_not_export_to_stdout(self, monkeypatch, capfd): from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 monkeypatch.delenv("NEW_RELIC_LICENSE_KEY", raising=False) @@ -3055,7 +3055,11 @@ class TestPresetDegradation: logger = in_fresh_context(_maybe_construct_otel_v2, "newrelic", []) is_otel_v2_enabled.cache_clear() - assert logger is None + assert logger is not None + capfd.readouterr() + emit(logger.tracer_provider) + logger.tracer_provider.force_flush() + assert capfd.readouterr().out == "" def test_an_explicit_console_exporter_keeps_a_credentialless_preset_on_v2(self, monkeypatch, capfd): """``OTEL_EXPORTER=console`` reads exactly like the placeholder ``_normalize`` @@ -3277,8 +3281,7 @@ ARIZE_DEST = OtelDestination( class _ExporterCapture: """Stands an in-memory exporter in for every exporter the provider builds, keyed - by the headers it would have sent (``None`` for the stdout placeholder), so a test - reads what each account received rather than which processors were wired.""" + by the headers it would have sent, so tests read which destinations received spans.""" def __init__(self) -> None: self.built: tuple[tuple[str | None, InMemorySpanExporter], ...] = () @@ -3348,8 +3351,7 @@ class TestArizeTenantOnly: assert capture.received() == { "space_id=space-operator,api_key=key-operator": ("chat gpt-4",), - None: ("chat gpt-4",), - }, "the operator's space, plus the stdout placeholder every credentialed preset keeps today" + } def test_the_standard_otlp_headers_still_reach_the_operators_space(self, monkeypatch): credential_less_arize(monkeypatch) @@ -3362,8 +3364,7 @@ class TestArizeTenantOnly: assert capture.received() == { "space_id=space-operator,api_key=key-operator": ("chat gpt-4",), - None: ("chat gpt-4",), - }, "the operator's space, plus the stdout placeholder every credentialed preset keeps today" + } def test_a_credential_less_arize_still_delivers_a_team_destination(self, monkeypatch): from litellm.integrations.otel.plumbing.providers import attach_tenant_fan_out From 3b8a61aafb538d0fdbf6cc9de09482e07a7771a3 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 28 Oct 2026 21:31:02 +0000 Subject: [PATCH 3/3] fix(otel): keep the legacy fallback for a credential-less newrelic preset Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/presets/newrelic.py | 2 +- tests/unit/integrations/otel/test_otel_v2_destinations.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/otel/presets/newrelic.py b/litellm/integrations/otel/presets/newrelic.py index 25fe149eb46..3858de4f035 100644 --- a/litellm/integrations/otel/presets/newrelic.py +++ b/litellm/integrations/otel/presets/newrelic.py @@ -55,7 +55,7 @@ def newrelic_preset( return base.model_copy( update={ "exporters": [ - *operator_exporters(base.exporters), + *(operator_exporters(base.exporters) if settings.license_key else base.exporters), ExporterSpec( kind="otlp_http", endpoint=endpoint, diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index ab477cab08c..04ebf772862 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -3043,7 +3043,7 @@ class TestPresetDegradation: "https://otlp.nr-data.net", ] - def test_a_credentialless_newrelic_fallback_does_not_export_to_stdout(self, monkeypatch, capfd): + 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) @@ -3055,11 +3055,7 @@ class TestPresetDegradation: logger = in_fresh_context(_maybe_construct_otel_v2, "newrelic", []) is_otel_v2_enabled.cache_clear() - assert logger is not None - capfd.readouterr() - emit(logger.tracer_provider) - logger.tracer_provider.force_flush() - assert capfd.readouterr().out == "" + 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``