fix(langfuse): name a malformed prompt cache TTL before the SDK import, keep metadata ints JSON safe, retry every 5xx export

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-21 18:35:26 +00:00
parent 2046264d55
commit c662597f0d
6 changed files with 103 additions and 25 deletions

View file

@ -174,6 +174,7 @@ def _as_steering_key_sequence(value: object) -> tuple[str, ...]:
MINIMUM_LANGFUSE_VERSION: Final = "4.7"
UNSUPPORTED_LANGFUSE_VERSION: Final = "5"
PROMPT_CACHE_TTL_ENV: Final = "LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS"
def installed_langfuse_version() -> str:
@ -205,6 +206,22 @@ def raise_if_unsupported_langfuse_version(installed_version: str) -> None:
)
def whole_number(raw: str) -> int | None:
try:
return int(raw)
except ValueError:
return None
def raise_if_unusable_prompt_cache_ttl() -> None:
"""The v4 SDK runs ``int()`` on this variable while it is being imported, so a value that is not a whole
number has to be named here, before that import fails with a bare ``ValueError`` on every request."""
raw: Final = os.environ.get(PROMPT_CACHE_TTL_ENV)
if raw is None or whole_number(raw) is not None:
return
raise ValueError(f"\033[91m{PROMPT_CACHE_TTL_ENV}={raw!r} must be a whole number of seconds\033[0m")
def _optional_str(value: object) -> str | None:
"""v4 sets attribute values raw; a non-string version would be dropped by the server."""
return str(value) if value is not None else None
@ -284,6 +301,7 @@ class LangFuseLogger:
f"\033[91mLangfuse not installed, try running 'pip install langfuse' to fix this error: {e}\033[0m"
) 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
self.public_key, self.secret_key, self.langfuse_host = resolve_langfuse_credentials(

View file

@ -23,6 +23,7 @@ from .langfuse import (
LangFuseLogger,
installed_langfuse_version,
raise_if_unsupported_langfuse_version,
raise_if_unusable_prompt_cache_ttl,
resolve_langfuse_credentials,
warn_if_upstream_langfuse_configured,
)
@ -70,6 +71,8 @@ def langfuse_client_init(
Raises:
Exception: If langfuse package is not installed
"""
raise_if_unsupported_langfuse_version(installed_langfuse_version())
raise_if_unusable_prompt_cache_ttl()
try:
from .langfuse_sdk import build_langfuse_client
except Exception as e:
@ -89,7 +92,6 @@ def langfuse_client_init(
# add http:// if unset, assume communicating over private network - e.g. render
langfuse_host = "http://" + langfuse_host
raise_if_unsupported_langfuse_version(installed_langfuse_version())
warn_if_upstream_langfuse_configured()
httpx_client: Final = create_mock_langfuse_client() if should_use_langfuse_mock() else HTTPHandler().client
@ -119,6 +121,7 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge
self.langfuse_sdk_version = installed_langfuse_version()
raise_if_unsupported_langfuse_version(self.langfuse_sdk_version)
raise_if_unusable_prompt_cache_ttl()
from .langfuse_sdk import acquire_langfuse_tracing, configured_release

View file

@ -32,6 +32,7 @@ from opentelemetry.util.types import Attributes, AttributeValue
import litellm
from litellm._logging import verbose_logger
from litellm.integrations.langfuse.langfuse import PROMPT_CACHE_TTL_ENV, 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
@ -70,8 +71,7 @@ _CHANNEL_RETIRE_GRACE_SECONDS: Final = 60.0
_DEFAULT_TIMEOUT_SECONDS: Final = 20.0
_DEFAULT_MAX_RETRIES: Final = 3
_DEFAULT_PROMPT_CACHE_TTL_SECONDS: Final = 60.0
_INT64_MIN: Final = -(2**63)
_INT64_MAX: Final = 2**63 - 1
_JSON_SAFE_INT: Final = 2**53 - 1
_COMMON_RELEASE_ENVS: Final = (
"RENDER_GIT_COMMIT",
"CI_COMMIT_SHA",
@ -162,10 +162,11 @@ def _present(entries: Iterable[tuple[str, AttributeValue | None]]) -> Mapping[st
def _metadata_value(value: object) -> AttributeValue | None:
"""A metadata value as OTLP can carry it: ints past int64 go as strings, as the v2 serializer sent them."""
"""A metadata value as it survives the trip: OTLP drops ints past int64 and a JSON reader rounds ints past
2**53, so those go as strings, which is how v2's readback showed them."""
if isinstance(value, (str, bool)):
return value
if isinstance(value, int) and _INT64_MIN <= value <= _INT64_MAX:
if isinstance(value, int) and -_JSON_SAFE_INT <= value <= _JSON_SAFE_INT:
return value
return _serialize(value)
@ -488,19 +489,21 @@ def configured_release() -> str | None:
def configured_prompt_cache_ttl() -> float:
"""``LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS``, the SDK's knob, with its 60 s default when unset or unusable."""
raw: Final = os.environ.get("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS")
"""``LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS`` in whole seconds as the SDK reads it, its 60 s default when unset
or unusable; ``raise_if_unusable_prompt_cache_ttl`` has already named a value that is not a whole number."""
raw: Final = os.environ.get(PROMPT_CACHE_TTL_ENV)
if raw is None:
return _DEFAULT_PROMPT_CACHE_TTL_SECONDS
parsed: Final = _parse_float(raw)
parsed: Final = whole_number(raw)
if parsed is None or parsed < 0:
verbose_logger.warning(
"LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS=%r is not a number of seconds at or above 0; caching prompts for %.0f s",
"%s=%r is not a whole number of seconds at or above 0; caching prompts for %.0f s",
PROMPT_CACHE_TTL_ENV,
raw,
_DEFAULT_PROMPT_CACHE_TTL_SECONDS,
)
return _DEFAULT_PROMPT_CACHE_TTL_SECONDS
return parsed
return float(parsed)
def configured_flush_at() -> int:
@ -538,10 +541,14 @@ class DiscardingSpanExporter(SpanExporter):
return True
_RETRYABLE_EXPORT_STATUSES: Final = frozenset({408, 429, 500, 502, 503, 504})
_ExportOutcome = Literal["delivered", "retry", "rejected"]
def _retryable_status(status: int) -> bool:
"""Any 5xx, a timeout or a rate limit: what the v2 consumer re-sent, plus the 408 the OTLP exporter retries."""
return status in (408, 429) or 500 <= status <= 599
@dataclass(frozen=True, slots=True)
class LangfuseSpanExporter(SpanExporter):
"""OTLP/HTTP protobuf export through litellm's own HTTP handler.
@ -581,7 +588,7 @@ class LangfuseSpanExporter(SpanExporter):
self.handler.post(self.endpoint, data=body, headers=dict(self.headers), timeout=self.timeout)
except httpx.HTTPStatusError as error:
status: Final = error.response.status_code
if status in _RETRYABLE_EXPORT_STATUSES:
if _retryable_status(status):
return "retry"
verbose_logger.error("Langfuse rejected an export to %s with HTTP %d", self.endpoint, status)
return "rejected"

View file

@ -238,3 +238,18 @@ def test_old_sdk_fails_with_the_upgrade_message_before_the_otel_module_is_import
assert "2.59.7" in str(raised.value)
assert "langfuse_otel" in str(raised.value)
@pytest.mark.parametrize("raw", ["abc", "2.5"], ids=["text", "fraction"])
def test_prompt_cache_ttl_typo_is_named_instead_of_reported_as_not_installed(monkeypatch, raw):
"""The v4 SDK runs ``int()`` on this variable at import, and ``langfuse_client_init`` wraps any import
failure as "Langfuse not installed", so the gate has to run before that import."""
monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw)
monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse_sdk", None)
langfuse_client_init.cache_clear()
with pytest.raises(ValueError, match="LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS") as raised:
langfuse_client_init(langfuse_public_key="pk-ttl", langfuse_secret="sk-ttl", langfuse_host="http://127.0.0.1:1")
assert "not installed" not in str(raised.value)
assert repr(raw) in str(raised.value)

View file

@ -527,17 +527,30 @@ def test_host_otel_resource_env_does_not_reach_the_langfuse_resource(monkeypatch
@pytest.mark.parametrize(
("value", "encoded"),
[
(2**63 - 1, ("int_value", 2**63 - 1)),
(2**53 - 1, ("int_value", 2**53 - 1)),
(-(2**53) + 1, ("int_value", -(2**53) + 1)),
(2**53, ("string_value", str(2**53))),
(2**63 - 1, ("string_value", str(2**63 - 1))),
(2**63, ("string_value", str(2**63))),
(10**20, ("string_value", str(10**20))),
(-(2**63) - 1, ("string_value", str(-(2**63) - 1))),
(True, ("bool_value", True)),
],
ids=["int64-max", "int64-max-plus-one", "huge", "int64-min-minus-one", "bool"],
ids=[
"json-safe-max",
"json-safe-min",
"json-safe-plus-one",
"int64-max",
"int64-max-plus-one",
"huge",
"int64-min-minus-one",
"bool",
],
)
def test_metadata_ints_past_int64_reach_the_wire_as_strings(value, encoded):
"""OTLP carries int64 only and its encoder silently drops any attribute it cannot fit, while the
export still succeeds; v2's serializer sent such ints as strings, so the value has to survive."""
def test_metadata_ints_past_the_json_safe_range_reach_the_wire_as_strings(value, encoded):
"""OTLP carries int64 only and its encoder silently drops any attribute it cannot fit, while the export
still succeeds, and Langfuse's reader rounds ints past 2**53 (int64 max read back as 9223372036854776000
on 2026-09-21, where the v2 leg showed the exact digits as a string), so both ranges go as strings."""
from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans
exporter = InMemorySpanExporter()
@ -562,18 +575,19 @@ def test_metadata_ints_past_int64_reach_the_wire_as_strings(value, encoded):
@pytest.mark.parametrize(
("raw", "expected"),
[(None, 60.0), ("5", 5.0), ("0", 0.0), ("2.5", 2.5), ("-1", 60.0), ("abc", 60.0)],
ids=["unset", "whole", "zero", "fraction", "negative", "text"],
[(None, 60.0), ("5", 5.0), ("0", 0.0), (" -1 ", 60.0), ("2.5", 60.0), ("abc", 60.0)],
ids=["unset", "whole", "zero", "negative", "fraction", "text"],
)
def test_prompt_cache_ttl_env_falls_back_instead_of_raising(monkeypatch: pytest.MonkeyPatch, raw, expected, caplog):
"""A typo in the SDK's TTL knob used to raise out of logger construction and fail the request."""
"""The SDK reads this knob as whole seconds; a negative one passes its import but must not cache forever,
and anything else falls back rather than raising out of logger construction."""
if raw is None:
monkeypatch.delenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raising=False)
else:
monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw)
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
assert configured_prompt_cache_ttl() == expected
assert ("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS" in caplog.text) is (raw in ("-1", "abc"))
assert ("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS" in caplog.text) is (expected == 60.0 and raw is not None)
def test_many_metadata_keys_never_evict_the_generation_input_and_output():
@ -1088,11 +1102,11 @@ def test_exporter_posts_the_otlp_batch_through_litellm_http_handler(monkeypatch)
@pytest.mark.parametrize(
"failure",
[httpx.ReadTimeout("stalled"), httpx.ConnectError("refused"), 503, 429],
ids=["read-timeout", "connect-error", "http-503", "http-429"],
[httpx.ReadTimeout("stalled"), httpx.ConnectError("refused"), 503, 429, 408, 501, 507, 599],
ids=["read-timeout", "connect-error", "http-503", "http-429", "http-408", "http-501", "http-507", "http-599"],
)
def test_exporter_retries_a_failed_round_trip_and_then_succeeds(monkeypatch, failure):
"""A stalled or restarting destination used to drop the batch outright; v2 backed off and re-sent it."""
"""A stalled or restarting destination used to drop the batch outright; v2 backed off and re-sent every 5xx."""
slept = []
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", slept.append)
exporter, seen = _exporter_over([failure, failure, 200], delays=(0.5, 1.5, 2.5))
@ -1113,7 +1127,7 @@ def test_exporter_gives_up_after_the_last_delay(monkeypatch):
assert slept == [1.0, 2.0]
@pytest.mark.parametrize("status", [400, 401, 403, 404])
@pytest.mark.parametrize("status", [400, 401, 403, 404, 413, 422, 499])
def test_exporter_does_not_retry_a_rejected_batch(monkeypatch, status):
"""Bad credentials or a bad payload will not get better on the next attempt, so retrying only delays the flush."""
slept = []

