feat(otel)!: let a team capture_message_content override the global setting for its OTel v2 destination (#44264)

* feat(otel): let a team strip captured message content from its own OTel v2 destination

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(proxy): split the shared callback option conflict check and sync dashboard API types

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): address review feedback on team capture_message_content

Classify capture_message_content as NotSecret in the credential slot registry, inject test exporters through register_exporter_factory instead of patching _exporter_from_spec, type the test helpers, and stop reassigning processor in build_tracer_provider

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel): add integration cells for team capture_message_content

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel): cover weave_otel and newrelic team destinations in capture_message_content cells

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): stop rejecting client-sent capture_message_content at the request body boundary

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(otel): let a team capture_message_content override the global setting in both directions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): keep a team's capture_message_content on its own OTel v2 destination

The setting no longer rides the flattened team callback vars, so a sibling destination and a routed provider follow the global mode. Request bodies get the base 400 back, a failure-only entry rejects the setting, and entries for one backend must agree

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel): type the capture_message_content callback validation tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel): type the remaining capture_message_content test signatures

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(otel): mark the new capture_message_content test locals Final

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): default tenant capture to callback_settings.otel and keep CaptureMessageContent formatting bare

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(otel): keep CaptureMessageContent a plain str like main

The str-to-Enum switch changed the public type: the config field's type,
repr and model_dump, a constructor that now raised on unknown values, and
unknown env values coerced to no_content. CaptureMessageContent is the plain
str subclass main ships again, the field is a str, and a
captures_span_content helper replaces the enum property. A test pins that a
published preset's capture mode stays the default for an unowned
destination, which is what main forwards.

* test(otel): drop the module-level CaptureMessageContent import the rewritten test no longer uses

* fix(lint): keep the merged branch within main's type budgets

Freeze the capture-mode settings model (LIT015) and read stored logging
entries through one typed sequence helper, so the conflicting-capture checks
add no unknown types.

* fix(otel): reject a destination capture_message_content while OTel v2 is off

The legacy OTel callbacks never read the per-destination setting, so a team
could register no_content and still export prompts and replies under the
global capture mode. Registration now refuses the var unless the proxy runs
with LITELLM_OTEL_V2=true, the same rule the Arize transport and New Relic
routing vars follow.

* test(otel): type the OTel v2 fixtures and the v2-off regression test

* fix(otel): accept only no_content and span_only on a team or key destination

OTel v2 writes captured content only to span attributes, so event_only
behaved like no_content and span_and_event like span_only on a
destination. The proxy-wide setting keeps all four values

* docs(otel): say an omitted capture setting follows the proxy's own callback for that backend

* test(otel): describe the general capture fallback in the unowned destination test

---------

Co-authored-by: mrinal <mrinal@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <yucheng@berri.ai>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-10 13:52:32 -07:00 • committed by GitHub
parent 3981228d70
commit dc95de5209
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2019 additions and 85 deletions

View file

@ -295,7 +295,9 @@ nothing here imports outside it:
- [`config.py`](./model/config.py) — `OpenTelemetryV2Config`, a pydantic-settings
model that reads `OTEL_*` / `LITELLM_OTEL_*` env vars, plus the feature gate.
`capture_span_content` gates whether prompt/response bodies may be written as
span attributes; it defaults **off** (`no_content`). The Baggage allowlists are
span attributes; it defaults **off** (`no_content`). A team or key can override
the global mode per OTel v2 destination with `capture_message_content`; omitted
values follow the proxy's own callback for that backend, else the global mode. The Baggage allowlists are
configurable, not hard-coded: set `LITELLM_OTEL_BAGGAGE_PROMOTED_KEYS` /
`LITELLM_OTEL_BAGGAGE_METADATA_KEYS` /
`LITELLM_OTEL_BAGGAGE_TEAM_METADATA_KEYS` (comma-separated) as env vars, or

View file

@ -51,6 +51,10 @@ class LangfuseContentOpenTelemetryV2(LangfuseOpenTelemetryV2):
response: "AsyncIterator[ModelResponseStream]",
request_data: Mapping[str, object],
) -> "AsyncGenerator[ModelResponseStream, None]":
if not self._capture_span_content():
async for chunk in response:
yield chunk
return
relayed: Final[list[ModelResponseStream]] = [] # mutable-ok: relayed as they arrive, assembled at end of stream
async for chunk in response:
relayed.append(chunk)
@ -58,6 +62,8 @@ class LangfuseContentOpenTelemetryV2(LangfuseOpenTelemetryV2):
self._stamp_root_io(request_data, lambda: stream_output(tuple(relayed), request_data))
def _stamp_root_io(self, data: Mapping[str, object], render_output: Callable[[], str | None]) -> None:
if not self._capture_span_content():
return
root: Final = request_root_span()
if root is None or not root.is_recording():
return

View file

@ -58,6 +58,7 @@ from litellm.integrations.otel.plumbing.context import (
is_recordable_span,
mcp_message_transport_span,
post_response_root,
request_destinations,
request_root_http_route,
request_root_span,
resolve_internal_call_span_context,
@ -82,6 +83,7 @@ from litellm.integrations.otel.plumbing.providers import (
resolve_meter_provider,
)
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
from litellm.types.utils import captures_span_content
if TYPE_CHECKING:
from opentelemetry.metrics import MeterProvider
@ -254,6 +256,11 @@ class OpenTelemetryV2(CustomLogger):
return None
return GenAIEventRecorder(get_event_logger(provider, LITELLM_TRACER_NAME), provider.resource)
def _capture_span_content(self) -> bool:
return self.config.capture_span_content or any(
captures_span_content(destination.capture_message_content) for destination in request_destinations()
)
# ====================================================================== #
# Proxy global registration
# ====================================================================== #
@ -433,7 +440,7 @@ class OpenTelemetryV2(CustomLogger):
return False
payload: Final = cast("StandardLoggingPayload", raw_payload)
data: Final = MCPToolCallSpanData.from_standard_logging_payload(
payload, capture_content=self.config.capture_span_content
payload, capture_content=self._capture_span_content()
)
# A stray LLM carrier from a ``pre_call`` that mis-fired for this id would
# otherwise linger until evicted; drop it so it's neither leaked nor closed
@ -477,7 +484,7 @@ class OpenTelemetryV2(CustomLogger):
return False
payload: Final = cast("StandardLoggingPayload", raw_payload)
data: Final = MCPListToolsSpanData.from_standard_logging_payload(
payload, capture_content=self.config.capture_span_content
payload, capture_content=self._capture_span_content()
)
if data.identity.call_id:
self._release_carrier(self._open_llm_calls.pop(data.identity.call_id, None))
@ -569,7 +576,7 @@ class OpenTelemetryV2(CustomLogger):
return None
data: Final = LLMCallSpanData.from_standard_logging_payload(
payload,
capture_content=self.config.capture_span_content,
capture_content=self._capture_span_content(),
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
request_route=request_root_http_route(),
request_purpose=call.purpose,
@ -1076,6 +1083,6 @@ def build_otel_v2_logger(
def _logger_class(config: OpenTelemetryV2Config) -> type[OpenTelemetryV2]:
if "langfuse" not in config.mapper_names:
return OpenTelemetryV2
from litellm.integrations.otel.langfuse_logger import LangfuseContentOpenTelemetryV2, LangfuseOpenTelemetryV2
from litellm.integrations.otel.langfuse_logger import LangfuseContentOpenTelemetryV2
return LangfuseContentOpenTelemetryV2 if config.capture_span_content else LangfuseOpenTelemetryV2
return LangfuseContentOpenTelemetryV2

View file

@ -16,19 +16,17 @@ from litellm.integrations.otel.model.baggage import (
)
from litellm.integrations.otel.model.spans import POSTGRESQL, db_system
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import OtelSpanScope
from litellm.types.utils import (
CAPTURE_MESSAGE_CONTENT_VALUES,
CaptureMessageContent,
OtelSpanScope,
captures_span_content,
)
#: Master feature-flag env var. The logger is inert until this is truthy.
OTEL_V2_ENV: Final = "LITELLM_OTEL_V2"
class CaptureMessageContent(str):
NO_CONTENT = "no_content"
SPAN_ONLY = "span_only"
EVENT_ONLY = "event_only"
SPAN_AND_EVENT = "span_and_event"
class ExporterOwner(str, Enum):
"""The preset that contributed an exporter. Values match the callback names
in ``presets.PRESET_BY_CALLBACK`` so per-request dynamic-credential routing
@ -297,9 +295,7 @@ class OpenTelemetryV2Config(BaseSettings):
here are lower_snake_case; normalizing at the boundary keeps both
spellings working and lets every downstream comparison stay exact.
"""
if isinstance(value, str):
return value.lower()
return value
return _normalized_capture_message_content(value)
@field_validator("langfuse_span_scope", mode="before")
@classmethod
@ -371,22 +367,39 @@ class OpenTelemetryV2Config(BaseSettings):
@property
def capture_span_content(self) -> bool:
"""Whether prompt/response content may be stamped as span attributes.
Defaults off (``no_content``): an operator must opt in before message
bodies leave the process, so a user request can never force its prompt
or completion into the configured backend while capture is disabled.
"""
return self.capture_message_content in (
CaptureMessageContent.SPAN_ONLY,
CaptureMessageContent.SPAN_AND_EVENT,
)
"""Whether the global setting permits prompt/response span attributes."""
return captures_span_content(self.capture_message_content)
@classmethod
def from_env(cls) -> "OpenTelemetryV2Config":
return cls()
def _normalized_capture_message_content(value: object) -> object:
if isinstance(value, str):
return value.lower()
return value
class _CaptureMessageContentSettings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore", frozen=True)
capture_message_content: str = Field(
default=CaptureMessageContent.NO_CONTENT,
validation_alias=AliasChoices("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
)
@field_validator("capture_message_content", mode="before")
@classmethod
def _normalize_capture_message_content(cls, value: object) -> object:
return _normalized_capture_message_content(value)
def capture_message_content_from_env() -> str:
"""The global capture mode, read without building a settings model that rereads every other env var"""
return _CaptureMessageContentSettings().capture_message_content
_EXCLUDED_SERVICES_INPUT: Final[TypeAdapter[str | tuple[object, ...]]] = TypeAdapter(str | tuple[object, ...])
@ -395,6 +408,10 @@ def excluded_db_systems_from(value: object) -> frozenset[str]:
return _normalize_excluded_services(excluded_service_names(value))
def parse_capture_message_content(value: object) -> str | None:
return value if isinstance(value, str) and value in CAPTURE_MESSAGE_CONTENT_VALUES else None
def excluded_service_names(value: object) -> frozenset[str]:
"""Read a YAML list or comma-separated string of service names, logging and dropping unusable input
so a malformed value cannot stop the OTel logger from being built"""

View file

@ -11,7 +11,7 @@ from urllib.parse import quote
from pydantic import ConfigDict, Field
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import OtelSpanScope
from litellm.types.utils import OtelSpanScope, captures_span_content
class OtelDestination(LiteLLMBaseModel):
@ -50,6 +50,17 @@ class OtelDestination(LiteLLMBaseModel):
default=None,
description="Same, for the requests with a failed span in their tree; ``None`` forwards every one.",
)
capture_message_content: str | None = Field(
default=None,
description=(
"An explicit mode overrides the global capture policy for this destination. "
"Omitted follows the global setting."
),
)
def captures_content(self, default: str) -> bool:
setting: Final = self.capture_message_content
return captures_span_content(default if setting is None else setting)
def header_string(self) -> str:
"""Render headers as the ``k=v,k2=v2`` form an ``ExporterSpec`` expects.
@ -64,9 +75,9 @@ class OtelDestination(LiteLLMBaseModel):
def cache_key(self) -> tuple[str, tuple[tuple[str, str], ...], tuple[tuple[str, str], ...], str | None]:
"""Identity for processor reuse, so one destination means one exporter.
``span_scope`` and the sampling rates are left out on purpose: they decide which
spans reach the processor, not how the processor exports them, so a full and an
``llm_only`` view of the same account share one exporter.
``span_scope``, the sampling rates and ``capture_message_content`` are left out on
purpose: they decide which spans reach the processor and what they carry, not how
the processor exports them, so two views of the same account share one exporter.
"""
return (
self.endpoint,

View file

@ -5,7 +5,7 @@ import random
import threading
import time
from collections import OrderedDict
from collections.abc import Callable, Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal
@ -40,8 +40,19 @@ from opentelemetry.util.types import Attributes, AttributeValue
from litellm._logging import verbose_logger
from litellm._version import version as litellm_version
from litellm.integrations.otel.mappers.langfuse import LANGFUSE_TRACE_NAME
from litellm.integrations.otel.model.config import ExporterOwner, ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.mappers.langfuse import (
LANGFUSE_OBSERVATION_INPUT,
LANGFUSE_OBSERVATION_OUTPUT,
LANGFUSE_TRACE_NAME,
)
from litellm.integrations.otel.model.config import (
CaptureMessageContent,
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
capture_message_content_from_env,
captures_span_content,
)
from litellm.integrations.otel.model.semconv import (
DB,
MCP,
@ -385,6 +396,26 @@ _CAPTURED_HEADER_PREFIXES: Final = ("http.request.header.", "http.response.heade
# ``?key=`` query parameter.
_URL_KEYS: Final = frozenset({"http.url", "http.target", "url.full"})
_URL_QUERY_KEY: Final = "url.query"
# Prompt and response content, in every vocabulary the mappers write it in once the
# global capture policy collects it: GenAI semconv (MCP tool-call arguments and results
# included), Langfuse, OpenInference's blobs and per-index messages, Langtrace, Weave.
_MESSAGE_CONTENT_KEYS: Final = frozenset(
{
GenAI.INPUT_MESSAGES,
GenAI.OUTPUT_MESSAGES,
GenAI.SYSTEM_INSTRUCTIONS,
GenAI.TOOL_CALL_ARGUMENTS,
GenAI.TOOL_CALL_RESULT,
LANGFUSE_OBSERVATION_INPUT,
LANGFUSE_OBSERVATION_OUTPUT,
"input.value",
"output.value",
"llm.prompts",
"llm.completions",
"weave.output",
}
)
_INDEXED_MESSAGE_PREFIXES: Final = ("llm.input_messages.", "llm.output_messages.")
class _SpanView(ReadableSpan):
@ -415,6 +446,19 @@ class _SpanView(ReadableSpan):
)
def _is_message_content(key: str) -> bool:
return key in _MESSAGE_CONTENT_KEYS or key.startswith(_INDEXED_MESSAGE_PREFIXES)
def without_message_content(span: ReadableSpan) -> ReadableSpan:
"""``span`` without its prompt and response content, everything else left as it was."""
attributes: Final = span.attributes or _NO_ATTRIBUTES
if not any(_is_message_content(key) for key in attributes):
return span
kept: Final = MappingProxyType({key: value for key, value in attributes.items() if not _is_message_content(key)})
return _SpanView(span, span.resource, kept, span.events, span.status, parent=span.parent)
def _is_database_span(attributes: Mapping[str, AttributeValue]) -> bool:
return any(key in attributes for key in _DB_SYSTEM_KEYS)
@ -553,7 +597,11 @@ def _tenant_resource(resource: Resource, defaults: Mapping[str, str], extra: Map
return filled.merge(Resource(extra)) if extra else filled
def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> ReadableSpan:
def _for_destination(
span: ReadableSpan,
destination: "OtelDestination",
default_capture: str,
) -> ReadableSpan:
"""The view of ``span`` a tenant destination receives.
A span the tenant's own call produced keeps its error text. Every other span is
@ -564,8 +612,10 @@ def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> Read
exception it raised and names the operator's guardrail endpoint. Stack traces walk
the operator's install and come off every span, as do the headers the operator
captures on the server span, whose request side holds the caller's bearer token,
and the query string of the request URL, which can hold the same key. The span
itself stays, so the tenant still gets the whole trace tree.
and the query string of the request URL, which can hold the same key. A
destination whose effective setting disables span content loses the prompt and
response content too. The span itself stays, so the tenant still gets the whole
trace tree.
"""
extra: Final = destination.resource_attributes
defaults: Final = destination.resource_defaults
@ -573,11 +623,12 @@ def _for_destination(span: ReadableSpan, destination: "OtelDestination") -> Read
database: Final = _is_database_span(attributes)
owned: Final = _is_tenant_owned_span(attributes)
unreachable: Final = _guardrail_unreachable(attributes)
redacted: Final = not destination.captures_content(default_capture)
kept: Final = MappingProxyType(
{
key: _without_query(key, value)
for key, value in attributes.items()
if _tenant_visible(key, database, owned, unreachable)
if _tenant_visible(key, database, owned, unreachable) and not (redacted and _is_message_content(key))
}
)
recorded: Final = span.events
@ -612,6 +663,8 @@ class TenantFanOutSpanProcessor(SpanProcessor):
shutdown_drain_seconds: float = _SHUTDOWN_DRAIN_SECONDS,
operator_sinks: 'Mapping[_SinkKey, "OtelSpanScope"]' = MappingProxyType({}),
excluded_db_systems: frozenset[str] = frozenset(),
default_capture: str = CaptureMessageContent.NO_CONTENT,
default_capture_by_backend: Mapping[str, str] = MappingProxyType({}),
pending_drains: int = _MAX_PENDING_DRAINS,
drain_pool: _DrainPool | None = None,
sampling_draw: Callable[[], float] | None = None,
@ -619,6 +672,8 @@ class TenantFanOutSpanProcessor(SpanProcessor):
self._operator_sinks: Final = operator_sinks
self._draw: Final = sampling_draw if sampling_draw is not None else random.random
self._excluded_db_systems: Final = excluded_db_systems
self._default_capture: Final = default_capture
self._default_capture_by_backend: Final = default_capture_by_backend
self._drain_seconds: Final = shutdown_drain_seconds
self._lock: Final = threading.Condition()
self._closed = False # guarded by ``_lock``: an unlocked read races the teardown it gates
@ -661,12 +716,22 @@ class TenantFanOutSpanProcessor(SpanProcessor):
for held_span, held_destination in self._settle(trace_id, failed):
self._forward(held_span, held_destination)
def _default_capture_for(self, destination: "OtelDestination") -> str:
backend: Final = destination.callback_name
return (
self._default_capture_by_backend.get(backend, self._default_capture) if backend else self._default_capture
)
def _forward(self, span: ReadableSpan, destination: "OtelDestination") -> None:
processor: Final = self._acquire(destination)
if processor is None:
return
try:
processor.on_end(_scoped(_for_destination(span, destination), destination.span_scope))
processor.on_end(
_scoped(
_for_destination(span, destination, self._default_capture_for(destination)), destination.span_scope
)
)
except Exception as exc: # noqa: BLE001 # one destination's failure must not cost the others their span
verbose_logger.debug("OTel V2 fan-out: forwarding to %s failed: %s", destination.endpoint, exc)
finally:
@ -963,14 +1028,15 @@ class _OverriddenBackendFilter(SpanProcessor):
Wrapping is the only place this works: ``SynchronousMultiSpanProcessor.on_end``
ignores return values, so a sibling processor can never veto the export.
Under ``additive`` mode nothing is suppressed, so the wrapper passes every span
straight through and the operator keeps its copy.
Under ``additive`` mode spans are not suppressed, though global content policy
still strips message content when disabled.
``scope`` narrows what the exporter receives independently of that: under
``llm_only`` the model-call spans go through as trace roots and the rest of the
tree is held back, unless a destination of the request names ``sink``, the account
this exporter writes to, with a wider scope: the fan-out then delivers the rest of
the tree there and the model call keeps its place in it.
the tree there and the model call keeps its place in it. A destination naming
``sink`` sets this exporter's content policy; otherwise it follows global capture.
"""
def __init__(
@ -979,11 +1045,15 @@ class _OverriddenBackendFilter(SpanProcessor):
owner: str | None,
scope: "OtelSpanScope" = "full",
sink: _SinkKey | None = None,
global_captures: bool = True,
filter_content: bool = True,
) -> None:
self._inner: Final = inner
self._owner: Final = owner
self._scope: Final = scope
self._sink: Final = sink
self._global_captures: Final = global_captures
self._filter_content: Final = filter_content
def on_start(self, span: SDKSpan, parent_context: Context | None = None) -> None:
self._inner.on_start(span, parent_context)
@ -991,7 +1061,28 @@ class _OverriddenBackendFilter(SpanProcessor):
def on_end(self, span: ReadableSpan) -> None:
if self._owner in suppressed_backends() or not _in_scope(span, self._scope):
return
self._inner.on_end(_scoped(span, self._account_scope()))
scoped: Final = _scoped(span, self._account_scope())
self._inner.on_end(
without_message_content(scoped) if self._filter_content and not self._account_captures_content() else scoped
)
def _account_captures_content(self) -> bool:
"""Whether a destination of this request that names ``sink`` captures content.
The fan-out skips a destination this exporter already writes to, so this copy is
the one that account gets and has to honor the destination's content mode.
"""
explicit: Final = next(
(
destination.capture_message_content
for destination in request_destinations()
if self._sink is not None
and destination.capture_message_content is not None
and _sink_key(destination.endpoint, destination.headers) == self._sink
),
None,
)
return captures_span_content(explicit) if explicit is not None else self._global_captures
def _account_scope(self) -> "OtelSpanScope":
if self._scope == "full" or self._sink is None:
@ -1010,6 +1101,25 @@ class _OverriddenBackendFilter(SpanProcessor):
return self._inner.force_flush(timeout_millis)
class _MessageContentFilter(SpanProcessor):
"""Strip prompt and response content from every span before ``inner`` exports it."""
def __init__(self, inner: SpanProcessor) -> None:
self._inner: Final = inner
def on_start(self, span: SDKSpan, parent_context: Context | None = None) -> None:
self._inner.on_start(span, parent_context)
def on_end(self, span: ReadableSpan) -> None:
self._inner.on_end(without_message_content(span))
def shutdown(self) -> None:
self._inner.shutdown()
def force_flush(self, timeout_millis: int = 30000) -> bool:
return self._inner.force_flush(timeout_millis)
def build_span_exporter(config: OpenTelemetryV2Config) -> SpanExporter:
"""Build a single exporter from the top-level config fields.
@ -1261,12 +1371,23 @@ def build_resource(config: OpenTelemetryV2Config) -> Resource:
return Resource.create(attributes)
def _spec_processor(
spec: ExporterSpec, use_simple_processor: bool | None, strip_message_content: bool
) -> SpanProcessor:
exporting: Final = _processor_for(
_exporter_from_spec(spec),
(spec.use_simple_processor if spec.use_simple_processor is not None else use_simple_processor),
)
return _MessageContentFilter(exporting) if strip_message_content else exporting
def build_tracer_provider(
config: OpenTelemetryV2Config,
exporter: SpanExporter | None = None,
baggage_processor: SpanProcessor | None = None,
use_simple_processor: bool | None = None,
tenant_overrides: bool = False,
strip_message_content: bool = False,
) -> TracerProvider:
"""Build the shared :class:`TracerProvider`.
@ -1285,6 +1406,9 @@ def build_tracer_provider(
``config.langfuse_span_scope`` narrows the exporter owned by ``langfuse_otel``
alone; a collector or any other backend in the same config keeps the full tree.
``strip_message_content`` removes prompt and response content before every
exporter, for a routed provider whose spans can carry content another
destination asked for.
"""
provider: Final = TracerProvider(resource=build_resource(config))
if baggage_processor is None:
@ -1300,17 +1424,23 @@ def build_tracer_provider(
for spec in config.exporters:
if spec.requires_headers and not spec.headers:
continue
exp = _exporter_from_spec(spec)
processor = _processor_for(
exp,
(spec.use_simple_processor if spec.use_simple_processor is not None else use_simple_processor),
)
processor = _spec_processor(spec, use_simple_processor, strip_message_content)
owner = spec.owner.value if tenant_overrides and spec.owner is not None else None
scope = _operator_scope(config, spec)
sink = _sink_key(spec.endpoint, parse_headers(spec.headers)) if _exports_to_the_wire(spec) else None
provider.add_span_processor(
_OverriddenBackendFilter(processor, owner, scope, sink)
if owner is not None or scope != "full"
_OverriddenBackendFilter(
processor,
owner,
scope,
sink,
config.capture_span_content,
tenant_overrides,
)
if owner is not None
or scope != "full"
or sink is not None
or (tenant_overrides and not config.capture_span_content)
else processor
)
return provider
@ -1340,10 +1470,29 @@ def attach_tenant_fan_out(
TenantFanOutSpanProcessor(
operator_sinks=operator_sink_scopes(*configs),
excluded_db_systems=excluded_db_systems,
default_capture=_operator_capture_mode(configs),
default_capture_by_backend=MappingProxyType(dict(_owned_capture_modes(configs))),
)
)
def _operator_capture_mode(configs: Sequence[OpenTelemetryV2Config]) -> str:
"""The mode of the operator's generic ``otel`` callback, so ``callback_settings.otel`` counts as the global default"""
return next(
(config.capture_message_content for config in configs if any(spec.owner is None for spec in config.exporters)),
capture_message_content_from_env(),
)
def _owned_capture_modes(
configs: Sequence[OpenTelemetryV2Config],
) -> Iterator[tuple[str, str]]:
for config in configs:
for spec in config.exporters:
if spec.owner is not None:
yield spec.owner.value, config.capture_message_content
def deliverable_destinations(
destinations: Iterable["OtelDestination"],
provider: trace.TracerProvider | None = None,

View file

@ -282,7 +282,8 @@ class TenantTracerCache:
self._providers.move_to_end(cache_key)
return cached
built: Final = build_tracer_provider(
self._routed_config(credential_headers, project_headers, endpoint, service_name)
self._routed_config(credential_headers, project_headers, endpoint, service_name),
strip_message_content=not self._config.capture_span_content,
)
self._providers[cache_key] = built
return built

View file

@ -187,6 +187,7 @@ def destination_for(
callback_name: str,
params: StandardCallbackDynamicParams,
service_name: str | None = None,
capture_message_content: str | None = None,
) -> OtelDestination | None:
"""The destination ``params`` names for ``callback_name``, or ``None``.
@ -217,6 +218,7 @@ def destination_for(
callback_name=callback_name,
protocol=protocol,
span_scope=_span_scope(callback_name, params),
capture_message_content=capture_message_content,
success_sampling_rate=success_sampling_rate,
error_sampling_rate=error_sampling_rate,
)

View file

@ -9,6 +9,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from litellm._logging import verbose_logger
from litellm.integrations.otel.model.config import (
CaptureMessageContent,
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
@ -66,7 +67,11 @@ def newrelic_preset(
# New Relic ingests the OTLP GenAI semantic conventions natively.
"mapper_names": ensure_mappers(base.mapper_names, "genai"),
**(
{"capture_message_content": ("span_only" if settings.record_content else "no_content")}
{
"capture_message_content": (
CaptureMessageContent.SPAN_ONLY if settings.record_content else CaptureMessageContent.NO_CONTENT
)
}
if settings.record_content is not None
else {}
),

View file

@ -6,6 +6,7 @@ from typing import Any, Final
from litellm.types.utils import (
ARIZE_OTLP_PROTOCOLS,
CAPTURE_MESSAGE_CONTENT_VALUES,
OTEL_SPAN_SCOPES,
TRUSTED_CALLBACK_VARS_FIELD,
StandardCallbackDynamicParams,
@ -91,6 +92,13 @@ def validate_arize_otlp_protocol_value(value: str) -> None:
raise ValueError(f"Invalid arize_otlp_protocol {value!r}: must be one of {sorted(ARIZE_OTLP_PROTOCOLS)}")
def validate_capture_message_content_value(value: str) -> None:
if value not in CAPTURE_MESSAGE_CONTENT_VALUES:
raise ValueError(
f"Invalid capture_message_content {value!r}: must be one of {sorted(CAPTURE_MESSAGE_CONTENT_VALUES)}"
)
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
_supported_callback_params: Final[tuple[str, ...]] = (
"langfuse_public_key",

View file

@ -33,6 +33,7 @@ from litellm._uuid import uuid
from litellm.constants import DEFAULT_STAGGER_WINDOW_SECONDS, MCP_STDIO_ALLOWED_COMMANDS
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
validate_arize_otlp_protocol_value,
validate_capture_message_content_value,
validate_langfuse_environment_value,
validate_langfuse_span_scope_value,
validate_no_callback_env_reference,
@ -79,6 +80,7 @@ from litellm.types.router import AllowedModelRegion, RouterErrors, UpdateRouterC
from litellm.types.router_weights import validate_router_settings_dict
from litellm.types.secret_managers.main import KeyManagementSystem
from litellm.types.utils import (
CAPTURE_MESSAGE_CONTENT_VAR,
AzureSpillover,
CallTypes,
CostBreakdown,
@ -2202,7 +2204,7 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
@classmethod
def validate_callback_vars(cls, values):
callback_vars: Final = values.get("callback_vars", {})
valid_keys: Final = set(StandardCallbackDynamicParams.__annotations__.keys())
valid_keys: Final = {*StandardCallbackDynamicParams.__annotations__, CAPTURE_MESSAGE_CONTENT_VAR}
for key, value in callback_vars.items():
if key not in valid_keys:
raise ValueError(f"Invalid callback variable: {key}. Must be one of {valid_keys}")
@ -2214,6 +2216,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
validate_langfuse_span_scope_value(callback_vars[key])
if key == "arize_otlp_protocol":
validate_arize_otlp_protocol_value(callback_vars[key])
if key == CAPTURE_MESSAGE_CONTENT_VAR:
validate_capture_message_content_value(callback_vars[key])
return values

View file

@ -10,6 +10,8 @@ from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Final
from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR
_NEWRELIC_CALLBACK: Final = "newrelic"
_NEWRELIC_VAR_PREFIX: Final = "newrelic_"
_LANGFUSE_OTEL_CALLBACK: Final = "langfuse_otel"
@ -36,6 +38,9 @@ def callback_config_error(
)
if langfuse_error is not None:
return langfuse_error
capture_error: Final = _capture_message_content_error(callback_name, callback_vars, callback_type)
if capture_error is not None:
return capture_error
if callback_name != _NEWRELIC_CALLBACK:
return None
return _newrelic_config_error(callback_vars)
@ -80,6 +85,32 @@ def _langfuse_span_scope_error(callback_name: str | None, callback_vars: Mapping
return None
def _capture_message_content_error(
callback_name: str | None, callback_vars: Mapping[str, str], callback_type: str | None
) -> str | None:
value: Final = callback_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)
if value is None:
return None
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.integrations.otel.presets.destinations import destination_capable_backends
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
validate_capture_message_content_value,
)
supported: Final = sorted(destination_capable_backends())
if callback_name not in supported:
return f"{CAPTURE_MESSAGE_CONTENT_VAR} applies to the OTel v2 callbacks {supported} only, not {callback_name!r}"
if not is_otel_v2_enabled():
return f"Per-destination {CAPTURE_MESSAGE_CONTENT_VAR} requires the proxy to run with LITELLM_OTEL_V2=true."
try:
validate_capture_message_content_value(value)
except ValueError as e:
return str(e)
if callback_type == "failure":
return f"{CAPTURE_MESSAGE_CONTENT_VAR} needs callback_type 'success' or 'success_and_failure'"
return None
# Which credential family a dynamic variable belongs to. The families are the
# integrations that share one account: every langfuse_* variable configures the
# same Langfuse project whether it rides the classic callback or the OTel one,
@ -190,20 +221,41 @@ def conflicting_span_scope_error(
)
def conflicting_capture_error(
callback_name: str,
callback_vars: Mapping[str, str] | None,
stored_entries: Sequence[tuple[str | None, Mapping[str, str]]],
) -> str | None:
incoming: Final = None if callback_vars is None else callback_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)
if incoming is None:
return None
return next(
(
f"{CAPTURE_MESSAGE_CONTENT_VAR} is already set to {stored!r} by another {callback_name} entry. "
f"Every {callback_name} entry shares one value: remove that entry or send the same value."
for name, entry_vars in stored_entries
if name == callback_name and (stored := entry_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)) not in (None, incoming)
),
None,
)
def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str | None:
"""Validate every ``logging`` entry of a team/key metadata payload."""
if not metadata:
return None
entries: Final = metadata.get("logging")
if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)):
entries: Final = _object_sequence(metadata.get("logging"))
if entries is None:
return None
entry_vars: Final = tuple(_entry_callback_vars(entry) for entry in entries)
named_vars: Final = tuple(zip((_entry_callback_name(entry) for entry in entries), entry_vars))
return next(
(
error
for error in (
*(_logging_entry_error(entry) for entry in entries),
*(conflicting_span_scope_error(entry_vars[i], entry_vars[:i]) for i in range(len(entry_vars))),
*(_conflicting_entry_capture_error(named_vars[i], named_vars[:i]) for i in range(len(named_vars))),
)
if error is not None
),
@ -211,6 +263,31 @@ def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str
)
def stored_capture_entries(logging_entries: object) -> tuple[tuple[str | None, Mapping[str, str]], ...]:
"""The callback name and vars of every stored ``logging`` entry, for ``conflicting_capture_error``."""
entries: Final = _object_sequence(logging_entries) or ()
return tuple((_entry_callback_name(entry), _entry_callback_vars(entry)) for entry in entries)
def _object_sequence(value: object) -> Sequence[object] | None:
return value if isinstance(value, Sequence) and not isinstance(value, (str, bytes)) else None
def _entry_callback_name(entry: object) -> str | None:
match entry:
case {"callback_name": str() as callback_name}:
return callback_name
case _:
return None
def _conflicting_entry_capture_error(
entry: tuple[str | None, Mapping[str, str]], earlier: Sequence[tuple[str | None, Mapping[str, str]]]
) -> str | None:
callback_name, callback_vars = entry
return None if callback_name is None else conflicting_capture_error(callback_name, callback_vars, earlier)
def _entry_callback_vars(entry: object) -> Mapping[str, str]:
callback_vars: Final = entry.get("callback_vars") if isinstance(entry, Mapping) else None
if not isinstance(callback_vars, Mapping):

View file

@ -227,6 +227,7 @@ from litellm.types.litellm_params import AGENTIC_LOOP_KWARG_NAMES
from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS
from litellm.types.services import ServiceTypes
from litellm.types.utils import (
CAPTURE_MESSAGE_CONTENT_VAR,
CustomPricingLiteLLMParams,
LlmProviders,
ProviderSpecificHeader,
@ -975,6 +976,8 @@ def convert_key_logging_metadata_to_callback(
team_callback_settings_obj.callbacks.append(data.callback_name)
for var, value in data.callback_vars.items():
if var == CAPTURE_MESSAGE_CONTENT_VAR:
continue
# New Relic routing reads these from the trusted-vars overlay with no
# callback-name check, so scope them to the newrelic entry: a team that
# put newrelic_* under a different callback never asked for New Relic and
@ -1162,7 +1165,6 @@ def resolve_tenant_otel_destinations(
span still routes to the tenant's credentials the way it did then.
"""
from litellm.integrations.otel.model.config import is_otel_v2_enabled
from litellm.integrations.otel.presets.destinations import destination_for
if not is_otel_v2_enabled():
return ()
@ -1186,23 +1188,28 @@ def resolve_tenant_otel_destinations(
for name in dict.fromkeys(
callback.callback_name for callback in callbacks if callback.callback_type != "failure"
)
if (
destination := destination_for(
name,
_tenant_otel_params(
MappingProxyType(
{
var: value
for callback in callbacks
if callback.callback_name == name
for var, value in callback.callback_vars.items()
}
)
),
_tenant_service_name(user_api_key_dict),
if (destination := _tenant_destination(name, callbacks, _tenant_service_name(user_api_key_dict))) is not None
)
def _tenant_destination(
name: str, callbacks: Sequence[AddTeamCallback], service_name: str | None
) -> "OtelDestination | None":
from litellm.integrations.otel.model.config import parse_capture_message_content
from litellm.integrations.otel.presets.destinations import destination_for
callback_vars: Final = MappingProxyType(
dict(
itertools.chain.from_iterable(
callback.callback_vars.items() for callback in callbacks if callback.callback_name == name
)
)
is not None
)
return destination_for(
name,
_tenant_otel_params(callback_vars),
service_name,
parse_capture_message_content(callback_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)),
)

View file

@ -31,8 +31,10 @@ from litellm.proxy._types import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_config_validation import (
callback_config_error,
conflicting_capture_error,
conflicting_span_scope_error,
cross_entry_family_error,
stored_capture_entries,
)
from litellm.proxy.common_utils.callback_utils import ( # noqa: F401 # legacy module exports
_CALLBACK_VAR_ENCRYPTED_PREFIX, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
@ -288,6 +290,9 @@ async def add_team_callbacks(
- langfuse_host: The host for the Langfuse callback
- langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)
- langfuse_span_scope: For langfuse_otel, "full" (default) sends the whole request trace, "llm_only" sends only the model-call spans
- capture_message_content: For OTel v2 callbacks, "no_content" or "span_only".
An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting.
"span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out
- gcs_bucket_name: The name of the GCS bucket
- gcs_path_service_account: The path to the GCS service account
- langsmith_api_key: The API key for the Langsmith callback
@ -351,11 +356,19 @@ async def add_team_callbacks(
# Decrypted, because the checks compare the incoming values against
# the stored ones and the credentials are encrypted at rest.
decrypted_logging: Final = decrypt_callback_vars(team_metadata).get("logging")
stored_capture: Final = stored_capture_entries(decrypted_logging)
stored_entries: Final = decrypted_logging if isinstance(decrypted_logging, list) else ()
stored_entry_vars: Final = [entry.get("callback_vars") or {} for entry in stored_entries]
scope_error: Final = conflicting_span_scope_error(data.callback_vars, stored_entry_vars)
if scope_error is not None:
raise _callback_config_error(scope_error)
capture_error: Final = conflicting_capture_error(
data.callback_name,
data.callback_vars,
stored_capture,
)
if capture_error is not None:
raise _callback_config_error(capture_error)
# One entry has to own a credential family end to end. The entries are
# flattened into one dict before a request reads them, so an entry
# naming only a destination would pair with a key written on another

View file

@ -3726,6 +3726,28 @@ ArizeOtlpProtocol = Literal["grpc", "http/protobuf"]
ARIZE_OTLP_PROTOCOLS: Final[frozenset[str]] = frozenset(get_args(ArizeOtlpProtocol))
CAPTURE_MESSAGE_CONTENT_VAR: Final = "capture_message_content"
class CaptureMessageContent(str):
NO_CONTENT = "no_content"
SPAN_ONLY = "span_only"
EVENT_ONLY = "event_only"
SPAN_AND_EVENT = "span_and_event"
# The values a team or key destination may set. OTel v2 writes content only to span attributes,
# so the event modes stay global-only settings
CAPTURE_MESSAGE_CONTENT_VALUES: Final[frozenset[str]] = frozenset(
{CaptureMessageContent.NO_CONTENT, CaptureMessageContent.SPAN_ONLY}
)
def captures_span_content(mode: str | None) -> bool:
"""Whether a capture mode puts prompt and response content on spans"""
return mode in (CaptureMessageContent.SPAN_ONLY, CaptureMessageContent.SPAN_AND_EVENT)
class StandardCallbackDynamicParams(TypedDict, total=False):
# Langfuse dynamic params
langfuse_public_key: str | None

View file

@ -186,6 +186,7 @@ class _Handler(BaseHTTPRequestHandler):
"count": len(recorded),
"host": self.headers.get("host", ""),
"headers": dict(self.headers),
"span_ids": [span["span_id"] for span in recorded],
}
)
self._send_json({"recorded": len(recorded)}, status=self.state.status)
@ -466,9 +467,7 @@ def _await_sink(url: str) -> None:
def owned_grpc_sink(directory: Path) -> Iterator[GrpcSink]:
http_port: Final = _free_port()
grpc_port: Final = _free_port()
with _spawn_sink(
directory, "otlp-grpc-sink.log", ["--port", str(http_port), "--grpc-port", str(grpc_port)]
):
with _spawn_sink(directory, "otlp-grpc-sink.log", ["--port", str(http_port), "--grpc-port", str(grpc_port)]):
control_url: Final = f"http://127.0.0.1:{http_port}"
_await_sink(control_url)
yield GrpcSink(url=f"http://127.0.0.1:{grpc_port}", control_url=control_url)

View file

@ -0,0 +1,720 @@
import json
import re
import uuid
from collections.abc import Generator, Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal
from urllib.parse import urlparse
import httpx
import pytest
import yaml
from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment, object_value
from integration._support.otlp_sink import (
ConnectSink,
Span,
SpanSinks,
configure_sink,
owned_connect_sink,
recorded_requests,
recorded_spans,
)
from integration._support.process import OwnedProxy, owned_proxy_process
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
MARKER: Final = re.compile(rb"cmc-(?:tool-|fail-)?[0-9a-f]{32}")
JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
SERVER: Final = 2
CALL_ID: Final = "litellm.call_id"
OPERATION: Final = "gen_ai.operation.name"
INPUT_TOKENS: Final = "gen_ai.usage.input_tokens"
Endpoint = Literal["chat", "responses", "messages"]
Capture = Literal["no_content", "span_only"]
def _marker(kind: str = "") -> str:
return f"cmc-{kind}{uuid.uuid4().hex}"
def _usage() -> dict[str, JsonValue]:
return {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
def _chat_reply(marker: str, stream: bool) -> Reply:
identity: Final = f"chatcmpl-{uuid.uuid4().hex}"
if marker.startswith("cmc-tool-"):
call: Final[dict[str, JsonValue]] = {
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": json.dumps({"query": marker})},
}
message: Final[dict[str, JsonValue]] = {"role": "assistant", "content": None, "tool_calls": [call]}
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": message, "finish_reason": "tool_calls"}],
"usage": _usage(),
}
).encode()
)
if not stream:
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": marker}, "finish_reason": "stop"}
],
"usage": _usage(),
}
).encode()
)
chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini"}
deltas: Final[tuple[dict[str, JsonValue], ...]] = (
{**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": marker}}]},
{**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
{**chunk, "choices": [], "usage": _usage()},
)
return Reply(
content_type="text/event-stream",
chunks=(*(b"data: " + json.dumps(delta).encode() + b"\n\n" for delta in deltas), b"data: [DONE]\n\n"),
)
def _responses_reply(marker: str, stream: bool) -> Reply:
identity: Final = uuid.uuid4().hex
response: Final[dict[str, JsonValue]] = {
"id": f"resp_{identity}",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-4o-mini",
"output": [
{
"id": f"msg_{identity}",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": marker, "annotations": []}],
}
],
"usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9},
}
if not stream:
return Reply(body=json.dumps(response).encode())
events: Final[tuple[dict[str, JsonValue], ...]] = (
{
"type": "response.created",
"sequence_number": 0,
"response": {**response, "status": "in_progress", "output": []},
},
{
"type": "response.output_text.delta",
"sequence_number": 1,
"item_id": f"msg_{identity}",
"output_index": 0,
"content_index": 0,
"delta": marker,
},
{"type": "response.completed", "sequence_number": 2, "response": response},
)
return Reply(
content_type="text/event-stream",
chunks=tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events),
)
def _upstream(request: Request) -> Reply:
found: Final = MARKER.search(request.body)
if found is None:
return Reply(status=404, body=b'{"error":"no marker"}')
marker: Final = found.group(0).decode()
if marker.startswith("cmc-fail-"):
return Reply(status=500, body=b'{"error":{"message":"scripted upstream failure","type":"server_error"}}')
stream: Final = object_value(JSON.validate_json(request.body)).get("stream") is True
if request.target.endswith("/responses"):
return _responses_reply(marker, stream)
return _chat_reply(marker, stream)
def _body(model: str, endpoint: Endpoint, marker: str, stream: bool) -> tuple[str, dict[str, JsonValue]]:
if endpoint == "responses":
return "/v1/responses", {"model": model, "input": marker, "stream": stream}
messages: Final[list[JsonValue]] = [{"role": "user", "content": marker}]
if endpoint == "messages":
return "/v1/messages", {"model": model, "max_tokens": 16, "messages": messages, "stream": stream}
return "/v1/chat/completions", {"model": model, "messages": messages, "stream": stream}
@dataclass(frozen=True, slots=True)
class Sent:
marker: str
trace_id: str
call_id: str
status: int
text: str
@dataclass(frozen=True, slots=True)
class Cursors:
operator: int
tenant: int
arize: int
newrelic: int
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
owned: OwnedProxy
scenario: Scenario
model: str
upstream: Wire
sinks: SpanSinks
newrelic: ConnectSink
def cursors(self) -> Cursors:
self.upstream.drain()
return Cursors(
recorded_spans(self.sinks.operator)[0],
recorded_spans(self.sinks.tenant)[0],
recorded_spans(self.sinks.arize)[0],
recorded_spans(self.newrelic.control_url)[0],
)
def upstream_hits(self, marker: str) -> int:
return sum(1 for request in self.upstream.drain() if marker.encode() in request.body)
def langfuse(self, name: str, host: str | None = None) -> dict[str, str]:
return {
"langfuse_public_key": f"pk-lf-{name}",
"langfuse_secret_key": f"sk-lf-{name}",
"langfuse_host": host or self.sinks.tenant,
}
def attach(self, team: str, callback: str, variables: Mapping[str, str], kind: str | None = None) -> httpx.Response:
body: Final[dict[str, JsonValue]] = {"callback_name": callback, "callback_vars": dict(variables)}
return self.proxy.request(
"POST", f"/team/{team}/callback", body if kind is None else {**body, "callback_type": kind}
)
def team_key(
self,
capture: Capture | None,
name: str,
*,
callback: str = "langfuse_otel",
variables: Mapping[str, str] | None = None,
kind: str | None = None,
) -> tuple[str, str]:
team: Final = self.scenario.team()
base: Final = dict(variables) if variables is not None else self.langfuse(name)
attached: Final = self.attach(
team, callback, base if capture is None else {**base, "capture_message_content": capture}, kind
)
assert attached.status_code == 200, attached.text
return team, self.scenario.key(team_id=team)
def send(
self,
key: str,
endpoint: Endpoint = "chat",
stream: bool = False,
*,
marker: str | None = None,
extra: Mapping[str, JsonValue] | None = None,
) -> Sent:
tagged: Final = marker or _marker()
path, body = _body(self.model, endpoint, tagged, stream)
trace_id: Final = uuid.uuid4().hex
call_id: Final = str(uuid.uuid4())
headers: Final = {
"Authorization": f"Bearer {key}",
"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01",
"x-litellm-call-id": call_id,
}
with self.proxy.client.stream("POST", path, json={**body, **(extra or {})}, headers=headers) as response:
text: Final = response.read().decode()
return Sent(tagged, trace_id, call_id, response.status_code, text)
def _served(sent: Sent) -> Sent:
assert sent.status == 200, sent.text
assert sent.marker in sent.text, sent.text
return sent
Credential = tuple[str, str]
def _sent_with(url: str, credential: Credential) -> frozenset[str]:
"""Span ids ``url`` received in export requests whose ``credential`` header matched."""
header, value = credential
return frozenset(
span_id
for request in recorded_requests(url)
if {name.lower(): seen for name, seen in object_value(request.get("headers") or {}).items()}.get(header)
== value
for span_id in request.get("span_ids") or ()
if isinstance(span_id, str)
)
def _of_request(url: str, since: int, sent: Sent, credential: Credential | None = None) -> tuple[Span, ...]:
"""Spans of ``sent``'s trace at ``url``, narrowed to one account's export requests when ``credential`` is set."""
spans: Final = recorded_spans(url, since)[1]
traces: Final = {sent.trace_id} | {
span["trace_id"] for span in spans if span["attributes"].get(CALL_ID) == sent.call_id
}
owned: Final = None if credential is None else _sent_with(url, credential)
return tuple(span for span in spans if span["trace_id"] in traces and (owned is None or span["span_id"] in owned))
def _carries(span: Span, marker: str) -> bool:
return marker in json.dumps(span["attributes"])
def _model_spans(spans: tuple[Span, ...]) -> tuple[Span, ...]:
return tuple(span for span in spans if OPERATION in span["attributes"])
def _names(spans: tuple[Span, ...]) -> list[str]:
return sorted(span["name"] for span in spans)
def _with_content(url: str, since: int, sent: Sent) -> tuple[Span, ...]:
"""The copy of ``sent`` at ``url`` once its model span arrived and some span carries the marker."""
return eventually(
lambda: _of_request(url, since, sent),
lambda spans: bool(_model_spans(spans)) and any(_carries(span, sent.marker) for span in spans),
seconds=40,
)
def _arrived(
url: str, since: int, sent: Sent, expected: frozenset[str], credential: Credential | None = None
) -> tuple[Span, ...]:
"""The copy of ``sent`` at ``url`` once every span name in ``expected`` and a usage-bearing model span landed."""
spans: Final = eventually(
lambda: _of_request(url, since, sent, credential),
lambda found: (
expected <= {span["name"] for span in found}
and any(INPUT_TOKENS in span["attributes"] for span in _model_spans(found))
),
seconds=40,
)
assert all(
span["attributes"].get(INPUT_TOKENS) == 7 for span in _model_spans(spans) if INPUT_TOKENS in span["attributes"]
)
return spans
def _assert_redacted_twin(
rig: Rig, sent: Sent, cursors: Cursors, sink: str, since: int, credential: Credential | None = None
) -> tuple[Span, ...]:
operator: Final = _with_content(rig.sinks.operator, cursors.operator, sent)
carried: Final = frozenset(span["name"] for span in operator if _carries(span, sent.marker))
tenant: Final = _arrived(sink, since, sent, carried, credential)
leaked: Final = [span["name"] for span in tenant if _carries(span, sent.marker)]
assert leaked == [], f"{leaked} carried content; tenant saw {_names(tenant)}"
model: Final = _model_spans(tenant)[0]["attributes"]
assert "gen_ai.input.messages" not in model and "gen_ai.output.messages" not in model, sorted(model)
assert model.get("gen_ai.request.model"), sorted(model)
return tenant
def _assert_content_twin(rig: Rig, sent: Sent, cursors: Cursors) -> None:
operator: Final = _with_content(rig.sinks.operator, cursors.operator, sent)
carried: Final = frozenset(span["name"] for span in operator if _carries(span, sent.marker))
tenant: Final = _arrived(rig.sinks.tenant, cursors.tenant, sent, carried)
kept: Final = frozenset(span["name"] for span in tenant if _carries(span, sent.marker))
assert carried <= kept, f"content lost on {sorted(carried - kept)}"
@pytest.mark.parametrize(
("endpoint", "stream"),
[("chat", False), ("chat", True), ("responses", False), ("messages", True)],
ids=["chat-unary", "chat-stream", "responses-unary", "messages-stream"],
)
def test_no_content_team_keeps_its_trace_and_metadata_without_content(
rig: Rig, endpoint: Endpoint, stream: bool
) -> None:
_, key = rig.team_key("no_content", "a")
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key, endpoint, stream))
assert rig.upstream_hits(sent.marker) == 1
_assert_redacted_twin(rig, sent, cursors, rig.sinks.tenant, cursors.tenant)
@pytest.mark.parametrize("capture", ["span_only", None], ids=["span_only", "omitted"])
def test_span_only_and_omitted_teams_keep_the_globally_captured_content(rig: Rig, capture: Capture | None) -> None:
_, key = rig.team_key(capture, "b")
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key))
_assert_content_twin(rig, sent, cursors)
def test_tool_call_arguments_never_reach_a_no_content_team(rig: Rig) -> None:
_, key = rig.team_key("no_content", "a")
cursors: Final = rig.cursors()
tool: Final[dict[str, JsonValue]] = {
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}},
}
sent: Final = rig.send(key, marker=_marker("tool-"), extra={"tools": [tool]})
assert sent.status == 200 and sent.marker in sent.text, sent.text
operator: Final = _with_content(rig.sinks.operator, cursors.operator, sent)
assert any(sent.marker in str(span["attributes"].get("gen_ai.output.messages", "")) for span in operator), (
"operator copy lacks the tool-call arguments"
)
tenant: Final = _assert_redacted_twin(rig, sent, cursors, rig.sinks.tenant, cursors.tenant)
assert "lookup" in json.dumps([span["attributes"] for span in tenant]), "tool definitions are metadata and stay"
def test_cache_hit_twin_of_a_no_content_team_is_redacted(rig: Rig) -> None:
_, key = rig.team_key("no_content", "a")
marker: Final = _marker()
_served(rig.send(key, marker=marker))
assert rig.upstream_hits(marker) == 1
cursors: Final = rig.cursors()
hit: Final = eventually(
lambda: _served(rig.send(key, marker=marker)), lambda _: rig.upstream_hits(marker) == 0, seconds=20
)
operator: Final = eventually(
lambda: _of_request(rig.sinks.operator, cursors.operator, hit),
lambda spans: any(span["kind"] == SERVER for span in spans),
seconds=40,
)
expected: Final = frozenset(span["name"] for span in operator if span["kind"] == SERVER)
tenant: Final = eventually(
lambda: _of_request(rig.sinks.tenant, cursors.tenant, hit),
lambda spans: expected <= {span["name"] for span in spans},
seconds=40,
)
assert [span["name"] for span in tenant if _carries(span, marker)] == [], _names(tenant)
def test_a_failure_only_entry_rejects_the_setting(rig: Rig) -> None:
team: Final = rig.scenario.team()
response: Final = rig.attach(
team, "langfuse_otel", {**rig.langfuse("fail"), "capture_message_content": "no_content"}, "failure"
)
assert response.status_code == 400, response.text
assert "success_and_failure" in response.text, response.text
def test_key_level_logging_with_no_content_is_redacted(rig: Rig) -> None:
logging: Final[list[JsonValue]] = [
{
"callback_name": "langfuse_otel",
"callback_type": "success",
"callback_vars": {**rig.langfuse("key"), "capture_message_content": "no_content"},
}
]
key: Final = rig.scenario.key(metadata={"logging": logging})
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key))
_assert_redacted_twin(rig, sent, cursors, rig.sinks.tenant, cursors.tenant)
def test_same_account_destination_redacts_the_shared_copy_only_for_that_team(rig: Rig) -> None:
_, restricted = rig.team_key("no_content", "operator", variables=rig.langfuse("operator", host=rig.sinks.operator))
_, other = rig.team_key("span_only", "b")
cursors: Final = rig.cursors()
shared: Final = _served(rig.send(restricted))
assert rig.upstream_hits(shared.marker) == 1
copy: Final = eventually(
lambda: _of_request(rig.sinks.operator, cursors.operator, shared),
lambda spans: any(INPUT_TOKENS in span["attributes"] for span in _model_spans(spans)),
seconds=40,
)
assert [span["name"] for span in copy if _carries(span, shared.marker)] == [], _names(copy)
assert len([span for span in copy if span["kind"] == SERVER and span["name"].startswith("POST ")]) == 1, _names(
copy
)
_assert_content_twin(rig, _served(rig.send(other)), cursors)
def test_arize_destination_drops_openinference_content(rig: Rig) -> None:
_, key = rig.team_key(
"no_content", "arize", callback="arize", variables={"arize_space_key": "space-a", "arize_api_key": "arize-a"}
)
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key))
operator: Final = _with_content(rig.sinks.operator, cursors.operator, sent)
assert any(sent.marker in str(span["attributes"].get("input.value", "")) for span in operator), (
"operator copy has no OpenInference content to strip"
)
tenant: Final = _assert_redacted_twin(
rig, sent, cursors, rig.sinks.arize, cursors.arize, ("arize-space-id", "space-a")
)
model: Final = _model_spans(tenant)[0]["attributes"]
assert not any(name.startswith(("llm.input_messages.", "llm.output_messages.")) for name in model), sorted(model)
assert "input.value" not in model and "output.value" not in model, sorted(model)
assert model.get("llm.model_name"), sorted(model)
def test_weave_destination_drops_its_content(rig: Rig) -> None:
_, key = rig.team_key(
"no_content",
"weave",
callback="weave_otel",
variables={"wandb_api_key": "wandb-a", "weave_project_id": "team/a"},
)
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key))
operator: Final = eventually(
lambda: _of_request(rig.sinks.arize, cursors.arize, sent, ("project_id", "operator/weave")),
lambda spans: any(sent.marker in str(span["attributes"].get("weave.output", "")) for span in spans),
seconds=40,
)
assert operator, "operator weave copy has no weave.output to strip"
tenant: Final = _assert_redacted_twin(rig, sent, cursors, rig.sinks.arize, cursors.arize, ("project_id", "team/a"))
assert not any("weave.output" in span["attributes"] for span in tenant), _names(tenant)
def test_newrelic_destination_drops_genai_content(rig: Rig) -> None:
_, key = rig.team_key("no_content", "newrelic", callback="newrelic", variables={"newrelic_api_key": "nr-team-a"})
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key))
tenant: Final = _assert_redacted_twin(
rig, sent, cursors, rig.newrelic.control_url, cursors.newrelic, ("api-key", "nr-team-a")
)
hosts: Final = {
object_value(request.get("headers") or {}).get("Host")
for request in recorded_requests(rig.newrelic.control_url)
if object_value(request.get("headers") or {}).get("api-key") == "nr-team-a"
}
assert hosts == {"otlp.nr-data.net"}, hosts
assert tenant
def test_span_only_team_lifts_global_no_content_only_for_its_destination(dark_rig: Rig) -> None:
"""The sibling Arize entry gets a fan-out destination and the SigNoz entry rides the routed tracer."""
team, key = dark_rig.team_key("span_only", "b")
siblings: Final = (
dark_rig.attach(team, "arize", {"arize_space_key": "space-sibling", "arize_api_key": "arize-sibling"}),
dark_rig.attach(
team,
"signoz",
{"signoz_ingestion_endpoint": dark_rig.sinks.arize + "/v1/traces", "signoz_ingestion_key": "signoz-team"},
),
)
assert [response.status_code for response in siblings] == [200, 200], [response.text for response in siblings]
cursors: Final = dark_rig.cursors()
sent: Final = _served(dark_rig.send(key))
assert dark_rig.upstream_hits(sent.marker) == 1
operator: Final = _arrived(dark_rig.sinks.operator, cursors.operator, sent, frozenset())
tenant: Final = _with_content(dark_rig.sinks.tenant, cursors.tenant, sent)
sibling: Final = _arrived(
dark_rig.sinks.arize,
cursors.arize,
sent,
frozenset(),
("arize-space-id", "space-sibling"),
)
signoz: Final = _arrived(
dark_rig.sinks.arize,
cursors.arize,
sent,
frozenset(),
("signoz-ingestion-key", "signoz-team"),
)
assert any(_carries(span, sent.marker) for span in tenant), _names(tenant)
assert not any(_carries(span, sent.marker) for span in operator), _names(operator)
assert not any(_carries(span, sent.marker) for span in sibling), _names(sibling)
assert not any(_carries(span, sent.marker) for span in signoz), _names(signoz)
def test_an_unsupported_value_fails_registration(rig: Rig) -> None:
team: Final = rig.scenario.team()
response: Final = rig.attach(team, "langfuse_otel", {**rig.langfuse("a"), "capture_message_content": "bogus"})
assert response.status_code == 422, response.text
assert "Invalid capture_message_content 'bogus'" in response.text, response.text
def test_classic_langfuse_rejects_the_setting(rig: Rig) -> None:
team: Final = rig.scenario.team()
response: Final = rig.attach(team, "langfuse", {**rig.langfuse("a"), "capture_message_content": "no_content"})
assert response.status_code == 400, response.text
assert "capture_message_content" in response.text, response.text
def test_a_second_entry_for_the_same_backend_cannot_disagree(rig: Rig) -> None:
team, _ = rig.team_key("no_content", "a")
response: Final = rig.attach(team, "langfuse_otel", {"capture_message_content": "span_only"}, "success_and_failure")
assert response.status_code == 400, response.text
assert "already set to 'no_content' by another langfuse_otel entry" in response.text, response.text
def test_replacing_the_registration_switches_the_team_to_content(rig: Rig) -> None:
team, key = rig.team_key("no_content", "a")
cursors: Final = rig.cursors()
_assert_redacted_twin(rig, _served(rig.send(key)), cursors, rig.sinks.tenant, cursors.tenant)
removed: Final = rig.proxy.request("DELETE", f"/team/{team}/callback/langfuse_otel")
assert removed.status_code == 200, removed.text
replaced: Final = rig.attach(team, "langfuse_otel", {**rig.langfuse("a"), "capture_message_content": "span_only"})
assert replaced.status_code == 200, replaced.text
def attempt() -> bool:
since: Final = rig.cursors()
sent: Final = _served(rig.send(key))
operator: Final = _with_content(rig.sinks.operator, since.operator, sent)
carried: Final = frozenset(span["name"] for span in operator if _carries(span, sent.marker))
tenant: Final = _arrived(rig.sinks.tenant, since.tenant, sent, carried)
return any(_carries(span, sent.marker) for span in tenant)
assert eventually(attempt, bool, seconds=60)
def test_request_metadata_cannot_lift_the_team_restriction(rig: Rig) -> None:
_, key = rig.team_key("no_content", "a")
cursors: Final = rig.cursors()
sent: Final = _served(rig.send(key, extra={"metadata": {"capture_message_content": "span_only"}}))
assert rig.upstream_hits(sent.marker) == 1
_assert_redacted_twin(rig, sent, cursors, rig.sinks.tenant, cursors.tenant)
def test_stalled_tenant_during_a_mixed_burst_keeps_serving_and_each_teams_policy(rig: Rig) -> None:
_, restricted = rig.team_key("no_content", "a")
_, open_key = rig.team_key("span_only", "b")
shapes: Final[tuple[tuple[Endpoint, bool], ...]] = (
("chat", False),
("chat", True),
("responses", False),
("messages", True),
)
plan: Final = [(restricted if index % 2 else open_key, *shapes[index % len(shapes)]) for index in range(30)]
cursors: Final = rig.cursors()
configure_sink(rig.sinks.tenant, paused=True)
try:
with ThreadPoolExecutor(max_workers=10) as pool:
sent: Final = list(pool.map(lambda job: rig.send(job[0], job[1], job[2]), plan))
finally:
configure_sink(rig.sinks.tenant, paused=False)
assert [result.status for result in sent] == [200] * 30, [result.text for result in sent if result.status != 200]
for (key, _, _), result in zip(plan, sent, strict=True):
roots = [
span
for span in _with_content(rig.sinks.operator, cursors.operator, result)
if span["kind"] == SERVER and span["name"].startswith("POST ")
]
assert len(roots) == 1, roots
if key == restricted:
_assert_redacted_twin(rig, result, cursors, rig.sinks.tenant, cursors.tenant)
else:
_assert_content_twin(rig, result, cursors)
def _write_config(directory: Path, sinks: SpanSinks, name: str, callbacks: tuple[str, ...]) -> Path:
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
config["litellm_settings"] = {
**config.get("litellm_settings", {}),
"callbacks": list(callbacks),
"otel_tenant_destination_mode": "additive",
"provider_url_destination_allowed_hosts": [
urlparse(sinks.tenant).netloc,
urlparse(sinks.operator).netloc,
urlparse(sinks.arize).netloc,
],
}
config["general_settings"] = {**config.get("general_settings", {}), "user_api_key_cache_ttl": 2}
path: Final = directory / f"{name}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@contextmanager
def _started(
provider: Wire,
sinks: SpanSinks,
newrelic: ConnectSink,
directory: Path,
capture: str | None,
callbacks: tuple[str, ...] = ("langfuse_otel",),
) -> Generator[Rig]:
environment: Final = {
"LITELLM_OTEL_V2": "1",
"OTEL_BSP_SCHEDULE_DELAY": "300",
"MAPPER_NAMES": "genai,openinference",
"LANGFUSE_HOST": sinks.operator,
"LANGFUSE_PUBLIC_KEY": "pk-lf-operator",
"LANGFUSE_SECRET_KEY": "sk-lf-operator",
"ARIZE_SPACE_KEY": "space-operator",
"ARIZE_API_KEY": "arize-operator",
"ARIZE_HTTP_ENDPOINT": sinks.arize + "/v1/traces",
"WANDB_API_KEY": "wandb-operator",
"WANDB_PROJECT_ID": "operator/weave",
"WANDB_HOST": sinks.arize,
"SIGNOZ_INGESTION_ENDPOINT": sinks.arize + "/v1/traces",
"SIGNOZ_INGESTION_KEY": "signoz-operator",
"HTTPS_PROXY": newrelic.proxy_url,
"NO_PROXY": "127.0.0.1,localhost",
"REQUESTS_CA_BUNDLE": newrelic.ca_pem,
**({} if capture is None else {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": capture}),
}
with (
gateway_from_environment() as gateway,
owned_proxy_process(
gateway,
directory,
environment,
config=_write_config(directory, sinks, "capture", callbacks),
remove_environment=(
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT",
"LITELLM_OTEL_TENANT_DESTINATION_MODE",
),
workers=2,
) as owned,
owned.gateway.scenario() as scenario,
):
yield Rig(
owned.gateway, owned, scenario, scenario.model(api_base=provider.url + "/v1"), provider, sinks, newrelic
)
@pytest.fixture(scope="module")
def provider() -> Iterator[Wire]:
with wire_server(_upstream) as wire:
yield wire
@pytest.fixture(scope="module")
def newrelic_sink(tmp_path_factory: pytest.TempPathFactory) -> Iterator[ConnectSink]:
with owned_connect_sink(tmp_path_factory.mktemp("newrelic-sink")) as sink:
yield sink
@pytest.fixture(scope="module")
def rig(
provider: Wire, audit_sinks: SpanSinks, newrelic_sink: ConnectSink, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[Rig]:
directory: Final = tmp_path_factory.mktemp("capture-span-only")
with _started(provider, audit_sinks, newrelic_sink, directory, "span_only") as started:
yield started
@pytest.fixture(scope="module")
def dark_rig(
provider: Wire, audit_sinks: SpanSinks, newrelic_sink: ConnectSink, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[Rig]:
with _started(
provider,
audit_sinks,
newrelic_sink,
tmp_path_factory.mktemp("capture-no-content"),
None,
("langfuse_otel", "signoz"),
) as started:
yield started

View file

@ -16,6 +16,7 @@ import litellm # noqa: E402
from litellm.caching.dual_cache import DualCache # noqa: E402
from litellm.integrations.otel.logger import OpenTelemetryV2, build_otel_v2_logger # noqa: E402
from litellm.integrations.otel.model.config import OpenTelemetryV2Config, is_otel_v2_enabled # noqa: E402
from litellm.integrations.otel.model.destination import OtelDestination # noqa: E402
from litellm.integrations.otel.model.spans import LITELLM_PROXY_REQUEST_SPAN_NAME, SpanRole # noqa: E402
from litellm.integrations.otel.plumbing import context as otel_context # noqa: E402
from litellm.integrations.otel.plumbing import providers # noqa: E402
@ -155,6 +156,25 @@ def test_chat_request_stamps_root_observation_input_and_output():
assert [(turn["role"], turn["content"]) for turn in output] == [("assistant", "pong")]
def test_content_on_destination_stamps_langfuse_root_io_when_global_capture_is_off() -> None:
logger, exporter = _logger(capture="no_content")
destination: Final = OtelDestination(
endpoint="https://team.example.com/api/public/otel",
callback_name="langfuse_otel",
capture_message_content="span_only",
)
token: Final = otel_context.set_request_destinations((destination,))
try:
response: Final = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
_run_request(logger, CHAT_DATA, "acompletion", response)
finally:
otel_context.reset_request_destinations(token)
attrs: Final = _root_attrs(exporter)
assert json.loads(attrs[INPUT_ATTR]) == [{"role": "user", "content": "ping"}]
assert json.loads(attrs[OUTPUT_ATTR]) == [{"role": "assistant", "content": "pong"}]
def test_responses_request_folds_instructions_into_input_and_stamps_output_items():
logger, exporter = _logger()
data = {"model": "gpt-5.4-mini", "instructions": "be terse", "input": "ping"}
@ -461,7 +481,9 @@ def test_a_request_without_trace_controls_stamps_none_of_them():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger, exporter, {"metadata": {"user_api_key_team_id": "t1", "tags": []}, "proxy_server_request": {"headers": {}}}
logger,
exporter,
{"metadata": {"user_api_key_team_id": "t1", "tags": []}, "proxy_server_request": {"headers": {}}},
)
assert set(TRACE_CONTROL_ATTRS).isdisjoint(root_attrs)
@ -472,7 +494,7 @@ def test_a_request_without_trace_controls_stamps_none_of_them():
("capture", "mappers"),
[("no_content", ("genai", "langfuse")), ("span_only", ("genai",))],
)
def test_factory_keeps_the_base_logger_unless_langfuse_content_capture_is_on(capture, mappers):
def test_langfuse_root_io_requires_global_or_destination_capture(capture: str, mappers: tuple[str, ...]) -> None:
logger, exporter = _logger(capture=capture, mappers=mappers)
_run_request(logger, CHAT_DATA, "acompletion", ModelResponse())
@ -484,13 +506,13 @@ def test_factory_keeps_the_base_logger_unless_langfuse_content_capture_is_on(cap
("capture", "mappers", "relays_streams"),
[
("span_only", ("genai", "langfuse"), True),
("no_content", ("genai", "langfuse"), False),
("no_content", ("genai", "langfuse"), True),
("span_only", ("genai",), False),
],
)
def test_only_langfuse_content_capture_takes_proxy_streams_off_the_fast_path(
monkeypatch, capture, mappers, relays_streams
):
def test_langfuse_mapper_relays_streams_for_per_request_content_capture(
monkeypatch: pytest.MonkeyPatch, capture: str, mappers: tuple[str, ...], relays_streams: bool
) -> None:
logger, _ = _logger(capture=capture, mappers=mappers)
monkeypatch.setattr(litellm, "callbacks", [logger])

View file

@ -5,6 +5,7 @@ import contextvars
import time
from base64 import b64encode
from collections.abc import Mapping
from dataclasses import replace
from datetime import datetime, timezone
from functools import reduce
from types import MappingProxyType
@ -13,13 +14,14 @@ from typing import Final
import pytest
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import Status, StatusCode
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.otel import GenAIOperation
from litellm.integrations.otel import logger as otel_logger
from litellm.integrations.otel.logger import (
OpenTelemetryV2,
@ -27,13 +29,24 @@ from litellm.integrations.otel.logger import (
fan_out_provider,
publish_global_otel_v2_provider,
)
from litellm.integrations.otel.mappers import resolve_mappers
from litellm.integrations.otel.model.config import (
CaptureMessageContent,
ExporterOwner,
ExporterSpec,
OpenTelemetryV2Config,
is_otel_v2_enabled,
)
from litellm.integrations.otel.model.destination import OtelDestination
from litellm.integrations.otel.model.payloads import (
LLMCallSpanData,
LLMRequestParams,
LLMUsage,
MCPToolCallSpanData,
RequestIdentity,
ServerInfo,
ToolDefinition,
)
from litellm.integrations.otel.plumbing import providers as otel_providers
from litellm.integrations.otel.plumbing.context import (
destination_backends,
@ -44,9 +57,11 @@ from litellm.integrations.otel.plumbing.providers import (
TenantFanOutSpanProcessor,
_OverriddenBackendFilter,
_sink_key,
attach_tenant_fan_out,
build_tracer_provider,
deliverable_destinations,
operator_sink_scopes,
register_exporter_factory,
)
from litellm.integrations.otel.plumbing.routing import TenantTracerCache, get_tracer
from litellm.integrations.otel.presets.arize import arize_preset
@ -55,7 +70,7 @@ from litellm.integrations.otel.presets.destinations import (
destination_for,
)
from litellm.integrations.otel.presets.langfuse import langfuse_preset
from litellm.proxy._types import AddTeamCallback, UserAPIKeyAuth
from litellm.proxy._types import AddTeamCallback, TeamCallbackMetadata, UserAPIKeyAuth
from litellm.proxy.litellm_pre_call_utils import (
convert_key_logging_metadata_to_callback,
resolve_tenant_otel_destinations,
@ -4228,6 +4243,569 @@ class TestTenantHostSsrfGuard:
assert sum("provider_url_destination_allowed_hosts" in record.message for record in caplog.records) == 1
_ALL_MAPPERS: Final = ("genai", "legacy", "openinference", "langfuse", "weave", "langtrace")
_SECRET: Final = "SECRET-MARKER"
_MODEL_CALL_WITH_CONTENT: Final = LLMCallSpanData(
operation=GenAIOperation.CHAT,
provider="openai",
request_model="gpt-4o",
response_model="gpt-4o-2024",
response_id="resp_1",
request_params=LLMRequestParams(temperature=0.5, max_tokens=128),
usage=LLMUsage(input_tokens=12, output_tokens=8, total_tokens=20),
finish_reasons=("tool_calls",),
error=None,
response_cost=0.001,
server=ServerInfo("api.openai.com", 443),
identity=RequestIdentity(call_id="c1", team_id="t1"),
tools=(ToolDefinition(name="lookup", description="Find a city", parameters_json='{"type":"object"}'),),
messages_in=(
{"role": "system", "content": f"system {_SECRET}"},
{"role": "user", "content": f"prompt {_SECRET}"},
),
choices_out=(
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": f"answer {_SECRET}",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": f'{{"city": "{_SECRET}"}}'},
}
],
},
},
),
)
_MODEL_CALL_WITHOUT_CONTENT: Final = replace(_MODEL_CALL_WITH_CONTENT, messages_in=(), choices_out=())
_TOOL_CALL_WITH_CONTENT: Final = MCPToolCallSpanData(
operation=GenAIOperation.EXECUTE_TOOL,
method="tools/call",
tool_name="lookup",
server_name="maps",
server_address="maps.local",
server_port=443,
session_id="s1",
arguments_json=f'{{"city": "{_SECRET}"}}',
result_json=f'{{"weather": "{_SECRET}"}}',
error=None,
response_cost=None,
identity=RequestIdentity(call_id="c2", team_id="t1"),
)
_TOOL_CALL_WITHOUT_CONTENT: Final = replace(_TOOL_CALL_WITH_CONTENT, arguments_json=None, result_json=None)
NO_CONTENT_DEST = OtelDestination(
endpoint="http://team-a.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic YQ=="}),
callback_name="langfuse_otel",
capture_message_content="no_content",
)
INHERITING_DEST = OtelDestination(
endpoint="http://team-b.local/v1",
headers=MappingProxyType({"api_key": "k", "arize-space-id": "s"}),
callback_name="arize",
)
def mapped(data: LLMCallSpanData | MCPToolCallSpanData) -> Mapping[str, object]:
"""What the configured mappers write on the span, the way the logger stamps it."""
return MappingProxyType(reduce(lambda acc, mapper: {**acc, **mapper.map(data)}, resolve_mappers(_ALL_MAPPERS), {}))
def recorded(attributes: Mapping[str, object]) -> dict[str, object]:
"""``attributes`` the way the SDK records them, sequences frozen into tuples."""
return {key: tuple(value) if isinstance(value, list) else value for key, value in attributes.items()}
def model_call_tree(provider: TracerProvider, attributes: Mapping[str, object]) -> None:
tracer: Final = get_tracer(provider, "litellm")
with tracer.start_as_current_span("POST /v1/chat/completions"):
with tracer.start_as_current_span("chat gpt-4o") as llm:
llm.set_attributes(attributes)
llm.add_event("gen_ai.content.first_chunk", {"gen_ai.response.model": "gpt-4o-2024"})
def by_name(exporter: InMemorySpanExporter) -> dict[str, ReadableSpan]:
return {span.name: span for span in exporter.get_finished_spans()}
def carries_content(span: ReadableSpan) -> bool:
return any(_SECRET in str(value) for value in span.attributes.values())
class TestCaptureMessageContent:
"""A destination's explicit capture mode overrides the global default."""
@staticmethod
def _fan_out(
operator: InMemorySpanExporter,
exporters: Mapping[str, InMemorySpanExporter],
default_capture: CaptureMessageContent = CaptureMessageContent.SPAN_ONLY,
) -> TracerProvider:
provider: Final = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(operator))
provider.add_span_processor(
TenantFanOutSpanProcessor(
processor_factory=lambda d: SimpleSpanProcessor(exporters[d.endpoint]),
default_capture=default_capture,
)
)
return provider
@staticmethod
def _run(
provider: TracerProvider, destinations: tuple[OtelDestination, ...], attributes: Mapping[str, object]
) -> None:
def run() -> None:
set_request_destinations(destinations)
model_call_tree(provider, attributes)
in_fresh_context(run)
@pytest.mark.parametrize(
("setting", "content_exported"),
[
(None, True),
("no_content", False),
("span_only", True),
("event_only", False),
("span_and_event", True),
],
)
def test_a_destination_setting_controls_its_copy_and_omission_uses_the_global_default(
self, setting: str | None, content_exported: bool
) -> None:
attributes: Final = mapped(_MODEL_CALL_WITH_CONTENT)
destination: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
capture_message_content=setting,
)
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
self._run(self._fan_out(operator, {destination.endpoint: tenant}), (destination,), attributes)
exported: Final = by_name(tenant)["chat gpt-4o"]
assert carries_content(exported) is content_exported
assert carries_content(by_name(operator)["chat gpt-4o"])
assert exported.attributes["gen_ai.request.model"] == "gpt-4o"
assert exported.attributes["gen_ai.usage.input_tokens"] == 12
assert exported.attributes["gen_ai.usage.output_tokens"] == 8
def test_a_span_only_team_lifts_global_no_content_only_for_its_destination(self) -> None:
operator_exporter, team_exporter, sibling_exporter = (
InMemorySpanExporter(),
InMemorySpanExporter(),
InMemorySpanExporter(),
)
kind: Final = "lit8244_global_no_content_capture"
register_exporter_factory(kind, lambda _spec: operator_exporter)
config: Final = OpenTelemetryV2Config(
capture_message_content=CaptureMessageContent.NO_CONTENT,
exporters=[ExporterSpec(kind=kind)],
)
provider: Final = build_tracer_provider(config, use_simple_processor=True, tenant_overrides=True)
team: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
capture_message_content="span_only",
)
sibling: Final = INHERITING_DEST
provider.add_span_processor(
TenantFanOutSpanProcessor(
processor_factory=lambda destination: SimpleSpanProcessor(
team_exporter if destination.endpoint == team.endpoint else sibling_exporter
),
default_capture=config.capture_message_content,
)
)
self._run(
provider,
(team, sibling),
mapped(_MODEL_CALL_WITH_CONTENT),
)
assert carries_content(by_name(team_exporter)["chat gpt-4o"])
assert not any(carries_content(span) for span in operator_exporter.get_finished_spans())
assert not any(carries_content(span) for span in sibling_exporter.get_finished_spans())
@pytest.mark.parametrize(
("with_content", "without_content"),
[
(_MODEL_CALL_WITH_CONTENT, _MODEL_CALL_WITHOUT_CONTENT),
(_TOOL_CALL_WITH_CONTENT, _TOOL_CALL_WITHOUT_CONTENT),
],
ids=["model_call", "mcp_tool_call"],
)
def test_no_content_removes_exactly_what_global_capture_adds_in_every_mapper_vocabulary(
self,
with_content: LLMCallSpanData | MCPToolCallSpanData,
without_content: LLMCallSpanData | MCPToolCallSpanData,
) -> None:
captured, uncaptured = mapped(with_content), mapped(without_content)
assert frozenset(captured) - frozenset(uncaptured), "the fixture must exercise captured content"
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
self._run(self._fan_out(operator, {NO_CONTENT_DEST.endpoint: tenant}), (NO_CONTENT_DEST,), captured)
exported: Final = by_name(tenant)["chat gpt-4o"]
assert dict(exported.attributes) == recorded(uncaptured), "only content goes, every other attribute stays"
assert not carries_content(exported)
def test_restricting_one_destination_leaves_the_original_span_and_the_other_destination_alone(self) -> None:
attributes: Final = mapped(_MODEL_CALL_WITH_CONTENT)
operator, restricted, inheriting = InMemorySpanExporter(), InMemorySpanExporter(), InMemorySpanExporter()
exporters: Final = {NO_CONTENT_DEST.endpoint: restricted, INHERITING_DEST.endpoint: inheriting}
self._run(self._fan_out(operator, exporters), (NO_CONTENT_DEST, INHERITING_DEST), attributes)
original: Final = by_name(operator)
kept: Final = by_name(inheriting)
stripped: Final = by_name(restricted)
assert dict(original["chat gpt-4o"].attributes) == recorded(attributes)
assert dict(kept["chat gpt-4o"].attributes) == recorded(attributes)
assert not carries_content(stripped["chat gpt-4o"])
for copy in (kept, stripped):
for name, span in copy.items():
assert span.context == original[name].context, "same trace id and span id"
assert span.parent == original[name].parent, "same place in the tree"
assert [e.name for e in copy["chat gpt-4o"].events] == [e.name for e in original["chat gpt-4o"].events]
def test_an_omitted_setting_and_span_only_export_the_same_span(self) -> None:
attributes: Final = mapped(_MODEL_CALL_WITH_CONTENT)
span_only: Final = INHERITING_DEST.model_copy(
update={"capture_message_content": CaptureMessageContent.SPAN_ONLY}
)
omitted_exporter, span_only_exporter = InMemorySpanExporter(), InMemorySpanExporter()
for destination, exporter in ((INHERITING_DEST, omitted_exporter), (span_only, span_only_exporter)):
self._run(
self._fan_out(InMemorySpanExporter(), {destination.endpoint: exporter}), (destination,), attributes
)
assert {n: dict(s.attributes) for n, s in by_name(omitted_exporter).items()} == {
n: dict(s.attributes) for n, s in by_name(span_only_exporter).items()
}
@pytest.mark.parametrize(
("globally_captured", "setting", "content_exported"),
[
(True, "no_content", False),
(True, None, True),
(False, "span_only", True),
(False, None, False),
],
)
def test_the_operators_exporter_on_the_same_account_honors_the_teams_setting_once(
self,
monkeypatch: pytest.MonkeyPatch,
globally_captured: bool,
setting: str | None,
content_exported: bool,
) -> None:
"""The fan-out skips a destination the operator already writes to, so the operator's
copy is the one that account receives and must not bypass the team's restriction."""
monkeypatch.setattr(litellm, "otel_tenant_destination_mode", "additive", raising=False)
shared: Final = InMemorySpanExporter()
provider: Final = TracerProvider()
provider.add_span_processor(
_OverriddenBackendFilter(
SimpleSpanProcessor(shared),
"langfuse_otel",
"full",
TestRoutingMode.OPERATOR_SINK,
global_captures=globally_captured,
)
)
provider.add_span_processor(
TenantFanOutSpanProcessor(
processor_factory=lambda _d: SimpleSpanProcessor(shared),
operator_sinks=MappingProxyType({TestRoutingMode.OPERATOR_SINK: "full"}),
)
)
destination: Final = OtelDestination(
endpoint=TestRoutingMode.SAME_ACCOUNT_ENDPOINT,
headers=MappingProxyType({"Authorization": "Basic op"}),
callback_name="langfuse_otel",
capture_message_content=setting,
)
self._run(provider, (destination,), mapped(_MODEL_CALL_WITH_CONTENT))
model_calls: Final = [s for s in shared.get_finished_spans() if s.name == "chat gpt-4o"]
assert len(model_calls) == 1, "the same account received the span twice"
assert carries_content(model_calls[0]) is content_exported
@pytest.mark.parametrize("mode", ["override", "additive"])
def test_an_operator_collector_on_the_teams_account_does_not_bypass_no_content(
self, monkeypatch: pytest.MonkeyPatch, mode: str
) -> None:
monkeypatch.setattr(litellm, "otel_tenant_destination_mode", mode, raising=False)
shared: Final = InMemorySpanExporter()
kind: Final = f"lit8244_collector_{mode}"
register_exporter_factory(kind, lambda _spec: shared)
config: Final = OpenTelemetryV2Config(
exporters=[
ExporterSpec(
kind=kind,
endpoint=TestRoutingMode.OPERATOR_SINK[0],
headers="authorization=Basic op",
)
]
)
provider: Final = build_tracer_provider(config, use_simple_processor=True, tenant_overrides=True)
provider.add_span_processor(
TenantFanOutSpanProcessor(
processor_factory=lambda _d: SimpleSpanProcessor(shared),
operator_sinks=operator_sink_scopes(config),
)
)
destination: Final = OtelDestination(
endpoint=TestRoutingMode.SAME_ACCOUNT_ENDPOINT,
headers=MappingProxyType({"Authorization": "Basic op"}),
callback_name="langfuse_otel",
capture_message_content="no_content",
)
self._run(provider, (destination,), mapped(_MODEL_CALL_WITH_CONTENT))
model_calls: Final = [s for s in shared.get_finished_spans() if s.name == "chat gpt-4o"]
assert model_calls, "the account must receive the model call before its content can be judged absent"
assert not any(carries_content(span) for span in model_calls)
@pytest.mark.parametrize(
("global_capture", "content_exported"),
[(CaptureMessageContent.NO_CONTENT, False), (CaptureMessageContent.SPAN_ONLY, True)],
)
def test_a_routed_provider_exports_content_only_when_the_global_setting_captures(
self, global_capture: str, content_exported: bool
) -> None:
"""A sibling destination's span_only makes the logger collect content, and a request
routed to this callback's team credentials must not carry it under a global no_content."""
exporters: dict[ExporterOwner | None, InMemorySpanExporter] = {}
kind: Final = f"lit8244_routed_{global_capture}"
register_exporter_factory(kind, lambda spec: exporters.setdefault(spec.owner, InMemorySpanExporter()))
config: Final = OpenTelemetryV2Config(
capture_message_content=global_capture,
exporters=[
ExporterSpec(
kind=kind,
endpoint="http://op.local",
owner=ExporterOwner.LANGFUSE_OTEL,
use_simple_processor=True,
),
ExporterSpec(kind=kind),
],
)
cache: Final = TenantTracerCache(config, "langfuse_otel", "litellm")
route: Final = cache.route_for(
get_tracer(TracerProvider(), "litellm"),
{"langfuse_public_key": "pk-team", "langfuse_secret_key": "sk-team"},
)
with route.tracer.start_as_current_span("chat gpt-4o") as llm:
llm.set_attributes(mapped(_MODEL_CALL_WITH_CONTENT))
cache.release(route.provider)
assert route.detached is True
for exporter in (exporters[ExporterOwner.LANGFUSE_OTEL], exporters[None]):
copy = by_name(exporter)["chat gpt-4o"]
assert carries_content(copy) is content_exported
assert copy.attributes["gen_ai.usage.input_tokens"] == 12
@pytest.mark.parametrize("first", ["newrelic", "langfuse_otel"])
def test_an_omitted_setting_follows_its_own_callbacks_mode_whatever_the_callback_order(
self, monkeypatch: pytest.MonkeyPatch, first: str
) -> None:
monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False)
tenant: Final = InMemorySpanExporter()
monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, "otlp_http", lambda _spec: tenant)
langfuse: Final = OpenTelemetryV2Config(
capture_message_content=CaptureMessageContent.SPAN_ONLY,
exporters=[ExporterSpec(kind="in_memory", owner=ExporterOwner.LANGFUSE_OTEL)],
)
newrelic: Final = OpenTelemetryV2Config(
capture_message_content=CaptureMessageContent.NO_CONTENT,
exporters=[ExporterSpec(kind="in_memory", owner=ExporterOwner.NEWRELIC)],
)
provider: Final = TracerProvider()
attach_tenant_fan_out(provider, *((newrelic, langfuse) if first == "newrelic" else (langfuse, newrelic)))
destination: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
)
self._run(provider, (destination,), mapped(_MODEL_CALL_WITH_CONTENT))
provider.force_flush()
assert carries_content(by_name(tenant)["chat gpt-4o"])
def test_an_omitted_setting_follows_the_capture_mode_set_in_callback_settings_otel(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False)
tenant: Final = InMemorySpanExporter()
monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, "otlp_http", lambda _spec: tenant)
operator: Final = OpenTelemetryV2Config(**{"capture_message_content": "span_only", "exporter": "in_memory"})
provider: Final = TracerProvider()
attach_tenant_fan_out(provider, operator)
destination: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
)
self._run(provider, (destination,), mapped(_MODEL_CALL_WITH_CONTENT))
provider.force_flush()
assert carries_content(by_name(tenant)["chat gpt-4o"]), "the YAML value is the global default, not only the env"
def test_a_published_presets_capture_mode_is_the_default_for_an_unowned_destination(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A destination for a backend the proxy does not export to itself falls back to the published logger's mode,
here New Relic with record_content on, so it keeps the content."""
monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False)
tenant: Final = InMemorySpanExporter()
monkeypatch.setitem(otel_providers._EXPORTER_FACTORIES, "otlp_http", lambda _spec: tenant)
newrelic: Final = OpenTelemetryV2Config(
capture_message_content=CaptureMessageContent.SPAN_ONLY,
exporters=[
ExporterSpec(kind="console"),
ExporterSpec(kind="in_memory", owner=ExporterOwner.NEWRELIC),
],
)
provider: Final = TracerProvider()
attach_tenant_fan_out(provider, newrelic)
destination: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
)
self._run(provider, (destination,), mapped(_MODEL_CALL_WITH_CONTENT))
provider.force_flush()
assert carries_content(by_name(tenant)["chat gpt-4o"])
@pytest.mark.usefixtures("allow_test_hosts")
@pytest.mark.parametrize("setting", ["no_content", "span_only", None])
def test_the_setting_rides_the_teams_destination_and_omission_stays_omitted(
self, monkeypatch: pytest.MonkeyPatch, setting: str | None
) -> None:
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
callback_vars: Final = {
"langfuse_public_key": "pk-team",
"langfuse_secret_key": "sk-team",
"langfuse_host": "http://team.local",
}
if setting is not None:
callback_vars["capture_message_content"] = setting
auth: Final = UserAPIKeyAuth(
team_metadata={
"logging": [
{"callback_name": "langfuse_otel", "callback_type": "success", "callback_vars": callback_vars},
{
"callback_name": "arize",
"callback_type": "success",
"callback_vars": {"arize_api_key": "k", "arize_space_id": "s"},
},
]
}
)
destinations: Final = {d.callback_name: d for d in resolve_tenant_otel_destinations(auth)}
assert destinations["langfuse_otel"].capture_message_content == setting
assert destinations["arize"].capture_message_content is None, "the setting stays on its own callback"
@pytest.mark.usefixtures("allow_test_hosts")
@pytest.mark.parametrize("setting", ["event_only", "span_and_event"])
def test_a_stored_event_mode_is_skipped_like_any_invalid_entry(
self, monkeypatch: pytest.MonkeyPatch, setting: str
) -> None:
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
auth: Final = UserAPIKeyAuth(
team_metadata={
"logging": [
{
"callback_name": "langfuse_otel",
"callback_type": "success",
"callback_vars": {
"langfuse_public_key": "pk-team",
"langfuse_secret_key": "sk-team",
"langfuse_host": "http://team.local",
"capture_message_content": setting,
},
},
{
"callback_name": "arize",
"callback_type": "success",
"callback_vars": {"arize_api_key": "k", "arize_space_id": "s"},
},
]
}
)
destinations: Final = [d.callback_name for d in resolve_tenant_otel_destinations(auth)]
assert destinations == ["arize"]
@pytest.mark.parametrize("value", ["full", "NO_CONTENT", "", "true", "event_only", "span_and_event"])
def test_an_unsupported_value_fails_registration(self, value: str) -> None:
with pytest.raises(
ValueError,
match=r"Invalid capture_message_content .*\['no_content', 'span_only'\]",
):
AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
callback_vars={
"langfuse_public_key": "pk",
"langfuse_secret_key": "sk",
"capture_message_content": value,
},
)
@pytest.mark.parametrize("value", ["no_content", "span_only"])
def test_a_supported_value_is_stored_as_given(self, value: str) -> None:
saved: Final = AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
callback_vars={"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "capture_message_content": value},
)
assert saved.callback_vars["capture_message_content"] == value
def test_a_team_wide_callback_vars_map_cannot_carry_the_setting(self) -> None:
"""The flattened map is shared by every callback, so a value there would apply to all of them."""
with pytest.raises(ValueError, match="Invalid callback variable: capture_message_content"):
TeamCallbackMetadata(
success_callback=["langfuse_otel", "signoz"], callback_vars={"capture_message_content": "span_only"}
)
def test_flattening_the_entries_leaves_the_setting_on_its_own_entry(self) -> None:
flattened: Final = convert_key_logging_metadata_to_callback(
AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
callback_vars={"langfuse_public_key": "pk", "capture_message_content": "span_only"},
),
None,
)
assert flattened.callback_vars == {"langfuse_public_key": "pk"}
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

View file

@ -9,11 +9,13 @@ hooks, proxy SERVER span lifecycle (start + setters), parent-context resolution
import ast
import asyncio
import contextlib
import contextvars
import os
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Final
from unittest.mock import patch
@ -43,7 +45,8 @@ from litellm.integrations.otel import ( # noqa: E402
OpenTelemetryV2Config,
)
from litellm.integrations.otel.logger import OpenTelemetryV2 # noqa: E402
from litellm.integrations.otel.model.config import ExporterSpec # noqa: E402
from litellm.integrations.otel.model.config import CaptureMessageContent, ExporterSpec # noqa: E402
from litellm.integrations.otel.model.destination import OtelDestination # noqa: E402
from litellm.integrations.otel.model.spans import ( # noqa: E402
LITELLM_PROXY_REQUEST_SPAN_NAME,
SpanRole,
@ -55,6 +58,7 @@ from litellm.integrations.otel.plumbing.context import ( # noqa: E402
reset_mcp_message_transport_span,
set_mcp_message_trace_carrier,
set_mcp_message_transport_span,
set_request_destinations,
set_request_root_span,
)
from litellm.types.utils import AUTOROUTER_CLASSIFIER_CALL_ORIGIN # noqa: E402
@ -617,8 +621,6 @@ def _mcp_payload(**overrides):
def _logger_capturing():
from litellm.integrations.otel.model.config import CaptureMessageContent
cfg = OpenTelemetryV2Config(
exporter="in_memory",
legacy_compat=False,
@ -684,6 +686,57 @@ def test_mcp_tool_call_captures_io_when_enabled():
assert "21" in span.attributes["gen_ai.tool.call.result"]
@pytest.mark.parametrize(
("capture_mode", "captures_content"),
[
(None, False),
(CaptureMessageContent.NO_CONTENT, False),
(CaptureMessageContent.SPAN_ONLY, True),
(CaptureMessageContent.EVENT_ONLY, False),
(CaptureMessageContent.SPAN_AND_EVENT, True),
],
)
def test_only_a_request_destination_can_turn_on_llm_payload_content(
capture_mode: CaptureMessageContent | None, captures_content: bool
) -> None:
config: Final = OpenTelemetryV2Config(
exporter="in_memory",
capture_message_content=CaptureMessageContent.NO_CONTENT,
)
exporter: Final = InMemorySpanExporter()
logger: Final = OpenTelemetryV2(
config=config, tracer_provider=providers.build_tracer_provider(config, exporter=exporter)
)
payload: Final = _payload(
messages=[{"role": "user", "content": "prompt marker"}],
response={
"id": "resp_1",
"model": "gpt-4o-2024",
"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "answer marker"}}],
},
)
kwargs: Final = {
**_kwargs(payload),
"standard_callback_dynamic_params": {"capture_message_content": "span_only"},
}
destination: Final = OtelDestination(
endpoint="http://team.local/api/public/otel",
headers=MappingProxyType({"Authorization": "Basic dA=="}),
callback_name="langfuse_otel",
capture_message_content=capture_mode,
)
def emit() -> None:
set_request_destinations((destination,))
_emit_llm(logger, kwargs)
contextvars.copy_context().run(emit)
(span,) = exporter.get_finished_spans()
assert ("gen_ai.input.messages" in span.attributes) is captures_content
assert ("gen_ai.output.messages" in span.attributes) is captures_content
def test_mcp_tool_call_failure_marks_error():
logger, exporter = _logger()
payload = _mcp_payload(

View file

@ -1692,6 +1692,19 @@ def test_capture_message_content_normalizer_only_touches_strings():
OpenTelemetryV2Config(capture_message_content=123)
def test_unknown_capture_message_content_env_is_kept_as_a_plain_string_that_captures_nothing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "Invalid-Mode")
config: Final = OpenTelemetryV2Config()
assert type(config.capture_message_content) is str
assert config.capture_message_content == "invalid-mode"
assert config.capture_span_content is False
assert config.model_dump()["capture_message_content"] == "invalid-mode"
def test_v2_flag_is_off_by_default(monkeypatch):
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()

View file

@ -1,7 +1,11 @@
from collections.abc import Iterator
from typing import Final
import pytest
from litellm.proxy.common_utils.callback_config_validation import (
callback_config_error,
conflicting_capture_error,
conflicting_span_scope_error,
cross_entry_family_error,
logging_metadata_config_error,
@ -259,3 +263,114 @@ def test_arize_sampling_rates_are_not_family_credentials():
stored = [{"arize_api_key": "k1", "arize_success_sampling_rate": "0.5"}]
assert cross_entry_family_error({"arize_success_sampling_rate": "0.1"}, stored) is None
assert cross_entry_family_error({"arize_error_sampling_rate": "0.5"}, stored) is None
@pytest.fixture
def otel_v2_on(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
from litellm.integrations.otel.model.config import is_otel_v2_enabled
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
yield
is_otel_v2_enabled.cache_clear()
@pytest.mark.usefixtures("otel_v2_on")
@pytest.mark.parametrize("callback_name", ["langfuse_otel", "arize", "weave_otel", "newrelic"])
@pytest.mark.parametrize("value", ["no_content", "span_only"])
def test_capture_message_content_is_accepted_on_every_otel_v2_destination(callback_name: str, value: str) -> None:
assert callback_config_error(callback_name, {"capture_message_content": value}) is None
def test_capture_message_content_is_rejected_while_otel_v2_is_off(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.integrations.otel.model.config import is_otel_v2_enabled
monkeypatch.delenv("LITELLM_OTEL_V2", raising=False)
is_otel_v2_enabled.cache_clear()
try:
error: Final = callback_config_error("langfuse_otel", {"capture_message_content": "no_content"})
finally:
is_otel_v2_enabled.cache_clear()
assert error is not None and "capture_message_content" in error and "LITELLM_OTEL_V2" in error
@pytest.mark.parametrize("callback_name", ["langfuse", "datadog", "otel", "arize_phoenix", None])
def test_capture_message_content_is_rejected_where_it_would_never_take_effect(callback_name: str | None) -> None:
error: Final = callback_config_error(callback_name, {"capture_message_content": "no_content"})
assert error is not None and "capture_message_content" in error and "langfuse_otel" in error
@pytest.mark.usefixtures("otel_v2_on")
@pytest.mark.parametrize("value", ["full", "event_only", "span_and_event"])
def test_an_unsupported_capture_message_content_is_rejected_on_key_logging_metadata(value: str) -> None:
error: Final = logging_metadata_config_error(
{
"logging": [
{
"callback_name": "langfuse_otel",
"callback_type": "success",
"callback_vars": {"capture_message_content": value},
}
]
}
)
assert error is not None and "Invalid capture_message_content" in error
@pytest.mark.usefixtures("otel_v2_on")
def test_each_entry_keeps_its_own_capture_message_content() -> None:
error: Final = logging_metadata_config_error(
{
"logging": [
{
"callback_name": "langfuse_otel",
"callback_type": "success",
"callback_vars": {"capture_message_content": "span_only"},
},
{
"callback_name": "arize",
"callback_type": "success",
"callback_vars": {"capture_message_content": "no_content"},
},
]
}
)
assert error is None
@pytest.mark.usefixtures("otel_v2_on")
def test_a_failure_only_entry_rejects_capture_message_content() -> None:
error: Final = callback_config_error("langfuse_otel", {"capture_message_content": "no_content"}, "failure")
assert error is not None and "capture_message_content" in error and "success_and_failure" in error
@pytest.mark.parametrize(
("stored", "rejected"),
[
([("langfuse_otel", {"capture_message_content": "no_content"})], True),
([("langfuse_otel", {"capture_message_content": "span_only"})], False),
([("langfuse_otel", {"langfuse_public_key": "pk"})], False),
([("newrelic", {"capture_message_content": "no_content"})], False),
],
)
def test_entries_for_one_backend_share_one_capture_message_content(
stored: list[tuple[str, dict[str, str]]], rejected: bool
) -> None:
"""The backend's entries merge into one destination, so a second value would silently win or lose."""
error: Final = conflicting_capture_error("langfuse_otel", {"capture_message_content": "span_only"}, stored)
assert (error is not None) is rejected
@pytest.mark.usefixtures("otel_v2_on")
def test_key_logging_entries_for_one_backend_may_not_disagree_on_capture_message_content() -> None:
def entry(callback_type: str, capture: str) -> dict[str, str | dict[str, str]]:
return {
"callback_name": "langfuse_otel",
"callback_type": callback_type,
"callback_vars": {"capture_message_content": capture},
}
error: Final = logging_metadata_config_error(
{"logging": [entry("success", "no_content"), entry("success_and_failure", "span_only")]}
)
assert error is not None and "already set to 'no_content' by another langfuse_otel entry" in error

View file

@ -7,6 +7,7 @@ redacted audit rows for callback mutations.
"""
import json
from collections.abc import Iterator
from typing import Final
from unittest.mock import AsyncMock, MagicMock, Mock, patch
@ -1712,3 +1713,62 @@ def test_add_team_callback_accepts_arize_sampling_rate_vars():
)
assert data.callback_vars["arize_success_sampling_rate"] == "0.5"
assert data.callback_vars["arize_error_sampling_rate"] == "1.0"
@pytest.mark.asyncio
async def test_add_team_callbacks_rejects_capture_message_content_on_a_non_otel_v2_callback(patched_prisma):
data: Final = AddTeamCallback(
callback_name="langfuse",
callback_type="success",
callback_vars={
"langfuse_public_key": "pk",
"langfuse_secret_key": "sk",
"capture_message_content": "no_content",
},
)
with pytest.raises(HTTPException) as exc:
await add_team_callbacks(
data=data,
http_request=Mock(spec=Request),
team_id="team-victim",
user_api_key_dict=_admin_auth(),
)
assert exc.value.status_code == 400
assert "capture_message_content" in str(exc.value.detail)
patched_prisma.db.litellm_teamtable.update.assert_not_called()
@pytest.fixture
def otel_v2_on(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
from litellm.integrations.otel.model.config import is_otel_v2_enabled
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
is_otel_v2_enabled.cache_clear()
yield
is_otel_v2_enabled.cache_clear()
@pytest.mark.usefixtures("otel_v2_on")
@pytest.mark.asyncio
@pytest.mark.parametrize("capture", ["no_content", "span_only"])
async def test_add_team_callbacks_stores_capture_message_content_in_the_existing_callback_metadata(
patched_prisma, capture: str
) -> None:
data: Final = AddTeamCallback(
callback_name="langfuse_otel",
callback_type="success",
callback_vars={
"langfuse_public_key": "pk",
"langfuse_secret_key": "sk",
"capture_message_content": capture,
},
)
await add_team_callbacks(
data=data,
http_request=Mock(spec=Request),
team_id="team-victim",
user_api_key_dict=_admin_auth(),
)
patched_prisma.db.litellm_teamtable.update.assert_awaited_once()
stored: Final = json.loads(patched_prisma.db.litellm_teamtable.update.await_args.kwargs["data"]["metadata"])
assert [entry["callback_vars"].get("capture_message_content") for entry in stored["logging"]] == [capture]

View file

@ -0,0 +1,40 @@
import pytest
from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VALUES, CaptureMessageContent, captures_span_content
@pytest.mark.parametrize(
"mode",
[
CaptureMessageContent.NO_CONTENT,
CaptureMessageContent.SPAN_ONLY,
CaptureMessageContent.EVENT_ONLY,
CaptureMessageContent.SPAN_AND_EVENT,
],
)
def test_capture_message_content_members_are_plain_strings(mode: str) -> None:
assert type(mode) is str
assert (str(mode), f"{mode}", repr(mode)) == (mode, mode, repr(str(mode)))
def test_a_destination_may_only_set_the_span_capture_modes() -> None:
assert CAPTURE_MESSAGE_CONTENT_VALUES == {"no_content", "span_only"}
def test_capture_message_content_constructor_accepts_any_string() -> None:
assert CaptureMessageContent("custom") == "custom"
@pytest.mark.parametrize(
("mode", "captures"),
[
("no_content", False),
("span_only", True),
("event_only", False),
("span_and_event", True),
("bogus", False),
(None, False),
],
)
def test_captures_span_content(mode: str | None, captures: bool) -> None:
assert captures_span_content(mode) is captures

View file

@ -17246,6 +17246,9 @@ export interface paths {
* - langfuse_host: The host for the Langfuse callback
* - langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)
* - langfuse_span_scope: For langfuse_otel, "full" (default) sends the whole request trace, "llm_only" sends only the model-call spans
* - capture_message_content: For OTel v2 callbacks, "no_content" or "span_only".
* An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting.
* "span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out
* - gcs_bucket_name: The name of the GCS bucket
* - gcs_path_service_account: The path to the GCS service account
* - langsmith_api_key: The API key for the Langsmith callback