mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rename fork-flag to unit-flag now that it applies on every event * test: move tests/test_litellm root and small trees into tests/unit Pure renames, no content changes. Follow-up commits in this PR fix references, merge the three files that already existed in tests/unit, keep live-provider tests in tests/test_litellm and wire CI. * test: carry tests/test_litellm conftest isolation into tests/unit Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS, proxy-URL and keychain env, and session-end client cleanup now reset for unit tests too. The environment isolation owns its MonkeyPatch so a test's own monkeypatch is undone before the model-cost teardown runs. * test: merge, split and prune the moved root and small-tree tests Merge batches/test_batch_utils.py and the chat_completions and messages dispatch tests into the files that already existed in tests/unit. Keep the live Gemini interactions tests, the async image-fetch format test and the OpenAI embedding scorer test in tests/test_litellm since they need real network or keys. Put test_router.py under tests/unit/test_router so the existing package no longer shadows it. Delete eight tests the audit found superseded by stronger ones kept in this move. * ci: run the moved root and small-tree tests under their legacy flags Add the misc and responses-caching-types flags to unit_selection.sh and CircleCI, extend enterprise-routing and mcp-integration, and point the legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest and change classifier at the new paths. * test: make the new tests/unit directories packages tests/unit/test_package_layout.py requires every directory to carry an __init__.py, and without one the moved and retained test_litellm_responses_bridge.py modules collide on import. * test: scope the unit socket block to tests/unit in shared sessions The GHA shards collect the legacy test-path and the unit selection in one pytest session. The unit conftest's loopback-only block leaked into legacy modules that reach the network at import. The legacy conftest now lifts the restriction at collect and setup time, and the unit conftest re-applies it when collecting its own modules. * test: move tests/test_litellm/llms into tests/unit/llms Rename-only. Moves the provider tests and the fine-tuning fixtures they load, mirroring the old paths. Follow-up commits merge, split and wire them. * test: merge, split and prune the moved llms tests Merges the Databricks chat transformation tests into the existing unit file, keeps the tests that need real keys or the network in tests/test_litellm, deletes the audited tests a stronger unit test already covers, and points imports at tests.unit.llms. * ci: run the moved llms tests under their legacy flags The Vertex AI and All Other Providers shards keep their legacy test-path for the retained files and add the llm-vertex-ai and llm-other-providers unit selections. CircleCI gets matching unit jobs. * test: make the tests/unit/llms directories packages Adds __init__.py to the moved dirs and drops the legacy ones whose directories no longer hold tests. * test: drop script runners and path hacks the llms split left dangling The __main__ runners in the split openai_like files and the Databricks e2e runner called tests that now live in the other half of the split or were deleted. The retained legacy halves also no longer need sys.path edits. * test: give the shard-script tests their own GITHUB_OUTPUT They only passed where the runner set it. The CircleCI unit job's env allowlist drops it, so the script's redirect failed there. * test: point the router and module-deletion checks at tests/unit router_code_coverage and code_qa_check_tests only searched tests/test_litellm, so the moved router tests no longer counted. The two silent-experiment tests the audit deleted were the only direct callers of those methods; they are replaced with tests that assert the forwarded shadow request and the recursion guard. * test: move tests/test_litellm integrations and secret_managers into tests/unit Rename-only. Mirrors the old paths, including the directory conftests and the prompt and JSON fixtures. Follow-up commits prune and wire them. * test: prune and repoint the moved integrations tests Deletes the 7 audited tests a stronger test in the same tree already covers, imports the TLS sink helpers from their new conftest path, and restores os.environ after each integrations test. Some presets write OTEL_EXPORTER_OTLP_HEADERS straight into os.environ, and without the legacy tree's test ordering that header leaked into the AgentOps tests. * ci: run the moved integrations tests under their legacy flag The integrations GHA shard and a new CircleCI job run the integrations unit selection. secret_managers joins the misc selection. * docs: point integrations and secret_managers references at tests/unit * test: make the moved integrations directories packages * test: keep the Databricks manual e2e runner and fix the SageMaker Nova run path The Databricks e2e file is a manual script whose main() calls the tests that were pruned, so pruning them broke the documented run. It is back to its main version. The SageMaker Nova docstring now points at the file's real location in tests/local_testing. * test: keep the job's UNIT_FLAG out of the shard-script tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1759 lines
72 KiB
Python
1759 lines
72 KiB
Python
"""Covers litellm's own Langfuse export channel: plain OTel spans carrying the v2 contracts.
|
|
|
|
The timestamp assertions are the regression guard for the migration: the v4 SDK's public
|
|
API has no observation start time, so a callback running after the model call would
|
|
otherwise record its own duration instead of the call's.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
import uuid
|
|
from base64 import b64encode
|
|
from datetime import datetime, timedelta, timezone
|
|
from time import monotonic, sleep
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import opentelemetry.trace as otel_trace
|
|
import pytest
|
|
from langfuse import LangfuseOtelSpanAttributes as A
|
|
from langfuse.api.core.api_error import ApiError
|
|
from langfuse.api.core.request_options import RequestOptions
|
|
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
|
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
|
|
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
from litellm.integrations.langfuse.langfuse import (
|
|
MINIMUM_LANGFUSE_VERSION,
|
|
installed_langfuse_version,
|
|
raise_if_unsupported_langfuse_version,
|
|
)
|
|
from litellm.integrations.langfuse.langfuse_sdk import (
|
|
DiscardingSpanExporter,
|
|
LangfuseApiClient,
|
|
LangfusePromptError,
|
|
LangfuseSpanExporter,
|
|
LangfuseTracing,
|
|
_build_span_exporter,
|
|
_encode,
|
|
acquire_langfuse_tracing,
|
|
build_langfuse_client,
|
|
build_langfuse_tracing,
|
|
configured_flush_at,
|
|
configured_prompt_cache_ttl,
|
|
configured_sample_rate,
|
|
enable_langfuse_debug_logging,
|
|
flush_langfuse_tracing,
|
|
observation_attributes,
|
|
release_langfuse_tracing,
|
|
resolve_observation_id,
|
|
resolve_trace_id,
|
|
start_child_span,
|
|
start_generation,
|
|
to_unix_nanos,
|
|
trace_attributes,
|
|
)
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
|
|
CALL_START = datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
FIRST_TOKEN = CALL_START + timedelta(seconds=5)
|
|
CALL_END = CALL_START + timedelta(seconds=20)
|
|
TRACE_A = "a" * 32
|
|
PARENT_C = "c" * 16
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _own_channel_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Channels leaked by other test modules would otherwise take part in every process-wide flush here."""
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._TRACING", {})
|
|
|
|
|
|
@pytest.fixture(name="channel")
|
|
def _channel() -> tuple[LangfuseTracing, InMemorySpanExporter]:
|
|
exporter = InMemorySpanExporter()
|
|
return (
|
|
build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
),
|
|
exporter,
|
|
)
|
|
|
|
|
|
def _only_span(exporter, name):
|
|
return next(s for s in exporter.get_finished_spans() if s.name == name)
|
|
|
|
|
|
def _generation(
|
|
tracing,
|
|
*,
|
|
name="gen",
|
|
trace_id=TRACE_A,
|
|
parent=None,
|
|
existing=False,
|
|
observation_id=None,
|
|
public=None,
|
|
attributes=None,
|
|
):
|
|
return start_generation(
|
|
tracing=tracing,
|
|
trace_id=trace_id,
|
|
parent_observation_id=parent,
|
|
existing_trace=existing,
|
|
observation_id=observation_id,
|
|
name=name,
|
|
start_time=CALL_START,
|
|
public=public,
|
|
attributes=attributes if attributes is not None else {},
|
|
)
|
|
|
|
|
|
def test_generation_records_the_model_call_window_not_the_callback(channel):
|
|
tracing, exporter = channel
|
|
attributes = observation_attributes(observation_type="generation", completion_start_time=FIRST_TOKEN)
|
|
_generation(tracing, attributes=attributes).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "gen")
|
|
assert span.start_time == to_unix_nanos(CALL_START)
|
|
assert span.end_time == to_unix_nanos(CALL_END)
|
|
assert (span.end_time - span.start_time) == 20 * 1_000_000_000
|
|
assert datetime.fromisoformat(json.loads(span.attributes[A.OBSERVATION_COMPLETION_START_TIME])) == FIRST_TOKEN
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"supplied",
|
|
[1709294400.5, datetime(2024, 3, 1, 12, 0, 0, 500000, tzinfo=timezone.utc)],
|
|
ids=["unix-seconds-float", "datetime"],
|
|
)
|
|
def test_timestamps_accept_both_shapes_guardrails_and_callbacks_use(supplied):
|
|
"""Guardrail entries carry unix seconds as floats, the callback carries datetimes."""
|
|
assert to_unix_nanos(supplied) == 1709294400500000000
|
|
|
|
|
|
def test_guardrail_span_with_float_timestamps_keeps_its_own_window_under_the_generation(channel):
|
|
tracing, exporter = channel
|
|
guardrail_start = 1709294400.0
|
|
generation = _generation(tracing)
|
|
start_child_span(
|
|
tracing=tracing, parent=generation, name="guardrail", start_time=guardrail_start, attributes={}
|
|
).end(guardrail_start + 2)
|
|
generation.end(CALL_END)
|
|
tracing.flush()
|
|
|
|
guardrail = _only_span(exporter, "guardrail")
|
|
exported_generation = _only_span(exporter, "gen")
|
|
assert (guardrail.end_time - guardrail.start_time) == 2 * 1_000_000_000
|
|
assert guardrail.context.trace_id == exported_generation.context.trace_id
|
|
assert guardrail.parent.span_id == exported_generation.context.span_id
|
|
|
|
|
|
def test_requested_trace_id_is_the_exported_trace_id_and_the_generation_is_its_root(channel):
|
|
"""v2 ``trace(id=...)``: the caller's id is the trace and the generation has no parent."""
|
|
tracing, exporter = channel
|
|
generation = _generation(tracing)
|
|
generation.end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "gen")
|
|
assert generation.trace_id == TRACE_A
|
|
assert format(span.context.trace_id, "032x") == TRACE_A
|
|
assert span.parent is None
|
|
|
|
|
|
def test_parent_observation_id_nests_the_generation_under_the_callers_observation(channel):
|
|
tracing, exporter = channel
|
|
_generation(tracing, name="child-gen", parent=PARENT_C).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "child-gen")
|
|
assert format(span.context.trace_id, "032x") == TRACE_A
|
|
assert format(span.parent.span_id, "016x") == PARENT_C
|
|
assert span.parent.is_remote
|
|
|
|
|
|
def test_existing_trace_is_appended_to_rather_than_rewritten(channel):
|
|
"""v2 ``existing_trace_id``: the generation joins the trace without becoming its root."""
|
|
tracing, exporter = channel
|
|
_generation(tracing, name="continued", existing=True).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "continued")
|
|
assert format(span.context.trace_id, "032x") == TRACE_A
|
|
assert span.parent is not None
|
|
assert span.parent.span_id != 0
|
|
|
|
|
|
def test_generation_does_not_hang_under_the_callers_active_span(channel):
|
|
"""The caller's own OTel span must stay untouched and must not become the generation's parent."""
|
|
tracing, exporter = channel
|
|
app_tracer = TracerProvider().get_tracer("app")
|
|
with app_tracer.start_as_current_span("app-span") as app_span:
|
|
_generation(tracing, trace_id="b" * 32).end(CALL_END)
|
|
attributes_after = dict(app_span.attributes or {})
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "gen")
|
|
assert span.parent is None
|
|
assert format(span.context.trace_id, "032x") == "b" * 32
|
|
assert attributes_after == {}
|
|
|
|
|
|
def test_requested_observation_id_becomes_the_exported_span_id(channel):
|
|
"""v2 ``generation(id=...)``: the caller's id is what the export carries and what ``.id`` returns."""
|
|
tracing, exporter = channel
|
|
requested = resolve_observation_id("chatcmpl-123")
|
|
|
|
generation = _generation(tracing, observation_id=requested)
|
|
generation.end(CALL_END)
|
|
tracing.flush()
|
|
|
|
assert generation.id == requested
|
|
assert format(_only_span(exporter, "gen").context.span_id, "016x") == requested
|
|
|
|
|
|
def test_requested_ids_do_not_leak_into_the_next_span(channel):
|
|
tracing, exporter = channel
|
|
requested = resolve_observation_id("chatcmpl-123")
|
|
|
|
_generation(tracing, name="first", observation_id=requested).end(CALL_END)
|
|
second = _generation(tracing, name="second", trace_id=resolve_trace_id(None))
|
|
second.end(CALL_END)
|
|
child = start_child_span(tracing=tracing, parent=second, name="child", start_time=CALL_END, attributes={})
|
|
child.end(CALL_END)
|
|
tracing.flush()
|
|
|
|
assert second.id != requested
|
|
assert child.id not in (requested, second.id)
|
|
assert len({span.context.span_id for span in exporter.get_finished_spans()}) == 3
|
|
|
|
|
|
@pytest.mark.parametrize("public", [True, False], ids=["public", "private"])
|
|
def test_trace_public_flag_lands_on_the_root_observation(channel, public):
|
|
tracing, exporter = channel
|
|
_generation(tracing, public=public, attributes=trace_attributes(public=public)).end(CALL_END)
|
|
tracing.flush()
|
|
assert _only_span(exporter, "gen").attributes[A.TRACE_PUBLIC] is public
|
|
|
|
|
|
def test_trace_public_flag_is_absent_when_not_requested(channel):
|
|
tracing, exporter = channel
|
|
_generation(tracing, attributes=trace_attributes(public=None)).end(CALL_END)
|
|
tracing.flush()
|
|
assert A.TRACE_PUBLIC not in _only_span(exporter, "gen").attributes
|
|
|
|
|
|
@pytest.mark.parametrize("public", [True, False, None], ids=["public", "private", "unset"])
|
|
def test_child_span_repeats_the_generation_public_flag(channel, public):
|
|
"""The server folds ``public`` across observations and reads a missing value as False.
|
|
|
|
A guardrail span without the flag turned a ``trace_public: true`` request private on Langfuse Cloud.
|
|
"""
|
|
tracing, exporter = channel
|
|
generation = _generation(tracing, public=public)
|
|
start_child_span(tracing=tracing, parent=generation, name="guardrail", start_time=CALL_END, attributes={}).end()
|
|
generation.end(CALL_END)
|
|
tracing.flush()
|
|
|
|
assert _only_span(exporter, "guardrail").attributes.get(A.TRACE_PUBLIC) is public
|
|
|
|
|
|
def test_trace_attributes_carry_the_v2_trace_fields():
|
|
attributes = trace_attributes(
|
|
name="trace-name",
|
|
user_id="user-1",
|
|
session_id="session-1",
|
|
version="v2",
|
|
release="rel-1",
|
|
tags=("a", "b"),
|
|
metadata={"tenant": "t1", "nested": {"k": 1}},
|
|
input={"messages": []},
|
|
output="answer",
|
|
)
|
|
assert attributes[A.TRACE_NAME] == "trace-name"
|
|
assert attributes[A.TRACE_USER_ID] == "user-1"
|
|
assert attributes[A.TRACE_SESSION_ID] == "session-1"
|
|
assert attributes[A.VERSION] == "v2"
|
|
assert attributes[A.RELEASE] == "rel-1"
|
|
assert attributes[A.TRACE_TAGS] == ("a", "b")
|
|
assert attributes[f"{A.TRACE_METADATA}.tenant"] == "t1"
|
|
assert json.loads(attributes[f"{A.TRACE_METADATA}.nested"]) == {"k": 1}
|
|
assert json.loads(attributes[A.TRACE_INPUT]) == {"messages": []}
|
|
assert attributes[A.TRACE_OUTPUT] == "answer"
|
|
|
|
|
|
def test_trace_attributes_skip_what_the_request_did_not_supply():
|
|
assert dict(trace_attributes()) == {}
|
|
|
|
|
|
def test_non_mapping_metadata_is_carried_whole_instead_of_raising():
|
|
"""A truthy non-dict ``trace_metadata`` used to blow up the callback on ``**`` unpacking."""
|
|
attributes = trace_attributes(metadata=("not", "a", "dict"))
|
|
assert json.loads(attributes[A.TRACE_METADATA]) == ["not", "a", "dict"]
|
|
|
|
|
|
def test_observation_attributes_serialize_the_generation_fields():
|
|
attributes = observation_attributes(
|
|
observation_type="generation",
|
|
input=[{"role": "user", "content": "hi"}],
|
|
output={"role": "assistant", "content": "hello"},
|
|
metadata=MappingProxyType({"litellm_call_id": "call-1", "cache_hit": False}),
|
|
level="ERROR",
|
|
status_message="boom",
|
|
model="gpt-4o",
|
|
model_parameters={"temperature": 0.1},
|
|
usage_details={"input": 1, "output": 2},
|
|
cost_details={"total": 0.01},
|
|
prompt="not-a-prompt-client",
|
|
)
|
|
assert attributes[A.OBSERVATION_TYPE] == "generation"
|
|
assert attributes[A.OBSERVATION_LEVEL] == "ERROR"
|
|
assert attributes[A.OBSERVATION_STATUS_MESSAGE] == "boom"
|
|
assert attributes[A.OBSERVATION_MODEL] == "gpt-4o"
|
|
assert json.loads(attributes[A.OBSERVATION_INPUT]) == [{"role": "user", "content": "hi"}]
|
|
assert json.loads(attributes[A.OBSERVATION_OUTPUT]) == {"role": "assistant", "content": "hello"}
|
|
assert json.loads(attributes[A.OBSERVATION_MODEL_PARAMETERS]) == {"temperature": 0.1}
|
|
assert json.loads(attributes[A.OBSERVATION_USAGE_DETAILS]) == {"input": 1, "output": 2}
|
|
assert json.loads(attributes[A.OBSERVATION_COST_DETAILS]) == {"total": 0.01}
|
|
assert attributes[f"{A.OBSERVATION_METADATA}.litellm_call_id"] == "call-1"
|
|
assert attributes[f"{A.OBSERVATION_METADATA}.cache_hit"] is False
|
|
assert A.OBSERVATION_PROMPT_NAME not in attributes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"supplied, expected",
|
|
[
|
|
("0123456789abcdef0123456789abcdef", "0123456789abcdef0123456789abcdef"),
|
|
("0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef0123456789abcdef"),
|
|
("3fe0c940-b69a-de3b-a77c-06102505349a", "3fe0c940b69ade3ba77c06102505349a"),
|
|
],
|
|
ids=["already-hex", "uppercase-hex", "uuid-with-dashes"],
|
|
)
|
|
def test_trace_id_passes_through_when_it_is_already_usable(supplied, expected):
|
|
assert resolve_trace_id(supplied) == expected
|
|
|
|
|
|
def test_arbitrary_trace_id_is_hashed_deterministically():
|
|
first = resolve_trace_id("order-4471")
|
|
assert first == resolve_trace_id("order-4471")
|
|
assert len(first) == 32 and first == first.lower()
|
|
assert first != resolve_trace_id("order-4472")
|
|
|
|
|
|
@pytest.mark.parametrize("supplied", [12345, 12.5, True, False], ids=["int", "float", "true", "false"])
|
|
def test_non_string_trace_id_is_normalized(supplied):
|
|
resolved = resolve_trace_id(supplied)
|
|
|
|
assert len(resolved) == 32
|
|
assert resolved == resolve_trace_id(supplied)
|
|
|
|
|
|
@pytest.mark.parametrize("supplied", [12345, 12.5, True, False], ids=["int", "float", "true", "false"])
|
|
def test_non_string_observation_id_is_normalized(supplied):
|
|
resolved = resolve_observation_id(supplied)
|
|
|
|
assert len(resolved) == 16
|
|
assert resolved == resolve_observation_id(supplied)
|
|
|
|
|
|
def test_all_zero_ids_are_hashed_instead_of_passed_through():
|
|
zero_trace = "0" * 32
|
|
zero_span = "0" * 16
|
|
|
|
assert resolve_trace_id(zero_trace) != zero_trace
|
|
assert resolve_trace_id(zero_trace) == resolve_trace_id(zero_trace)
|
|
assert int(resolve_trace_id(zero_trace), 16) != 0
|
|
assert resolve_observation_id(zero_span) != zero_span
|
|
assert int(resolve_observation_id(zero_span), 16) != 0
|
|
|
|
|
|
def test_hyphen_only_trace_ids_are_deterministic():
|
|
assert resolve_trace_id("---") == resolve_trace_id("---")
|
|
|
|
|
|
def test_trace_id_with_trailing_newline_is_hashed():
|
|
supplied = "a" * 32 + "\n"
|
|
|
|
resolved = resolve_trace_id(supplied)
|
|
|
|
assert resolved != supplied
|
|
assert len(resolved) == 32
|
|
|
|
|
|
def test_missing_trace_id_still_yields_a_valid_trace_id():
|
|
generated = resolve_trace_id(None)
|
|
assert len(generated) == 32
|
|
assert int(generated, 16) >= 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"supplied, expected",
|
|
[
|
|
("0123456789abcdef", "0123456789abcdef"),
|
|
(None, None),
|
|
("", None),
|
|
],
|
|
ids=["already-hex", "none", "empty"],
|
|
)
|
|
def test_observation_id_normalisation(supplied, expected):
|
|
assert resolve_observation_id(supplied) == expected
|
|
|
|
|
|
def test_arbitrary_observation_id_is_hashed_to_a_span_id():
|
|
resolved = resolve_observation_id("my-parent-observation")
|
|
assert len(resolved) == 16
|
|
assert resolved == resolve_observation_id("my-parent-observation")
|
|
|
|
|
|
@pytest.mark.parametrize("unsupported", ["2.59.7", "3.15.0", "5.0.0"], ids=["v2", "v3", "v5"])
|
|
def test_unsupported_sdk_fails_loudly_rather_than_dropping_every_event(unsupported):
|
|
with pytest.raises(ImportError) as raised:
|
|
raise_if_unsupported_langfuse_version(unsupported)
|
|
assert unsupported in str(raised.value)
|
|
assert MINIMUM_LANGFUSE_VERSION in str(raised.value)
|
|
|
|
|
|
def test_supported_sdk_is_accepted():
|
|
assert raise_if_unsupported_langfuse_version(installed_langfuse_version()) is None
|
|
|
|
|
|
def test_channel_carries_environment_and_release_on_the_resource():
|
|
tracing = build_langfuse_tracing(
|
|
exporter=DiscardingSpanExporter(),
|
|
environment="staging",
|
|
release="v9",
|
|
sample_rate=1.0,
|
|
flush_interval_millis=10,
|
|
)
|
|
attributes = tracing.provider.resource.attributes
|
|
assert attributes[A.ENVIRONMENT] == "staging"
|
|
assert attributes[A.RELEASE] == "v9"
|
|
|
|
|
|
def _generations_exported_at(sample_rate: float, trace_ids: tuple[str, ...]) -> frozenset[str]:
|
|
exporter: Final = InMemorySpanExporter()
|
|
tracing: Final = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=sample_rate, flush_interval_millis=10
|
|
)
|
|
for trace_id in trace_ids:
|
|
_generation(tracing, name="sampled", trace_id=trace_id).end(CALL_END)
|
|
tracing.flush()
|
|
return frozenset(format(span.context.trace_id, "032x") for span in exporter.get_finished_spans())
|
|
|
|
|
|
def test_sample_rate_zero_drops_and_one_keeps_every_trace():
|
|
trace_ids: Final = tuple(resolve_trace_id(uuid.uuid4()) for _ in range(20))
|
|
assert _generations_exported_at(0, trace_ids) == frozenset()
|
|
assert _generations_exported_at(1, trace_ids) == frozenset(trace_ids)
|
|
|
|
|
|
def test_fractional_sample_rate_keeps_a_deterministic_share_of_uuid_trace_ids():
|
|
trace_ids: Final = tuple(resolve_trace_id(uuid.uuid4()) for _ in range(400))
|
|
kept: Final = _generations_exported_at(0.5, trace_ids)
|
|
assert 140 <= len(kept) <= 260
|
|
assert _generations_exported_at(0.5, trace_ids) == kept
|
|
assert kept < _generations_exported_at(0.9, trace_ids)
|
|
|
|
|
|
@pytest.mark.parametrize("raw", ["1.5", "-0.5", "abc"])
|
|
def test_unusable_sample_rate_warns_and_exports_everything(
|
|
monkeypatch: pytest.MonkeyPatch, raw: str, caplog: pytest.LogCaptureFixture
|
|
):
|
|
monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", raw)
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
assert configured_sample_rate() == 1.0
|
|
assert "LANGFUSE_SAMPLE_RATE" in caplog.text
|
|
|
|
|
|
def test_configured_sample_rate_reads_the_env_var(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.delenv("LANGFUSE_SAMPLE_RATE", raising=False)
|
|
assert configured_sample_rate() == 1.0
|
|
monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "0.25")
|
|
assert configured_sample_rate() == 0.25
|
|
|
|
|
|
def _exported_generations(exporter: InMemorySpanExporter, tracing: LangfuseTracing, count: int) -> tuple:
|
|
for _ in range(count):
|
|
_generation(tracing, trace_id=resolve_trace_id(uuid.uuid4())).end(CALL_END)
|
|
tracing.flush()
|
|
return exporter.get_finished_spans()
|
|
|
|
|
|
def test_full_sample_rate_exports_every_trace_even_when_the_host_turned_otel_sampling_off(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A provider built without a sampler reads ``OTEL_TRACES_SAMPLER``, which belongs to the host's tracing."""
|
|
monkeypatch.setenv("OTEL_TRACES_SAMPLER", "always_off")
|
|
exporter = InMemorySpanExporter()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
assert len(_exported_generations(exporter, tracing, 5)) == 5
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("variable", "value"),
|
|
[
|
|
("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "4"),
|
|
("OTEL_ATTRIBUTE_COUNT_LIMIT", "4"),
|
|
("OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT", "8"),
|
|
("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT", "8"),
|
|
],
|
|
)
|
|
def test_host_otel_span_limits_do_not_truncate_langfuse_observations(
|
|
monkeypatch: pytest.MonkeyPatch, variable: str, value: str
|
|
):
|
|
monkeypatch.setenv(variable, value)
|
|
exporter = InMemorySpanExporter()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
attributes = {f"langfuse.observation.metadata.k{i}": "v" * 32 for i in range(40)}
|
|
_generation(tracing, attributes=attributes).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "gen")
|
|
assert span.dropped_attributes == 0
|
|
assert all(span.attributes[key] == "v" * 32 for key in attributes)
|
|
|
|
|
|
def test_host_otel_resource_env_does_not_reach_the_langfuse_resource(monkeypatch: pytest.MonkeyPatch):
|
|
"""``OTEL_RESOURCE_ATTRIBUTES`` and ``OTEL_SERVICE_NAME`` belong to the host's tracing; Langfuse files a
|
|
trace under any ``deployment.environment`` it finds on the resource, and v2 shipped no resource at all."""
|
|
monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "team.secret.note=internal-only,deployment.environment=hijack")
|
|
monkeypatch.setenv("OTEL_SERVICE_NAME", "the-hosts-own-service")
|
|
tracing = build_langfuse_tracing(
|
|
exporter=InMemorySpanExporter(), environment="prod", release="r1", sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
|
|
assert dict(tracing.provider.resource.attributes) == {A.ENVIRONMENT: "prod", A.RELEASE: "r1"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "encoded"),
|
|
[
|
|
(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=[
|
|
"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_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()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
attributes = observation_attributes(observation_type="generation", metadata={"order_id": value, "sibling": "kept"})
|
|
_generation(tracing, attributes=attributes).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
(encoded_span,) = encode_spans(exporter.get_finished_spans()).resource_spans[0].scope_spans[0].spans
|
|
wire = {kv.key: kv.value for kv in encoded_span.attributes}
|
|
order_id = wire[f"{A.OBSERVATION_METADATA}.order_id"]
|
|
carried = {
|
|
"int_value": order_id.int_value,
|
|
"string_value": order_id.string_value,
|
|
"bool_value": order_id.bool_value,
|
|
}
|
|
assert (order_id.WhichOneof("value"), carried[order_id.WhichOneof("value")]) == encoded
|
|
assert wire[f"{A.OBSERVATION_METADATA}.sibling"].string_value == "kept"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[(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):
|
|
"""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 (expected == 60.0 and raw is not None)
|
|
|
|
|
|
def test_many_metadata_keys_never_evict_the_generation_input_and_output():
|
|
"""OTel's default 128-attribute cap drops the earliest attributes, and v2 never capped metadata."""
|
|
exporter = InMemorySpanExporter()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
attributes = {
|
|
A.OBSERVATION_INPUT: "the-prompt",
|
|
A.OBSERVATION_OUTPUT: "the-completion",
|
|
**{f"langfuse.observation.metadata.k{i}": str(i) for i in range(300)},
|
|
}
|
|
_generation(tracing, attributes=attributes).end(CALL_END)
|
|
tracing.flush()
|
|
|
|
span = _only_span(exporter, "gen")
|
|
assert span.dropped_attributes == 0
|
|
assert span.attributes[A.OBSERVATION_INPUT] == "the-prompt"
|
|
assert span.attributes[A.OBSERVATION_OUTPUT] == "the-completion"
|
|
assert span.attributes["langfuse.observation.metadata.k299"] == "299"
|
|
|
|
|
|
def test_otel_sdk_disabled_still_wins_but_is_called_out(monkeypatch: pytest.MonkeyPatch, caplog):
|
|
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
|
|
exporter = InMemorySpanExporter()
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
assert "OTEL_SDK_DISABLED" in caplog.text
|
|
assert _exported_generations(exporter, tracing, 3) == ()
|
|
|
|
|
|
def test_spans_carry_the_langfuse_sdk_scope_name(channel):
|
|
"""Langfuse keys on the SDK's instrumentation scope (langfuse 4.15.2, ``langfuse/_client/constants.py``,
|
|
read 2026-09-17); any other scope is foreign OTel traffic whose raw attributes get echoed into metadata."""
|
|
tracing, exporter = channel
|
|
_generation(tracing).end(CALL_END)
|
|
tracing.flush()
|
|
assert _only_span(exporter, "gen").instrumentation_scope.name == "langfuse-sdk"
|
|
|
|
|
|
class _GatedExporter(SpanExporter):
|
|
"""Hold the export thread until released, so spans pile up in the processor queue."""
|
|
|
|
def __init__(self) -> None:
|
|
self.gate = threading.Event()
|
|
self.batches: list[int] = []
|
|
|
|
def export(self, spans) -> SpanExportResult:
|
|
self.gate.wait(timeout=30)
|
|
self.batches.append(len(spans))
|
|
return SpanExportResult.SUCCESS
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
return True
|
|
|
|
|
|
def test_export_queue_holds_a_v2_sized_burst_while_the_destination_stalls():
|
|
"""v2 queued 100k events; OTel's default 2048 dropped most of a burst during a destination stall."""
|
|
exporter = _GatedExporter()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
for _ in range(6000):
|
|
_generation(tracing, trace_id=resolve_trace_id(uuid.uuid4())).end(CALL_END)
|
|
exporter.gate.set()
|
|
assert tracing.flush(timeout_millis=30_000) is True
|
|
assert sum(exporter.batches) == 6000
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[(None, 512), ("64", 64), ("0", 512), ("-5", 512), ("abc", 512), ("100001", 512), ("100000", 100_000)],
|
|
ids=["unset", "valid", "zero", "negative", "text", "over-queue", "at-queue"],
|
|
)
|
|
def test_langfuse_flush_at_is_parsed_like_the_sdk_did(monkeypatch: pytest.MonkeyPatch, raw, expected, caplog):
|
|
if raw is None:
|
|
monkeypatch.delenv("LANGFUSE_FLUSH_AT", raising=False)
|
|
else:
|
|
monkeypatch.setenv("LANGFUSE_FLUSH_AT", raw)
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
assert configured_flush_at() == expected
|
|
assert ("LANGFUSE_FLUSH_AT" in caplog.text) is (raw is not None and str(expected) != raw)
|
|
|
|
|
|
def test_langfuse_flush_at_sizes_the_export_batches(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("LANGFUSE_FLUSH_AT", "64")
|
|
exporter = _GatedExporter()
|
|
exporter.gate.set()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter,
|
|
environment=None,
|
|
release=None,
|
|
sample_rate=1.0,
|
|
flush_interval_millis=60_000,
|
|
flush_at=configured_flush_at(),
|
|
)
|
|
for _ in range(200):
|
|
_generation(tracing, trace_id=resolve_trace_id(uuid.uuid4())).end(CALL_END)
|
|
tracing.flush()
|
|
assert sum(exporter.batches) == 200
|
|
assert max(exporter.batches) == 64
|
|
|
|
|
|
def test_acquired_channel_reads_langfuse_flush_at(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("LANGFUSE_FLUSH_AT", "7")
|
|
small = _acquire(public_key="pk-flush-at-test")
|
|
monkeypatch.setenv("LANGFUSE_FLUSH_AT", "9")
|
|
assert _acquire(public_key="pk-flush-at-test") is not small
|
|
|
|
|
|
def test_channel_does_not_take_over_the_process_tracer_provider():
|
|
provider_before = otel_trace.get_tracer_provider()
|
|
|
|
tracing = acquire_langfuse_tracing(
|
|
public_key="pk-global-test",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
environment=None,
|
|
release=None,
|
|
flush_interval=1.0,
|
|
mock_mode=True,
|
|
)
|
|
|
|
assert otel_trace.get_tracer_provider() is provider_before
|
|
assert tracing.provider is not provider_before
|
|
|
|
|
|
def _acquire(**overrides):
|
|
parameters = {
|
|
"public_key": "pk-cache-test",
|
|
"secret_key": "sk-cache",
|
|
"base_url": "http://127.0.0.1:1",
|
|
"environment": None,
|
|
"release": None,
|
|
"flush_interval": 1.0,
|
|
"mock_mode": True,
|
|
}
|
|
return acquire_langfuse_tracing(**{**parameters, **overrides})
|
|
|
|
|
|
def test_same_credentials_share_one_channel():
|
|
assert _acquire() is _acquire()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"override",
|
|
[
|
|
{"secret_key": "sk-rotated"},
|
|
{"base_url": "http://127.0.0.1:2"},
|
|
{"environment": "staging"},
|
|
{"mock_mode": False},
|
|
],
|
|
ids=["secret", "host", "environment", "mock-to-live"],
|
|
)
|
|
def test_changed_credentials_or_settings_get_their_own_channel(override):
|
|
assert _acquire() is not _acquire(**override)
|
|
|
|
|
|
class _RecordsShutdown(InMemorySpanExporter):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.shutdowns = 0
|
|
|
|
def shutdown(self) -> None:
|
|
self.shutdowns += 1
|
|
super().shutdown()
|
|
|
|
|
|
def _acquire_recorded(monkeypatch: pytest.MonkeyPatch, public_key: str) -> tuple[LangfuseTracing, _RecordsShutdown]:
|
|
exporter = _RecordsShutdown()
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._build_span_exporter", lambda **_: exporter)
|
|
return _acquire(public_key=public_key, mock_mode=False, flush_interval=600.0), exporter
|
|
|
|
|
|
def test_channel_is_retired_only_after_its_last_holder_releases_it(monkeypatch: pytest.MonkeyPatch):
|
|
"""Two loggers on one credential set share the channel: the first release must leave it
|
|
exporting for the second, and the last release must shut the batch thread down and drop
|
|
the registry entry so the next logger gets a fresh channel instead of a dead one."""
|
|
first, exporter = _acquire_recorded(monkeypatch, "pk-lease-test")
|
|
second = _acquire(public_key="pk-lease-test", mock_mode=False, flush_interval=600.0)
|
|
assert second is first
|
|
|
|
release_langfuse_tracing(first, grace_seconds=0.0)
|
|
second.tracer.start_span("generation").end()
|
|
assert exporter.shutdowns == 0
|
|
assert flush_langfuse_tracing() is True
|
|
assert len(exporter.get_finished_spans()) == 1
|
|
|
|
release_langfuse_tracing(second, grace_seconds=0.0)
|
|
assert exporter.shutdowns == 1
|
|
assert _acquire(public_key="pk-lease-test", mock_mode=False, flush_interval=600.0) is not first
|
|
|
|
|
|
def test_release_flushes_the_queued_spans_before_the_channel_goes_away(monkeypatch: pytest.MonkeyPatch):
|
|
tracing, exporter = _acquire_recorded(monkeypatch, "pk-lease-flush-test")
|
|
tracing.tracer.start_span("generation").end()
|
|
|
|
release_langfuse_tracing(tracing, grace_seconds=0.0)
|
|
|
|
assert len(exporter.get_finished_spans()) == 1
|
|
|
|
|
|
def test_channel_reacquired_within_the_grace_is_kept(monkeypatch: pytest.MonkeyPatch):
|
|
"""A logger rebuilt for the same credentials right after the old one expired, and a callback
|
|
that fetched the old logger just before expiry, both keep exporting through the same channel."""
|
|
tracing, exporter = _acquire_recorded(monkeypatch, "pk-lease-grace-test")
|
|
|
|
release_langfuse_tracing(tracing, grace_seconds=0.2)
|
|
assert _acquire(public_key="pk-lease-grace-test", mock_mode=False, flush_interval=600.0) is tracing
|
|
|
|
threading.Event().wait(0.5)
|
|
tracing.tracer.start_span("generation").end()
|
|
assert exporter.shutdowns == 0
|
|
assert flush_langfuse_tracing() is True
|
|
assert len(exporter.get_finished_spans()) == 1
|
|
|
|
|
|
def test_retire_timer_of_an_earlier_release_cannot_kill_a_reacquired_channel(monkeypatch: pytest.MonkeyPatch):
|
|
"""release, re-acquire, release: the first timer used to fire into a channel that a later holder still
|
|
counted on for its own grace period, shutting the batch thread down while spans were still queued."""
|
|
tracing, exporter = _acquire_recorded(monkeypatch, "pk-lease-race-test")
|
|
|
|
release_langfuse_tracing(tracing, grace_seconds=0.2)
|
|
assert _acquire(public_key="pk-lease-race-test", mock_mode=False, flush_interval=600.0) is tracing
|
|
release_langfuse_tracing(tracing, grace_seconds=600.0)
|
|
|
|
threading.Event().wait(0.5)
|
|
assert exporter.shutdowns == 0
|
|
assert _acquire(public_key="pk-lease-race-test", mock_mode=False, flush_interval=600.0) is tracing
|
|
tracing.tracer.start_span("generation").end()
|
|
assert tracing.flush() is True
|
|
assert len(exporter.get_finished_spans()) == 1
|
|
|
|
|
|
class _RejectsEverything(SpanExporter):
|
|
def export(self, spans) -> SpanExportResult:
|
|
return SpanExportResult.FAILURE
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
return True
|
|
|
|
|
|
def test_flush_is_false_when_the_destination_rejected_a_batch(monkeypatch: pytest.MonkeyPatch):
|
|
"""The shutdown hook logs "channels flushed" off this value; a drained queue whose batches all
|
|
failed at the destination is a loss, not a flush."""
|
|
monkeypatch.setattr(
|
|
"litellm.integrations.langfuse.langfuse_sdk._build_span_exporter", lambda **_: _RejectsEverything()
|
|
)
|
|
tracing = _acquire(public_key="pk-flush-truth-test", mock_mode=False, flush_interval=600.0)
|
|
tracing.tracer.start_span("generation").end()
|
|
|
|
assert tracing.flush() is False
|
|
assert flush_langfuse_tracing() is True, "an empty queue after the loss has nothing left to fail"
|
|
|
|
|
|
def test_release_of_a_channel_the_registry_never_handed_out_is_a_no_op():
|
|
exporter = InMemorySpanExporter()
|
|
tracing = build_langfuse_tracing(
|
|
exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10
|
|
)
|
|
|
|
release_langfuse_tracing(tracing, grace_seconds=0.0)
|
|
tracing.tracer.start_span("generation").end()
|
|
|
|
assert tracing.flush() is True
|
|
assert len(exporter.get_finished_spans()) == 1
|
|
|
|
|
|
def test_flush_langfuse_tracing_exports_the_queued_spans_of_every_channel(monkeypatch: pytest.MonkeyPatch):
|
|
"""The proxy shutdown hook flushes through this, so a span finished just before a
|
|
graceful restart must reach the exporter without waiting for the batch interval."""
|
|
exporters: Final[
|
|
list[InMemorySpanExporter]
|
|
] = [] # mutable-ok: collects the exporters the patched builder hands out
|
|
|
|
def build_in_memory(*, public_key: str, secret_key: str, base_url: str) -> InMemorySpanExporter:
|
|
exporters.append(InMemorySpanExporter())
|
|
return exporters[-1]
|
|
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._build_span_exporter", build_in_memory)
|
|
for public_key in ("pk-flush-test-a", "pk-flush-test-b"):
|
|
_acquire(public_key=public_key, mock_mode=False, flush_interval=600.0).tracer.start_span("generation").end()
|
|
|
|
assert [len(exporter.get_finished_spans()) for exporter in exporters] == [0, 0]
|
|
assert flush_langfuse_tracing() is True
|
|
assert [len(exporter.get_finished_spans()) for exporter in exporters] == [1, 1]
|
|
|
|
|
|
def test_flush_langfuse_tracing_flushes_channels_concurrently_under_one_deadline(monkeypatch: pytest.MonkeyPatch):
|
|
"""A channel stuck on an unreachable host must not spend the whole deadline before the
|
|
next channel gets its turn; the first exporter here only returns once the second exported."""
|
|
second_exported = threading.Event()
|
|
|
|
class WaitsForTheOther(SpanExporter):
|
|
def export(self, spans):
|
|
return SpanExportResult.SUCCESS if second_exported.wait(timeout=5.0) else SpanExportResult.FAILURE
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
class Unblocks(SpanExporter):
|
|
def export(self, spans):
|
|
second_exported.set()
|
|
return SpanExportResult.SUCCESS
|
|
|
|
def shutdown(self) -> None:
|
|
return None
|
|
|
|
exporters = iter((WaitsForTheOther(), Unblocks()))
|
|
|
|
def build_next(*, public_key: str, secret_key: str, base_url: str) -> SpanExporter:
|
|
return next(exporters)
|
|
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._build_span_exporter", build_next)
|
|
for public_key in ("pk-concurrent-flush-a", "pk-concurrent-flush-b"):
|
|
_acquire(public_key=public_key, mock_mode=False, flush_interval=600.0).tracer.start_span("generation").end()
|
|
|
|
assert flush_langfuse_tracing(timeout_millis=2_000) is True
|
|
assert second_exported.is_set()
|
|
|
|
|
|
def test_flush_langfuse_tracing_leaves_an_overrunning_channel_on_a_daemon_thread():
|
|
"""A channel whose flush outlives the deadline is reported as failed and must not be able to
|
|
hold up interpreter exit, so the thread still flushing it has to be a daemon."""
|
|
release = threading.Event()
|
|
|
|
class BlocksUntilReleased(SpanProcessor):
|
|
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
return release.wait(timeout=10.0)
|
|
|
|
_acquire(public_key="pk-overrunning-flush", mock_mode=True, flush_interval=600.0).provider.add_span_processor(
|
|
BlocksUntilReleased()
|
|
)
|
|
try:
|
|
assert flush_langfuse_tracing(timeout_millis=200) is False
|
|
stuck = [thread for thread in threading.enumerate() if thread.name.startswith("langfuse-flush")]
|
|
assert stuck and all(thread.daemon for thread in stuck)
|
|
finally:
|
|
release.set()
|
|
|
|
|
|
def test_a_changed_sample_rate_rebuilds_the_channel(monkeypatch: pytest.MonkeyPatch):
|
|
monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "0.25")
|
|
quarter = _acquire(public_key="pk-resample-test")
|
|
monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "1")
|
|
full = _acquire(public_key="pk-resample-test")
|
|
|
|
assert full is not quarter
|
|
assert quarter.provider.sampler.get_description() == "TraceIdHashSampler{0.25}"
|
|
assert "TraceIdHashSampler" not in full.provider.sampler.get_description()
|
|
|
|
|
|
def _recording_transport(requests: list[httpx.Request], status: int = 401) -> httpx.Client:
|
|
def record(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(status, json=_PROJECTS_BODY if status == 200 else {"message": "unauthorized"})
|
|
|
|
return httpx.Client(transport=httpx.MockTransport(record))
|
|
|
|
|
|
_PROJECTS_BODY: Final = {
|
|
"data": [{"id": "proj-under-test", "name": "p", "metadata": {}, "organization": {"id": "o", "name": "o"}}]
|
|
}
|
|
|
|
|
|
def test_rest_client_authenticates_with_the_credentials_it_was_built_with():
|
|
"""Two loggers for one public key but different secrets or hosts each talk to their own project."""
|
|
requests: list[httpx.Request] = []
|
|
build_langfuse_client(
|
|
public_key="pk-rest-test",
|
|
secret_key="sk-first",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=_recording_transport(requests),
|
|
)
|
|
rotated = build_langfuse_client(
|
|
public_key="pk-rest-test",
|
|
secret_key="sk-second",
|
|
base_url="http://127.0.0.1:2",
|
|
httpx_client=_recording_transport(requests),
|
|
)
|
|
|
|
assert rotated.auth_check() is not None
|
|
assert requests[-1].url.host == "127.0.0.1" and requests[-1].url.port == 2
|
|
assert requests[-1].headers["authorization"] == "Basic " + b64encode(b"pk-rest-test:sk-second").decode()
|
|
|
|
|
|
def test_rest_client_without_keys_fails_auth_check_instead_of_raising(monkeypatch):
|
|
"""``/health/services?service=langfuse`` with no credentials must report a failed check, not crash."""
|
|
for name in ("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
client = build_langfuse_client(public_key=None, secret_key=None, base_url="http://127.0.0.1:1", httpx_client=None)
|
|
assert client.auth_check() is not None
|
|
|
|
|
|
def test_auth_check_names_the_servers_rejection(caplog):
|
|
"""``/health/services`` used to print the 401 verbatim; a generic credentials message hides a 403 or a 500."""
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=_recording_transport([], status=401),
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
failure = client.auth_check()
|
|
assert failure is not None
|
|
assert failure.reason == "status_code: 401, body: {'message': 'unauthorized'}"
|
|
assert failure.reason in caplog.text
|
|
|
|
|
|
def test_auth_check_names_an_unreachable_destination_rather_than_the_keys():
|
|
def refuse(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("connection refused by lf.internal.example", request=request)
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://lf.internal.example",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(refuse)),
|
|
)
|
|
failure = client.auth_check()
|
|
assert failure is not None
|
|
assert "connection refused by lf.internal.example" in failure.reason
|
|
|
|
|
|
def test_auth_check_fails_when_the_keys_reach_no_project():
|
|
"""A 200 with an empty project list is what the SDK's own ``auth_check`` raises on; it is not a pass."""
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"data": []}))),
|
|
)
|
|
failure = client.auth_check()
|
|
assert failure is not None
|
|
assert "no project" in failure.reason
|
|
|
|
|
|
@pytest.mark.parametrize("status", [500, 503, 429], ids=["http-500", "http-503", "http-429"])
|
|
def test_auth_check_and_project_id_make_one_round_trip_when_langfuse_is_down(status):
|
|
"""Both run on the event loop; the generated client's default retries sleep for seconds, or for Retry-After."""
|
|
requests: list[httpx.Request] = []
|
|
|
|
def fail(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(status, request=request, headers={"retry-after": "20"}, json={"message": "down"})
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(fail)),
|
|
)
|
|
|
|
started = monotonic()
|
|
failure = client.auth_check()
|
|
with pytest.raises(ApiError):
|
|
client.project_id()
|
|
assert failure is not None and f"status_code: {status}" in failure.reason
|
|
assert len(requests) == 2
|
|
assert monotonic() - started < 0.5
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "round_trips"),
|
|
[(500, 2), (503, 2), (429, 1), (404, 1)],
|
|
ids=["http-500", "http-503", "http-429", "http-404"],
|
|
)
|
|
def test_cold_prompt_miss_never_sleeps_when_langfuse_is_down(status: int, round_trips: int):
|
|
"""A cold ``get_prompt`` fetches inline on the event loop; with the generated client's default retries a
|
|
429 carrying ``Retry-After: 30`` used to hold the loop for a minute. A 5xx gets the v2 client's one
|
|
quick retry, a 429 or 4xx none."""
|
|
requests: list[httpx.Request] = []
|
|
|
|
def fail(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(status, request=request, headers={"retry-after": "30"}, json={"message": "down"})
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(fail)),
|
|
)
|
|
|
|
started = monotonic()
|
|
with pytest.raises(LangfusePromptError) as caught:
|
|
client.get_prompt("greeting")
|
|
assert len(requests) == round_trips
|
|
assert monotonic() - started < 0.5
|
|
assert caught.value.status_code == status
|
|
|
|
|
|
@pytest.mark.parametrize("first_failure", [503, "connect-error"], ids=["http-503", "connect-error"])
|
|
def test_one_transient_failure_on_a_cold_prompt_miss_does_not_fail_the_call(first_failure: int | str):
|
|
"""The v2 client retried a cold fetch once; a single Langfuse blip must not fail the LLM call."""
|
|
requests: list[httpx.Request] = []
|
|
|
|
def flaky(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
if len(requests) > 1:
|
|
return httpx.Response(200, request=request, json=_TEXT_PROMPT_BODY)
|
|
if isinstance(first_failure, int):
|
|
return httpx.Response(first_failure, request=request, json={"message": "down"})
|
|
raise httpx.ConnectError("refused", request=request)
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(flaky)),
|
|
)
|
|
|
|
started = monotonic()
|
|
assert client.get_prompt("greeting").compile() == "hello"
|
|
assert len(requests) == 2
|
|
assert monotonic() - started < 0.5
|
|
assert client.get_prompt("greeting").compile() == "hello", "the retried prompt is cached like any other"
|
|
assert len(requests) == 2
|
|
|
|
|
|
def test_prompt_fetch_error_carries_status_and_body_but_no_upstream_headers():
|
|
"""The proxy forwards an exception's ``headers`` to its client and prints ``str(e)``; the generated
|
|
``ApiError`` carries Langfuse's response headers in both."""
|
|
upstream_headers = {"server": "langfuse-edge", "set-cookie": "session=abc; HttpOnly", "x-upstream-internal": "1"}
|
|
|
|
def not_found(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(404, request=request, headers=upstream_headers, json={"message": "Prompt not found"})
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(not_found)),
|
|
)
|
|
|
|
with pytest.raises(Exception, match="Prompt not found") as caught:
|
|
client.get_prompt("missing")
|
|
|
|
error = caught.value
|
|
assert getattr(error, "headers", None) is None
|
|
assert getattr(error, "status_code", None) == 404
|
|
assert not any(header in str(error) for header in upstream_headers)
|
|
assert error.__cause__ is None and error.__suppress_context__, "the header-bearing ApiError must not ride along"
|
|
|
|
|
|
_TEXT_PROMPT_BODY: Final[dict[str, object]] = {
|
|
"type": "text",
|
|
"name": "n",
|
|
"version": 1,
|
|
"config": {},
|
|
"labels": ["production"],
|
|
"tags": [],
|
|
"prompt": "hello",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "encoded"),
|
|
[
|
|
("what?", "what%3F"),
|
|
("folder/greeting", "folder%2Fgreeting"),
|
|
("my-prompt?label=staging", "my-prompt%3Flabel%3Dstaging"),
|
|
("100% sure#1", "100%25%20sure%231"),
|
|
],
|
|
ids=["question-mark", "folder-slash", "query-injection", "percent-space-hash"],
|
|
)
|
|
def test_prompt_name_is_url_encoded_into_the_request_path(name: str, encoded: str):
|
|
"""The v2 client quoted the name before building the path and the v4 SDK's ``get_prompt`` does too; the
|
|
generated client alone puts the raw name into the URL, so ``what?`` fetched prompt ``what`` and
|
|
``a/b`` left the prompts route."""
|
|
requests: list[httpx.Request] = []
|
|
|
|
def record(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(200, request=request, json=_TEXT_PROMPT_BODY)
|
|
|
|
client = build_langfuse_client(
|
|
public_key="pk",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=httpx.Client(transport=httpx.MockTransport(record)),
|
|
)
|
|
|
|
client.get_prompt(name, label="staging")
|
|
|
|
(request,) = requests
|
|
assert request.url.raw_path == f"/api/public/v2/prompts/{encoded}?label=staging".encode()
|
|
|
|
|
|
def test_rest_client_reports_the_project_id_and_a_passing_auth_check():
|
|
requests: list[httpx.Request] = []
|
|
client = build_langfuse_client(
|
|
public_key="pk-project-test",
|
|
secret_key="sk",
|
|
base_url="http://127.0.0.1:1",
|
|
httpx_client=_recording_transport(requests, status=200),
|
|
)
|
|
assert client.project_id() == "proj-under-test"
|
|
assert client.auth_check() is None
|
|
|
|
|
|
def test_rest_client_leaves_a_host_applications_langfuse_client_alone():
|
|
"""The SDK hands every ``Langfuse()`` built for one public key the same resource bundle, so a
|
|
litellm-built SDK client used to make a host application's client fetch through litellm's
|
|
host, secret and httpx client. litellm now speaks REST directly and registers nothing."""
|
|
from langfuse import Langfuse
|
|
|
|
requests: list[httpx.Request] = []
|
|
litellm_client = build_langfuse_client(
|
|
public_key="pk-shared-with-host",
|
|
secret_key="sk-litellm",
|
|
base_url="http://litellm.example",
|
|
httpx_client=_recording_transport(requests, status=200),
|
|
)
|
|
assert litellm_client.project_id() == "proj-under-test"
|
|
|
|
host_requests: list[httpx.Request] = []
|
|
host = Langfuse(
|
|
public_key="pk-shared-with-host",
|
|
secret_key="sk-host",
|
|
base_url="http://host.example",
|
|
httpx_client=_recording_transport(host_requests, status=200),
|
|
tracing_enabled=False,
|
|
)
|
|
try:
|
|
assert host.auth_check() is True
|
|
finally:
|
|
host.shutdown()
|
|
|
|
assert [request.url.host for request in requests] == ["litellm.example"]
|
|
assert host_requests[-1].url.host == "host.example"
|
|
assert host_requests[-1].headers["authorization"] == "Basic " + b64encode(b"pk-shared-with-host:sk-host").decode()
|
|
|
|
|
|
def test_rest_client_does_not_take_over_the_process_tracer_provider():
|
|
provider_before = otel_trace.get_tracer_provider()
|
|
build_langfuse_client(
|
|
public_key="pk-sdk-global-test", secret_key="sk", base_url="http://127.0.0.1:1", httpx_client=None
|
|
)
|
|
assert otel_trace.get_tracer_provider() is provider_before
|
|
|
|
|
|
def _finished_span():
|
|
provider = TracerProvider()
|
|
span = provider.get_tracer("t").start_span("generation")
|
|
span.end()
|
|
return span
|
|
|
|
|
|
def _exporter_over(responses, *, delays=(0.5, 1.5), timeout=5.0):
|
|
"""A LangfuseSpanExporter whose litellm HTTPHandler talks to a scripted transport instead of the network."""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
|
|
seen = []
|
|
script = list(responses)
|
|
|
|
def transport(request: httpx.Request) -> httpx.Response:
|
|
seen.append(request)
|
|
step = script.pop(0)
|
|
if isinstance(step, Exception):
|
|
raise step
|
|
return httpx.Response(step, request=request)
|
|
|
|
handler = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(transport)))
|
|
exporter = LangfuseSpanExporter(
|
|
handler=handler,
|
|
endpoint="https://lf.internal.example/api/public/otel/v1/traces",
|
|
headers=MappingProxyType({"Authorization": "Basic cGs6c2s=", "Content-Type": "application/x-protobuf"}),
|
|
timeout=timeout,
|
|
delays=delays,
|
|
)
|
|
return exporter, seen
|
|
|
|
|
|
def test_exporter_posts_the_otlp_batch_through_litellm_http_handler(monkeypatch):
|
|
"""Traces travel through litellm's own handler, so litellm's TLS and proxy settings apply to them."""
|
|
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
|
|
|
slept = []
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", slept.append)
|
|
exporter, seen = _exporter_over([200])
|
|
span = _finished_span()
|
|
|
|
assert exporter.export((span,)) is SpanExportResult.SUCCESS
|
|
|
|
(request,) = seen
|
|
assert request.method == "POST"
|
|
assert str(request.url) == "https://lf.internal.example/api/public/otel/v1/traces"
|
|
assert request.headers["Authorization"] == "Basic cGs6c2s="
|
|
assert request.headers["Content-Type"] == "application/x-protobuf"
|
|
decoded = ExportTraceServiceRequest()
|
|
decoded.ParseFromString(request.content)
|
|
exported = decoded.resource_spans[0].scope_spans[0].spans[0]
|
|
assert exported.name == "generation"
|
|
assert exported.span_id == span.context.span_id.to_bytes(8, "big")
|
|
assert slept == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure",
|
|
[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 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))
|
|
|
|
assert exporter.export((_finished_span(),)) is SpanExportResult.SUCCESS
|
|
assert len(seen) == 3
|
|
assert len({request.content for request in seen}) == 1
|
|
assert slept == [0.5, 1.5]
|
|
|
|
|
|
def test_exporter_gives_up_after_the_last_delay(monkeypatch):
|
|
slept = []
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", slept.append)
|
|
exporter, seen = _exporter_over([httpx.ConnectError("refused")] * 3, delays=(1.0, 2.0))
|
|
|
|
assert exporter.export((_finished_span(),)) is SpanExportResult.FAILURE
|
|
assert len(seen) == 3
|
|
assert slept == [1.0, 2.0]
|
|
|
|
|
|
def _exporter_with_body_cap(max_bytes: int, *, deliveries: list[int]):
|
|
"""A destination that answers 413 to any body over ``max_bytes``, the way an ingress with a body limit does."""
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
|
|
def transport(request: httpx.Request) -> httpx.Response:
|
|
if len(request.content) > max_bytes:
|
|
return httpx.Response(413, request=request)
|
|
deliveries.append(len(request.content))
|
|
return httpx.Response(200, request=request)
|
|
|
|
return LangfuseSpanExporter(
|
|
handler=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(transport))),
|
|
endpoint="https://lf.internal.example/api/public/otel/v1/traces",
|
|
headers=MappingProxyType({}),
|
|
timeout=5.0,
|
|
delays=(),
|
|
)
|
|
|
|
|
|
def test_exporter_splits_a_batch_the_destination_finds_too_large(monkeypatch):
|
|
"""One 413 used to drop every span in the batch; the v2 consumer sized its batches by bytes before posting."""
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
spans = tuple(_finished_span() for _ in range(8))
|
|
whole = _encode(spans)
|
|
assert whole is not None
|
|
deliveries: list[int] = []
|
|
exporter = _exporter_with_body_cap(len(whole) // 2, deliveries=deliveries)
|
|
|
|
assert exporter.export(spans) is SpanExportResult.SUCCESS
|
|
assert len(deliveries) >= 2
|
|
assert all(size <= len(whole) // 2 for size in deliveries)
|
|
assert (
|
|
sum(deliveries) >= len(whole) - 8 * 8
|
|
) # each half repeats the resource and scope envelope, spans are not lost
|
|
|
|
|
|
def test_exporter_drops_only_the_single_span_that_alone_exceeds_the_cap(monkeypatch, caplog):
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
provider = TracerProvider()
|
|
huge = provider.get_tracer("t").start_span("generation", attributes={"body": "x" * 4000})
|
|
huge.end()
|
|
small = tuple(_finished_span() for _ in range(3))
|
|
single_small = _encode(small[:1])
|
|
assert single_small is not None
|
|
deliveries: list[int] = []
|
|
exporter = _exporter_with_body_cap(len(single_small) * 3, deliveries=deliveries)
|
|
|
|
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
|
|
result = exporter.export((*small, huge))
|
|
|
|
assert result is SpanExportResult.FAILURE
|
|
assert len(deliveries) >= 1 and all(size <= len(single_small) * 3 for size in deliveries)
|
|
assert "single" in caplog.text and "too large" in caplog.text
|
|
|
|
|
|
def _decoded_attributes(body: bytes) -> dict[str, str]:
|
|
decoded = ExportTraceServiceRequest()
|
|
decoded.ParseFromString(body)
|
|
return {
|
|
attribute.key: attribute.value.string_value
|
|
for attribute in decoded.resource_spans[0].scope_spans[0].spans[0].attributes
|
|
}
|
|
|
|
|
|
def _generation_span(**attributes: str):
|
|
provider = TracerProvider()
|
|
span = provider.get_tracer("t").start_span("generation", attributes=attributes)
|
|
span.end()
|
|
return span
|
|
|
|
|
|
def test_exporter_truncates_a_single_oversized_span_the_way_v2_did_instead_of_dropping_it(monkeypatch, caplog):
|
|
"""v2 replaced the largest of input, output and metadata with a marker and still delivered the observation; a
|
|
vision request over a self-hosted ingress cap used to lose the whole generation, model and usage included."""
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
span = _generation_span(
|
|
**{
|
|
"langfuse.observation.input": "data:image/png;base64," + "A" * 6000,
|
|
"langfuse.trace.input": "data:image/png;base64," + "A" * 200,
|
|
"langfuse.observation.output": "o" * 1000,
|
|
"langfuse.observation.metadata.team": "m" * 100,
|
|
"langfuse.observation.model.name": "gpt-4o",
|
|
}
|
|
)
|
|
bodies: list[bytes] = []
|
|
|
|
def transport(request: httpx.Request) -> httpx.Response:
|
|
if len(request.content) > 2000:
|
|
return httpx.Response(413, request=request)
|
|
bodies.append(request.content)
|
|
return httpx.Response(200, request=request)
|
|
|
|
exporter = LangfuseSpanExporter(
|
|
handler=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(transport))),
|
|
endpoint="https://lf.internal.example/api/public/otel/v1/traces",
|
|
headers=MappingProxyType({}),
|
|
timeout=5.0,
|
|
delays=(),
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
result = exporter.export((span,))
|
|
|
|
assert result is SpanExportResult.SUCCESS
|
|
delivered = _decoded_attributes(bodies[-1])
|
|
assert delivered["langfuse.observation.input"] == "<truncated due to size exceeding limit>"
|
|
assert delivered["langfuse.trace.input"] == "<truncated due to size exceeding limit>"
|
|
assert delivered["langfuse.observation.output"] == "o" * 1000
|
|
assert delivered["langfuse.observation.metadata.team"] == "m" * 100
|
|
assert delivered["langfuse.observation.model.name"] == "gpt-4o"
|
|
assert "dropping it" not in caplog.text and "truncated" in caplog.text
|
|
|
|
|
|
def test_exporter_truncates_largest_first_and_drops_only_when_nothing_is_left(monkeypatch, caplog):
|
|
"""Langfuse stores a bare ``langfuse.observation.metadata`` string as nothing, so the metadata marker travels
|
|
under a flattened key the way every other metadata value does."""
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
span = _generation_span(
|
|
**{
|
|
"langfuse.observation.input": "i" * 3000,
|
|
"langfuse.observation.output": "o" * 2000,
|
|
"langfuse.observation.metadata.a": "m" * 500,
|
|
"langfuse.trace.metadata.b": "m" * 500,
|
|
}
|
|
)
|
|
posted: list[dict[str, str]] = []
|
|
|
|
def always_too_large(request: httpx.Request) -> httpx.Response:
|
|
posted.append(_decoded_attributes(request.content))
|
|
return httpx.Response(413, request=request)
|
|
|
|
exporter = LangfuseSpanExporter(
|
|
handler=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(always_too_large))),
|
|
endpoint="https://lf.internal.example/api/public/otel/v1/traces",
|
|
headers=MappingProxyType({}),
|
|
timeout=5.0,
|
|
delays=(),
|
|
)
|
|
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
|
|
assert exporter.export((span,)) is SpanExportResult.FAILURE
|
|
|
|
marker = "<truncated due to size exceeding limit>"
|
|
assert [sorted(key for key, value in body.items() if value == marker) for body in posted] == [
|
|
[],
|
|
["langfuse.observation.input"],
|
|
["langfuse.observation.input", "langfuse.observation.output"],
|
|
[
|
|
"langfuse.observation.input",
|
|
"langfuse.observation.metadata.truncated",
|
|
"langfuse.observation.output",
|
|
"langfuse.trace.metadata.truncated",
|
|
],
|
|
]
|
|
assert "langfuse.observation.metadata.a" not in posted[-1] and "langfuse.trace.metadata.b" not in posted[-1]
|
|
assert "dropping it" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("status", [400, 401, 403, 404, 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 = []
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", slept.append)
|
|
exporter, seen = _exporter_over([status, 200])
|
|
|
|
assert exporter.export((_finished_span(),)) is SpanExportResult.FAILURE
|
|
assert len(seen) == 1
|
|
assert slept == []
|
|
|
|
|
|
def test_exporter_names_the_server_floor_when_the_otlp_route_is_missing(monkeypatch, caplog):
|
|
"""A Langfuse server too old to serve the OTLP route answers 404; a bare status leaves the operator guessing."""
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
exporter, _ = _exporter_over([404])
|
|
|
|
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
|
|
assert exporter.export((_finished_span(),)) is SpanExportResult.FAILURE
|
|
|
|
assert "HTTP 404" in caplog.text and "3.63.0" in caplog.text
|
|
|
|
|
|
def _finished_span_named(name: object):
|
|
provider = TracerProvider()
|
|
span = provider.get_tracer("t").start_span("placeholder")
|
|
span._name = name # pyright: ignore[reportAttributeAccessIssue, reportPrivateUsage] # the SDK only stores str
|
|
span.end()
|
|
return span
|
|
|
|
|
|
def test_exporter_drops_a_span_the_encoder_rejects_and_still_posts_the_rest(monkeypatch, caplog):
|
|
"""One span the OTLP encoder cannot serialize used to raise out of ``export`` and lose every span in the batch."""
|
|
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
|
|
|
monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk.sleep", lambda _: None)
|
|
exporter, seen = _exporter_over([200])
|
|
|
|
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
|
|
result = exporter.export((_finished_span(), _finished_span_named(12345), _finished_span()))
|
|
|
|
assert result is SpanExportResult.SUCCESS
|
|
(request,) = seen
|
|
decoded = ExportTraceServiceRequest()
|
|
decoded.ParseFromString(request.content)
|
|
assert [span.name for span in decoded.resource_spans[0].scope_spans[0].spans] == ["generation", "generation"]
|
|
assert "dropped 1 span(s)" in caplog.text
|
|
|
|
|
|
def test_exporter_reports_failure_when_no_span_of_the_batch_can_be_encoded(monkeypatch):
|
|
exporter, seen = _exporter_over([200])
|
|
|
|
assert exporter.export((_finished_span_named(12345),)) is SpanExportResult.FAILURE
|
|
assert seen == []
|
|
|
|
|
|
def test_built_exporter_uses_the_shared_litellm_handler_and_langfuse_headers(monkeypatch):
|
|
"""No private requests session or TLS adapter: the channel is the same handler the rest of litellm uses."""
|
|
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
|
|
|
|
monkeypatch.delenv("LANGFUSE_TIMEOUT", raising=False)
|
|
monkeypatch.delenv("LANGFUSE_MAX_RETRIES", raising=False)
|
|
default = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example")
|
|
assert default.handler is _get_httpx_client()
|
|
assert default.timeout == 20
|
|
assert len(default.delays) == 3
|
|
|
|
monkeypatch.setenv("LANGFUSE_TIMEOUT", "7.5")
|
|
monkeypatch.setenv("LANGFUSE_MAX_RETRIES", "1")
|
|
exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example")
|
|
assert exporter.endpoint == "https://lf.internal.example/api/public/otel/v1/traces"
|
|
assert exporter.timeout == 7.5
|
|
assert exporter.delays == (1.0,)
|
|
assert exporter.headers["Authorization"] == "Basic " + b64encode(b"pk:sk").decode()
|
|
assert exporter.headers["x-langfuse-public-key"] == "pk"
|
|
assert exporter.headers["x-langfuse-sdk-version"] == installed_langfuse_version()
|
|
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 3 < 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_absurd_retry_count_is_clamped_instead_of_allocating_one_delay_per_retry(monkeypatch, caplog):
|
|
"""A retry count with twelve digits must not turn callback init into a multi-gigabyte tuple allocation."""
|
|
monkeypatch.setenv("LANGFUSE_MAX_RETRIES", "999999999999")
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example")
|
|
assert 3 < len(exporter.delays) <= 1025
|
|
assert exporter.delays[-1] <= 64.0
|
|
assert any("LANGFUSE_MAX_RETRIES=999999999999" in record.getMessage() for record in caplog.records)
|
|
|
|
caplog.clear()
|
|
monkeypatch.setenv("LANGFUSE_MAX_RETRIES", "5")
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
modest = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example")
|
|
assert len(modest.delays) == 5
|
|
assert not any("LANGFUSE_MAX_RETRIES" in record.getMessage() for record in caplog.records)
|
|
|
|
|
|
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])
|
|
langfuse_logger = logging.getLogger("langfuse")
|
|
level_before = langfuse_logger.level
|
|
try:
|
|
with caplog.at_level(logging.INFO, logger="langfuse"):
|
|
assert exporter.export((_finished_span(),)) is SpanExportResult.SUCCESS
|
|
assert "Exported" not in caplog.text
|
|
enable_langfuse_debug_logging()
|
|
assert langfuse_logger.level == logging.DEBUG
|
|
exporter_after, _ = _exporter_over([200])
|
|
assert exporter_after.export((_finished_span(),)) is SpanExportResult.SUCCESS
|
|
assert "Exported" in caplog.text and "lf.internal.example" in caplog.text
|
|
finally:
|
|
langfuse_logger.setLevel(level_before)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("base_url", "export_path", "expected"),
|
|
[
|
|
("https://lf.internal.example/", None, "https://lf.internal.example/api/public/otel/v1/traces"),
|
|
("https://lf.internal.example", "/otel/traces", "https://lf.internal.example/otel/traces"),
|
|
("https://lf.internal.example/", "/otel/traces", "https://lf.internal.example/otel/traces"),
|
|
("https://lf.internal.example", "otel/traces", "https://lf.internal.example/otel/traces"),
|
|
(
|
|
"https://lf.internal.example",
|
|
"//elsewhere.example/otel",
|
|
"https://lf.internal.example/elsewhere.example/otel",
|
|
),
|
|
(
|
|
"https://lf.internal.example",
|
|
"https://elsewhere.example/otel",
|
|
"https://lf.internal.example/https://elsewhere.example/otel",
|
|
),
|
|
],
|
|
ids=[
|
|
"default",
|
|
"leading-slash",
|
|
"both-slashes",
|
|
"no-slash",
|
|
"scheme-relative-stays-on-host",
|
|
"absolute-stays-on-host",
|
|
],
|
|
)
|
|
def test_export_endpoint_never_doubles_the_slash_or_leaves_the_configured_host(
|
|
monkeypatch, base_url, export_path, expected
|
|
):
|
|
if export_path is None:
|
|
monkeypatch.delenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH", raising=False)
|
|
else:
|
|
monkeypatch.setenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH", export_path)
|
|
|
|
exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url=base_url)
|
|
|
|
assert exporter.endpoint == expected
|
|
|
|
|
|
class _RecordingPromptsApi:
|
|
"""Answers ``prompts.get`` with a text prompt that names the label it was asked for."""
|
|
|
|
def __init__(self) -> None:
|
|
self.prompts = self
|
|
self.requests: list[tuple[str, int | None, str | None]] = [] # mutable-ok: test-side call log
|
|
|
|
def get(self, name: str, *, version: int | None, label: str | None, request_options: RequestOptions):
|
|
from langfuse.api import Prompt_Text
|
|
|
|
assert request_options.get("max_retries") == 0, "a prompt fetch must not sleep through the client's retries"
|
|
self.requests.append((name, version, label))
|
|
return Prompt_Text(
|
|
name=name,
|
|
version=version or 1,
|
|
config={},
|
|
labels=[label or "production"],
|
|
tags=[],
|
|
prompt=f"label={label!r}",
|
|
)
|
|
|
|
|
|
class _BlockingPromptsApi(_RecordingPromptsApi):
|
|
"""Every fetch after the first blocks until the test releases it, and may be told to fail."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.release = threading.Event()
|
|
self.fail_refresh = False
|
|
|
|
def get(self, name: str, *, version: int | None, label: str | None, request_options: RequestOptions):
|
|
is_refresh = bool(self.requests)
|
|
prompt = super().get(name, version=version, label=label, request_options=request_options)
|
|
if is_refresh:
|
|
assert self.release.wait(5), "refresh was never released"
|
|
if self.fail_refresh:
|
|
raise RuntimeError("langfuse is down")
|
|
return prompt
|
|
|
|
|
|
def _wait_until(predicate, timeout: float = 5.0) -> None:
|
|
for _ in range(int(timeout / 0.01)):
|
|
if predicate():
|
|
return
|
|
sleep(0.01)
|
|
raise AssertionError("condition not met in time")
|
|
|
|
|
|
def test_stale_prompt_is_served_at_once_while_the_refresh_runs_elsewhere():
|
|
"""``get_prompt`` runs on the proxy's event loop; a stale entry used to refetch inline and block every
|
|
request on the REST round trip. The stale prompt is returned immediately and refreshed off-thread."""
|
|
api = _BlockingPromptsApi()
|
|
client = LangfuseApiClient(api, prompt_cache_ttl_seconds=0) # pyright: ignore[reportArgumentType] # duck-typed prompts API
|
|
|
|
first = client.get_prompt("greeting")
|
|
started = monotonic()
|
|
stale = client.get_prompt("greeting")
|
|
|
|
assert stale is first, "the stale prompt must come back without waiting on the refresh"
|
|
assert monotonic() - started < 1.0, "the stale read waited on the blocked refresh"
|
|
_wait_until(lambda: len(api.requests) == 2)
|
|
api.release.set()
|
|
_wait_until(lambda: client.get_prompt("greeting") is not first)
|
|
|
|
|
|
def test_a_failed_background_refresh_keeps_the_stale_prompt_in_service(caplog):
|
|
api = _BlockingPromptsApi()
|
|
api.fail_refresh = True
|
|
client = LangfuseApiClient(api, prompt_cache_ttl_seconds=0) # pyright: ignore[reportArgumentType] # duck-typed prompts API
|
|
|
|
first = client.get_prompt("greeting")
|
|
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
|
started = monotonic()
|
|
assert client.get_prompt("greeting") is first
|
|
assert monotonic() - started < 1.0, "the stale read waited on the blocked refresh"
|
|
api.release.set()
|
|
_wait_until(lambda: "refresh failed" in caplog.text)
|
|
assert client.get_prompt("greeting") is first
|
|
|
|
|
|
def test_only_one_refresh_runs_for_a_stale_prompt_under_concurrent_reads():
|
|
api = _BlockingPromptsApi()
|
|
client = LangfuseApiClient(api, prompt_cache_ttl_seconds=0.3) # pyright: ignore[reportArgumentType] # duck-typed prompts API
|
|
|
|
first = client.get_prompt("greeting")
|
|
sleep(0.3)
|
|
for _ in range(20):
|
|
assert client.get_prompt("greeting") is first
|
|
_wait_until(lambda: len(api.requests) == 2)
|
|
api.release.set()
|
|
_wait_until(lambda: client.get_prompt("greeting") is not first)
|
|
assert len(api.requests) == 2
|
|
|
|
|
|
def test_prompt_cache_keeps_a_missing_label_apart_from_the_label_named_none():
|
|
"""A prompt labelled ``"None"`` and the unlabelled default are different prompts in Langfuse
|
|
and must not answer each other's requests from the cache."""
|
|
api = _RecordingPromptsApi()
|
|
client = LangfuseApiClient(api, prompt_cache_ttl_seconds=60) # pyright: ignore[reportArgumentType] # duck-typed prompts API
|
|
|
|
unlabelled = client.get_prompt("greeting")
|
|
named_none = client.get_prompt("greeting", label="None")
|
|
cached_unlabelled = client.get_prompt("greeting")
|
|
|
|
assert unlabelled.prompt == "label=None"
|
|
assert named_none.prompt == "label='None'"
|
|
assert cached_unlabelled is unlabelled
|
|
assert api.requests == [("greeting", None, None), ("greeting", None, "None")]
|