diff --git a/litellm/integrations/otel/presets/arize.py b/litellm/integrations/otel/presets/arize.py index e7871320b52..0e2534acfd2 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 ensure_mappers +from litellm.integrations.otel.presets.utils import credential_gated_exporters, ensure_mappers from litellm.types.utils import StandardCallbackDynamicParams #: Arize routes an export to a project by the ``model_id`` resource attribute and @@ -37,6 +37,18 @@ def arize_preset( mappers: Final = ensure_mappers(base.mapper_names, "openinference") arize_cfg: Final = _V1ArizeLogger.get_arize_config() headers: Final = _arize_headers(arize_cfg) + resource_attributes: Final = { + **base.resource_attributes, + "model_id": arize_cfg.project_name or base.resource_attributes.get("model_id") or ARIZE_DEFAULT_PROJECT, + } + if headers is None: + return base.model_copy( + update={ + "exporters": credential_gated_exporters(base.exporters, ExporterOwner.ARIZE_AX), + "mapper_names": mappers, + "resource_attributes": resource_attributes, + } + ) return base.model_copy( update={ "exporters": [ @@ -49,10 +61,7 @@ def arize_preset( ), ], "mapper_names": mappers, - "resource_attributes": { - **base.resource_attributes, - "model_id": arize_cfg.project_name or base.resource_attributes.get("model_id") or ARIZE_DEFAULT_PROJECT, - }, + "resource_attributes": resource_attributes, } ) @@ -66,7 +75,8 @@ def _arize_headers(arize_cfg) -> str | None: if not pieces: # Fall back to the standard OTLP headers env var when no Arize # credentials are configured. - return _ArizeSettings().otlp_traces_headers + fallback: Final = _ArizeSettings().otlp_traces_headers + return (fallback.strip() or None) if fallback else None return ",".join(pieces) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 15c53990d7c..9931452fc6e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5223,9 +5223,15 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom collector) keeps the base exporters, since it has nothing else to deliver through. A preset that needs operator credentials it cannot find is allowed to build only when it serves a key/team destination in that situation. Otherwise 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. + raises, or whose only ungated exporter is the default console placeholder, returns + ``None``, so the caller falls through to the legacy path exactly as before V2 + landed. One that dropped its exporters instead stays on V2 and exports nowhere + until a key/team destination appears: the proxy builds the operator's callback at + startup, before any request has resolved a destination, and the legacy path there + would post every non-team request to the backend keyless. That logger is reused by + later calls as long as the preset would again build exporting nowhere; one that + was degraded for a destination while the preset raises without one is not, since + the degrade was justified by that destination alone. """ from litellm.integrations.otel.model.config import is_otel_v2_enabled @@ -5241,22 +5247,26 @@ def _maybe_construct_otel_v2(callback_name: str, _in_memory_loggers: list[Custom 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) - and callback.callback_name == callback_name - and (serves_a_destination or not _exports_nowhere(callback.config)) - ): - return callback + existing: Final = next( + ( + callback + for callback in _in_memory_loggers + if isinstance(callback, OpenTelemetryV2) and callback.callback_name == callback_name + ), + None, + ) + if existing is not None and (serves_a_destination or not _exports_nowhere(existing.config)): + return existing try: 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) - if gated and not carried and not _has_operator_exporter(built): + if _is_credential_gated(built) and not carried and _only_the_placeholder_would_export(built): return None + if existing is not None and _exports_nowhere(built): + return existing config: Final = _only_the_presets_own_exporters(built, callback_name) if has_v2_logger else built if _exports_nowhere(config): verbose_logger.warning( @@ -5278,11 +5288,12 @@ 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.""" +def _only_the_placeholder_would_export(config: "OpenTelemetryV2Config") -> bool: + """Whether every ungated exporter is the console placeholder ``_normalize`` folds in for an empty list.""" 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) + ungated: Final = tuple(spec for spec in config.exporters if not _is_gated(spec)) + return bool(ungated) and all(is_unconfigured_placeholder(spec) for spec in ungated) def _only_the_presets_own_exporters(config: "OpenTelemetryV2Config", callback_name: str) -> "OpenTelemetryV2Config": diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index 93bb9eeb6df..39c1bb07483 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -2371,6 +2371,188 @@ class TestPresetDegradation: assert "http://collector.local:4318" in {spec.endpoint for spec in langtrace.config.exporters} +def credential_less_arize(monkeypatch) -> None: + """An operator with no Arize account and no generic OTLP collector or headers.""" + for name in ( + "ARIZE_SPACE_ID", + "ARIZE_SPACE_KEY", + "ARIZE_API_KEY", + "ARIZE_ENDPOINT", + "ARIZE_HTTP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + *_OTEL_SHORTHAND_ENV, + ): + monkeypatch.delenv(name, raising=False) + + +ARIZE_DEST = OtelDestination( + endpoint="https://otlp.arize.com/v1", + headers={"arize-space-id": "space-team", "api_key": "key-team"}, + callback_name="arize", + protocol="otlp_grpc", +) + + +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.""" + + def __init__(self) -> None: + self.built: tuple[tuple[str | None, InMemorySpanExporter], ...] = () + + def build(self, spec: ExporterSpec) -> InMemorySpanExporter: + exporter: Final = InMemorySpanExporter() + self.built = (*self.built, (spec.headers, exporter)) + return exporter + + def received(self) -> Mapping[str | None, tuple[str, ...]]: + """Every span name each set of headers received; an exporter that got nothing is absent.""" + return MappingProxyType( + { + headers: tuple(span.name for span in exporter.get_finished_spans()) + for headers, exporter in self.built + if exporter.get_finished_spans() + } + ) + + +class TestArizeTenantOnly: + """An operator whose teams each bring their own Arize space keeps no Arize + credentials of their own; the preset then exports nowhere for traffic without a + team destination instead of posting it keyless to Arize.""" + + @staticmethod + def _capture_exporters(monkeypatch) -> "_ExporterCapture": + capture: Final = _ExporterCapture() + for kind in ("otlp_grpc", "otlp_http", "console"): + monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, kind, capture.build) + return capture + + def test_a_credential_less_arize_exports_nowhere(self, monkeypatch): + credential_less_arize(monkeypatch) + capture = self._capture_exporters(monkeypatch) + config = arize_preset(allow_missing_credentials=True) + provider = build_tracer_provider(config, tenant_overrides=True) + + emit(provider) + provider.force_flush() + + assert capture.received() == {}, "not to Arize, and not to the stdout placeholder either" + assert "openinference" in config.mapper_names + + def test_a_blank_otlp_headers_variable_is_no_credential_either(self, monkeypatch): + """``OTEL_EXPORTER_OTLP_TRACES_HEADERS=`` left empty in a compose file must not + turn into an Arize exporter that posts keyless.""" + credential_less_arize(monkeypatch) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", " ") + capture = self._capture_exporters(monkeypatch) + provider = build_tracer_provider(arize_preset(allow_missing_credentials=True), tenant_overrides=True) + + emit(provider) + provider.force_flush() + + assert capture.received() == {}, "a blank header string is no credential: nothing leaves" + + def test_the_operators_own_credentials_still_reach_the_operators_space(self, monkeypatch): + credential_less_arize(monkeypatch) + monkeypatch.setenv("ARIZE_SPACE_ID", "space-operator") + monkeypatch.setenv("ARIZE_API_KEY", "key-operator") + capture = self._capture_exporters(monkeypatch) + provider = build_tracer_provider(arize_preset(allow_missing_credentials=True), tenant_overrides=True) + + emit(provider) + provider.force_flush() + + 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) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "space_id=space-operator,api_key=key-operator") + capture = self._capture_exporters(monkeypatch) + provider = build_tracer_provider(arize_preset(allow_missing_credentials=True), tenant_overrides=True) + + emit(provider) + provider.force_flush() + + 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 + + credential_less_arize(monkeypatch) + capture = self._capture_exporters(monkeypatch) + config = arize_preset(allow_missing_credentials=True) + provider = build_tracer_provider(config, tenant_overrides=True) + attach_tenant_fan_out(provider, config) + + def run(): + set_request_destinations(deliverable_destinations((ARIZE_DEST,), provider)) + emit(provider) + + in_fresh_context(run) + provider.force_flush() + + assert capture.received() == {ARIZE_DEST.header_string(): ("chat gpt-4",)} + + def test_a_credential_less_proxy_builds_the_gated_arize_logger_beside_a_v2_carrier(self, monkeypatch): + from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 + + credential_less_arize(monkeypatch) + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + carrier = build_otel_v2_logger(OpenTelemetryV2Config(exporter="in_memory")) + + def run(): + set_request_destinations((ARIZE_DEST,)) + return _maybe_construct_otel_v2("arize", [carrier]) + + is_otel_v2_enabled.cache_clear() + logger = in_fresh_context(run) + is_otel_v2_enabled.cache_clear() + + 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_arize_stays_on_v2_at_startup_instead_of_the_keyless_legacy_logger(self, monkeypatch): + from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 + + credential_less_arize(monkeypatch) + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + capture = self._capture_exporters(monkeypatch) + + is_otel_v2_enabled.cache_clear() + logger = in_fresh_context(_maybe_construct_otel_v2, "arize", []) + is_otel_v2_enabled.cache_clear() + + assert isinstance(logger, OpenTelemetryV2), "None hands 'arize' to the legacy logger, which posts keyless" + emit(logger.tracer_provider) + logger.tracer_provider.force_flush() + assert capture.received() == {}, "no operator credentials: nothing leaves until a team destination exists" + + def test_the_startup_logger_is_reused_by_later_requests_without_a_destination(self, monkeypatch): + """Every master-key request re-initialises the callback; building a fresh + provider each time would grow ``_in_memory_loggers`` for the life of the proxy.""" + from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 + + credential_less_arize(monkeypatch) + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + loggers = [] + + is_otel_v2_enabled.cache_clear() + at_startup = in_fresh_context(_maybe_construct_otel_v2, "arize", loggers) + later = in_fresh_context(_maybe_construct_otel_v2, "arize", loggers) + is_otel_v2_enabled.cache_clear() + + assert later is at_startup + assert loggers == [at_startup] + + class TestContextIsolation: def test_destinations_do_not_leak_between_requests(self): def first():