fix(langfuse): honor LANGFUSE_DEBUG on the callbacks path, cap retry backoff, name the auth failure status and body

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-22 18:37:46 +00:00
parent 6b41d4ece1
commit 0b0e309b3b
4 changed files with 60 additions and 7 deletions

View file

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

View file

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

View file

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

View file

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