From 358e4ea27a51c31b86cc318c59a49b3fdba85cf9 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 22:00:02 +0000 Subject: [PATCH] fix(otel v2): stop langfuse_span_scope tripping the family guard, normalize its spelling, and keep tenant routes on the full scope Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/config.py | 7 +++++ litellm/integrations/otel/plumbing/routing.py | 6 ++-- .../callback_config_validation.py | 14 +++++++-- .../otel/test_otel_v2_destinations.py | 29 +++++++++++++++++++ .../test_team_callback_endpoints.py | 6 ++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index ce53103ed50..5447a8ee80a 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -255,6 +255,13 @@ class OpenTelemetryV2Config(BaseSettings): return value.lower() return value + @field_validator("langfuse_span_scope", mode="before") + @classmethod + def _normalize_langfuse_span_scope(cls, value: object) -> object: + if isinstance(value, str): + return value.strip().lower() + return value + @field_validator( "baggage_promoted_keys", "baggage_metadata_keys", diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index f78d18d943c..b2d1f50f370 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -374,10 +374,8 @@ class TenantTracerCache: self._routed_exporter(spec, credential_headers, project_headers, endpoint) for spec in self._config.exporters ] - update: Final = ( - {"exporters": exporters} if service_name is None else {"exporters": exporters, "service_name": service_name} - ) - return self._config.model_copy(update=update) + routed: Final = self._config.model_copy(update={"exporters": exporters, "langfuse_span_scope": "full"}) + return routed if service_name is None else routed.model_copy(update={"service_name": service_name}) def _routed_exporter( self, diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index 0cc891acd94..8a6b554c56a 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -12,6 +12,7 @@ from typing import Final _NEWRELIC_CALLBACK: Final = "newrelic" _NEWRELIC_VAR_PREFIX: Final = "newrelic_" _LANGFUSE_OTEL_CALLBACK: Final = "langfuse_otel" +_LANGFUSE_SPAN_SCOPE_VAR: Final = "langfuse_span_scope" def callback_config_error(callback_name: str | None, callback_vars: Mapping[str, str] | None) -> str | None: @@ -48,11 +49,13 @@ def _langfuse_environment_error(callback_vars: Mapping[str, str]) -> str | None: def _langfuse_span_scope_error(callback_name: str | None, callback_vars: Mapping[str, str]) -> str | None: - value: Final = callback_vars.get("langfuse_span_scope") + value: Final = callback_vars.get(_LANGFUSE_SPAN_SCOPE_VAR) if value is None: return None if callback_name != _LANGFUSE_OTEL_CALLBACK: - return f"langfuse_span_scope applies to the {_LANGFUSE_OTEL_CALLBACK} callback only, not {callback_name!r}" + return ( + f"{_LANGFUSE_SPAN_SCOPE_VAR} applies to the {_LANGFUSE_OTEL_CALLBACK} callback only, not {callback_name!r}" + ) from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( validate_langfuse_span_scope_value, ) @@ -83,13 +86,18 @@ _VAR_FAMILIES: Final[Mapping[str, str]] = MappingProxyType( } ) +_FAMILY_OPTION_VARS: Final[frozenset[str]] = frozenset({_LANGFUSE_SPAN_SCOPE_VAR}) + def _family_of(var: str) -> str | None: """The credential family ``var`` configures, or ``None`` if it configures none. ``turn_off_message_logging`` and friends belong to no backend, so they carry - no credentials anyone could redirect. + no credentials anyone could redirect. ``langfuse_span_scope`` shares the Langfuse + prefix but is a fixed enum choosing what the family exports, not where to. """ + if var in _FAMILY_OPTION_VARS: + return None return next((family for prefix, family in _VAR_FAMILIES.items() if var.startswith(prefix)), 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 17f21f28adf..9cb3dbb9deb 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_destinations.py @@ -1819,6 +1819,35 @@ class TestSpanScope: with pytest.raises(ValueError, match="langfuse_span_scope"): OpenTelemetryV2Config(langfuse_span_scope="everything") + @pytest.mark.parametrize("spelling", ["LLM_ONLY", "Llm_Only", " llm_only\n"]) + def test_the_env_var_is_read_case_and_whitespace_insensitively(self, monkeypatch, spelling): + """A misspelt env var would otherwise fail validation inside the logger builder, + which swallows the error and leaves the proxy up with OTel v2 silently off.""" + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_SPAN_SCOPE", spelling) + + assert OpenTelemetryV2Config().langfuse_span_scope == "llm_only" + + def test_the_operator_scope_does_not_reach_a_tenants_routed_provider(self, monkeypatch): + """The routed clone carries the tenant's credentials on the operator's Langfuse + exporter. The operator's ``llm_only`` is a choice about the operator's account, + so the clone must export the full tree, as the field's contract promises.""" + tenant = InMemorySpanExporter() + monkeypatch.setattr(otel_providers, "_exporter_from_spec", lambda _spec: tenant) + config = OpenTelemetryV2Config( + langfuse_span_scope="llm_only", + exporters=[ExporterSpec(kind="otlp_http", endpoint="http://op.local", owner=ExporterOwner.LANGFUSE_OTEL)], + ) + cache = TenantTracerCache(config, "langfuse_otel", "litellm") + route = cache.route_for( + get_tracer(TracerProvider(), "litellm"), {"langfuse_public_key": "pk", "langfuse_secret_key": "sk"} + ) + assert route.provider is not None + + request_tree(route.provider) + route.provider.force_flush() + + assert names(tenant) == REQUEST_TREE + 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() diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index bdc12dad4bc..dfda61b6560 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -1538,6 +1538,12 @@ async def test_proxy_admin_still_told_the_team_is_unknown(): ({"langsmith_api_key": "k"}, [{"dd_api_key": "k"}], False), # variables that configure no backend carry nothing to redirect ({"turn_off_message_logging": "true"}, [{"langfuse_secret_key": "sk"}], False), + # the span scope picks what the family exports, not where to, so a second + # entry may set either legal value next to the family's credentials + ({"langfuse_span_scope": "llm_only"}, [{"langfuse_public_key": "pk", "langfuse_secret_key": "sk"}], False), + ({"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_span_scope": "full"}, [{"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_span_scope": "llm_only"}], False), + # the scope on the stored entry must not shield a redirect riding next to it + ({"langfuse_host": "http://attacker.invalid", "langfuse_span_scope": "llm_only"}, [{"langfuse_public_key": "pk", "langfuse_secret_key": "sk", "langfuse_span_scope": "llm_only"}], True), # the same integration registered for a second event: identical values # flatten to the identical dict, so there is nothing to redirect ({"langfuse_host": "https://us.cloud.langfuse.com", "langfuse_public_key": "pk", "langfuse_secret_key": "sk"}, [{"langfuse_host": "https://us.cloud.langfuse.com", "langfuse_public_key": "pk", "langfuse_secret_key": "sk"}], False),