mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
80f18e1326
commit
44d5dacbaa
3 changed files with 224 additions and 21 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue