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>
This commit is contained in:
yucheng 2026-09-19 22:00:02 +00:00
parent 95c1d5b0a6
commit 358e4ea27a
5 changed files with 55 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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

View file

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