fix(otel/v2): restore per-request team/key OTLP credential routing for the gen-AI span

The v2 rewrite routed the gen-AI span only by admin-owned destinations (request_destinations),
dropping the pre-existing per-request credential path: a team configured with its own Langfuse,
Arize or Weave keys via standard_callback_dynamic_params (team callback_vars) had its trace
exported with the global/operator credentials instead of the team's, silently landing a tenant's
prompts and completions in the operator's account.

Restore dynamic_otlp_headers + the per-preset arize/langfuse/weave dynamic-header builders,
re-add LLMCallEvent.dynamic_params, and layer the credential-scoped tracer over the destination
fan-out: genai_tracers_for now routes the gen-AI span to a credential-scoped provider (the
configured exporters with only this backend's own exporter rewritten to the request's team/key
credentials) when present, plus each admin destination, so the global collector still receives
the span exactly once. Absent dynamic credentials this is the unchanged destination fan-out.

Wire-verified: on v2 with a team's langfuse callback_vars, the gen-AI span now exports with the
team's credentials (was the global ones). The V1-parity test suite is restored; the inert-on-v2
test that locked the regression is removed. Closing the request-body credential path (a caller
spoofing its own creds, distinct from admin-configured team callback_vars) is tracked separately.
This commit is contained in:
Yucheng Zhu 2026-07-30 16:28:23 -07:00
parent f4fd544c5c
commit 34cfd85a40
8 changed files with 271 additions and 49 deletions

View file

