mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
de74f81c69
commit
80f18e1326
5 changed files with 138 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue