feat(otel): route Phoenix traces to per-key/team projects under otel v2

The v2 arize_phoenix preset read PHOENIX_PROJECT_NAME once at startup into a
static resource attribute, silently dropping the per-key/team project routing
v1 supported. Route it via Phoenix's x-project-name OTLP/HTTP header instead:
the env var stays the global default, and a phoenix_project_name (or
phoenix_project_name_override) in key/team metadata sends that key's traces
to the named project.

The project comes only from user_api_key_auth_metadata (server-set at auth),
never from client request metadata or StandardCallbackDynamicParams, since
choosing the telemetry destination is a data-exfiltration primitive. The
header is appended to the exporter's static headers rather than replacing
them, so the preset's Authorization survives, and it is gated to OTLP/HTTP
exporters because Phoenix only reads it on /v1/traces.

Also unban the bare phoenix_project_name fields from the request-body gate:
the proxy integrations ignore them (only user_api_key_auth_metadata routes,
and that stays banned), so rejecting them just broke SDK-style callers.
This commit is contained in:
mubashir1osmani 2026-08-12 14:48:46 -07:00
parent 32535987e8
commit fad0cd515a
9 changed files with 522 additions and 54 deletions

View file

@ -21,6 +21,7 @@ from litellm.integrations.otel.model.config import OpenTelemetryV2Config
from litellm.integrations.otel.model.metadata import (
LLMCallEvent,
RequestIdentity,
auth_metadata,
model_from_request_data,
)
from litellm.integrations.otel.model.payloads import (
@ -265,7 +266,7 @@ class OpenTelemetryV2(CustomLogger):
call.provisional_span_name,
parent_context=parent_context,
start_time_ns=start_time_ns,
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params),
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params, call.auth_metadata),
)
self._open_llm_calls[call_id] = _LLMCallSpan(span=span, start_time_ns=start_time_ns)
# Evict the oldest open call if the map is over budget. A call that opens
@ -376,6 +377,7 @@ class OpenTelemetryV2(CustomLogger):
start_time_ns=to_ns(start_time),
end_time_ns=to_ns(end_time),
links=links,
tracer=self._tenant_tracers.tracer_for(self.tracer, None, auth_metadata(payload, kwargs)),
)
return True
@ -412,6 +414,7 @@ class OpenTelemetryV2(CustomLogger):
start_time_ns=to_ns(start_time),
end_time_ns=to_ns(end_time),
links=links,
tracer=self._tenant_tracers.tracer_for(self.tracer, None, auth_metadata(payload, kwargs)),
)
return True
@ -465,7 +468,7 @@ class OpenTelemetryV2(CustomLogger):
parent_context=parent_ctx,
start_time_ns=carrier.start_time_ns,
end_time_ns=end_time_ns,
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params),
tracer=self._tenant_tracers.tracer_for(self.tracer, call.dynamic_params, call.auth_metadata),
)
# ====================================================================== #

View file

@ -36,8 +36,9 @@ model. They coincide on the SDK path, which is correct.
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, cast
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
@ -195,6 +196,10 @@ class LLMCallEvent:
# The ``standard_callback_dynamic_params`` routing the call to a per-tenant
# tracer (its own exporter/endpoint), or ``None`` when the call isn't scoped.
dynamic_params: Any
# The key/team config the proxy resolved at auth (``user_api_key_auth_metadata``),
# routing the call to that tenant's telemetry project. Server-set and so
# trusted, unlike ``dynamic_params``, which carries client-supplied metadata.
auth_metadata: Mapping[str, str] | None
# True for synthetic proxy-gate logs (auth / rate-limit rejections): they fire
# the ``pre_call`` hook but never made an upstream call, so they get no span.
is_no_upstream_call: bool
@ -214,6 +219,7 @@ class LLMCallEvent:
call_id=_call_id(payload, kwargs),
payload=payload,
dynamic_params=kwargs.get("standard_callback_dynamic_params"),
auth_metadata=auth_metadata(payload, kwargs),
is_no_upstream_call=bool(kwargs.get(LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL)),
provisional_span_name=f"{operation.value} {model}".strip(),
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
@ -235,6 +241,64 @@ def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None:
return completion_start - api_call_start
def auth_metadata(payload: StandardLoggingPayload | None, kwargs: Mapping[str, object]) -> Mapping[str, str] | None:
"""The key/team config the proxy resolved at auth, or ``None`` off the proxy.
Read from the payload once the call closes and from ``litellm_params`` at
``pre_call``, where no payload exists yet — the LLM-call span is *created* at
``pre_call``, so the tracer (and therefore the destination) must be
resolvable there. Values arrive untyped, so non-string entries are dropped
rather than passed on to header builders.
"""
return next(
(
typed
for metadata in _metadata_dicts(payload, kwargs)
if (typed := _string_entries(metadata.get("user_api_key_auth_metadata")))
),
None,
)
def _as_str_mapping(value: object) -> Mapping[str, object] | None:
"""A read-only view of ``value`` when it is a mapping, else ``None``."""
if not isinstance(value, Mapping):
return None
return cast("Mapping[str, object]", value) # cast-ok: isinstance-guarded, JSON metadata has str keys
def _string_entries(value: object) -> Mapping[str, str] | None:
entries: Final = _as_str_mapping(value)
if entries is None:
return None
typed: Final = MappingProxyType({key: item for key, item in entries.items() if isinstance(item, str)})
return typed or None
def _metadata_dicts(
payload: StandardLoggingPayload | None, kwargs: Mapping[str, object]
) -> Iterator[Mapping[str, object]]:
"""Request metadata dicts, closed-call payload first then the live kwargs.
``litellm_metadata`` is the metadata field on the Anthropic-shaped routes;
litellm copies it onto ``metadata``, but both are yielded so a route that
populates only one is still covered.
"""
payload_view: Final = _as_str_mapping(payload)
if payload_view is not None:
payload_metadata: Final = _as_str_mapping(payload_view.get("metadata"))
if payload_metadata is not None:
yield payload_metadata
params: Final = _as_str_mapping(kwargs.get("litellm_params"))
if params is None:
return
yield from (
metadata
for key in ("metadata", "litellm_metadata")
if (metadata := _as_str_mapping(params.get(key))) is not None
)
def _call_id(payload: StandardLoggingPayload | None, kwargs: Mapping[str, Any]) -> str | None:
"""The call id from the payload (when closed) or the bare kwargs (at pre_call)."""
if payload is not None:

View file

