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:
yucheng 2026-09-18 02:47:02 +00:00
parent db37977307
commit 72e847288a
15 changed files with 433 additions and 12 deletions

View file

@ -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"

View file

@ -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 ------------ #

View file

@ -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())),

View file

@ -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

View file

@ -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),
)

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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",
},

View file

@ -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