litellm/tests/unit/integrations/otel/test_langfuse_logger.py
devin-ai-integration[bot] 0657ed46cf
fix(langfuse_otel)!: derive trace-level name, input, output and tags on traces litellm starts (#42705)
* fix(langfuse_otel): derive trace-level name, input, output and tags on the generation span

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): keep every reasoning summary in the derived observation output

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): leave joined traces untouched by derived trace fields

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): restore preset cache key fallback and type the derived-field helpers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): keep trace tags off joined traces entirely

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): tolerate out-of-band upstream requests and add sink outage chaos cell

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): build the langfuse rig config without mutating the loaded yaml

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(langfuse_otel): flatten output items with itertools.chain to satisfy LIT014 budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(langfuse_otel): type the _RecordingSpan and _emitted unit helpers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(langfuse_otel): keep joined-trace tags, mask trace io, leave v2 trace io to the root

Joined traces (existing_trace_id) keep the caller tags they carried before,
and only the derived trace fields are skipped for them. mask_input and
mask_output now redact the derived trace input and output, matching the
vanilla Langfuse callback. The v2 mapper derives the trace name only,
because the root observation already carries the trace input and output
there. Uses the public preset cache key helper, moves imports to module
top, restores the original formatting of the unit test file, and adds
integration cells for the v2 llm_only scope and SDK sync/async calls.

* fix(langfuse_otel): split string caller tags the way Langfuse reads them

A caller tags value given as a comma list or a JSON array string (body
metadata.tags or the langfuse_tags header) used to reach Langfuse raw,
where ingestion split it into separate tags. Merging it with the derived
tags kept it as one tag, so parse it the same way before the merge

* test(otel): expect langfuse.trace.name on the v2 langfuse_otel generation span

* fix(langfuse_otel): read string mask flags the way the classic callback does

The langfuse_mask_input / langfuse_mask_output headers arrive as strings,
so "false" redacted the derived trace io. Parse them with str_to_bool and
fall back to truthiness, as the classic langfuse callback does

* fix(langfuse_otel): derive trace fields only on traces litellm starts

