mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
feat(otel v2): opt-in llm_only span scope for Langfuse destinations and the operator Langfuse exporter
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
db37977307
commit
72e847288a
15 changed files with 433 additions and 12 deletions
|
|
@ -259,6 +259,13 @@
|
|||
"ui_name": "Tracing Environment",
|
||||
"description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)",
|
||||
"required": false
|
||||
},
|
||||
"langfuse_span_scope": {
|
||||
"type": "select",
|
||||
"ui_name": "Span Scope",
|
||||
"description": "full sends the whole request trace, llm_only sends just the model-call spans",
|
||||
"options": ["full", "llm_only"],
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"description": "Langfuse v3 OTEL Logging Integration"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from litellm.integrations.otel.model.baggage import (
|
|||
DEFAULT_BAGGAGE_METADATA_KEYS,
|
||||
DEFAULT_BAGGAGE_TEAM_METADATA_KEYS,
|
||||
)
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
#: Master feature-flag env var. The logger is inert until this is truthy.
|
||||
OTEL_V2_ENV: Final = "LITELLM_OTEL_V2"
|
||||
|
|
@ -163,6 +164,15 @@ class OpenTelemetryV2Config(BaseSettings):
|
|||
validation_alias=AliasChoices("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"),
|
||||
)
|
||||
legacy_compat: bool = Field(default=True, validation_alias=AliasChoices("LITELLM_OTEL_LEGACY_COMPAT"))
|
||||
langfuse_span_scope: OtelSpanScope = Field(
|
||||
default="full",
|
||||
validation_alias=AliasChoices("langfuse_span_scope", "LITELLM_OTEL_LANGFUSE_SPAN_SCOPE"),
|
||||
description=(
|
||||
"``llm_only`` keeps just the model-call spans on the operator's own Langfuse "
|
||||
"exporter (the spec whose owner is ``langfuse_otel``). Other exporters and "
|
||||
"key/team destinations are not affected."
|
||||
),
|
||||
)
|
||||
|
||||
# ----- explicit multi-destination / vocabulary configuration ------------ #
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from urllib.parse import quote
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
|
||||
class OtelDestination(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
|
@ -25,6 +27,10 @@ class OtelDestination(BaseModel):
|
|||
"scheme: Arize's ``https://otlp.arize.com/v1`` is gRPC."
|
||||
),
|
||||
)
|
||||
span_scope: OtelSpanScope = Field(
|
||||
default="full",
|
||||
description="``llm_only`` keeps just the model-call spans; the rest of the request tree is not forwarded.",
|
||||
)
|
||||
|
||||
def header_string(self) -> str:
|
||||
"""Render headers as the ``k=v,k2=v2`` form an ``ExporterSpec`` expects.
|
||||
|
|
@ -37,7 +43,12 @@ class OtelDestination(BaseModel):
|
|||
return ",".join(f"{key}={quote(value, safe='')}" for key, value in self.headers.items())
|
||||
|
||||
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."""
|
||||
"""Identity for processor reuse, so one destination means one exporter.
|
||||
|
||||
``span_scope`` is left out on purpose: the scope decides 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.
|
||||
"""
|
||||
return (
|
||||
self.endpoint,
|
||||
tuple(sorted(self.headers.items())),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ 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.model.config import ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.model.config import ExporterOwner, ExporterSpec, OpenTelemetryV2Config
|
||||
from litellm.integrations.otel.model.semconv import (
|
||||
DB,
|
||||
MCP,
|
||||
|
|
@ -63,6 +63,7 @@ if TYPE_CHECKING:
|
|||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from litellm.integrations.otel.model.destination import OtelDestination
|
||||
from litellm.types.utils import OtelSpanScope
|
||||
|
||||
_SPAN_KIND_BY_ROLE_KIND: Final[dict[LiteLLMSpanKind, SpanKind]] = {
|
||||
LiteLLMSpanKind.SERVER: SpanKind.SERVER,
|
||||
|
|
@ -414,6 +415,22 @@ def _is_tenant_owned_span(attributes: Mapping[str, AttributeValue]) -> bool:
|
|||
return any(key in attributes for key in _TENANT_OWNED_KEYS)
|
||||
|
||||
|
||||
def is_llm_call_span(span: ReadableSpan) -> bool:
|
||||
"""Whether ``span`` is the model call itself.
|
||||
|
||||
The GenAI mapper stamps ``gen_ai.operation.name`` on the model call and on the
|
||||
MCP tool call, so the MCP method name tells the two apart. Guardrail, request
|
||||
root, auth and database spans never carry the operation name; ``gen_ai.request.model``
|
||||
would not do, since baggage promotes it onto every child span.
|
||||
"""
|
||||
attributes: Final = span.attributes or _NO_ATTRIBUTES
|
||||
return GenAI.OPERATION_NAME in attributes and MCP.METHOD_NAME not in attributes
|
||||
|
||||
|
||||
def _in_scope(span: ReadableSpan, scope: "OtelSpanScope") -> bool:
|
||||
return scope == "full" or is_llm_call_span(span)
|
||||
|
||||
|
||||
def _guardrail_unreachable(attributes: Mapping[str, AttributeValue]) -> bool:
|
||||
return attributes.get(LiteLLM.GUARDRAIL_STATUS) in _GUARDRAIL_UNREACHABLE_STATUSES
|
||||
|
||||
|
|
@ -527,7 +544,7 @@ class TenantFanOutSpanProcessor(SpanProcessor):
|
|||
def on_end(self, span: ReadableSpan) -> None:
|
||||
suppressed: Final = suppressed_backends()
|
||||
for destination in request_destinations():
|
||||
if self._operator_already_writes(destination, suppressed):
|
||||
if self._operator_already_writes(destination, suppressed) or not _in_scope(span, destination.span_scope):
|
||||
continue
|
||||
processor = self._acquire(destination) # rebind-ok: loop variable; pyright forbids Final in a loop
|
||||
if processor is None:
|
||||
|
|
@ -753,17 +770,21 @@ class _OverriddenBackendFilter(SpanProcessor):
|
|||
|
||||
Under ``additive`` mode nothing is suppressed, so the wrapper passes every span
|
||||
straight through and the operator keeps its copy.
|
||||
|
||||
``scope`` narrows what the exporter receives independently of that: under
|
||||
``llm_only`` the model-call spans go through and the rest of the tree is held back.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: SpanProcessor, owner: str) -> None:
|
||||
def __init__(self, inner: SpanProcessor, owner: str | None, scope: "OtelSpanScope" = "full") -> None:
|
||||
self._inner: Final = inner
|
||||
self._owner: Final = owner
|
||||
self._scope: Final = scope
|
||||
|
||||
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:
|
||||
if self._owner in suppressed_backends():
|
||||
if self._owner in suppressed_backends() or not _in_scope(span, self._scope):
|
||||
return
|
||||
self._inner.on_end(span)
|
||||
|
||||
|
|
@ -1040,6 +1061,9 @@ def build_tracer_provider(
|
|||
tenant is a separate job, done once by :func:`attach_tenant_fan_out`. The
|
||||
per-tenant providers this same function builds must leave it off, or they would
|
||||
filter out the very spans they exist to carry.
|
||||
|
||||
``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.
|
||||
"""
|
||||
provider: Final = TracerProvider(resource=build_resource(config))
|
||||
if baggage_processor is None:
|
||||
|
|
@ -1060,9 +1084,10 @@ def build_tracer_provider(
|
|||
exp,
|
||||
(spec.use_simple_processor if spec.use_simple_processor is not None else use_simple_processor),
|
||||
)
|
||||
owner = spec.owner.value if spec.owner is not None else None
|
||||
owner = spec.owner.value if tenant_overrides and spec.owner is not None else None
|
||||
scope = config.langfuse_span_scope if spec.owner is ExporterOwner.LANGFUSE_OTEL else "full"
|
||||
provider.add_span_processor(
|
||||
_OverriddenBackendFilter(processor, owner) if tenant_overrides and owner is not None else processor
|
||||
_OverriddenBackendFilter(processor, owner, scope) if owner is not None or scope != "full" else processor
|
||||
)
|
||||
return provider
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import litellm
|
|||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.otel.model.destination import OtelDestination
|
||||
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
from litellm.types.utils import OtelSpanScope, StandardCallbackDynamicParams
|
||||
|
||||
#: An endpoint plus the OTLP transport to reach it with, or ``None`` when the backend
|
||||
#: names no destination. The transport is ``None`` where the backend has only one.
|
||||
|
|
@ -111,6 +111,13 @@ _REQUIRED_HEADERS_BY_CALLBACK: Final[Mapping[str, frozenset[str]]] = MappingProx
|
|||
_NO_ATTRS: Final[Mapping[str, str]] = MappingProxyType({})
|
||||
|
||||
|
||||
def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope:
|
||||
"""The export scope the tenant configured; only Langfuse offers one, every other backend gets the full tree."""
|
||||
if callback_name != "langfuse_otel":
|
||||
return "full"
|
||||
return params.get("langfuse_span_scope") or "full"
|
||||
|
||||
|
||||
def destination_capable_backends() -> frozenset[str]:
|
||||
"""Backends a key or team can point at its own account."""
|
||||
from litellm.integrations.otel.presets import DYNAMIC_HEADERS_BY_CALLBACK
|
||||
|
|
@ -149,4 +156,5 @@ def destination_for(
|
|||
resource_attributes=MappingProxyType({"service.name": service_name}) if service_name else _NO_ATTRS,
|
||||
callback_name=callback_name,
|
||||
protocol=protocol,
|
||||
span_scope=_span_scope(callback_name, params),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import re
|
|||
from collections.abc import Iterator, Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.types.utils import TRUSTED_CALLBACK_VARS_FIELD, StandardCallbackDynamicParams
|
||||
from litellm.types.utils import OTEL_SPAN_SCOPES, TRUSTED_CALLBACK_VARS_FIELD, StandardCallbackDynamicParams
|
||||
|
||||
_CLIENT_CALLBACK_METADATA_SLOTS: Final[tuple[str, ...]] = ("litellm_metadata", "metadata")
|
||||
|
||||
|
|
@ -62,6 +62,11 @@ def validate_langfuse_environment_value(value: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def validate_langfuse_span_scope_value(value: str) -> None:
|
||||
if value not in OTEL_SPAN_SCOPES:
|
||||
raise ValueError(f"Invalid langfuse_span_scope {value!r}: must be one of {sorted(OTEL_SPAN_SCOPES)}")
|
||||
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params: Final[tuple[str, ...]] = (
|
||||
"langfuse_public_key",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,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_langfuse_environment_value,
|
||||
validate_langfuse_span_scope_value,
|
||||
validate_no_callback_env_reference,
|
||||
)
|
||||
from litellm.types.integrations.compression_interception import (
|
||||
|
|
@ -2187,6 +2188,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
|
|||
validate_no_callback_env_reference(key, callback_vars[key], source="key/team callback metadata")
|
||||
if key == "langfuse_environment":
|
||||
validate_langfuse_environment_value(callback_vars[key])
|
||||
if key == "langfuse_span_scope":
|
||||
validate_langfuse_span_scope_value(callback_vars[key])
|
||||
return values
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ _NEWRELIC_VAR_PREFIX: Final = "newrelic_"
|
|||
def callback_config_error(callback_name: str | None, callback_vars: Mapping[str, str] | None) -> str | None:
|
||||
if not callback_vars:
|
||||
return None
|
||||
env_error: Final = _langfuse_environment_error(callback_vars)
|
||||
if env_error is not None:
|
||||
return env_error
|
||||
langfuse_error: Final = _langfuse_environment_error(callback_vars) or _langfuse_span_scope_error(callback_vars)
|
||||
if langfuse_error is not None:
|
||||
return langfuse_error
|
||||
if callback_name != _NEWRELIC_CALLBACK:
|
||||
return None
|
||||
return _newrelic_config_error(callback_vars)
|
||||
|
|
@ -44,6 +44,21 @@ def _langfuse_environment_error(callback_vars: Mapping[str, str]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _langfuse_span_scope_error(callback_vars: Mapping[str, str]) -> str | None:
|
||||
value: Final = callback_vars.get("langfuse_span_scope")
|
||||
if value is None:
|
||||
return None
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_langfuse_span_scope_value,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_langfuse_span_scope_value(value)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ async def add_team_callbacks(
|
|||
- langfuse_secret: The secret for the Langfuse callback
|
||||
- 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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -3513,6 +3513,10 @@ OPENAI_RESPONSE_HEADERS: Final = [
|
|||
]
|
||||
|
||||
|
||||
OtelSpanScope = Literal["full", "llm_only"]
|
||||
OTEL_SPAN_SCOPES: Final[frozenset[str]] = frozenset(get_args(OtelSpanScope))
|
||||
|
||||
|
||||
class StandardCallbackDynamicParams(TypedDict, total=False):
|
||||
# Langfuse dynamic params
|
||||
langfuse_public_key: str | None
|
||||
|
|
@ -3520,6 +3524,7 @@ class StandardCallbackDynamicParams(TypedDict, total=False):
|
|||
langfuse_secret_key: str | None
|
||||
langfuse_host: str | None
|
||||
langfuse_environment: ReadOnly[str | None]
|
||||
langfuse_span_scope: ReadOnly[OtelSpanScope | None]
|
||||
|
||||
# Langfuse prompt version
|
||||
langfuse_prompt_version: int | None
|
||||
|
|
|
|||
|
|
@ -1403,6 +1403,305 @@ class TestDestinationResolution:
|
|||
assert parse_headers(destination.header_string())["authorization"] == destination.headers["Authorization"]
|
||||
|
||||
|
||||
LLM_ONLY_DEST = OtelDestination(
|
||||
endpoint="http://tenant.local/api/public/otel",
|
||||
headers={"Authorization": "Basic dGVuYW50"},
|
||||
callback_name="langfuse_otel",
|
||||
span_scope="llm_only",
|
||||
)
|
||||
|
||||
#: Every span kind the proxy emits for one chat request, plus the two spans that
|
||||
#: look like a model call to a naive classifier: the MCP tool call carries
|
||||
#: ``gen_ai.operation.name`` too, and baggage promotes ``gen_ai.request.model``
|
||||
#: onto children that are not the call.
|
||||
REQUEST_TREE = frozenset(
|
||||
{
|
||||
"POST /v1/chat/completions",
|
||||
"auth /v1/chat/completions",
|
||||
"postgres SELECT",
|
||||
"redis GET",
|
||||
"execute_guardrail pii",
|
||||
"tools/call get_weather",
|
||||
"chat gpt-4",
|
||||
"chat claude-haiku",
|
||||
"cost_tracking",
|
||||
}
|
||||
)
|
||||
LLM_SPANS = frozenset({"chat gpt-4", "chat claude-haiku"})
|
||||
TRACE_CONTROLS = MappingProxyType(
|
||||
{
|
||||
"langfuse.observation.type": "generation",
|
||||
"langfuse.trace.name": "checkout",
|
||||
"user.id": "user-7",
|
||||
"session.id": "sess-1",
|
||||
"langfuse.trace.tags": ("beta", "eu"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def request_tree(provider: TracerProvider) -> None:
|
||||
tracer = get_tracer(provider, "litellm")
|
||||
with tracer.start_as_current_span("POST /v1/chat/completions"):
|
||||
with tracer.start_as_current_span("auth /v1/chat/completions"):
|
||||
with tracer.start_as_current_span("postgres SELECT") as db:
|
||||
db.set_attribute("db.system", "postgresql")
|
||||
with tracer.start_as_current_span("redis GET") as cache:
|
||||
cache.set_attribute("db.system", "redis")
|
||||
with tracer.start_as_current_span("execute_guardrail pii") as guard:
|
||||
guard.set_attributes({"litellm.guardrail.name": "pii", "litellm.guardrail.status": "success"})
|
||||
with tracer.start_as_current_span("tools/call get_weather") as tool:
|
||||
tool.set_attributes({"gen_ai.operation.name": "execute_tool", "mcp.method.name": "tools/call"})
|
||||
with tracer.start_as_current_span("chat gpt-4") as llm:
|
||||
llm.set_attributes({"gen_ai.operation.name": "chat", "gen_ai.request.model": "gpt-4", **TRACE_CONTROLS})
|
||||
with tracer.start_as_current_span("cost_tracking") as child:
|
||||
child.set_attribute("gen_ai.request.model", "gpt-4")
|
||||
with tracer.start_as_current_span("chat claude-haiku") as retry:
|
||||
retry.set_attributes({"gen_ai.operation.name": "chat", "gen_ai.request.model": "claude-haiku"})
|
||||
|
||||
|
||||
def names(exporter: InMemorySpanExporter) -> frozenset[str]:
|
||||
return frozenset(s.name for s in exporter.get_finished_spans())
|
||||
|
||||
|
||||
class TestSpanScope:
|
||||
"""``llm_only`` keeps the model-call spans and drops the rest of the request tree.
|
||||
|
||||
The tenant's switch rides the destination; the operator's rides the config and
|
||||
reaches only the exporter ``langfuse_otel`` owns. Neither reparents or promotes
|
||||
a span, so what does get through still hangs off the same trace.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _additive(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "otel_tenant_destination_mode", "additive", raising=False)
|
||||
|
||||
@staticmethod
|
||||
def _run(provider, destinations):
|
||||
def run():
|
||||
set_request_destinations(destinations)
|
||||
request_tree(provider)
|
||||
|
||||
in_fresh_context(run)
|
||||
|
||||
@staticmethod
|
||||
def _operator_provider(operator_exporter, dest_exporter, scope="full"):
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(
|
||||
_OverriddenBackendFilter(SimpleSpanProcessor(operator_exporter), "langfuse_otel", scope)
|
||||
)
|
||||
provider.add_span_processor(
|
||||
TenantFanOutSpanProcessor(processor_factory=lambda _d: SimpleSpanProcessor(dest_exporter))
|
||||
)
|
||||
return provider
|
||||
|
||||
def test_off_and_off_is_the_full_tree_on_both_sides(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant), (LANGFUSE_DEST,))
|
||||
|
||||
assert names(operator) == REQUEST_TREE
|
||||
assert names(tenant) == REQUEST_TREE
|
||||
|
||||
def test_a_tenant_asking_for_llm_only_gets_just_the_model_calls(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant), (LLM_ONLY_DEST,))
|
||||
|
||||
assert names(tenant) == LLM_SPANS
|
||||
assert names(operator) == REQUEST_TREE, "the tenant's scope must not narrow the operator's exporter"
|
||||
|
||||
def test_an_operator_asking_for_llm_only_keeps_the_tenants_tree_whole(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant, scope="llm_only"), (LANGFUSE_DEST,))
|
||||
|
||||
assert names(operator) == LLM_SPANS
|
||||
assert names(tenant) == REQUEST_TREE, "the operator's scope must not narrow a tenant destination"
|
||||
|
||||
def test_both_on_narrows_both(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant, scope="llm_only"), (LLM_ONLY_DEST,))
|
||||
|
||||
assert names(operator) == LLM_SPANS
|
||||
assert names(tenant) == LLM_SPANS
|
||||
|
||||
def test_an_operator_scope_does_not_undo_the_override(self):
|
||||
"""Under the default override mode an overridden backend stays suppressed on
|
||||
the operator's exporter no matter what scope it carries."""
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant, scope="llm_only"), (LLM_ONLY_DEST,))
|
||||
|
||||
assert operator.get_finished_spans() == ()
|
||||
assert names(tenant) == LLM_SPANS
|
||||
|
||||
def test_a_kept_generation_still_hangs_off_the_request_trace_with_its_trace_controls(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
operator, tenant = InMemorySpanExporter(), InMemorySpanExporter()
|
||||
|
||||
self._run(self._operator_provider(operator, tenant), (LLM_ONLY_DEST,))
|
||||
|
||||
root = next(s for s in operator.get_finished_spans() if s.name == "POST /v1/chat/completions")
|
||||
kept = {s.name: s for s in tenant.get_finished_spans()}["chat gpt-4"]
|
||||
assert kept.context.trace_id == root.context.trace_id
|
||||
assert kept.parent is not None and kept.parent.span_id == root.context.span_id, "no reparenting"
|
||||
assert {k: kept.attributes[k] for k in TRACE_CONTROLS} == dict(TRACE_CONTROLS)
|
||||
|
||||
def test_a_non_langfuse_destination_of_the_same_request_keeps_the_full_tree(self, monkeypatch):
|
||||
self._additive(monkeypatch)
|
||||
by_backend = {"langfuse_otel": InMemorySpanExporter(), "arize": InMemorySpanExporter()}
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(
|
||||
TenantFanOutSpanProcessor(
|
||||
processor_factory=lambda d: SimpleSpanProcessor(by_backend[d.callback_name]),
|
||||
)
|
||||
)
|
||||
arize = OtelDestination(endpoint="https://otlp.arize.com", headers={"api_key": "k"}, callback_name="arize")
|
||||
|
||||
self._run(provider, (LLM_ONLY_DEST, arize))
|
||||
|
||||
assert names(by_backend["langfuse_otel"]) == LLM_SPANS
|
||||
assert names(by_backend["arize"]) == REQUEST_TREE
|
||||
|
||||
def test_two_views_of_one_account_share_the_exporter_but_not_the_filter(self):
|
||||
"""A full and an ``llm_only`` destination for the same account are one exporter
|
||||
(``cache_key`` leaves the scope out), and each request is still filtered by its own scope."""
|
||||
built, tenant = [], InMemorySpanExporter()
|
||||
provider = TracerProvider()
|
||||
|
||||
def factory(destination):
|
||||
built.append(destination)
|
||||
return SimpleSpanProcessor(tenant)
|
||||
|
||||
provider.add_span_processor(TenantFanOutSpanProcessor(processor_factory=factory))
|
||||
|
||||
self._run(provider, (LLM_ONLY_DEST,))
|
||||
assert names(tenant) == LLM_SPANS
|
||||
tenant.clear()
|
||||
|
||||
self._run(provider, (LANGFUSE_DEST,))
|
||||
assert names(tenant) == REQUEST_TREE
|
||||
assert len(built) == 1, "the same account must not get a second exporter for a second scope"
|
||||
|
||||
def test_the_config_scope_reaches_only_the_exporter_langfuse_owns(self, monkeypatch):
|
||||
exporters = {}
|
||||
|
||||
def exporter_for(spec):
|
||||
return exporters.setdefault(spec.owner, InMemorySpanExporter())
|
||||
|
||||
monkeypatch.setattr(otel_providers, "_exporter_from_spec", exporter_for)
|
||||
config = OpenTelemetryV2Config(
|
||||
langfuse_span_scope="llm_only",
|
||||
exporters=[
|
||||
ExporterSpec(kind="in_memory", owner=ExporterOwner.LANGFUSE_OTEL),
|
||||
ExporterSpec(kind="in_memory", owner=ExporterOwner.ARIZE_AX),
|
||||
ExporterSpec(kind="in_memory"),
|
||||
],
|
||||
)
|
||||
|
||||
self._run(build_tracer_provider(config, use_simple_processor=True), ())
|
||||
|
||||
assert names(exporters[ExporterOwner.LANGFUSE_OTEL]) == LLM_SPANS
|
||||
assert names(exporters[ExporterOwner.ARIZE_AX]) == REQUEST_TREE
|
||||
assert names(exporters[None]) == REQUEST_TREE, "a bare collector must never be narrowed"
|
||||
|
||||
@pytest.mark.parametrize("tenant_overrides", [False, True])
|
||||
def test_the_config_default_leaves_every_exporter_on_the_full_tree(self, monkeypatch, tenant_overrides):
|
||||
exporters = {}
|
||||
monkeypatch.setattr(
|
||||
otel_providers,
|
||||
"_exporter_from_spec",
|
||||
lambda spec: exporters.setdefault(spec.owner, InMemorySpanExporter()),
|
||||
)
|
||||
config = OpenTelemetryV2Config(exporters=[ExporterSpec(kind="in_memory", owner=ExporterOwner.LANGFUSE_OTEL)])
|
||||
|
||||
self._run(build_tracer_provider(config, use_simple_processor=True, tenant_overrides=tenant_overrides), ())
|
||||
|
||||
assert names(exporters[ExporterOwner.LANGFUSE_OTEL]) == REQUEST_TREE
|
||||
|
||||
def test_the_env_var_sets_the_operator_scope(self, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_SPAN_SCOPE", "llm_only")
|
||||
|
||||
assert OpenTelemetryV2Config().langfuse_span_scope == "llm_only"
|
||||
|
||||
def test_the_env_var_narrows_the_exporter_the_langfuse_preset_builds(self, monkeypatch):
|
||||
"""The whole operator path: env var -> preset -> provider, with a bare collector alongside."""
|
||||
monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_SPAN_SCOPE", "llm_only")
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk")
|
||||
exporters = {}
|
||||
monkeypatch.setattr(
|
||||
otel_providers,
|
||||
"_exporter_from_spec",
|
||||
lambda spec: exporters.setdefault(spec.owner, InMemorySpanExporter()),
|
||||
)
|
||||
config = langfuse_preset(config_overrides=OpenTelemetryV2Config(exporters=[ExporterSpec(kind="in_memory")]))
|
||||
|
||||
self._run(build_tracer_provider(config, use_simple_processor=True), ())
|
||||
|
||||
assert names(exporters[ExporterOwner.LANGFUSE_OTEL]) == LLM_SPANS
|
||||
assert names(exporters[None]) == REQUEST_TREE
|
||||
|
||||
def test_an_unknown_scope_is_rejected_by_the_config(self):
|
||||
with pytest.raises(ValueError, match="langfuse_span_scope"):
|
||||
OpenTelemetryV2Config(langfuse_span_scope="everything")
|
||||
|
||||
def test_a_team_callback_var_becomes_the_destinations_scope(self, monkeypatch, allow_test_hosts):
|
||||
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
|
||||
is_otel_v2_enabled.cache_clear()
|
||||
auth = 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",
|
||||
"langfuse_span_scope": "llm_only",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert [d.span_scope for d in resolve_tenant_otel_destinations(auth)] == ["llm_only"]
|
||||
|
||||
def test_a_team_that_named_no_scope_gets_the_full_tree(self, allow_test_hosts):
|
||||
creds = {"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_host": "http://x"}
|
||||
|
||||
assert destination_for("langfuse_otel", creds).span_scope == "full"
|
||||
|
||||
def test_only_langfuse_honours_the_scope_var(self):
|
||||
arize = destination_for("arize", {"arize_api_key": "k", "arize_space_id": "s", "langfuse_span_scope": "llm_only"})
|
||||
|
||||
assert arize is not None and arize.span_scope == "full"
|
||||
|
||||
@pytest.mark.parametrize("scope", ["everything", "LLM_ONLY", ""])
|
||||
def test_an_unknown_scope_is_rejected_when_the_callback_is_saved(self, scope):
|
||||
with pytest.raises(ValueError, match=r"Invalid langfuse_span_scope .*must be one of \['full', 'llm_only'\]"):
|
||||
AddTeamCallback(
|
||||
callback_name="langfuse_otel",
|
||||
callback_type="success",
|
||||
callback_vars={"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_span_scope": scope},
|
||||
)
|
||||
|
||||
def test_a_known_scope_is_accepted_when_the_callback_is_saved(self):
|
||||
saved = AddTeamCallback(
|
||||
callback_name="langfuse_otel",
|
||||
callback_type="success",
|
||||
callback_vars={"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_span_scope": "llm_only"},
|
||||
)
|
||||
|
||||
assert saved.callback_vars["langfuse_span_scope"] == "llm_only"
|
||||
|
||||
|
||||
#: Anything that makes ``OpenTelemetryV2Config`` synthesize a real operator destination.
|
||||
_OTEL_SHORTHAND_ENV = (
|
||||
"OTEL_ENDPOINT",
|
||||
|
|
|
|||
|
|
@ -10,3 +10,19 @@ def test_callback_config_error_rejects_invalid_langfuse_environment():
|
|||
|
||||
assert callback_config_error("langfuse", {"langfuse_environment": "team-a-prod"}) is None
|
||||
assert callback_config_error("langfuse", {"langfuse_public_key": "pk"}) is None
|
||||
|
||||
|
||||
def test_callback_config_error_rejects_an_unknown_langfuse_span_scope():
|
||||
for bad in ["everything", "LLM_ONLY", "llm-only", ""]:
|
||||
error = callback_config_error("langfuse_otel", {"langfuse_span_scope": bad})
|
||||
assert error is not None and "langfuse_span_scope" in error and "llm_only" in error
|
||||
|
||||
assert callback_config_error("langfuse_otel", {"langfuse_span_scope": "llm_only"}) is None
|
||||
assert callback_config_error("langfuse_otel", {"langfuse_span_scope": "full"}) is None
|
||||
|
||||
|
||||
def test_a_bad_span_scope_is_reported_even_when_the_environment_is_fine():
|
||||
error = callback_config_error(
|
||||
"langfuse_otel", {"langfuse_environment": "team-a-prod", "langfuse_span_scope": "everything"}
|
||||
)
|
||||
assert error is not None and "langfuse_span_scope" in error
|
||||
|
|
|
|||
|
|
@ -285,6 +285,20 @@ class TestNewRelicCallbackConfig:
|
|||
assert "NEW_RELIC_AI_MONITORING_RECORD_CONTENT_ENABLED" not in params
|
||||
|
||||
|
||||
class TestLangfuseOtelCallbackConfig:
|
||||
def test_span_scope_is_a_select_over_exactly_the_scopes_the_validator_accepts(self):
|
||||
from litellm.types.utils import OTEL_SPAN_SCOPES
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/callbacks/configs", headers={"Authorization": "Bearer sk-1234"})
|
||||
assert response.status_code == 200
|
||||
langfuse_otel = next(config for config in response.json() if config.get("id") == "langfuse_otel")
|
||||
scope = langfuse_otel["dynamic_params"]["langfuse_span_scope"]
|
||||
assert scope["type"] == "select"
|
||||
assert frozenset(scope["options"]) == OTEL_SPAN_SCOPES
|
||||
assert scope["required"] is False
|
||||
|
||||
|
||||
class TestNewRelicTeamCallbackValidation:
|
||||
def _data(self, callback_vars):
|
||||
from litellm.proxy._types import AddTeamCallback
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
|
|||
langfuse_secret_key: "password",
|
||||
langfuse_host: "text",
|
||||
langfuse_environment: "text",
|
||||
langfuse_span_scope: "select",
|
||||
},
|
||||
description: "Langfuse v3 OTEL Logging Integration",
|
||||
},
|
||||
|
|
|
|||
1
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
1
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -16091,6 +16091,7 @@ export interface paths {
|
|||
* - langfuse_secret: The secret for the Langfuse callback
|
||||
* - 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
|
||||
* - 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue