diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index 6806188c97c..5bd8aca55fa 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -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" diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index 5bda66ed618..ce53103ed50 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -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 ------------ # diff --git a/litellm/integrations/otel/model/destination.py b/litellm/integrations/otel/model/destination.py index 299253cac77..c9c035f24a1 100644 --- a/litellm/integrations/otel/model/destination.py +++ b/litellm/integrations/otel/model/destination.py @@ -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())), diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index 81f22c8c642..261fd3b4d25 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -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 diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index 2bf9bfa5261..4b4396e41b7 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -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), ) diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index 65c5b0d9799..e4744079622 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -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", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b0d31df92ce..a9b0650e8ce 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index c9d97068313..f705ea7a5c1 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -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, diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 1932e89717b..f7ae1eec06e 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -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 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 748c91a4792..00a7c81e612 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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 diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py index 67695d5aed8..c62f3a4cdbd 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -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", diff --git a/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py index 5a06bb92059..d6b57dfb7ae 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py index 272a8ffa972..5c5cfd0814d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_callback_management_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 8e343e1a6e0..c91840f078b 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -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", }, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fd882937e79..21492db46e4 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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