@ -63,6 +63,7 @@ if TYPE_CHECKING:
from litellm.integrations.otel.model.destination import OtelDestination
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.utils import (
StandardCallbackDynamicParams,
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
)
@ -255,7 +256,9 @@ class OpenTelemetryV2(CustomLogger):
start_time_ns=start_time_ns,
tracer=tracer,
)
for tracer in self._tenant_tracers.tracers_for(self.tracer, self._destinations_for_backend(call))
for tracer in self._tenant_tracers.genai_tracers_for(
self.tracer, self._destinations_for_backend(call), call.dynamic_params
)
)
self._open_llm_calls[call_id] = _LLMCallSpan(spans=spans, start_time_ns=start_time_ns)
# Evict the oldest open call if over budget; a call that opens but never closes would
@ -411,6 +414,7 @@ class OpenTelemetryV2(CustomLogger):
to_ns(start_time),
to_ns(end_time),
call.time_to_first_chunk_seconds,
call.dynamic_params,
)
end_time_ns = to_ns(end_time)
@ -435,6 +439,7 @@ class OpenTelemetryV2(CustomLogger):
carrier.start_time_ns,
end_time_ns,
call.time_to_first_chunk_seconds,
call.dynamic_params,
)
def _mark_closed(self, call_id: str | None) -> None:
@ -452,6 +457,7 @@ class OpenTelemetryV2(CustomLogger):
start_time_ns: int | None,
end_time_ns: int | None,
time_to_first_chunk_seconds: float | None = None,
dynamic_params: "StandardCallbackDynamicParams | None" = None,
) -> Span | None:
"""Emit an LLM-call span outside the ``pre_call`` boundary.
@ -470,7 +476,7 @@ class OpenTelemetryV2(CustomLogger):
parent_context=parent_ctx,
start_time_ns=start_time_ns,
end_time_ns=end_time_ns,
tracers=self._tenant_tracers.tracers_for(self.tracer, destinations),
tracers=self._tenant_tracers.genai_tracers_for(self.tracer, destinations, dynamic_params),
)
# ====================================================================== #

View file

@ -28,7 +28,7 @@ from litellm.integrations.otel.model.utils import as_str, to_seconds
from litellm.integrations.otel.plumbing.context import request_destinations
if TYPE_CHECKING:
from litellm.types.utils import StandardLoggingPayload
from litellm.types.utils import StandardCallbackDynamicParams, StandardLoggingPayload
@dataclass(frozen=True)
@ -141,6 +141,9 @@ class LLMCallEvent:
# The success/failure payload; ``None`` at ``pre_call`` or if the call closed with no payload.
payload: "StandardLoggingPayload | None"
otel_destinations: tuple[OtelDestination, ...]
# The request's ``standard_callback_dynamic_params`` (team/key OTLP credentials), or ``None``
# when the call isn't scoped; routes the gen-AI span to a credential-scoped tracer.
dynamic_params: "StandardCallbackDynamicParams | None"
# True for synthetic proxy-gate logs (auth/rate-limit rejections): no upstream call, so no span.
is_no_upstream_call: bool
# Best-effort ``"{operation} {model}"`` name at ``pre_call``; only matters for a leaked span (renamed at close).
@ -157,6 +160,7 @@ class LLMCallEvent:
call_id=_call_id(payload, kwargs),
payload=payload,
otel_destinations=request_destinations(),
dynamic_params=kwargs.get("standard_callback_dynamic_params"),
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),

View file

@ -3,9 +3,16 @@
``TenantTracerCache`` routes the gen-AI LLM-call span, building per-tenant clone
``TracerProvider``s that export to the request's admin-owned destinations plus the
configured/global exporter. ``TenantFanOutSpanProcessor`` (at the bottom) forwards the
proxy-internal spans (server, auth, DB, cost) to every destination. Both read the request's
destinations from the same server-only contextvar, so a caller can neither redirect a trace
nor spawn providers.
proxy-internal spans (server, auth, DB, cost) to every destination. The destinations both
read from a server-only contextvar, so a caller can neither redirect a trace nor spawn
providers through them.
Separately, ``genai_tracers_for`` also routes the gen-AI span by the request's
``standard_callback_dynamic_params`` (team/key OTLP credentials), restoring the per-request
credential routing that predates the admin-destination refactor: it rewrites only the owned
exporter's headers, bounded by the same LRU. Those params are partly caller-influenced, so
this path can spawn a per-request provider; closing that for request-body-supplied credentials
(vs admin-configured team ``callback_vars``) is tracked separately.
The gen-AI path (``tracers_for``) groups destinations by their backend-required Resource
attributes and builds one provider per group, because a span carries exactly one Resource and
@ -15,6 +22,7 @@ tagged span instead of a last-wins merge. Empty destinations -> the logger's def
import threading
from collections import OrderedDict
from typing import TYPE_CHECKING
from opentelemetry.context import Context
from opentelemetry.sdk.resources import Resource
@ -29,6 +37,10 @@ from litellm.integrations.otel.plumbing.providers import (
build_tracer_provider,
get_tracer,
)
from litellm.integrations.otel.presets import dynamic_otlp_headers
if TYPE_CHECKING:
from litellm.types.utils import StandardCallbackDynamicParams
_NON_OTLP_KINDS = ("console", "in_memory", "inmemory", "memory")
@ -77,21 +89,96 @@ class TenantTracerCache:
_, evicted = self._providers.popitem(last=False)
_shutdown_in_background(evicted)
def tracers_for(self, default: Tracer, destinations: "tuple[OtelDestination, ...]") -> "tuple[Tracer, ...]":
def tracers_for(
self,
default: Tracer,
destinations: "tuple[OtelDestination, ...]",
*,
include_base_on_first: bool = True,
) -> "tuple[Tracer, ...]":
"""The tracers for this request's gen-AI span, one per distinct Resource group.
A backend like Arize selects its project from the Resource, so destinations are grouped
by ``destination_resource_attrs`` and the caller emits the span once per tracer. The
configured/global exporters ride the FIRST group only, so the global receives the span
once. Empty ``destinations`` -> the logger's default tracer (deny).
``include_base_on_first`` is set to ``False`` by ``genai_tracers_for`` when a
credential-scoped tracer already carries the configured/global exporters, so the
destination groups don't also export the span to the global collector a second time.
"""
if not destinations:
return (default,)
return tuple(
self._tracer_for_group(resource_key, group, include_base=index == 0)
self._tracer_for_group(resource_key, group, include_base=include_base_on_first and index == 0)
for index, (resource_key, group) in enumerate(self._group_by_resource(destinations))
)
def genai_tracers_for(
self,
default: Tracer,
destinations: "tuple[OtelDestination, ...]",
dynamic_params: "StandardCallbackDynamicParams | None",
) -> "tuple[Tracer, ...]":
"""The gen-AI span's tracers, layering per-request credential routing over the
admin-destination fan-out.
When the request carries this backend's team/key OTLP credentials
(``standard_callback_dynamic_params``), the global export rides a credential-scoped
provider (the configured exporters with this backend's own exporter rewritten to those
credentials), and the admin-destination groups omit the base exporters so the span
reaches the global collector exactly once. Without dynamic credentials this is the plain
destination fan-out (empty destinations -> the default tracer).
"""
headers = dynamic_otlp_headers(self._callback_name, dynamic_params)
if not headers:
return self.tracers_for(default, destinations)
dynamic = self._credential_scoped_tracer(headers)
if not destinations:
return (dynamic,)
return (dynamic, *self.tracers_for(default, destinations, include_base_on_first=False))
def dynamic_tracer_for(self, default: Tracer, dynamic_params: "StandardCallbackDynamicParams | None") -> Tracer:
"""The credential-scoped tracer when the request carries this backend's team/key OTLP
credentials, else ``default``. Distinct from ``tracer_for`` (admin destinations); this
is the per-request path restored for parity with the pre-v2-refactor behavior."""
headers = dynamic_otlp_headers(self._callback_name, dynamic_params)
if not headers:
return default
return self._credential_scoped_tracer(headers)
def _credential_scoped_tracer(self, headers: "dict[str, str]") -> Tracer:
"""A cached provider that keeps the configured exporters and rewrites only this
backend's owned exporter's headers to ``headers`` (the per-request credentials)."""
cache_key: tuple[object, ...] = ("dynamic", tuple(sorted(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))
self._providers[cache_key] = provider
self._evict_if_full()
return get_tracer(provider, self._tracer_name)
def _config_with_headers(self, headers: "dict[str, str]") -> OpenTelemetryV2Config:
"""Clone the config, stamping ``headers`` onto this backend's own exporter only.
``headers`` are the per-request credentials of ``self._callback_name``, so they apply
only to the exporter that integration contributed (``spec.owner``). A request carrying
one tenant's Arize key must never rewrite a co-configured Langfuse or self-hosted
collector exporter, which would leak that key to a different backend.
"""
header_str = ",".join(f"{key}={value}" for key, value in headers.items())
exporters = [
(
spec.model_copy(update={"headers": header_str})
if spec.owner == self._callback_name and spec.kind.lower() not in _NON_OTLP_KINDS
else spec
)
for spec in self._config.exporters
]
return self._config.model_copy(update={"exporters": exporters})
def _group_by_resource(
self, destinations: "tuple[OtelDestination, ...]"
) -> "tuple[tuple[tuple[tuple[str, str], ...], tuple[OtelDestination, ...]], ...]":

View file

@ -7,22 +7,55 @@ maps a callback name (``"arize"``, ``"langfuse_otel"``, ...) to its preset so
the factory in ``litellm_logging`` can resolve a name and build a single
``OpenTelemetryV2`` instance from the result.
Per-key/team routing does not live here. A trace destination is admin-owned
infrastructure config, resolved server-side from a named credential into an
``OtelDestination`` (see ``litellm.integrations.otel.presets.destinations`` and
``plumbing.routing``); nothing in this package reads vendor credentials or a
host off a request.
Admin-owned trace destinations are resolved server-side from a named credential
into an ``OtelDestination`` (see ``litellm.integrations.otel.presets.destinations``
and ``plumbing.routing``). Separately, per-request team/key OTLP credentials from
``standard_callback_dynamic_params`` route the gen-AI span to a credential-scoped
tracer for the integrations that support it; ``dynamic_otlp_headers`` below builds
those per-request headers.
"""
from typing import Callable
from litellm.integrations.otel.presets.agentops import agentops_preset
from litellm.integrations.otel.presets.arize import arize_preset
from litellm.integrations.otel.presets.arize import arize_dynamic_headers, arize_preset
from litellm.integrations.otel.presets.base import Preset
from litellm.integrations.otel.presets.generic import generic_preset
from litellm.integrations.otel.presets.langfuse import langfuse_preset
from litellm.integrations.otel.presets.langfuse import (
langfuse_dynamic_headers,
langfuse_preset,
)
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.weave import weave_preset
from litellm.integrations.otel.presets.weave import weave_dynamic_headers, weave_preset
from litellm.types.utils import StandardCallbackDynamicParams
#: Callback name -> per-request OTLP header builder (team/key multi-tenant
#: routing). Only integrations that support dynamic credentials appear here;
#: Arize-Phoenix/Langtrace/Levo/AgentOps/generic don't, so they use the logger's
#: default tracer.
DYNAMIC_HEADERS_BY_CALLBACK: dict[str, Callable[[StandardCallbackDynamicParams], dict[str, str]]] = {
"arize": arize_dynamic_headers,
"langfuse_otel": langfuse_dynamic_headers,
"weave_otel": weave_dynamic_headers,
}
def dynamic_otlp_headers(
callback_name: str | None,
dynamic_params: "StandardCallbackDynamicParams | None",
) -> dict[str, str] | None:
"""Per-request OTLP headers for ``callback_name``, or ``None`` if N/A.
``None`` means "no per-request routing" -- the caller uses its default tracer.
"""
builder = DYNAMIC_HEADERS_BY_CALLBACK.get(callback_name or "")
if builder is None or not dynamic_params:
return None
headers = builder(dynamic_params)
return headers or None
#: Callback name → preset. The ``Preset`` annotation makes mypy verify every
#: registered value matches the preset interface.
@ -39,10 +72,12 @@ PRESET_BY_CALLBACK: dict[str, Preset] = {
__all__ = [
"DYNAMIC_HEADERS_BY_CALLBACK",
"PRESET_BY_CALLBACK",
"Preset",
"agentops_preset",
"arize_preset",
"dynamic_otlp_headers",
"generic_preset",
"langfuse_preset",
"langtrace_preset",

View file

@ -10,6 +10,7 @@ from litellm.integrations.otel.model.config import (
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.types.utils import StandardCallbackDynamicParams
class _ArizeSettings(BaseSettings):
@ -59,3 +60,16 @@ def _arize_headers(arize_cfg) -> str | None:
# credentials are configured.
return _ArizeSettings().otlp_traces_headers
return ",".join(pieces)
def arize_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
"""Per-request Arize OTLP headers from team/key dynamic params."""
headers: dict[str, str] = {}
# ``arize_space_key`` is the suggested param and wins over ``arize_space_id``.
space = params.get("arize_space_key") or params.get("arize_space_id")
if space:
headers["arize-space-id"] = space
api_key = params.get("arize_api_key")
if api_key:
headers["api_key"] = api_key
return headers

View file

@ -9,6 +9,20 @@ from litellm.integrations.otel.model.config import (
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.types.utils import StandardCallbackDynamicParams
def langfuse_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
"""Per-request Langfuse OTLP headers from team/key dynamic params."""
public_key = params.get("langfuse_public_key")
secret_key = params.get("langfuse_secret_key")
if public_key and secret_key:
return {
"Authorization": _V1Langfuse._get_langfuse_authorization_header(
public_key=public_key, secret_key=secret_key
)
}
return {}
def langfuse_preset(

View file

@ -6,7 +6,23 @@ from litellm.integrations.otel.model.config import (
OpenTelemetryV2Config,
)
from litellm.integrations.otel.presets.utils import ensure_mappers
from litellm.integrations.weave.weave_otel import get_weave_otel_config
from litellm.integrations.weave.weave_otel import (
_get_weave_authorization_header,
get_weave_otel_config,
)
from litellm.types.utils import StandardCallbackDynamicParams
def weave_dynamic_headers(params: StandardCallbackDynamicParams) -> dict[str, str]:
"""Per-request Weave OTLP headers from team/key dynamic params."""
headers: dict[str, str] = {}
api_key = params.get("wandb_api_key")
if api_key:
headers["Authorization"] = _get_weave_authorization_header(api_key=api_key)
project_id = params.get("weave_project_id")
if project_id:
headers["project_id"] = project_id
return headers
def weave_preset(

View file

@ -1,11 +1,13 @@
"""Per-tenant tracer routing on admin-owned OTEL destinations, with fan-out.
"""Per-tenant tracer routing on admin-owned OTEL destinations, with fan-out, plus the
per-request credential routing layered over it.
A request's identity chain is assigned a set of admin-owned exporters; the v2 logger
fans the trace out to all of them (plus the configured/global exporter), and never
routes on request-supplied vendor credentials. These tests lock the contract: the
request cannot route a trace, each destination's endpoint follows its resolved host
(cross-host fix), the configured exporters are kept (global also receives), and a
logger only exports the destinations tagged with its own backend.
A request's identity chain is assigned a set of admin-owned exporters; the v2 logger fans
the trace out to all of them (plus the configured/global exporter). Separately, a request's
``standard_callback_dynamic_params`` (team/key OTLP credentials) route the gen-AI span to a
credential-scoped tracer for the integrations that support it (V1 parity). These tests lock
both: each destination's endpoint follows its resolved host (cross-host fix), the configured
exporters are kept (global also receives), a logger only exports the destinations tagged with
its own backend, and request credentials rewrite only their own backend's exporter headers.
"""
import os
@ -319,38 +321,82 @@ def test_clone_provider_emits_genai_span_with_destination_resource():
assert resource_attrs.get("arize.project.name") == "team-b-proj"
# --- security: request credentials never route a trace --------------------- #
# --- request credentials route the gen-AI span (V1 parity) ------------------ #
@pytest.mark.parametrize(
"request_creds",
[
{
"langfuse_public_key": "pk-attacker",
"langfuse_secret_key": "sk-attacker",
"langfuse_host": "https://attacker.example",
},
{"arize_api_key": "K-attacker", "arize_space_id": "S-attacker"},
{"wandb_api_key": "w-attacker", "weave_endpoint": "https://attacker/otel"},
],
)
def test_request_credentials_are_inert_on_v2(request_creds):
"""Any backend's credentials in the request's dynamic params (no admin
destinations) produce no per-tenant routing."""
event = LLMCallEvent.from_dict(
{
"standard_callback_dynamic_params": request_creds,
"call_type": "acompletion",
"model": "gpt-4o",
}
_DYNAMIC_CREDS = [
("langfuse_otel", {"langfuse_public_key": "pk-teamA", "langfuse_secret_key": "sk-teamA"}, "Authorization="),
("arize", {"arize_space_id": "space-teamA", "arize_api_key": "key-teamA"}, "arize-space-id=space-teamA"),
("weave_otel", {"wandb_api_key": "wandb-teamA", "weave_project_id": "proj-teamA"}, "project_id=proj-teamA"),
]
@pytest.mark.parametrize("backend, creds, owned_fragment", _DYNAMIC_CREDS)
def test_request_credentials_rewrite_only_the_owned_exporter(backend, creds, owned_fragment):
"""A request's team/key OTLP credentials rewrite the headers of THIS backend's own
exporter and nothing else, so the gen-AI span exports to that team's account while a
co-configured backend's exporter is untouched (no cross-backend key leak)."""
from litellm.integrations.otel.presets import dynamic_otlp_headers
cache = _cache(
backend,
exporters=[
ExporterSpec(kind="otlp_http", endpoint="http://collector/otel", headers="Authorization=Basic GLOBAL", owner=backend),
ExporterSpec(kind="otlp_http", endpoint="http://other/otel", headers="x=coconfigured", owner="levo"),
],
)
headers = dynamic_otlp_headers(backend, creds)
assert headers, f"{backend} must support dynamic credentials"
scoped = cache._config_with_headers(headers)
assert owned_fragment in (scoped.exporters[0].headers or "")
assert "GLOBAL" not in (scoped.exporters[0].headers or "") # the global header was rewritten away
assert scoped.exporters[1].headers == "x=coconfigured" # a different backend's exporter is untouched
@pytest.mark.parametrize("backend, creds, owned_fragment", _DYNAMIC_CREDS)
def test_genai_tracers_for_spawns_credential_scoped_provider(backend, creds, owned_fragment):
"""genai_tracers_for with request creds and no admin destination returns exactly the
credential-scoped tracer (not the default) and caches its provider under a dynamic key."""
cache = _cache(
backend,
exporters=[ExporterSpec(kind="otlp_http", endpoint="http://c/otel", headers="Authorization=Basic GLOBAL", owner=backend)],
)
assert event.otel_destinations == ()
cache = _cache("langfuse_otel")
default = NoOpTracer()
assert cache.tracer_for(default, event.otel_destinations) is default
tracers = cache.genai_tracers_for(default, (), creds)
assert len(tracers) == 1
assert tracers[0] is not default
assert len(cache._providers) == 1
assert next(iter(cache._providers))[0] == "dynamic" # namespaced key, never aliases a destination group
def test_genai_tracers_for_without_creds_is_plain_default():
"""No dynamic creds and no destinations -> the logger's default tracer, no provider spawned."""
cache = _cache(
"langfuse_otel",
exporters=[ExporterSpec(kind="otlp_http", endpoint="http://c/otel", headers="", owner="langfuse_otel")],
)
default = NoOpTracer()
assert cache.genai_tracers_for(default, (), None) == (default,)
assert cache._providers == {}
def test_genai_tracers_compose_credential_scoped_plus_destination():
"""Request creds AND an admin destination: the span exports via the credential-scoped
tracer (which carries the global exporter) plus one per-destination tracer that omits the
base exporters, so the global collector receives the span exactly once."""
cache = _cache(
"langfuse_otel",
exporters=[ExporterSpec(kind="otlp_http", endpoint="http://global/otel", headers="Authorization=Basic GLOBAL", owner="langfuse_otel")],
)
creds = {"langfuse_public_key": "pk-teamA", "langfuse_secret_key": "sk-teamA"}
destinations = (_dest("https://cloud.langfuse.com/api/public/otel", auth="Basic ADMIN"),)
tracers = cache.genai_tracers_for(NoOpTracer(), destinations, creds)
assert len(tracers) == 2 # credential-scoped (global) + one destination group
# the destination group provider carries no base exporter (base rides the dynamic tracer)
dest_only = cache._config_with_destinations(destinations, include_base_exporters=False)
assert all(spec.endpoint != "http://global/otel" for spec in dest_only.exporters)
def test_admin_destinations_route():
event = _event(
[