View file

@ -2305,6 +2305,27 @@ def test_missing_sdk_is_reported_as_not_installed(monkeypatch):
_build_langfuse_logger(monkeypatch, langfuse_public_key="pk-no-sdk")
@pytest.mark.parametrize("raw", ["abc", "2.5", ""], ids=["text", "fraction", "empty"])
def test_prompt_cache_ttl_typo_is_named_before_the_sdk_is_imported(monkeypatch, raw):
"""The v4 SDK evaluates ``int(LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS)`` at import, so without this
gate every request failed with a bare ``invalid literal for int()`` that never named the variable."""
import sys
monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw)
monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse_sdk", None)
with pytest.raises(ValueError, match="LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS") as raised:
_build_langfuse_logger(monkeypatch, langfuse_public_key="pk-ttl-typo")
assert repr(raw) in str(raised.value)
@pytest.mark.parametrize("raw", ["5", " -3 ", "+0"], ids=["whole", "negative", "signed-zero"])
def test_whole_second_prompt_cache_ttl_passes_the_gate(monkeypatch, raw):
monkeypatch.setenv("LANGFUSE_PROMPT_CACHE_DEFAULT_TTL_SECONDS", raw)
langfuse_module.raise_if_unusable_prompt_cache_ttl()
def test_stopped_logger_hands_its_export_channel_back(monkeypatch):
"""`DynamicLoggingCache` calls `stop()` on expiry; the channel must be retired once every
logger that held it has stopped, or each credential rotation leaks a batch export thread."""