fix(otel): name an Arize project on every OTel v2 Arize export (#44605)

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:10 -05:00 • committed by GitHub
parent de74f81c69
commit 80f18e1326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 138 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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