litellm/tests/logging_callback_tests/test_langfuse_e2e_test.py
yucheng 2f33397626 refactor(langfuse): emit observations as plain OTel spans, keep the SDK for prompts and auth
The callback now owns an isolated TracerProvider and OTLP exporter and builds generation and child spans with public OpenTelemetry APIs plus the LangfuseOtelSpanAttributes constants. Caller trace ids, generation ids, parent observation ids and historical start and end times are honoured through the OTel id generator, remote SpanContext and explicit span timestamps, so no private Langfuse SDK tracing handle is used any more. The Langfuse client stays only for get_prompt and auth_check

This also resolves the gauntlet findings on the previous draft: fresh traces start from an empty context so caller application spans are never stamped, the Slack trace link is read from the request logging state instead of constructing a logger per alert, a truthy non-mapping trace_metadata is serialized instead of raising, trace_input and trace_output land on the root generation, discarding a cached client is done under the lock, and the prompt cache no longer leaks a task manager because the client cache no longer tears down shared providers

Fixtures under tests/logging_callback_tests lose the SDK-private langfuse.internal.as_root marker; every other exported attribute is unchanged

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-17 21:18:36 +00:00

544 lines
21 KiB
Python

import asyncio
import json
import logging
import os
import threading
from collections.abc import Mapping
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
from opentelemetry.proto.common.v1.common_pb2 import AnyValue
logging.basicConfig(level=logging.DEBUG)
import litellm
from litellm.integrations.langfuse.langfuse_sdk import resolve_observation_id, resolve_trace_id
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
litellm.num_retries = 3
litellm.success_callback = ["langfuse"]
os.environ["LANGFUSE_DEBUG"] = "True"
import pytest
import pytest_asyncio
LANGFUSE_EXPORT_POST: Final = "requests.Session.post"
LANGFUSE_EXPORT_PATH: Final = "/api/public/otel/v1/traces"
_PER_RUN_ATTRIBUTES: Final = frozenset(
{
"langfuse.observation.completion_start_time",
"langfuse.observation.metadata.applied_guardrails",
"langfuse.observation.metadata.cache_hit",
"langfuse.observation.metadata.hidden_params",
"langfuse.observation.metadata.litellm_call_id",
"langfuse.observation.metadata.litellm_response_cost",
"langfuse.observation.metadata.requester_metadata",
"langfuse.observation.metadata.response_id",
"langfuse.observation.metadata.usage_object",
}
)
def _decode_attribute(value: AnyValue) -> object:
match value.WhichOneof("value"):
case "string_value":
try:
return json.loads(value.string_value)
except json.JSONDecodeError:
return value.string_value
case "bool_value":
return value.bool_value
case "int_value":
return value.int_value
case "double_value":
return value.double_value
case "array_value":
return [_decode_attribute(item) for item in value.array_value.values]
case _:
return None
def _exported_spans(mock_post: MagicMock) -> list[dict[str, object]]:
spans: list[dict[str, object]] = []
for call in mock_post.call_args_list:
assert call.kwargs["url"].endswith(LANGFUSE_EXPORT_PATH), call.kwargs["url"]
request = ExportTraceServiceRequest.FromString(call.kwargs["data"])
for resource_spans in request.resource_spans:
for scope_spans in resource_spans.scope_spans:
for span in scope_spans.spans:
spans.append(
{
"name": span.name,
"trace_id": span.trace_id.hex(),
"span_id": span.span_id.hex(),
"parent_span_id": span.parent_span_id.hex() or None,
"attributes": {
attribute.key: _decode_attribute(attribute.value) for attribute in span.attributes
},
}
)
return spans
def _comparable(span: Mapping[str, object]) -> dict[str, object]:
attributes = span["attributes"]
assert isinstance(attributes, dict)
return {
"name": span["name"],
"parent_span_id": span["parent_span_id"],
"attributes": {key: value for key, value in sorted(attributes.items()) if key not in _PER_RUN_ATTRIBUTES},
}
def assert_langfuse_request_matches_expected(
spans: list[dict[str, object]],
expected_file_name: str,
trace_id: str,
):
"""Compare the generation langfuse exported for ``trace_id`` with the expected JSON file."""
pwd = os.path.dirname(os.path.realpath(__file__))
expected_body_path = os.path.join(pwd, "langfuse_expected_request_body", expected_file_name)
with open(expected_body_path, "r") as f:
expected_generation = json.load(f)
otel_trace_id: Final = resolve_trace_id(trace_id)
generations: Final = [
span
for span in spans
if span["trace_id"] == otel_trace_id and span["attributes"]["langfuse.observation.type"] == "generation" # pyright: ignore[reportIndexIssue] # built as dict in _exported_spans
]
assert len(generations) == 1, (
f"Expected exactly one generation for trace_id={trace_id} ({otel_trace_id}), "
f"got {len(generations)}. Spans: {json.dumps(spans, indent=2)}"
)
actual_generation: Final = _comparable(generations[0])
assert actual_generation == expected_generation, (
f"Difference in exported generation: {json.dumps(actual_generation, indent=2)} "
f"!= {json.dumps(expected_generation, indent=2)}"
)
class TestLangfuseLogging:
@pytest_asyncio.fixture
async def mock_setup(self):
"""Common setup for Langfuse logging tests"""
from litellm._uuid import uuid
mock_post = MagicMock(return_value=MagicMock(ok=True, status_code=200))
litellm.set_verbose = True
litellm.success_callback = ["langfuse"]
return {"trace_id": f"litellm-test-{uuid.uuid4()!s}", "mock_post": mock_post}
async def _verify_langfuse_call(
self,
mock_post,
expected_file_name: str,
trace_id: str,
):
"""Wait for the batch processor to export, then compare the generation it shipped."""
otel_trace_id: Final = resolve_trace_id(trace_id)
for _ in range(100):
if any(span["trace_id"] == otel_trace_id for span in _exported_spans(mock_post)):
break
await asyncio.sleep(0.1)
assert mock_post.call_count >= 1, "langfuse exported nothing"
assert_langfuse_request_matches_expected(
_exported_spans(mock_post),
expected_file_name,
trace_id,
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion(self, mock_setup):
"""Test Langfuse logging for chat completion"""
setup = mock_setup
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata={"trace_id": setup["trace_id"]},
)
await self._verify_langfuse_call(setup["mock_post"], "completion.json", setup["trace_id"])
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_tags(self, mock_setup):
"""Test Langfuse logging for chat completion with tags"""
setup = mock_setup
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata={
"trace_id": setup["trace_id"],
"tags": ["test_tag", "test_tag_2"],
},
)
await self._verify_langfuse_call(setup["mock_post"], "completion_with_tags.json", setup["trace_id"])
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_tags_stream(self, mock_setup):
"""Test Langfuse logging for chat completion with tags"""
setup = mock_setup
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata={
"trace_id": setup["trace_id"],
"tags": ["test_tag_stream", "test_tag_2_stream"],
},
)
await self._verify_langfuse_call(
setup["mock_post"],
"completion_with_tags_stream.json",
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_generation_id_metadata_names_the_exported_observation(self, mock_setup):
"""v2 let callers pick the generation id; v4 only has span ids, so the requested id must become one."""
setup = mock_setup
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata={"trace_id": setup["trace_id"], "generation_id": "my-generation"},
)
await self._verify_langfuse_call(setup["mock_post"], "completion.json", setup["trace_id"])
generation: Final = next(
span
for span in _exported_spans(setup["mock_post"])
if span["trace_id"] == resolve_trace_id(setup["trace_id"])
)
assert generation["span_id"] == resolve_observation_id("my-generation")
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_langfuse_metadata(self, mock_setup):
"""Test Langfuse logging for chat completion with metadata for langfuse"""
setup = mock_setup
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata={
"trace_id": setup["trace_id"],
"tags": ["test_tag", "test_tag_2"],
"generation_name": "test_generation_name",
"parent_observation_id": "test_parent_observation_id",
"version": "test_version",
"trace_user_id": "test_user_id",
"session_id": "test_session_id",
"trace_name": "test_trace_name",
"trace_metadata": {"test_key": "test_value"},
"trace_version": "test_trace_version",
"trace_release": "test_trace_release",
},
)
await self._verify_langfuse_call(
setup["mock_post"],
"completion_with_langfuse_metadata.json",
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_with_non_serializable_metadata(self, mock_setup):
"""Test Langfuse logging with metadata that requires preparation (Pydantic models, sets, etc)"""
import datetime
from pydantic import BaseModel
class UserPreferences(BaseModel):
favorite_colors: set[str]
last_login: datetime.datetime
settings: dict
setup = mock_setup
test_metadata = {
"user_prefs": UserPreferences(
favorite_colors={"red", "blue"},
last_login=datetime.datetime.now(),
settings={"theme": "dark", "notifications": True},
),
"nested_set": {
"inner_set": {1, 2, 3},
"inner_pydantic": UserPreferences(
favorite_colors={"green", "yellow"},
last_login=datetime.datetime.now(),
settings={"theme": "light"},
),
},
"trace_id": setup["trace_id"],
}
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata=test_metadata,
)
await self._verify_langfuse_call(
setup["mock_post"],
"completion_with_complex_metadata.json",
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"test_metadata, response_json_file",
[
({"a": 1, "b": 2, "c": 3}, "simple_metadata.json"),
(
{"a": {"nested_a": 1}, "b": {"nested_b": 2}},
"nested_metadata.json",
),
({"a": [1, 2, 3], "b": {4, 5, 6}}, "simple_metadata2.json"),
(
{"a": (1, 2), "b": frozenset([3, 4]), "c": {"d": [5, 6]}},
"simple_metadata3.json",
),
({"lock": threading.Lock()}, "metadata_with_lock.json"),
({"func": lambda x: x + 1}, "metadata_with_function.json"),
(
{
"int": 42,
"str": "hello",
"list": [1, 2, 3],
"set": {4, 5},
"dict": {"nested": "value"},
"non_copyable": threading.Lock(),
"function": print,
},
"complex_metadata.json",
),
(
{"list": ["list", "not", "a", "dict"]},
"complex_metadata_2.json",
),
({}, "empty_metadata.json"),
],
)
@pytest.mark.flaky(retries=6, delay=1)
async def test_langfuse_logging_with_various_metadata_types(self, mock_setup, test_metadata, response_json_file):
"""Test Langfuse logging with various metadata types including non-serializable objects"""
setup = mock_setup
if test_metadata is not None:
test_metadata["trace_id"] = setup["trace_id"]
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response="Hello! How can I assist you today?",
metadata=test_metadata,
)
await self._verify_langfuse_call(
setup["mock_post"],
response_json_file,
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_malformed_llm_response(self, mock_setup):
"""Test Langfuse logging for chat completion with malformed LLM response"""
setup = mock_setup
litellm._turn_on_debug()
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
mock_response = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(
prompt_tokens=10,
completion_tokens=10,
total_tokens=20,
),
model="gpt-3.5-turbo",
object="chat.completion",
created=1723081200,
).model_dump()
await litellm.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response=mock_response,
metadata={"trace_id": setup["trace_id"]},
)
await self._verify_langfuse_call(setup["mock_post"], "completion_with_no_choices.json", setup["trace_id"])
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_bedrock_llm_response(self, mock_setup):
"""Test Langfuse logging for chat completion with malformed LLM response"""
setup = mock_setup
litellm._turn_on_debug()
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
mock_response = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(
prompt_tokens=10,
completion_tokens=10,
total_tokens=20,
),
model="anthropic.claude-haiku-4-5-20251001-v1:0",
object="chat.completion",
created=1723081200,
).model_dump()
await litellm.acompletion(
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
messages=[{"role": "user", "content": "Hello!"}],
mock_response=mock_response,
metadata={"trace_id": setup["trace_id"]},
aws_access_key_id="fake-key",
aws_secret_access_key="fake-key",
aws_region="us-east-1",
)
await self._verify_langfuse_call(
setup["mock_post"],
"completion_with_bedrock_call.json",
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_completion_with_vertex_llm_response(self, mock_setup):
"""Test Langfuse logging for chat completion with malformed LLM response"""
setup = mock_setup
litellm._turn_on_debug()
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
mock_response = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(
prompt_tokens=10,
completion_tokens=10,
total_tokens=20,
),
model="vertex/gemini-2.0-flash-001",
object="chat.completion",
created=1723081200,
).model_dump()
await litellm.acompletion(
model="vertex_ai/gemini-2.0-flash-001",
messages=[{"role": "user", "content": "Hello!"}],
mock_response=mock_response,
metadata={"trace_id": setup["trace_id"]},
vertex_credentials="my-mock-credentials",
api_key="my-mock-credentials-2",
)
await self._verify_langfuse_call(
setup["mock_post"],
"completion_with_vertex_call.json",
setup["trace_id"],
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_vllm_embedding(self, mock_setup):
"""
Test that the request sent to the vllm embedding endpoint is correct.
Verifies the request body matches the expected JSON fixture,
including that the hosted_vllm/ prefix is stripped from the model name
and that no unexpected fields (e.g. encoding_format) are included.
"""
setup = mock_setup
vllm_response_data = {
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
"model": "BAAI/bge-small-en-v1.5",
"usage": {"prompt_tokens": 10, "total_tokens": 10},
}
mock_vllm_response = httpx.Response(
status_code=200,
json=vllm_response_data,
)
mock_async_client = AsyncHTTPHandler()
mock_async_client.post = AsyncMock(return_value=mock_vllm_response)
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
await litellm.aembedding(
model="hosted_vllm/BAAI/bge-small-en-v1.5",
input=["Hello from litellm!"],
api_base="http://my-fake-vllm.com/v1",
metadata={"trace_id": setup["trace_id"]},
client=mock_async_client,
)
# Verify the request sent to vllm matches the expected JSON fixture
assert mock_async_client.post.call_count == 1
actual_vllm_request = mock_async_client.post.call_args.kwargs["json"]
pwd = os.path.dirname(os.path.realpath(__file__))
expected_body_path = os.path.join(pwd, "langfuse_expected_request_body", "embedding_with_vllm.json")
with open(expected_body_path, "r") as f:
expected_vllm_request = json.load(f)
assert actual_vllm_request == expected_vllm_request, (
f"vllm request body mismatch:\n"
f"actual: {json.dumps(actual_vllm_request, indent=2)}\n"
f"expected: {json.dumps(expected_vllm_request, indent=2)}"
)
@pytest.mark.asyncio
@pytest.mark.flaky(retries=3, delay=1)
async def test_langfuse_logging_with_router(self, mock_setup):
"""Test Langfuse logging with router"""
litellm._turn_on_debug()
router = litellm.Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "gpt-3.5-turbo",
"mock_response": "Hello! How can I assist you today?",
"api_key": "test_api_key",
},
}
]
)
with patch(LANGFUSE_EXPORT_POST, mock_setup["mock_post"]):
mock_response = litellm.ModelResponse(
choices=[],
usage=litellm.Usage(
prompt_tokens=10,
completion_tokens=10,
total_tokens=20,
),
model="gpt-3.5-turbo",
object="chat.completion",
created=1723081200,
).model_dump()
await router.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello!"}],
mock_response=mock_response,
metadata={"trace_id": mock_setup["trace_id"]},
)
await self._verify_langfuse_call(
mock_setup["mock_post"],
"completion_with_router.json",
mock_setup["trace_id"],
)