@ -1,31 +1,43 @@
"""Per-request multi-tenant tracer routing.
When a request carries team/key vendor credentials in
``standard_callback_dynamic_params``, its spans must export through a
``TracerProvider`` whose OTLP headers carry those credentials.
``TenantTracerCache`` builds and caches one provider per distinct credential
set, and otherwise hands back the logger's default tracer. This lets a single
logger fan requests out to many tenants without needing a logger per tenant.
``standard_callback_dynamic_params``, or the key/team config resolved at auth
names a destination project, its spans must export through a
``TracerProvider`` whose OTLP headers carry those credentials / that project.
``TenantTracerCache`` builds and caches one provider per distinct
(credentials, project) pair, and otherwise hands back the logger's default
tracer. This lets a single logger fan requests out to many tenants without
needing a logger per tenant.
"""
from collections import OrderedDict
from collections.abc import Mapping
from typing import Any, Final
from types import MappingProxyType
from typing import Any, Final, TypeAlias
from urllib.parse import quote
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import Tracer
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.config import OpenTelemetryV2Config
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.plumbing.providers import (
build_tracer_provider,
get_tracer,
)
from litellm.integrations.otel.presets import dynamic_otlp_headers
from litellm.integrations.otel.presets import (
dynamic_otlp_headers,
project_routing_headers,
)
# Exporter kinds that ignore headers — never rewritten with dynamic credentials.
_NON_OTLP_KINDS: Final = ("console", "in_memory", "inmemory", "memory")
# gRPC exporters still take dynamic credentials (as gRPC metadata) but not
# project headers: the routing headers backends read (Phoenix's
# ``x-project-name``) are only honored on the OTLP/HTTP endpoint.
_GRPC_KINDS: Final = ("otlp_grpc", "grpc")
# Cap on distinct credential-scoped providers held at once. ``dynamic_params``
# can be populated from request metadata, so an unbounded cache lets a caller
# spawn one ``TracerProvider`` (plus its ``BatchSpanProcessor`` background
@ -34,6 +46,10 @@ _NON_OTLP_KINDS: Final = ("console", "in_memory", "inmemory", "memory")
# evicted providers so their threads are reclaimed.
_MAX_CACHED_PROVIDERS: Final = 256
_HeaderItems: TypeAlias = tuple[tuple[str, str], ...]
_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
def _shutdown_provider(provider: TracerProvider) -> None:
"""Flush + stop an evicted provider's processors (reclaims their threads).
@ -49,8 +65,19 @@ def _shutdown_provider(provider: TracerProvider) -> None:
verbose_logger.debug("OTel V2: error shutting down evicted provider: %s", e)
def _plain_header_string(headers: Mapping[str, str]) -> str:
return ",".join(f"{key}={value}" for key, value in headers.items())
def _encoded_header_string(headers: Mapping[str, str]) -> str:
"""Percent-encode values so one containing the ``k=v,k=v`` separators (e.g.
a project name with a comma) survives; ``parse_env_headers`` decodes it back.
"""
return ",".join(f"{key}={quote(value, safe='')}" for key, value in headers.items())
class TenantTracerCache:
"""Credential-scoped ``TracerProvider`` cache keyed by the dynamic headers."""
"""Credential/project-scoped ``TracerProvider`` cache keyed by the routing headers."""
def __init__(
self,
@ -61,49 +88,100 @@ class TenantTracerCache:
self._config = config
self._callback_name = callback_name
self._tracer_name = tracer_name
self._providers: OrderedDict[tuple[tuple[str, str], ...], TracerProvider] = OrderedDict()
self._providers: OrderedDict[tuple[_HeaderItems, _HeaderItems], TracerProvider] = OrderedDict()
self._project_routable = any(
spec.owner == callback_name and spec.kind.lower() not in (*_NON_OTLP_KINDS, *_GRPC_KINDS)
for spec in config.exporters
)
self._warned_project_unroutable = False
def tracer_for(self, default: Tracer, dynamic_params: Any) -> Tracer:
def tracer_for(
self,
default: Tracer,
dynamic_params: Any,
auth_metadata: Mapping[str, str] | None = None,
) -> Tracer:
"""Return the tracer for this request.
Use ``default`` unless the request's dynamic credentials require a
credential-scoped tracer, in which case build (or reuse) one. The cache
is a bounded LRU: the least-recently-used provider is flushed and shut
down on overflow so its exporter threads don't accumulate.
Use ``default`` unless the request's dynamic credentials or its key/team
project require a scoped tracer, in which case build (or reuse) one. The
cache is a bounded LRU: the least-recently-used provider is flushed and
shut down on overflow so its exporter threads don't accumulate.
"""
headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params)
if not headers:
credential_headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params) or _NO_HEADERS
project_headers: Final = self._project_headers(auth_metadata)
if not credential_headers and not project_headers:
return default
cache_key: Final = tuple(sorted(headers.items()))
cache_key: Final = (
tuple(sorted(credential_headers.items())),
tuple(sorted(project_headers.items())),
)
provider = self._providers.get(cache_key)
if provider is not None:
self._providers.move_to_end(cache_key)
else:
provider = build_tracer_provider(self._config_with_headers(headers))
provider = build_tracer_provider(self._routed_config(credential_headers, project_headers))
self._providers[cache_key] = provider
if len(self._providers) > _MAX_CACHED_PROVIDERS:
_, evicted = self._providers.popitem(last=False)
_shutdown_provider(evicted)
return get_tracer(provider, self._tracer_name)
def _config_with_headers(self, headers: Mapping[str, str]) -> OpenTelemetryV2Config:
"""Clone the config, stamping ``headers`` onto the credential's own exporter.
def _project_headers(self, auth_metadata: Mapping[str, str] | None) -> Mapping[str, str]:
"""The per-request project-routing headers, if this cache can apply them.
``headers`` are the per-request credentials of ``self._callback_name`` (the
integration that built this cache), so they apply only to the exporter that
integration contributed (``spec.owner``). A request that carries one
tenant's Arize key must never rewrite the headers of a co-configured
Langfuse or self-hosted collector exporter, which would leak that key to a
different backend.
A gRPC-only exporter can't (the project header route is HTTP-only), so
the request warns once and stays on the env-configured default project.
"""
header_str: Final = ",".join(f"{key}={value}" for key, value in headers.items())
header_update: Final[dict[str, str]] = {"headers": header_str}
exporters: Final = [
(
spec.model_copy(update=header_update)
if spec.owner == self._callback_name and spec.kind.lower() not in _NON_OTLP_KINDS
else spec
requested: Final = project_routing_headers(self._callback_name, auth_metadata)
if not requested or self._project_routable:
return requested
if not self._warned_project_unroutable:
self._warned_project_unroutable = True
verbose_logger.warning(
"OTel V2: %s key/team config names a per-request project, but its exporter "
"is not OTLP/HTTP and the project header is HTTP-only; spans stay in the "
"default project.",
self._callback_name,
)
for spec in self._config.exporters
return _NO_HEADERS
def _routed_config(
self,
credential_headers: Mapping[str, str],
project_headers: Mapping[str, str],
) -> OpenTelemetryV2Config:
"""Clone the config, rewriting headers on the callback's own exporter.
Both header sets apply only to the exporter ``self._callback_name``
contributed (``spec.owner``). A request that carries one tenant's Arize
key must never rewrite the headers of a co-configured Langfuse or
self-hosted collector exporter, which would leak that key to a
different backend.
Dynamic credentials REPLACE the exporter's headers — they are the
tenant's complete credential set. Project headers APPEND instead: the
preset's static headers carry the backend auth (Phoenix's
``Authorization``), which must survive routing to a project.
"""
exporters: Final = [
self._routed_exporter(spec, credential_headers, project_headers) for spec in self._config.exporters
]
return self._config.model_copy(update={"exporters": exporters})
def _routed_exporter(
self,
spec: ExporterSpec,
credential_headers: Mapping[str, str],
project_headers: Mapping[str, str],
) -> ExporterSpec:
kind: Final = spec.kind.lower()
if spec.owner != self._callback_name or kind in _NON_OTLP_KINDS:
return spec
base: Final = _plain_header_string(credential_headers) if credential_headers else spec.headers
routed: Final = (
",".join(part for part in (base, _encoded_header_string(project_headers)) if part)
if project_headers and kind not in _GRPC_KINDS
else base
)
return spec if routed == spec.headers else spec.model_copy(update={"headers": routed})

View file

@ -8,7 +8,8 @@ the factory in ``litellm_logging`` can resolve a name and build a single
``OpenTelemetryV2`` instance from the result.
"""
from collections.abc import Callable
from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import Final
from litellm.integrations.otel.presets.agentops import agentops_preset
@ -20,7 +21,10 @@ from litellm.integrations.otel.presets.langfuse import (
)
from litellm.integrations.otel.presets.langtrace import langtrace_preset
from litellm.integrations.otel.presets.levo import levo_preset
from litellm.integrations.otel.presets.phoenix import phoenix_preset
from litellm.integrations.otel.presets.phoenix import (
phoenix_preset,
phoenix_project_headers,
)
from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset
from litellm.types.utils import StandardCallbackDynamicParams
@ -47,6 +51,23 @@ DYNAMIC_HEADERS_BY_CALLBACK: Final[dict[str, Callable[[StandardCallbackDynamicPa
}
#: Callback name → per-request *routing* header builder, sourced from the key/team
#: config the proxy resolved at auth. Deliberately separate from
#: ``DYNAMIC_HEADERS_BY_CALLBACK``: that one is fed
#: ``StandardCallbackDynamicParams``, which is populated from client-supplied
#: request metadata. Naming a destination project is a data-exfiltration
#: primitive, so it must only ever come from server-set key/team config.
PROJECT_HEADERS_BY_CALLBACK: Final[Mapping[str, Callable[[Mapping[str, str] | None], Mapping[str, str]]]] = (
MappingProxyType(
{
"arize_phoenix": phoenix_project_headers,
}
)
)
_NO_PROJECT_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
def dynamic_otlp_headers(
callback_name: str | None,
dynamic_params: StandardCallbackDynamicParams | None,
@ -62,9 +83,25 @@ def dynamic_otlp_headers(
return headers or None
def project_routing_headers(
callback_name: str | None,
auth_metadata: Mapping[str, str] | None,
) -> Mapping[str, str]:
"""Per-request project-routing headers from trusted key/team config.
Empty means "no per-request project" — the caller keeps its default tracer,
whose resource attributes carry the env-configured project.
"""
builder: Final = PROJECT_HEADERS_BY_CALLBACK.get(callback_name or "")
if builder is None:
return _NO_PROJECT_HEADERS
return builder(auth_metadata)
__all__ = [
"DYNAMIC_HEADERS_BY_CALLBACK",
"PRESET_BY_CALLBACK",
"PROJECT_HEADERS_BY_CALLBACK",
"Preset",
"agentops_preset",
"arize_preset",
@ -73,5 +110,6 @@ __all__ = [
"langtrace_preset",
"levo_preset",
"phoenix_preset",
"project_routing_headers",
"weave_preset",
]

View file

@ -1,5 +1,7 @@
"""Arize-Phoenix preset."""
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
from pydantic import AliasChoices, Field
@ -25,6 +27,36 @@ class _PhoenixSettings(BaseSettings):
)
#: Phoenix routes an OTLP/HTTP export to a project by this header, which takes
#: precedence over the ``openinference.project.name`` resource attribute the env
#: var sets. Requires arize-phoenix 15.5.0+; older collectors ignore it and the
#: spans land in the resource attribute's project.
PHOENIX_PROJECT_HEADER: Final = "x-project-name"
#: Key/team config fields naming the target project, highest precedence first.
_PROJECT_KEYS: Final = ("phoenix_project_name_override", "phoenix_project_name")
_NO_PROJECT: Final[Mapping[str, str]] = MappingProxyType({})
def phoenix_project_headers(auth_metadata: Mapping[str, str] | None) -> Mapping[str, str]:
"""The per-request Phoenix project header for this key/team, if any.
``auth_metadata`` must be the key/team config the proxy resolved at auth
(``user_api_key_auth_metadata``), never client-supplied request metadata:
choosing the destination project is a data-exfiltration primitive, so a
caller must not be able to name one. Returns an empty mapping when the key
and team name no project, leaving the request on the env-configured default.
"""
if not auth_metadata:
return _NO_PROJECT
project: Final = next(
(stripped for key in _PROJECT_KEYS if (stripped := (auth_metadata.get(key) or "").strip())),
"",
)
return MappingProxyType({PHOENIX_PROJECT_HEADER: project}) if project else _NO_PROJECT
def phoenix_preset(
*,
config_overrides: OpenTelemetryV2Config | None = None,

View file

@ -217,8 +217,10 @@ _SAFE_CLIENT_CALLBACK_PARAMS: Final[frozenset[str]] = frozenset(
_EXTRA_BANNED_OBSERVABILITY_PARAMS: Final[frozenset[str]] = frozenset(
{
"posthog_api_url",
"phoenix_project_name",
"phoenix_project_name_override",
# ``phoenix_project_name`` / ``phoenix_project_name_override`` are NOT
# banned: on the proxy the Phoenix integrations only read them from
# ``user_api_key_auth_metadata`` (key/team config), so the bare request
# fields are inert and rejecting them just breaks SDK-style callers.
# Server-reserved: written exclusively by add_user_api_key_auth_to_request_metadata
# from the authenticated key's database record. A caller-supplied value
# would survive the server merge and let an authenticated user redirect

View file

@ -8,7 +8,11 @@ sys.path.insert(0, os.path.abspath("../../../.."))
from opentelemetry.trace import NoOpTracer
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.presets import dynamic_otlp_headers
from litellm.integrations.otel.presets import (
dynamic_otlp_headers,
project_routing_headers,
)
from litellm.integrations.otel.plumbing.providers import parse_headers
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
@ -128,7 +132,7 @@ def test_dynamic_headers_applied_to_otlp_exporter_only():
ExporterSpec(kind="in_memory", owner="arize"),
],
)
new_cfg = cache._config_with_headers({"arize-space-id": "S", "api_key": "K"})
new_cfg = cache._routed_config({"arize-space-id": "S", "api_key": "K"}, {})
otlp, in_mem = new_cfg.exporters
assert otlp.headers == "arize-space-id=S,api_key=K"
assert in_mem.headers is None # console/in_memory left untouched
@ -138,10 +142,9 @@ def test_dynamic_headers_do_not_leak_to_other_owners_exporter():
"""A tenant's Arize credentials must never be stamped onto a co-configured
exporter owned by a different backend (a self-hosted collector, Langfuse).
Regression for the cross-backend credential leak: ``_config_with_headers``
used to rewrite the headers of every OTLP exporter, so one request carrying
a team's Arize key clobbered the base collector's and Langfuse's headers
with that key.
Regression for the cross-backend credential leak: the header rewrite used
to hit every OTLP exporter, so one request carrying a team's Arize key
clobbered the base collector's and Langfuse's headers with that key.
"""
cache = _cache(
"arize",
@ -166,10 +169,131 @@ def test_dynamic_headers_do_not_leak_to_other_owners_exporter():
),
],
)
new_cfg = cache._config_with_headers(
{"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"}
new_cfg = cache._routed_config(
{"arize-space-id": "TEAMX", "api_key": "TEAMX_KEY"}, {}
)
by_owner = {e.owner: e.headers for e in new_cfg.exporters}
assert by_owner["arize"] == "arize-space-id=TEAMX,api_key=TEAMX_KEY"
assert by_owner[None] == "x=base-collector"
assert by_owner["langfuse_otel"] == "Authorization=Basic base-langfuse"
# --- per-request Phoenix project routing from trusted key/team config --- #
def _phoenix_cache(kind="otlp_http"):
return _cache(
"arize_phoenix",
exporters=[
ExporterSpec(
kind=kind,
endpoint="http://phoenix:6006",
headers="Authorization=Bearer phoenix-key",
owner="arize_phoenix",
),
],
)
def test_phoenix_project_headers_precedence_and_blanks():
assert project_routing_headers(
"arize_phoenix", {"phoenix_project_name": "team-proj"}
) == {"x-project-name": "team-proj"}
assert project_routing_headers(
"arize_phoenix",
{"phoenix_project_name_override": "override", "phoenix_project_name": "base"},
) == {"x-project-name": "override"}
assert (
project_routing_headers("arize_phoenix", {"phoenix_project_name": " "}) == {}
)
assert project_routing_headers("arize_phoenix", None) == {}
# Only Phoenix participates in project routing.
assert project_routing_headers("arize", {"phoenix_project_name": "p"}) == {}
def test_project_header_appends_and_preserves_phoenix_auth():
"""Regression: routing to a project must not drop the preset's static
``Authorization`` header — a replace would break Phoenix auth entirely."""
cache = _phoenix_cache()
cfg = cache._routed_config({}, {"x-project-name": "team-proj"})
(spec,) = cfg.exporters
parsed = parse_headers(spec.headers)
assert parsed["authorization"] == "Bearer phoenix-key"
assert parsed["x-project-name"] == "team-proj"
def test_project_name_with_header_separators_round_trips():
cache = _phoenix_cache()
cfg = cache._routed_config({}, {"x-project-name": "my proj, prod=1"})
(spec,) = cfg.exporters
parsed = parse_headers(spec.headers)
assert parsed["x-project-name"] == "my proj, prod=1"
assert parsed["authorization"] == "Bearer phoenix-key"
def test_project_header_does_not_touch_other_exporters():
cache = _cache(
"arize_phoenix",
exporters=[
ExporterSpec(
kind="otlp_http",
endpoint="http://collector:4318",
headers="x=base-collector",
owner=None,
),
ExporterSpec(
kind="otlp_http",
endpoint="http://phoenix:6006",
headers="Authorization=Bearer phoenix-key",
owner="arize_phoenix",
),
],
)
cfg = cache._routed_config({}, {"x-project-name": "team-proj"})
by_owner = {e.owner: e.headers for e in cfg.exporters}
assert by_owner[None] == "x=base-collector"
assert parse_headers(by_owner["arize_phoenix"])["x-project-name"] == "team-proj"
def test_provider_cached_per_project():
cache = _phoenix_cache()
default = NoOpTracer()
routed = cache.tracer_for(default, None, {"phoenix_project_name": "proj-a"})
assert routed is not default
cache.tracer_for(default, None, {"phoenix_project_name": "proj-a"})
assert len(cache._providers) == 1
cache.tracer_for(default, None, {"phoenix_project_name": "proj-b"})
assert len(cache._providers) == 2
for provider in cache._providers.values():
provider.shutdown()
def test_client_dynamic_params_cannot_choose_phoenix_project():
# ``StandardCallbackDynamicParams`` is populated from client-supplied
# request metadata; the project may only come from server-set key/team
# config (the ``auth_metadata`` argument).
cache = _phoenix_cache()
default = NoOpTracer()
assert cache.tracer_for(default, {"phoenix_project_name": "attacker"}) is default
assert (
cache.tracer_for(default, {"phoenix_project_name_override": "attacker"})
is default
)
assert cache._providers == {}
def test_auth_metadata_without_project_uses_default_tracer():
cache = _phoenix_cache()
default = NoOpTracer()
assert cache.tracer_for(default, None, {"logging_setting": "x"}) is default
assert cache._providers == {}
def test_grpc_exporter_gets_no_project_routing():
# ``x-project-name`` is only honored on the OTLP/HTTP endpoint, so a
# gRPC-only Phoenix exporter stays on the default project (warned once).
cache = _phoenix_cache(kind="otlp_grpc")
default = NoOpTracer()
assert cache.tracer_for(default, None, {"phoenix_project_name": "proj"}) is default
assert cache._providers == {}
assert cache._warned_project_unroutable is True

View file

@ -35,6 +35,7 @@ from litellm.integrations.otel.plumbing.context import ( # noqa: E402
set_request_root_span,
)
from litellm.integrations.otel.logger import OpenTelemetryV2 # noqa: E402
from litellm.integrations.otel.model.config import ExporterSpec # noqa: E402
from litellm.integrations.otel.model.spans import ( # noqa: E402
LITELLM_PROXY_REQUEST_SPAN_NAME,
SpanRole,
@ -2277,3 +2278,110 @@ def test_metrics_disabled_by_default_records_nothing(monkeypatch):
)
)
assert _emitted_metric_names(reader) == set()
# --------------------------------------------------------------------------- #
# Per-request Phoenix project routing (key/team auth metadata)
# --------------------------------------------------------------------------- #
def _phoenix_routing_logger(capture_kind):
"""A Phoenix-shaped logger whose owned exporter is a registered factory kind
that captures the exporter built per routed header set, so the test can
assert which destination each span actually exported through."""
captured = {}
def factory(spec):
exporter = InMemorySpanExporter()
captured[spec.headers] = exporter
return exporter
providers.register_exporter_factory(capture_kind, factory)
cfg = OpenTelemetryV2Config(
exporters=[
ExporterSpec(
kind=capture_kind,
endpoint="http://phoenix:6006",
headers="Authorization=Bearer phoenix-key",
owner="arize_phoenix",
)
]
)
default_exporter = InMemorySpanExporter()
tracer_provider = providers.build_tracer_provider(cfg, exporter=default_exporter)
logger = OpenTelemetryV2(
config=cfg, callback_name="arize_phoenix", tracer_provider=tracer_provider
)
return logger, default_exporter, captured
def test_key_team_auth_metadata_routes_llm_span_to_phoenix_project():
"""The proxy stamps the key/team config into ``user_api_key_auth_metadata``;
a ``phoenix_project_name`` there must route the LLM span through an exporter
carrying the ``x-project-name`` header while keeping the preset's auth."""
logger, default_exporter, captured = _phoenix_routing_logger("capture_route_a")
auth_md = {"phoenix_project_name": "team-proj"}
payload = _payload(metadata={"user_api_key_auth_metadata": auth_md})
kwargs = {
"standard_logging_object": payload,
"litellm_params": {"metadata": {"user_api_key_auth_metadata": auth_md}},
}
_emit_llm(logger, kwargs)
assert [s.name for s in default_exporter.get_finished_spans()] == []
(headers,) = captured
parsed = providers.parse_headers(headers)
assert parsed["x-project-name"] == "team-proj"
assert parsed["authorization"] == "Bearer phoenix-key"
routed_spans = captured[headers].get_finished_spans()
assert len(routed_spans) == 1
def test_client_request_metadata_cannot_route_phoenix_project():
"""A bare ``phoenix_project_name`` in client request metadata (not the
server-set ``user_api_key_auth_metadata``) must be ignored: the span stays
on the default tracer and no routed exporter is ever built."""
logger, default_exporter, captured = _phoenix_routing_logger("capture_route_b")
payload = _payload(metadata={"phoenix_project_name": "attacker-project"})
kwargs = {
"standard_logging_object": payload,
"litellm_params": {"metadata": {"phoenix_project_name": "attacker-project"}},
}
_emit_llm(logger, kwargs)
assert captured == {}
assert len(default_exporter.get_finished_spans()) == 1
def test_project_routing_resolves_at_pre_call_before_payload_exists():
"""Production ``pre_call`` runs before the standard logging payload exists,
so the destination project must resolve from ``litellm_params`` alone — the
span is created (and its exporter chosen) right there."""
logger, default_exporter, captured = _phoenix_routing_logger("capture_route_c")
auth_md = {"phoenix_project_name": "team-proj"}
litellm_params = {"metadata": {"user_api_key_auth_metadata": auth_md}}
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
with trace.use_span(server, end_on_exit=False):
logger.log_pre_api_call(
model="gpt-4o",
messages=[],
kwargs={"litellm_call_id": "call_1", "litellm_params": litellm_params},
)
server.end()
assert len(captured) == 1 # routed exporter already built at pre_call
close_kwargs = {
"standard_logging_object": _payload(
metadata={"user_api_key_auth_metadata": auth_md}
),
"litellm_params": litellm_params,
}
asyncio.run(logger.async_log_success_event(close_kwargs, None, None, None))
(headers,) = captured
assert [s.name for s in captured[headers].get_finished_spans()] == ["chat gpt-4o"]
assert all(
s.name != "chat gpt-4o" for s in default_exporter.get_finished_spans()
)

View file

@ -2653,8 +2653,6 @@ class TestObservabilityCallbackBans:
"posthog_api_url",
"braintrust_api_key",
"braintrust_project",
"phoenix_project_name",
"phoenix_project_name_override",
"wandb_api_key",
"weave_project_id",
"gcs_bucket_name",
@ -2685,8 +2683,7 @@ class TestObservabilityCallbackBans:
"langsmith_api_key",
"posthog_api_url",
"braintrust_project",
"phoenix_project_name",
"phoenix_project_name_override",
"user_api_key_auth_metadata",
],
)
def test_observability_field_in_metadata_dict_is_rejected(
@ -2707,6 +2704,28 @@ class TestObservabilityCallbackBans:
)
assert field in str(exc.value)
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
@pytest.mark.parametrize(
"field",
["phoenix_project_name", "phoenix_project_name_override"],
)
def test_phoenix_project_fields_in_metadata_are_accepted(self, metadata_key, field):
# The Phoenix integrations only honor the project from
# ``user_api_key_auth_metadata`` on the proxy, so the bare metadata
# fields are inert and must not 400 SDK-style callers that send them.
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
metadata_key: {field: "client-project"},
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_observability_field_in_litellm_params_metadata_is_rejected(self):
with pytest.raises(ValueError) as exc:
is_request_body_safe(