diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 23a47b73789..115799380ef 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -302,7 +302,7 @@ class LangFuseLogger: ) from e raise_if_unsupported_langfuse_version(self.langfuse_sdk_version) raise_if_unusable_prompt_cache_ttl() - from litellm.integrations.langfuse.langfuse_sdk import configured_release, enable_langfuse_debug_logging + from litellm.integrations.langfuse.langfuse_sdk import configured_release self.public_key, self.secret_key, self.langfuse_host = resolve_langfuse_credentials( langfuse_public_key=langfuse_public_key, @@ -318,8 +318,6 @@ class LangFuseLogger: self.langfuse_environment = self.resolve_deployment_environment() self.langfuse_release = configured_release() self.langfuse_debug = parse_langfuse_debug(os.getenv("LANGFUSE_DEBUG")) - if self.langfuse_debug: - enable_langfuse_debug_logging() self.langfuse_flush_interval = LangFuseLogger._get_langfuse_flush_interval(flush_interval) if should_use_langfuse_mock(): diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index 6bf41bf2956..62aeb953f5e 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -20,6 +20,7 @@ import httpx import opentelemetry.trace as otel_trace from langfuse import LangfuseOtelSpanAttributes from langfuse.api import LangfuseAPI, Prompt, Prompt_Chat +from langfuse.api.core.api_error import ApiError from langfuse.model import BasePromptClient, ChatPromptClient, PromptClient, TextPromptClient from opentelemetry.context import Context from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans @@ -30,10 +31,11 @@ from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import ALWAYS_ON, Decision, Sampler, SamplingResult from opentelemetry.trace import Link, NonRecordingSpan, Span, SpanContext, SpanKind, TraceFlags, Tracer, TraceState from opentelemetry.util.types import Attributes, AttributeValue +from pydantic import BaseModel, ConfigDict import litellm from litellm._logging import verbose_logger -from litellm.integrations.langfuse.langfuse import PROMPT_CACHE_TTL_ENV, whole_number +from litellm.integrations.langfuse.langfuse import PROMPT_CACHE_TTL_ENV, parse_langfuse_debug, whole_number from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.custom_httpx.http_handler import HTTPHandler, _get_httpx_client @@ -80,6 +82,7 @@ _DEFAULT_FLUSH_AT: Final = 512 _CHANNEL_RETIRE_GRACE_SECONDS: Final = 60.0 _DEFAULT_TIMEOUT_SECONDS: Final = 20.0 _DEFAULT_MAX_RETRIES: Final = 3 +_MAX_BACKOFF_EXPONENT: Final = 6 _DEFAULT_PROMPT_CACHE_TTL_SECONDS: Final = 60.0 _JSON_SAFE_INT: Final = 2**53 - 1 _COMMON_RELEASE_ENVS: Final = ( @@ -686,7 +689,7 @@ def _build_span_exporter(*, public_key: str, secret_key: str, base_url: str) -> } ), timeout=configured_timeout(), - delays=tuple(2.0**attempt for attempt in range(configured_max_retries())), + delays=tuple(2.0 ** min(attempt, _MAX_BACKOFF_EXPONENT) for attempt in range(configured_max_retries())), ) @@ -782,6 +785,8 @@ def acquire_langfuse_tracing( A provider owns a batch export thread, so a channel lives while any logger holds it and is retired through ``release_langfuse_tracing`` once the last holder lets go. """ + if parse_langfuse_debug(os.getenv("LANGFUSE_DEBUG")): + enable_langfuse_debug_logging() key: Final = _TracingKey( public_key=public_key, secret_key=secret_key, @@ -942,6 +947,19 @@ def _auth_check_failure(reason: str) -> AuthCheckFailure: return AuthCheckFailure(reason) +class _ApiErrorDetail(BaseModel): + """The status and body of an ``ApiError``, whose own ``str`` also dumps every response header.""" + + model_config = ConfigDict(frozen=True, from_attributes=True) + status_code: int | None + body: object + + +def _api_error_reason(error: ApiError) -> str: + detail: Final = _ApiErrorDetail.model_validate(error) + return f"status_code: {detail.status_code}, body: {detail.body}" + + class LangfuseApiClient: """litellm's handle on one Langfuse project over its REST API: prompts, ``auth_check`` and the project id. @@ -973,7 +991,9 @@ class LangfuseApiClient: """ try: projects: Final = self.api.projects.get().data - except Exception as error: # noqa: BLE001 # ApiError, httpx transport errors or a body the response model rejects + except ApiError as error: + return _auth_check_failure(_api_error_reason(error)) + except Exception as error: # noqa: BLE001 # httpx transport errors or a body the response model rejects return _auth_check_failure(str(error) or type(error).__name__) if not projects: return _auth_check_failure("no project found for the keys provided") diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py index 5e05ede0688..a7a553b2d9f 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py @@ -190,6 +190,31 @@ def test_langfuse_client_init_mock_mode_makes_no_network_calls(monkeypatch): assert received == [], f"LANGFUSE_MOCK still sent spans to the configured host: {received}" +def test_langfuse_debug_reaches_the_export_channel_through_the_registered_callback(monkeypatch): + """The registry maps ``langfuse`` to this class, whose constructor never runs ``LangFuseLogger.__init__``, + so wiring ``LANGFUSE_DEBUG`` only there left the flag a no-op on the YAML callback path.""" + import logging + + from litellm.integrations.langfuse.langfuse_sdk import release_langfuse_tracing + + monkeypatch.setenv("LANGFUSE_MOCK", "true") + monkeypatch.setenv("LANGFUSE_HOST", "http://127.0.0.1:1") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-pm-debug-wire") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-pm-debug-wire") + monkeypatch.setenv("LANGFUSE_DEBUG", "true") + langfuse_client_init.cache_clear() + langfuse_logger: Final = logging.getLogger("langfuse") + level_before: Final = langfuse_logger.level + langfuse_logger.setLevel(logging.WARNING) + try: + logger = LangfusePromptManagement() + assert langfuse_logger.level == logging.DEBUG + release_langfuse_tracing(logger.tracing, grace_seconds=0.0) + finally: + langfuse_logger.setLevel(level_before) + langfuse_client_init.cache_clear() + + @pytest.mark.asyncio async def test_async_log_failure_event_records_trace_id_for_alerting(monkeypatch): from litellm.integrations.langfuse.langfuse_sdk import resolve_trace_id diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index 010d77e15a4..2603aae6211 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -1004,7 +1004,7 @@ def test_auth_check_names_the_servers_rejection(caplog): with caplog.at_level(logging.WARNING, logger="LiteLLM"): failure = client.auth_check() assert failure is not None - assert "status_code: 401" in failure.reason and "unauthorized" in failure.reason + assert failure.reason == "status_code: 401, body: {'message': 'unauthorized'}" assert failure.reason in caplog.text @@ -1306,6 +1306,16 @@ def test_built_exporter_uses_the_shared_litellm_handler_and_langfuse_headers(mon assert exporter.headers["x-langfuse-ingestion-version"] == "4" +def test_large_retry_count_builds_an_exporter_with_capped_backoff(monkeypatch): + """``LANGFUSE_MAX_RETRIES=1025`` constructed a v2 client; here ``2.0**1024`` would raise ``OverflowError`` + and take the whole callback down at init.""" + monkeypatch.setenv("LANGFUSE_MAX_RETRIES", "1025") + exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example") + assert len(exporter.delays) == 1025 + assert exporter.delays[:4] == (1.0, 2.0, 4.0, 8.0) + assert max(exporter.delays) == exporter.delays[-1] <= 64.0 + + def test_enable_langfuse_debug_logging_makes_deliveries_visible_on_the_langfuse_logger(caplog): """``LANGFUSE_DEBUG`` turned on the v2 SDK's own logger; it has to do the same for litellm's export channel.""" exporter, _ = _exporter_over([200])