fix(otel): gate the Arize OTel v2 exporter on operator credentials (#44596)

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-06 09:23:11 -05:00 • committed by GitHub
parent 80f18e1326
commit 44d5dacbaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 224 additions and 21 deletions

View file

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

View file

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

View file

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