A litellm span that joins a trace an app or caller started (inbound
traceparent, the app's active span, a caller-provided parent span) no
longer gets a derived trace name, input, output or tags, like a request
joining an existing_trace_id. Langfuse applies the later-starting span's
trace update, so the derived values overwrote the app's own trace name
and io.

OTel v2 derives the name on the request root only when it has no parent,
and the generation span keeps main's caller-only trace name.

* test(langfuse_otel): keep main's OTel v2 generation key set in the arize family cell

The OTel v2 generation span no longer carries a derived langfuse.trace.name,
so the pinned key set goes back to main's.

* fix(langfuse_otel): name the OTel v2 request root once, from the request's own call

The v2 Langfuse logger re-derived langfuse.trace.name on the request root
for every LLM call in the request. A post_call llm_as_a_judge guardrail's
judge call replaced the caller's trace_name on the root with
litellm-acompletion, and with another v2 callback (otel, arize_phoenix)
listed before langfuse_otel that vendor's root span gained
langfuse.trace.name on every request.

The derived name now fills only an unnamed root this logger's provider
started, and never comes from a call litellm makes for its own features.
A caller trace_name still names the root on every call, as on main.

* fix(langfuse_otel): keep the derived trace fields from pushing other attributes off a full span

The derived trace name, tags, input and output were written after the OpenInference attributes with no room
check, so a span near the SDK's attribute limit (about 34 tools at the default 128) evicted keys main
exported, such as openinference.span.kind, metadata and llm.model_name, and raised the dropped count.
They are now written last and only into the room the limit leaves next to the error attributes a failed
call adds, so a full span exports what it would without them.

* test(langfuse_otel): give the full-span cells' derived request a caller trace id, as the joined one's traceparent does

* chore(langfuse_otel): drop the docstrings and comments the house rule disallows and type the new tests' parameters

* test(langfuse_otel): set the span attribute limit the full-span tests run at and keep Final locals out of the app-trace loop

---------

Co-authored-by: yucheng <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-27 10:34:55 -07:00

715 lines
28 KiB
Python

"""Tests for the Langfuse OTel v2 loggers: the trace name and the root observation's input and output are
stamped from the request task while the root span is still recording, so Langfuse can show them on the trace."""
import asyncio
import json
from collections.abc import AsyncIterator, Mapping, Sequence
from datetime import datetime, timezone
from typing import Final
import pytest
pytest.importorskip("opentelemetry")
from opentelemetry import trace # noqa: E402 # needs the importorskip above
from opentelemetry.context import Context # noqa: E402 # needs the importorskip above
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter # noqa: E402
from opentelemetry.trace import SpanKind # noqa: E402
import litellm # noqa: E402
from litellm.caching.dual_cache import DualCache # noqa: E402
from litellm.integrations.otel.logger import OpenTelemetryV2, build_otel_v2_logger # noqa: E402
from litellm.integrations.otel.model.config import OpenTelemetryV2Config, is_otel_v2_enabled # noqa: E402
from litellm.integrations.otel.model.destination import OtelDestination # noqa: E402
from litellm.integrations.otel.model.spans import LITELLM_PROXY_REQUEST_SPAN_NAME, SpanRole # noqa: E402
from litellm.integrations.otel.plumbing import context as otel_context # noqa: E402
from litellm.integrations.otel.plumbing import providers # noqa: E402
from litellm.integrations.otel.plumbing.context import set_request_root_span # noqa: E402
from litellm.litellm_core_utils.litellm_logging import _maybe_construct_otel_v2 # noqa: E402
from litellm.proxy._types import UserAPIKeyAuth # noqa: E402
from litellm.proxy.utils import ProxyLogging # noqa: E402
from litellm.types.llms.openai import ( # noqa: E402
ResponseCompletedEvent,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from litellm.types.utils import ( # noqa: E402
Choices,
Delta,
Embedding,
EmbeddingResponse,
Message,
ModelResponse,
ModelResponseStream,
StreamingChoices,
)
INPUT_ATTR: Final = "langfuse.observation.input"
OUTPUT_ATTR: Final = "langfuse.observation.output"
TRACE_NAME_ATTR: Final = "langfuse.trace.name"
COMPLETION_START_ATTR: Final = "langfuse.observation.completion_start_time"
TRACE_CONTROL_ATTRS: Final = (TRACE_NAME_ATTR, "user.id", "session.id", "langfuse.trace.tags")
CHAT_DATA: Final = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "ping"}]}
@pytest.fixture(autouse=True)
def _reset_request_root_span():
otel_context._request_root_span.set(None)
yield
otel_context._request_root_span.set(None)
def _logger(*, capture: str = "span_only", mappers: Sequence[str] = ("genai", "langfuse")):
cfg = OpenTelemetryV2Config(exporter="in_memory", mapper_names=list(mappers), capture_message_content=capture)
exporter = InMemorySpanExporter()
tracer_provider = providers.build_tracer_provider(cfg, exporter=exporter)
return build_otel_v2_logger(config=cfg, tracer_provider=tracer_provider), exporter
def _start_root(logger: OpenTelemetryV2, parent_context: Context | None = None):
root: Final = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME, parent_context)
set_request_root_span(root)
return root
def _root_attrs(exporter):
by_name = {span.name: span for span in exporter.get_finished_spans()}
return dict(by_name[LITELLM_PROXY_REQUEST_SPAN_NAME].attributes or {})
def _run_request(logger, data: dict, call_type: str, response: object):
root = _start_root(logger)
asyncio.run(logger.async_pre_call_hook(UserAPIKeyAuth(), DualCache(), data, call_type))
asyncio.run(logger.async_post_call_success_hook(data=data, user_api_key_dict=UserAPIKeyAuth(), response=response))
root.end()
async def _relay(logger, chunks: Sequence[object], data: dict) -> list[object]:
async def source() -> AsyncIterator[object]:
for chunk in chunks:
yield chunk
return [chunk async for chunk in logger.async_post_call_streaming_iterator_hook(UserAPIKeyAuth(), source(), data)]
def _run_stream(logger, data: dict, chunks: Sequence[object]) -> list[object]:
root = _start_root(logger)
asyncio.run(logger.async_pre_call_hook(UserAPIKeyAuth(), DualCache(), data, "acompletion"))
relayed = asyncio.run(_relay(logger, chunks, data))
root.end()
return relayed
def _chat_chunk(content: str | None, finish_reason: str | None = None) -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-1",
created=1,
model="gpt-5.4-mini",
choices=[StreamingChoices(index=0, delta=Delta(content=content), finish_reason=finish_reason)],
)
def _responses_api_response() -> ResponsesAPIResponse:
return ResponsesAPIResponse(
id="resp_1",
created_at=1,
output=[
{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "pong", "annotations": []}],
}
],
)
def _anthropic_sse_frames() -> tuple[bytes, ...]:
events = (
{
"type": "message_start",
"message": {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": None,
"usage": {"input_tokens": 1, "output_tokens": 0},
},
},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "po"}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ng"}},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}},
{"type": "message_stop"},
)
return tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events)
def test_chat_request_stamps_root_observation_input_and_output():
logger, exporter = _logger()
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
_run_request(logger, CHAT_DATA, "acompletion", response)
attrs = _root_attrs(exporter)
assert json.loads(attrs[INPUT_ATTR]) == [{"role": "user", "content": "ping"}]
output = json.loads(attrs[OUTPUT_ATTR])
assert [(turn["role"], turn["content"]) for turn in output] == [("assistant", "pong")]
def test_content_on_destination_stamps_langfuse_root_io_when_global_capture_is_off() -> None:
logger, exporter = _logger(capture="no_content")
destination: Final = OtelDestination(
endpoint="https://team.example.com/api/public/otel",
callback_name="langfuse_otel",
capture_message_content="span_only",
)
token: Final = otel_context.set_request_destinations((destination,))
try:
response: Final = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
_run_request(logger, CHAT_DATA, "acompletion", response)
finally:
otel_context.reset_request_destinations(token)
attrs: Final = _root_attrs(exporter)
assert json.loads(attrs[INPUT_ATTR]) == [{"role": "user", "content": "ping"}]
assert json.loads(attrs[OUTPUT_ATTR]) == [{"role": "assistant", "content": "pong"}]
def test_responses_request_folds_instructions_into_input_and_stamps_output_items():
logger, exporter = _logger()
data = {"model": "gpt-5.4-mini", "instructions": "be terse", "input": "ping"}
_run_request(logger, data, "aresponses", _responses_api_response())
attrs = _root_attrs(exporter)
assert json.loads(attrs[INPUT_ATTR]) == [
{"role": "system", "content": "be terse"},
{"role": "user", "content": "ping"},
]
output = json.loads(attrs[OUTPUT_ATTR])
assert output[0]["role"] == "assistant"
assert output[0]["content"][0]["text"] == "pong"
def test_anthropic_messages_request_folds_system_into_input_and_stamps_content_blocks():
logger, exporter = _logger()
data = {"model": "claude-sonnet-4-5", "system": "be terse", "messages": [{"role": "user", "content": "ping"}]}
response = {"type": "message", "role": "assistant", "content": [{"type": "text", "text": "pong"}]}
_run_request(logger, data, "aanthropic_messages", response)
attrs = _root_attrs(exporter)
assert json.loads(attrs[INPUT_ATTR]) == [
{"role": "system", "content": "be terse"},
{"role": "user", "content": "ping"},
]
assert json.loads(attrs[OUTPUT_ATTR]) == [{"role": "assistant", "content": [{"type": "text", "text": "pong"}]}]
def test_chat_stream_relays_chunks_untouched_and_stamps_assembled_output():
logger, exporter = _logger()
chunks = (_chat_chunk("po"), _chat_chunk("ng"), _chat_chunk(None, finish_reason="stop"))
relayed = _run_stream(logger, CHAT_DATA, chunks)
assert [id(chunk) for chunk in relayed] == [id(chunk) for chunk in chunks]
output = json.loads(_root_attrs(exporter)[OUTPUT_ATTR])
assert [(turn["role"], turn["content"]) for turn in output] == [("assistant", "pong")]
def test_responses_stream_stamps_output_from_the_completed_event():
logger, exporter = _logger()
completed = ResponseCompletedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=_responses_api_response()
)
chunks = ({"type": "response.created"}, {"type": "response.output_text.delta", "delta": "pong"}, completed)
relayed = _run_stream(logger, {"model": "gpt-5.4-mini", "input": "ping"}, chunks)
assert relayed == list(chunks)
output = json.loads(_root_attrs(exporter)[OUTPUT_ATTR])
assert output[0]["content"][0]["text"] == "pong"
def test_anthropic_sse_stream_stamps_output_from_the_assembled_frames():
logger, exporter = _logger()
frames = _anthropic_sse_frames()
relayed = _run_stream(
logger, {"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "ping"}]}, frames
)
assert relayed == list(frames)
output = json.loads(_root_attrs(exporter)[OUTPUT_ATTR])
assert [(turn["role"], turn["content"]) for turn in output] == [("assistant", "pong")]
def test_root_observation_io_survives_the_root_ending_before_the_success_callback():
logger, exporter = _logger()
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
root = _start_root(logger)
asyncio.run(logger.async_pre_call_hook(UserAPIKeyAuth(), DualCache(), CHAT_DATA, "acompletion"))
logger.log_pre_api_call(
model="gpt-5.4-mini",
messages=[],
kwargs={"litellm_call_id": "call_1", "litellm_params": {"metadata": {}}},
)
asyncio.run(
logger.async_post_call_success_hook(data=CHAT_DATA, user_api_key_dict=UserAPIKeyAuth(), response=response)
)
root.end()
payload = {
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-mini",
"messages": CHAT_DATA["messages"],
"response": response.model_dump(),
"status": "success",
"litellm_call_id": "call_1",
"metadata": {},
"hidden_params": {},
}
asyncio.run(
logger.async_log_success_event(
{"standard_logging_object": payload, "litellm_params": {"metadata": {}}}, response, None, None
)
)
attrs = _root_attrs(exporter)
assert INPUT_ATTR in attrs and OUTPUT_ATTR in attrs
generation = next(span for span in exporter.get_finished_spans() if span.name != LITELLM_PROXY_REQUEST_SPAN_NAME)
assert OUTPUT_ATTR in dict(generation.attributes or {})
def test_root_input_is_the_request_as_the_pre_call_chain_left_it():
logger, exporter = _logger()
raw = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "my ssn is 123-45-6789"}]}
masked = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "my ssn is [REDACTED]"}]}
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="noted"))])
root = _start_root(logger)
asyncio.run(logger.async_pre_call_hook(UserAPIKeyAuth(), DualCache(), raw, "acompletion"))
asyncio.run(logger.async_post_call_success_hook(data=masked, user_api_key_dict=UserAPIKeyAuth(), response=response))
root.end()
assert json.loads(_root_attrs(exporter)[INPUT_ATTR]) == masked["messages"]
def test_root_already_ended_is_left_alone():
logger, exporter = _logger()
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
root = _start_root(logger)
root.end()
asyncio.run(
logger.async_post_call_success_hook(data=CHAT_DATA, user_api_key_dict=UserAPIKeyAuth(), response=response)
)
attrs = _root_attrs(exporter)
assert INPUT_ATTR not in attrs and OUTPUT_ATTR not in attrs
def test_responses_without_a_message_body_stamp_neither_input_nor_output():
logger, exporter = _logger()
embedding = EmbeddingResponse(model="e", data=[Embedding(embedding=[0.1], index=0, object="embedding")])
_run_request(logger, {"model": "e", "input": "ping"}, "aembedding", embedding)
attrs = _root_attrs(exporter)
assert INPUT_ATTR not in attrs and OUTPUT_ATTR not in attrs
def test_unrenderable_output_never_raises_into_the_request():
logger, exporter = _logger()
_run_request(logger, CHAT_DATA, "acompletion", object())
attrs = _root_attrs(exporter)
assert INPUT_ATTR not in attrs and OUTPUT_ATTR not in attrs
def _inbound_traceparent() -> Context:
caller: Final = trace.SpanContext(
trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736,
span_id=0x00F067AA0BA902B7,
is_remote=True,
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
)
return trace.set_span_in_context(trace.NonRecordingSpan(caller))
def _run_named_request(
logger: OpenTelemetryV2,
exporter: InMemorySpanExporter,
litellm_params: Mapping[str, object],
parent_context: Context | None = None,
) -> tuple[Mapping[str, object], Mapping[str, object]]:
response: Final = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
root: Final = _start_root(logger, parent_context)
logger.log_pre_api_call(
model="gpt-5.4-mini",
messages=[],
kwargs={"litellm_call_id": "call_1", "litellm_params": litellm_params, "call_type": "acompletion"},
)
root.end()
payload: Final = {
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-mini",
"messages": CHAT_DATA["messages"],
"response": response.model_dump(),
"status": "success",
"litellm_call_id": "call_1",
"metadata": {},
"hidden_params": {},
}
asyncio.run(
logger.async_log_success_event(
{"standard_logging_object": payload, "litellm_params": litellm_params}, response, None, None
)
)
generation: Final = next(
span for span in exporter.get_finished_spans() if span.name != LITELLM_PROXY_REQUEST_SPAN_NAME
)
return _root_attrs(exporter), dict(generation.attributes or {})
@pytest.mark.parametrize("capture", ["span_only", "no_content"])
def test_langfuse_trace_name_header_names_the_root_and_the_generation_over_body_metadata(capture):
logger, exporter = _logger(capture=capture)
root_attrs, generation_attrs = _run_named_request(
logger,
exporter,
{
"metadata": {"trace_name": "from-body"},
"proxy_server_request": {"headers": {"langfuse_trace_name": "from-header"}},
},
)
assert root_attrs[TRACE_NAME_ATTR] == "from-header"
assert generation_attrs[TRACE_NAME_ATTR] == "from-header"
def test_body_metadata_trace_name_names_the_root_and_the_generation():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger, exporter, {"metadata": {"trace_name": "from-body"}, "proxy_server_request": {"headers": {}}}
)
assert root_attrs[TRACE_NAME_ATTR] == "from-body"
assert generation_attrs[TRACE_NAME_ATTR] == "from-body"
def test_unnamed_request_derives_the_trace_name_on_the_root_only():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(logger, exporter, {"proxy_server_request": {"headers": {}}})
assert root_attrs[TRACE_NAME_ATTR] == "litellm-acompletion"
assert TRACE_NAME_ATTR not in generation_attrs
def test_unnamed_request_under_an_inbound_traceparent_leaves_the_trace_name_to_the_caller():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger, exporter, {"proxy_server_request": {"headers": {}}}, _inbound_traceparent()
)
assert TRACE_NAME_ATTR not in root_attrs and TRACE_NAME_ATTR not in generation_attrs
def test_named_request_under_an_inbound_traceparent_keeps_the_caller_trace_name():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger,
exporter,
{"metadata": {"trace_name": "from-body"}, "proxy_server_request": {"headers": {}}},
_inbound_traceparent(),
)
assert root_attrs[TRACE_NAME_ATTR] == "from-body"
assert generation_attrs[TRACE_NAME_ATTR] == "from-body"
UNNAMED: Final = {"proxy_server_request": {"headers": {}}}
JUDGE_PARAMS: Final = {"metadata": {"internal_call_origin": "llm_as_a_judge_guardrail"}}
CALLER_NAMES: Final = (
{"metadata": {"trace_name": "from-caller"}, "proxy_server_request": {"headers": {}}},
{"proxy_server_request": {"headers": {"langfuse_trace_name": "from-caller"}}},
)
def _root_trace_name_after(
logger: OpenTelemetryV2,
calls: Sequence[tuple[str, Mapping[str, object]]],
root_logger: OpenTelemetryV2 | None = None,
) -> object:
root: Final = _start_root(root_logger or logger)
for index, (call_type, litellm_params) in enumerate(calls):
logger.log_pre_api_call(
model="gpt-5.4-mini",
messages=[],
kwargs={"litellm_call_id": f"call_{index}", "litellm_params": litellm_params, "call_type": call_type},
)
root.end()
return dict(root.attributes or {}).get(TRACE_NAME_ATTR)
@pytest.mark.parametrize("later", [JUDGE_PARAMS, UNNAMED], ids=["litellm-sub-call", "untagged-call"])
@pytest.mark.parametrize("caller", CALLER_NAMES, ids=["body", "header"])
def test_a_later_call_in_the_request_keeps_the_caller_trace_name_on_the_root(
caller: Mapping[str, object], later: Mapping[str, object]
) -> None:
logger, _ = _logger()
assert _root_trace_name_after(logger, (("acompletion", caller), ("acompletion", later))) == "from-caller"
@pytest.mark.parametrize("caller", CALLER_NAMES, ids=["body", "header"])
def test_a_caller_trace_name_replaces_the_name_an_earlier_call_derived(caller: Mapping[str, object]) -> None:
logger, _ = _logger()
assert _root_trace_name_after(logger, (("acompletion", JUDGE_PARAMS), ("acompletion", caller))) == "from-caller"
def test_a_later_call_keeps_the_trace_name_the_first_call_derived():
logger, _ = _logger()
assert (
_root_trace_name_after(logger, (("anthropic_messages", UNNAMED), ("acompletion", UNNAMED)))
== "litellm-anthropic_messages"
)
def test_a_litellm_sub_call_before_the_request_call_leaves_the_trace_name_to_the_request_call():
logger, _ = _logger()
assert (
_root_trace_name_after(logger, (("acompletion", JUDGE_PARAMS), ("anthropic_messages", UNNAMED)))
== "litellm-anthropic_messages"
)
def test_a_root_another_vendor_started_gets_no_derived_trace_name():
logger, _ = _logger()
other_vendor, _ = _logger(mappers=("genai",))
assert _root_trace_name_after(logger, (("acompletion", UNNAMED),), root_logger=other_vendor) is None
@pytest.mark.parametrize("caller", CALLER_NAMES, ids=["body", "header"])
def test_a_root_another_vendor_started_still_gets_the_caller_trace_name(caller: Mapping[str, object]) -> None:
logger, _ = _logger()
other_vendor, _ = _logger(mappers=("genai",))
assert _root_trace_name_after(logger, (("acompletion", caller),), root_logger=other_vendor) == "from-caller"
@pytest.mark.parametrize("capture", ["span_only", "no_content"])
def test_body_metadata_user_session_and_tags_land_on_the_root_and_the_generation(capture):
logger, exporter = _logger(capture=capture)
root_attrs, generation_attrs = _run_named_request(
logger,
exporter,
{
"metadata": {
"trace_user_id": "user-42",
"session_id": "session-7",
"tags": ["prod", "eval", "nightly"],
"user_api_key_team_id": "team-from-proxy",
},
"proxy_server_request": {"headers": {}},
},
)
for attrs in (root_attrs, generation_attrs):
assert attrs["user.id"] == "user-42"
assert attrs["session.id"] == "session-7"
assert tuple(attrs["langfuse.trace.tags"]) == ("prod", "eval", "nightly")
assert root_attrs[TRACE_NAME_ATTR] == "litellm-acompletion"
assert TRACE_NAME_ATTR not in generation_attrs
def test_langfuse_user_and_session_headers_beat_body_metadata_on_both_spans():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger,
exporter,
{
"metadata": {"trace_user_id": "from-body", "session_id": "from-body"},
"proxy_server_request": {
"headers": {"langfuse_trace_user_id": "from-header", "langfuse_session_id": "from-header-s"}
},
},
)
for attrs in (root_attrs, generation_attrs):
assert attrs["user.id"] == "from-header"
assert attrs["session.id"] == "from-header-s"
def test_caller_metadata_cannot_override_the_proxy_team_identity():
logger, exporter = _logger()
response: Final = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
litellm_params: Final = {
"metadata": {"trace_user_id": "u", "trace_metadata": {"team_id": "spoofed"}, "team_id": "spoofed"}
}
logger.log_pre_api_call(
model="gpt-5.4-mini", messages=[], kwargs={"litellm_call_id": "call_1", "litellm_params": litellm_params}
)
payload: Final = {
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-mini",
"messages": CHAT_DATA["messages"],
"response": response.model_dump(),
"status": "success",
"litellm_call_id": "call_1",
"metadata": {
"user_api_key_team_id": "real-team",
"user_api_key_team_alias": "real-alias",
"team_id": "spoofed",
"team_alias": "spoofed",
},
"hidden_params": {},
}
asyncio.run(
logger.async_log_success_event(
{"standard_logging_object": payload, "litellm_params": litellm_params}, response, None, None
)
)
attrs: Final = dict(exporter.get_finished_spans()[0].attributes or {})
assert attrs["user.id"] == "u"
assert attrs["langfuse.trace.metadata.team_id"] == "real-team"
assert attrs["langfuse.trace.metadata.team_alias"] == "real-alias"
assert "langfuse.trace.metadata" not in attrs and "langfuse.trace.id" not in attrs
def test_a_request_without_trace_controls_stamps_none_of_them():
logger, exporter = _logger()
root_attrs, generation_attrs = _run_named_request(
logger,
exporter,
{"metadata": {"user_api_key_team_id": "t1", "tags": []}, "proxy_server_request": {"headers": {}}},
)
caller_only: Final = tuple(attr for attr in TRACE_CONTROL_ATTRS if attr != TRACE_NAME_ATTR)
assert set(caller_only).isdisjoint(root_attrs)
assert root_attrs[TRACE_NAME_ATTR] == "litellm-acompletion"
assert set(TRACE_CONTROL_ATTRS).isdisjoint(generation_attrs)
@pytest.mark.parametrize(
("capture", "mappers"),
[("no_content", ("genai", "langfuse")), ("span_only", ("genai",))],
)
def test_langfuse_root_io_requires_global_or_destination_capture(capture: str, mappers: tuple[str, ...]) -> None:
logger, exporter = _logger(capture=capture, mappers=mappers)
_run_request(logger, CHAT_DATA, "acompletion", ModelResponse())
attrs = _root_attrs(exporter)
assert INPUT_ATTR not in attrs and OUTPUT_ATTR not in attrs
@pytest.mark.parametrize(
("capture", "mappers", "relays_streams"),
[
("span_only", ("genai", "langfuse"), True),
("no_content", ("genai", "langfuse"), True),
("span_only", ("genai",), False),
],
)
def test_langfuse_mapper_relays_streams_for_per_request_content_capture(
monkeypatch: pytest.MonkeyPatch, capture: str, mappers: tuple[str, ...], relays_streams: bool
) -> None:
logger, _ = _logger(capture=capture, mappers=mappers)
monkeypatch.setattr(litellm, "callbacks", [logger])
assert ProxyLogging._callback_capabilities().has_iterator_override is relays_streams
def test_langfuse_otel_preset_builds_a_logger_that_stamps_the_root(monkeypatch):
monkeypatch.setenv("LITELLM_OTEL_V2", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk")
monkeypatch.setenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "span_only")
is_otel_v2_enabled.cache_clear()
loggers: list = []
try:
built = _maybe_construct_otel_v2("langfuse_otel", loggers)
assert built is not None
assert _maybe_construct_otel_v2("langfuse_otel", loggers) is built
root = _start_root(built)
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="pong"))])
asyncio.run(
built.async_post_call_success_hook(data=CHAT_DATA, user_api_key_dict=UserAPIKeyAuth(), response=response)
)
attrs = dict(root.attributes or {})
assert INPUT_ATTR in attrs and OUTPUT_ATTR in attrs
finally:
is_otel_v2_enabled.cache_clear()
def _llm_call_kwargs(optional_params: Mapping[str, object], completion_start: datetime | None) -> dict[str, object]:
return {
"standard_logging_object": {
"litellm_call_id": "call_1",
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-mini",
"stream": bool(optional_params.get("stream")),
"status": "success",
"completionStartTime": datetime(2026, 10, 9, 12, 0, 9, tzinfo=timezone.utc).timestamp(),
},
"litellm_params": {"metadata": {}},
"optional_params": optional_params,
"api_call_start_time": datetime(2026, 10, 9, 12, 0, 0, tzinfo=timezone.utc),
"completion_start_time": completion_start,
}
def _generation_attrs(kwargs: dict[str, object], *, fail: bool = False) -> Mapping[str, object]:
logger, exporter = _logger()
logger.log_pre_api_call(model="gpt-5.4-mini", messages=[], kwargs=kwargs)
hook: Final = logger.async_log_failure_event if fail else logger.async_log_success_event
asyncio.run(hook(kwargs, None, None, None))
(generation,) = [span for span in exporter.get_finished_spans() if span.kind is SpanKind.CLIENT]
return dict(generation.attributes or {})
@pytest.mark.parametrize(
"optional_params",
[
pytest.param({"stream": True}, id="streamed"),
pytest.param({}, id="not_streamed_or_pass_through_stream"),
],
)
def test_generation_carries_litellms_completion_start_time_like_the_default_emitter(
optional_params: Mapping[str, object],
) -> None:
completion_start: Final = datetime(2026, 10, 9, 12, 0, 0, 250000, tzinfo=timezone.utc)
attrs: Final = _generation_attrs(_llm_call_kwargs(optional_params, completion_start))
assert datetime.fromisoformat(json.loads(attrs[COMPLETION_START_ATTR])) == completion_start
def test_failed_generation_without_a_first_chunk_has_no_completion_start_time() -> None:
attrs: Final = _generation_attrs(_llm_call_kwargs({"stream": True}, None), fail=True)
assert COMPLETION_START_ATTR not in attrs