From 80f18e13268ff9899b61dcc043b3606f51a252c8 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:23:10 -0500 Subject: [PATCH] fix(otel): name an Arize project on every OTel v2 Arize export (#44605) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../integrations/otel/model/destination.py | 7 ++ .../integrations/otel/plumbing/providers.py | 11 ++- litellm/integrations/otel/presets/arize.py | 7 +- .../integrations/otel/presets/destinations.py | 44 ++++++++++- .../otel/test_otel_v2_destinations.py | 73 +++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py index 2b25ef16347..4c8b3b1118d 100644 --- a/litellm/integrations/otel/model/destination.py +++ b/litellm/integrations/otel/model/destination.py @@ -20,6 +20,13 @@ class OtelDestination(LiteLLMBaseModel): endpoint: str headers: Mapping[str, str] = Field(default_factory=dict) resource_attributes: Mapping[str, str] = Field(default_factory=dict) + resource_defaults: Mapping[str, str] = Field( + default_factory=dict, + description=( + "Resource attributes the backend needs on every export, filled only where the " + "span's own resource names none; ``resource_attributes`` override it." + ), + ) callback_name: str | None = None protocol: str | None = Field( default=None, diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 8b01750b8f2..531cd528739 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -499,6 +499,12 @@ def _without_stack_trace(event: Event) -> Event: ) +def _tenant_resource(resource: Resource, defaults: Mapping[str, str], extra: Mapping[str, str]) -> Resource: + """``resource`` over the backend's ``defaults``, under the destination's ``extra``.""" + filled: Final = Resource(defaults).merge(resource) if defaults else resource + return filled.merge(Resource(extra)) if extra else filled + + def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> ReadableSpan: """The view of ``span`` a tenant destination receives. @@ -514,6 +520,7 @@ def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> Read itself stays, so the tenant still gets the whole trace tree. """ extra: Final = destination.resource_attributes + defaults: Final = destination.resource_defaults attributes: Final = span.attributes or _NO_ATTRIBUTES database: Final = _is_database_span(attributes) owned: Final = _is_tenant_owned_span(attributes) @@ -528,9 +535,9 @@ def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> Read recorded: Final = span.events events: Final = tuple(_without_stack_trace(event) for event in recorded) if owned else () unchanged: Final = owned and _same_attributes(kept, attributes) and all(a is b for a, b in zip(events, recorded)) - if not extra and unchanged: + if not extra and not defaults and unchanged: return span - resource: Final = span.resource.merge(Resource(extra)) if extra else span.resource + resource: Final = _tenant_resource(span.resource, defaults, extra) status: Final = span.status if owned else Status(span.status.status_code) return _SpanView(span, resource, kept, events, status, parent=span.parent) diff --git a/litellm/integrations/otel/presets/arize.py b/litellm/integrations/otel/presets/arize.py index d7ce87f5552..e7871320b52 100644 --- a/litellm/integrations/otel/presets/arize.py +++ b/litellm/integrations/otel/presets/arize.py @@ -14,6 +14,11 @@ from litellm.integrations.otel.model.config import ( from litellm.integrations.otel.presets.utils import ensure_mappers from litellm.types.utils import StandardCallbackDynamicParams +#: Arize routes an export to a project by the ``model_id`` resource attribute and +#: rejects one that names none, so this is the project when ``ARIZE_PROJECT_NAME`` +#: is unset. +ARIZE_DEFAULT_PROJECT: Final = "litellm" + class _ArizeSettings(BaseSettings): model_config = SettingsConfigDict(case_sensitive=False, extra="ignore") @@ -46,7 +51,7 @@ def arize_preset( "mapper_names": mappers, "resource_attributes": { **base.resource_attributes, - **({"model_id": arize_cfg.project_name} if arize_cfg.project_name else {}), + "model_id": arize_cfg.project_name or base.resource_attributes.get("model_id") or ARIZE_DEFAULT_PROJECT, }, } ) diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index e6cb775af1d..f3cbe0c8f9c 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -111,6 +111,47 @@ _REQUIRED_HEADERS_BY_CALLBACK: Final[Mapping[str, frozenset[str]]] = MappingProx _NO_ATTRS: Final[Mapping[str, str]] = MappingProxyType({}) +def _arize_project() -> Mapping[str, str]: + from litellm.integrations.arize.arize import ArizeLogger + + project: Final = ArizeLogger.get_arize_config().project_name + return MappingProxyType({"model_id": project} if project else {}) + + +def _arize_default_project() -> Mapping[str, str]: + from litellm.integrations.otel.presets.arize import ARIZE_DEFAULT_PROJECT + + return MappingProxyType({"model_id": ARIZE_DEFAULT_PROJECT}) + + +#: Resource attributes the operator chose for a backend, applied to every export the +#: team receives over the span's own resource: ``ARIZE_PROJECT_NAME`` for Arize. +_RESOURCE_ATTRIBUTES_BY_CALLBACK: Final[Mapping[str, Callable[[], Mapping[str, str]]]] = MappingProxyType( + {"arize": _arize_project} +) + +#: Resource attributes a backend needs on every export to accept it, filled only where +#: the span's own resource names none. Arize rejects a span that names no project. +_RESOURCE_DEFAULTS_BY_CALLBACK: Final[Mapping[str, Callable[[], Mapping[str, str]]]] = MappingProxyType( + {"arize": _arize_default_project} +) + + +def _resource_attributes(callback_name: str, service_name: str | None) -> Mapping[str, str]: + backend: Final = _RESOURCE_ATTRIBUTES_BY_CALLBACK.get(callback_name) + return MappingProxyType( + { + **({"service.name": service_name} if service_name else {}), + **(backend() if backend is not None else {}), + } + ) + + +def _resource_defaults(callback_name: str) -> Mapping[str, str]: + backend: Final = _RESOURCE_DEFAULTS_BY_CALLBACK.get(callback_name) + return backend() if backend is not None else _NO_ATTRS + + def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope: if callback_name != "langfuse_otel": return "full" @@ -152,7 +193,8 @@ def destination_for( return OtelDestination( endpoint=endpoint, headers=MappingProxyType(dict(headers)), - resource_attributes=MappingProxyType({"service.name": service_name}) if service_name else _NO_ATTRS, + resource_attributes=_resource_attributes(callback_name, service_name), + resource_defaults=_resource_defaults(callback_name), callback_name=callback_name, protocol=protocol, span_scope=_span_scope(callback_name, params), diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index 3fdc47f130c..93bb9eeb6df 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -8,6 +8,7 @@ from collections.abc import Mapping from datetime import datetime, timezone from functools import reduce from types import MappingProxyType +from typing import Final import pytest from opentelemetry.sdk.resources import Resource @@ -3460,3 +3461,75 @@ class TestTenantHostSsrfGuard: destination_for("langfuse_otel", self._langfuse("http://10.0.0.5:3000")) assert sum("provider_url_destination_allowed_hosts" in record.message for record in caplog.records) == 1 + + +class TestArizeProjectRouting: + """Arize rejects an export that names no project (400: set x-project-name, + arize.project.name, openinference.project.name, or model_id), so an Arize + destination carries ``model_id`` the way the operator's own preset does.""" + + TEAM_PARAMS: Final = MappingProxyType({"arize_space_id": "space-team", "arize_api_key": "key-team"}) + + @pytest.fixture(autouse=True) + def _operator_arize(self, monkeypatch): + for name in ("ARIZE_SPACE_KEY", "ARIZE_ENDPOINT", "ARIZE_HTTP_ENDPOINT", "ARIZE_PROJECT_NAME"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("ARIZE_SPACE_ID", "space-operator") + monkeypatch.setenv("ARIZE_API_KEY", "key-operator") + + @classmethod + def _team_destination(cls, service_name: str | None = None) -> OtelDestination: + destination: Final = destination_for("arize", dict(cls.TEAM_PARAMS), service_name=service_name) + assert destination is not None, "the team names a space and a key, so it must resolve" + return destination + + def test_a_team_destination_lands_in_the_operators_arize_project(self, monkeypatch): + monkeypatch.setenv("ARIZE_PROJECT_NAME", "gateway-prod") + + assert dict(self._team_destination().resource_attributes) == {"model_id": "gateway-prod"} + + @classmethod + def _forwarded_projects(cls, resource: Resource | None = None) -> set[str]: + dest = InMemorySpanExporter() + provider = TracerProvider(resource=resource) + provider.add_span_processor(TenantFanOutSpanProcessor(processor_factory=lambda _d: SimpleSpanProcessor(dest))) + + def run(): + set_request_destinations((cls._team_destination(),)) + emit(provider) + + in_fresh_context(run) + spans = dest.get_finished_spans() + assert spans, "the team destination must receive the request" + return {s.resource.attributes["model_id"] for s in spans} + + def test_a_team_export_names_a_project_even_when_the_operator_set_none(self): + assert self._forwarded_projects() == {"litellm"} + + def test_a_project_the_operators_own_resource_names_is_kept_for_the_team(self): + assert self._forwarded_projects(Resource({"model_id": "chosen"})) == {"chosen"} + + def test_arize_project_name_wins_over_the_project_the_operators_resource_names(self, monkeypatch): + monkeypatch.setenv("ARIZE_PROJECT_NAME", "gateway-prod") + + assert self._forwarded_projects(Resource({"model_id": "chosen"})) == {"gateway-prod"} + + def test_the_teams_service_name_rides_beside_the_project(self, monkeypatch): + monkeypatch.setenv("ARIZE_PROJECT_NAME", "gateway-prod") + + destination = self._team_destination(service_name="team-checkout") + + assert dict(destination.resource_attributes) == {"service.name": "team-checkout", "model_id": "gateway-prod"} + + def test_the_operators_exporter_names_a_project_even_without_arize_project_name(self): + assert arize_preset().resource_attributes["model_id"] == "litellm" + + def test_the_operators_exporter_keeps_the_project_its_config_overrides_name(self): + chosen = OpenTelemetryV2Config(resource_attributes={"model_id": "chosen"}) + + assert arize_preset(config_overrides=chosen).resource_attributes["model_id"] == "chosen" + + def test_every_span_forwarded_to_the_team_carries_the_project(self, monkeypatch): + monkeypatch.setenv("ARIZE_PROJECT_NAME", "gateway-prod") + + assert self._forwarded_projects() == {"gateway-prod"}