From a1ea22e9966c73addc0a8db75cbdb9444ca2092e Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Sat, 14 Nov 2026 15:45:11 -0800 Subject: [PATCH] feat(otel v2): proxy-level default langfuse_span_scope for key and team destinations (#51726) * feat(otel v2): proxy-level default langfuse_span_scope for key and team destinations Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): regenerate dashboard API documentation types Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): preserve tenant destinations with malformed otel settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel): cover explicit narrow scope and non-Langfuse defaults Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Read only the prefixed Langfuse destination scope environment variable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/config.py | 47 +++++- .../integrations/otel/presets/destinations.py | 9 +- litellm/proxy/litellm_pre_call_utils.py | 10 +- .../team_callback_endpoints.py | 5 +- .../components/team/LoggingSettings.test.tsx | 5 + .../src/components/team/LoggingSettings.tsx | 3 + .../src/i18n/locales/en.json | 1 + .../src/lib/http/schema.d.ts | 5 +- .../test_langfuse_destination_span_scope.py | 138 +++++++++++++++++ .../otel/test_otel_v2_destinations.py | 141 ++++++++++++++++++ 10 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 tests/integration/observability/test_langfuse_destination_span_scope.py diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index ea570bc19c9..3d4de310753 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from enum import Enum -from functools import lru_cache +from functools import cache, lru_cache from typing import Annotated, Final from pydantic import AliasChoices, ConfigDict, Field, TypeAdapter, ValidationError, field_validator, model_validator @@ -218,7 +218,8 @@ class OpenTelemetryV2Config(BaseSettings): 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." + "key/team destinations are not affected. Destinations default to " + "``langfuse_destination_span_scope`` instead." ), ) excluded_services: Annotated[frozenset[str], NoDecode] = Field( @@ -317,9 +318,7 @@ class OpenTelemetryV2Config(BaseSettings): @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 + return _normalized_span_scope(value) @field_validator( "baggage_promoted_keys", @@ -417,6 +416,44 @@ def capture_message_content_from_env() -> str: return _CaptureMessageContentSettings().capture_message_content +def _normalized_span_scope(value: object) -> object: + return value.strip().lower() if isinstance(value, str) else value + + +class _LangfuseDestinationSpanScopeSettings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore", frozen=True) + + langfuse_destination_span_scope: OtelSpanScope = Field( + default="full", + validation_alias=AliasChoices("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE"), + ) + + @field_validator("langfuse_destination_span_scope", mode="before") + @classmethod + def _normalize_scope(cls, value: object) -> object: + return _normalized_span_scope(value) + + +def langfuse_destination_span_scope(configured: object = None) -> OtelSpanScope: + return _cached_langfuse_destination_span_scope(None if configured is None else str(configured)) + + +@cache +def _cached_langfuse_destination_span_scope(configured: str | None) -> OtelSpanScope: + try: + return _LangfuseDestinationSpanScopeSettings.model_validate( + {"LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE": configured} if configured is not None else {} + ).langfuse_destination_span_scope + except ValidationError as error: + verbose_logger.warning( + "Invalid LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE or " + "callback_settings.otel.langfuse_destination_span_scope. " + "Allowed values are 'full' and 'llm_only'. Falling back to 'full'. %s", + error, + ) + return "full" + + _EXCLUDED_SERVICES_INPUT: Final[TypeAdapter[str | tuple[object, ...]]] = TypeAdapter(str | tuple[object, ...]) diff --git a/litellm/integrations/otel/presets/destinations.py b/litellm/integrations/otel/presets/destinations.py index a14d1b69b5a..f14e92686d8 100644 --- a/litellm/integrations/otel/presets/destinations.py +++ b/litellm/integrations/otel/presets/destinations.py @@ -161,10 +161,12 @@ def _resource_defaults(callback_name: str) -> Mapping[str, str]: return backend() if backend is not None else _NO_ATTRS -def _span_scope(callback_name: str, params: StandardCallbackDynamicParams) -> OtelSpanScope: +def _span_scope( + callback_name: str, params: StandardCallbackDynamicParams, langfuse_destination_span_scope: OtelSpanScope +) -> OtelSpanScope: if callback_name != "langfuse_otel": return "full" - return params.get("langfuse_span_scope") or "full" + return params.get("langfuse_span_scope") or langfuse_destination_span_scope def destination_capable_backends() -> frozenset[str]: @@ -199,6 +201,7 @@ def destination_for( capture_message_content: str | None = None, excluded_db_systems: frozenset[str] | None = None, metadata_keys: tuple[str, ...] | None = None, + langfuse_destination_span_scope: OtelSpanScope = "full", ) -> OtelDestination | None: """The destination ``params`` names for ``callback_name``, or ``None``. @@ -228,7 +231,7 @@ def destination_for( resource_defaults=_resource_defaults(callback_name), callback_name=callback_name, protocol=protocol, - span_scope=_span_scope(callback_name, params), + span_scope=_span_scope(callback_name, params, langfuse_destination_span_scope), capture_message_content=capture_message_content, excluded_db_systems=excluded_db_systems, metadata_keys=metadata_keys or (), diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 86f16411724..4fc2a1419c8 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -1595,7 +1595,11 @@ def _tenant_destination( name: str, callbacks: Sequence[AddTeamCallback], service_name: str | None ) -> "OtelDestination | None": from litellm.integrations.otel.model.baggage import parse_destination_metadata_keys - from litellm.integrations.otel.model.config import parse_capture_message_content, parse_excluded_services + from litellm.integrations.otel.model.config import ( + langfuse_destination_span_scope, + parse_capture_message_content, + parse_excluded_services, + ) from litellm.integrations.otel.presets.destinations import destination_for callback_vars: Final = MappingProxyType( @@ -1605,6 +1609,7 @@ def _tenant_destination( ) ) ) + otel_settings: Final = as_str_mapping((litellm.callback_settings or {}).get("otel")) or {} return destination_for( name, _tenant_otel_params(callback_vars), @@ -1612,6 +1617,9 @@ def _tenant_destination( parse_capture_message_content(callback_vars.get(CAPTURE_MESSAGE_CONTENT_VAR)), parse_excluded_services(callback_vars.get(EXCLUDED_SERVICES_VAR)), parse_destination_metadata_keys(callback_vars.get(BAGGAGE_METADATA_KEYS_VAR)), + langfuse_destination_span_scope=langfuse_destination_span_scope( + otel_settings.get("langfuse_destination_span_scope") + ), ) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 24675406567..ffe530cb5db 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -301,7 +301,10 @@ 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 + - langfuse_span_scope: For langfuse_otel, "full" sends the whole request trace, + "llm_only" sends only model-call spans. + Omitted follows LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE (default "full"), also configurable as + callback_settings.otel.langfuse_destination_span_scope. - capture_message_content: For OTel v2 callbacks, "no_content" or "span_only". An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting. "span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out diff --git a/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx b/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx index 670bd710d38..d418b0b1466 100644 --- a/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx @@ -230,6 +230,11 @@ describe("LoggingSettings", () => { renderWithProviders(); expect(screen.queryByPlaceholderText("os.environ/LANGFUSE_SPAN_SCOPE")).not.toBeInTheDocument(); + expect( + screen.getByText( + "Leave empty to use the proxy default (LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE, full if unset).", + ), + ).toBeInTheDocument(); await user.click(screen.getByRole("combobox", { name: "langfuse span scope" })); expect((await screen.findAllByRole("option")).map((option) => option.textContent)).toEqual(["full", "llm_only"]); await user.click(screen.getByRole("option", { name: "llm_only" })); diff --git a/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx b/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx index f091bbbb929..77bb2a792f4 100644 --- a/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx +++ b/litellm/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx @@ -250,6 +250,9 @@ const LoggingSettings: React.FC = ({ {paramType === "number" && ( {t("teams.loggingSettings.numberHint")} )} + {paramName === "langfuse_span_scope" && ( +

{t("teams.loggingSettings.langfuseSpanScopeHint")}

+ )} {renderParamControl(config, configIndex, paramName, { type: paramType, options: paramType === "select" ? paramOptions[paramName] || [] : [], diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index b52a8f6e4a8..5f9d04881e8 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -17339,6 +17339,7 @@ "number": "Number" }, "numberHint": "Value must be between 0 and 1", + "langfuseSpanScopeHint": "Leave empty to use the proxy default (LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE, full if unset).", "disabledCallbacks": { "title": "Disabled Callbacks", "tooltip": "Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.", diff --git a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts index 311b7297f4d..855aac16f03 100644 --- a/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -18844,7 +18844,10 @@ 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 + * - langfuse_span_scope: For langfuse_otel, "full" sends the whole request trace, + * "llm_only" sends only model-call spans. + * Omitted follows LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE (default "full"), also configurable as + * callback_settings.otel.langfuse_destination_span_scope. * - capture_message_content: For OTel v2 callbacks, "no_content" or "span_only". * An explicit value overrides the global setting for this destination; omitted follows the proxy's own callback for that backend, else the global setting. * "span_only" puts prompt and response content on the destination's spans, "no_content" leaves it out diff --git a/tests/integration/observability/test_langfuse_destination_span_scope.py b/tests/integration/observability/test_langfuse_destination_span_scope.py new file mode 100644 index 00000000000..e393828fd19 --- /dev/null +++ b/tests/integration/observability/test_langfuse_destination_span_scope.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import Final, Literal + +import pytest +from integration._support.client import Gateway, gateway_from_environment, held +from integration._support.otlp_sink import ( + AuditConfigWriter, + SpanSinks, + langfuse_callback_variables, + recorded_spans, + spans_for_trace, +) +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue + + +def _chat(request: Request) -> Reply: + assert request.target.endswith("/chat/completions"), request.target + assert b'"messages"' in request.body, request.body + return Reply( + body=( + b'{"id":"chatcmpl-scope","object":"chat.completion","created":1,"model":"gpt-4o-mini",' + b'"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],' + b'"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}' + ) + ) + + +@pytest.fixture(scope="module") +def provider() -> Iterator[Wire]: + with wire_server(_chat) as wire: + yield wire + + +@pytest.fixture(scope="module", params=["llm_only", None, "full"], ids=["proxy-llm-only", "proxy-unset", "proxy-full"]) +def proxy( + request: pytest.FixtureRequest, + audit_sinks: SpanSinks, + otel_audit_config: AuditConfigWriter, + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[tuple[Gateway, bool]]: + directory: Final = tmp_path_factory.mktemp("langfuse-destination-scope") + narrowed: Final = request.param == "llm_only" + default: Final = "llm_only" if narrowed else "full" + with ( + gateway_from_environment() as gateway, + owned_proxy_process( + gateway, + directory, + { + "LITELLM_OTEL_V2": "true", + "ARIZE_HTTP_ENDPOINT": audit_sinks.arize + "/v1/traces", + **({"LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE": default} if request.param is not None else {}), + }, + config=otel_audit_config(directory, {}), + remove_environment=( + "LANGFUSE_PUBLIC_KEY", + "LANGFUSE_SECRET_KEY", + "LANGFUSE_HOST", + "LITELLM_OTEL_LANGFUSE_SPAN_SCOPE", + *(("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE",) if request.param is None else ()), + ), + workers=2, + ) as owned, + ): + yield owned.gateway, narrowed + + +@pytest.mark.parametrize( + ("level", "scope", "backend"), + [ + ("key", None, "langfuse_otel"), + ("key", "full", "langfuse_otel"), + ("team", None, "langfuse_otel"), + ("key", "llm_only", "langfuse_otel"), + ("key", None, "arize"), + ], + ids=["key-default", "key-full", "team-default", "key-llm-only", "arize-default"], +) +def test_proxy_default_limits_only_destinations_without_an_explicit_scope( + level: Literal["key", "team"], + scope: str | None, + backend: Literal["langfuse_otel", "arize"], + proxy: tuple[Gateway, bool], + provider: Wire, + audit_sinks: SpanSinks, +) -> None: + gateway, narrowed = proxy + logging: Final[list[JsonValue]] = [ + { + "callback_name": backend, + "callback_type": "success", + "callback_vars": { + **( + langfuse_callback_variables(audit_sinks) + if backend == "langfuse_otel" + else {"arize_api_key": "arize-test-key", "arize_space_id": "arize-test-space"} + ), + **({"langfuse_span_scope": scope} if scope else {}), + }, + } + ] + sink: Final = audit_sinks.tenant if backend == "langfuse_otel" else audit_sinks.arize + with gateway.scenario() as scenario: + model: Final = scenario.model(api_base=provider.url + "/v1") + key: Final = ( + scenario.key(metadata={"logging": logging}) + if level == "key" + else scenario.key(team_id=scenario.team(metadata={"logging": logging})) + ) + since: Final = recorded_spans(sink)[0] + trace_id: Final = uuid.uuid4().hex + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": "destination span scope"}]}, + key=key, + headers={"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"}, + ) + assert response.status_code == 200, response.text + spans: Final = held( + lambda: spans_for_trace(recorded_spans(sink, since)[1], trace_id), + lambda found: any("gen_ai.operation.name" in span["attributes"] for span in found), + holding=3, + seconds=40, + ) + if backend == "langfuse_otel" and (scope == "llm_only" or (narrowed and scope is None)): + assert len(spans) == 1, [(span["name"], span["kind"]) for span in spans] + assert "gen_ai.operation.name" in spans[0]["attributes"], spans + assert not any(span["kind"] == 2 or span["attributes"].get("db.system") == "postgresql" for span in spans) + return + assert any(span["kind"] == 2 for span in spans), [(span["name"], span["kind"]) for span in spans] + assert any("gen_ai.operation.name" in span["attributes"] for span in spans), spans + assert any(span["attributes"].get("db.system") == "postgresql" for span in spans), spans diff --git a/tests/unit/integrations/otel/test_otel_v2_destinations.py b/tests/unit/integrations/otel/test_otel_v2_destinations.py index 60635c5ce45..5dff8f6a048 100644 --- a/tests/unit/integrations/otel/test_otel_v2_destinations.py +++ b/tests/unit/integrations/otel/test_otel_v2_destinations.py @@ -41,7 +41,9 @@ from litellm.integrations.otel.model.config import ( ExporterOwner, ExporterSpec, OpenTelemetryV2Config, + _cached_langfuse_destination_span_scope, is_otel_v2_enabled, + langfuse_destination_span_scope, parse_excluded_services, ) from litellm.integrations.otel.model.destination import OtelDestination @@ -115,6 +117,7 @@ def isolate_published_provider(monkeypatch): def forget_otel_v2_flag_after_each_test(): yield is_otel_v2_enabled.cache_clear() + _cached_langfuse_destination_span_scope.cache_clear() def in_fresh_context(fn, *args): @@ -3035,6 +3038,144 @@ class TestSpanScope: assert destination_for("langfuse_otel", creds).span_scope == "full" + @pytest.mark.parametrize("level", ["key", "team"]) + @pytest.mark.parametrize("source", ["env", "callback_settings"]) + @pytest.mark.parametrize( + ("default", "explicit", "expected"), + [("llm_only", None, "llm_only"), ("llm_only", "full", "full"), (None, None, "full")], + ) + def test_proxy_destination_scope_falls_back_without_overriding_tenant_choices( + self, + monkeypatch: pytest.MonkeyPatch, + allow_test_hosts: None, + level: str, + default: str | None, + explicit: str | None, + expected: str, + source: str, + ) -> None: + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_SPAN_SCOPE", "llm_only") + monkeypatch.setattr(litellm, "callback_settings", {}) + monkeypatch.delenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", raising=False) + if default is not None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", default if source == "env" else "full") + if source == "callback_settings": + monkeypatch.setattr( + litellm, "callback_settings", {"otel": {"langfuse_destination_span_scope": default}} + ) + is_otel_v2_enabled.cache_clear() + metadata: Final = { + "logging": [ + { + "callback_name": "langfuse_otel", + "callback_type": "success", + "callback_vars": { + "langfuse_public_key": "pk", + "langfuse_secret_key": "sk", + "langfuse_host": "http://x", + **({"langfuse_span_scope": explicit} if explicit is not None else {}), + }, + } + ] + } + auth: Final = UserAPIKeyAuth(**({"metadata": metadata} if level == "key" else {"team_metadata": metadata})) + destinations: Final = resolve_tenant_otel_destinations(auth) + assert [destination.span_scope for destination in destinations] == [expected] + + @pytest.mark.parametrize("otel_settings", [None, "invalid", [], 42]) + @pytest.mark.parametrize("default", [None, "llm_only"]) + def test_non_mapping_otel_settings_preserve_key_destination( + self, + monkeypatch: pytest.MonkeyPatch, + allow_test_hosts: None, + otel_settings: object, + default: str | None, + ) -> None: + monkeypatch.setenv("LITELLM_OTEL_V2", "true") + monkeypatch.setattr(litellm, "callback_settings", {"otel": otel_settings}) + monkeypatch.delenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", raising=False) + if default is not None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", default) + is_otel_v2_enabled.cache_clear() + auth: Final = UserAPIKeyAuth( + metadata={ + "logging": [ + { + "callback_name": "langfuse_otel", + "callback_type": "success", + "callback_vars": { + "langfuse_public_key": "pk", + "langfuse_secret_key": "sk", + "langfuse_host": "http://x", + }, + } + ] + } + ) + + assert [destination.span_scope for destination in resolve_tenant_otel_destinations(auth)] == [default or "full"] + + def test_callback_settings_destination_scope_wins_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", "full") + assert langfuse_destination_span_scope(" LLM_ONLY\n") == "llm_only" + + @pytest.mark.parametrize( + ("bare", "prefixed", "configured", "expected"), + [ + ("llm_only", None, None, "full"), + ("full", "llm_only", None, "llm_only"), + ("full", "full", "llm_only", "llm_only"), + ("llm_only", "llm_only", "full", "full"), + ], + ) + def test_only_prefixed_environment_sets_destination_scope( + self, + monkeypatch: pytest.MonkeyPatch, + bare: str, + prefixed: str | None, + configured: str | None, + expected: str, + ) -> None: + monkeypatch.setenv("langfuse_destination_span_scope", bare) + if prefixed is None: + monkeypatch.delenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", raising=False) + else: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", prefixed) + + assert langfuse_destination_span_scope(configured) == expected + + @pytest.mark.parametrize("spelling", ["LLM_ONLY", " llm_only\n", "full"]) + def test_destination_scope_config_normalizes_like_operator_scope( + self, + monkeypatch: pytest.MonkeyPatch, + spelling: str, + ) -> None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", spelling) + assert langfuse_destination_span_scope() == spelling.strip().lower() + + @pytest.mark.parametrize("invalid", ["", "everything"]) + @pytest.mark.parametrize("source", ["env", "callback_settings"]) + def test_invalid_destination_scope_falls_back_and_warns_once( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, invalid: str, source: str + ) -> None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", invalid if source == "env" else "full") + with caplog.at_level("WARNING", logger="LiteLLM"): + assert langfuse_destination_span_scope(invalid if source == "callback_settings" else None) == "full" + assert langfuse_destination_span_scope(invalid if source == "callback_settings" else None) == "full" + warnings: Final = [record.message for record in caplog.records if "DESTINATION_SPAN_SCOPE" in record.message] + assert len(warnings) == 1 + assert repr(invalid) in warnings[0] + assert "'full'" in warnings[0] and "'llm_only'" in warnings[0] + + def test_destination_scope_environment_is_a_process_snapshot(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", "llm_only") + assert langfuse_destination_span_scope() == "llm_only" + monkeypatch.setenv("LITELLM_OTEL_LANGFUSE_DESTINATION_SPAN_SCOPE", "full") + assert langfuse_destination_span_scope() == "llm_only" + assert langfuse_destination_span_scope("full") == "full" + assert langfuse_destination_span_scope("llm_only") == "llm_only" + 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"}