litellm/tests/integration/observability/test_langfuse_otel_trace_fields.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

660 lines
28 KiB
Python

import json
import threading
import uuid
from itertools import product
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import pytest
import yaml
from _s3_v2_support import SURFACES, call_surface, mixed_burst, surface_reply
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, wire_server
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
from opentelemetry.proto.common.v1.common_pb2 import AnyValue
from opentelemetry.proto.trace.v1.trace_pb2 import Span
REDACTED: Final = "redacted-by-litellm"
TRACE_FIELDS: Final = ("langfuse.trace.name", "langfuse.trace.input", "langfuse.trace.output", "langfuse.trace.tags")
SURFACE_CALL_TYPES: Final = {
"chat": "acompletion",
"chat_stream": "acompletion",
"messages": "anthropic_messages",
"messages_stream": "anthropic_messages",
"responses": "aresponses",
"responses_stream": "aresponses",
}
ANTHROPIC_MODEL: Final = "anthropic/claude-sonnet-4-5-20250929"
JUDGE_MODEL: Final = "lit8281-judge-model"
JUDGE_GUARDRAIL: Final = "lit8281-judge"
@dataclass(frozen=True, slots=True)
class SpanRecord:
name: str
trace_id: str
parent_span_id: str
attributes: Mapping[str, object]
dropped_attributes: int = 0
def tags(self) -> tuple[object, ...]:
raw: Final = self.attributes.get("langfuse.trace.tags")
if isinstance(raw, tuple):
return raw
if not isinstance(raw, str) or not raw:
return ()
if raw.strip().startswith("["):
return tuple(json.loads(raw))
return tuple(tag.strip() for tag in raw.split(",")) if "," in raw else (raw,)
def _attribute_value(value: AnyValue) -> object:
kind: Final = value.WhichOneof("value")
if kind == "array_value":
return tuple(_attribute_value(item) for item in value.array_value.values)
return getattr(value, kind) if kind is not None else None
def _exported_spans(body: bytes) -> Iterator[Span]:
for resource in ExportTraceServiceRequest.FromString(body).resource_spans:
for scope in resource.scope_spans:
yield from scope.spans
def _span_records(body: bytes) -> tuple[SpanRecord, ...]:
return tuple(
SpanRecord(
name=span.name,
trace_id=span.trace_id.hex(),
parent_span_id=span.parent_span_id.hex(),
attributes={pair.key: _attribute_value(pair.value) for pair in span.attributes},
dropped_attributes=span.dropped_attributes_count,
)
for span in _exported_spans(body)
)
class LangfuseSink:
def __init__(self) -> None:
self.lock: Final = threading.Lock()
self.outage: Final = threading.Event()
self.accepted: Final[list[SpanRecord]] = [] # mutable-ok: spans accumulate as the exporter delivers them
self.roots: Final[list[SpanRecord]] = [] # mutable-ok: root spans accumulate as the exporter delivers them
self.spans: Final[list[SpanRecord]] = [] # mutable-ok: every span accumulates as the exporter delivers it
self.refusals: Final[list[int]] = [] # mutable-ok: one entry per batch refused during the outage
def respond(self, request: Request) -> Reply:
if not request.target.endswith("/v1/traces"):
return Reply(body=b"")
assert request.headers.get("x-langfuse-ingestion-version") == "4", request.headers
if self.outage.is_set():
with self.lock:
self.refusals.append(1)
return Reply(status=503, body=b'{"error": "scripted outage"}')
records: Final = _span_records(request.body)
with self.lock:
self.accepted.extend(
record for record in records if record.attributes.get("langfuse.observation.type") == "generation"
)
self.roots.extend(record for record in records if not record.parent_span_id)
self.spans.extend(records)
return Reply(body=b"")
def matching(self, marker: str) -> tuple[SpanRecord, ...]:
with self.lock:
snapshot: Final = tuple(self.accepted)
return tuple(record for record in snapshot if marker in str(tuple(record.attributes.values())))
def one(self, marker: str) -> SpanRecord:
return eventually(lambda: self.matching(marker), lambda found: len(found) == 1, seconds=70)[0]
def in_trace(self, trace_id: str) -> tuple[SpanRecord, ...]:
with self.lock:
return tuple(record for record in self.spans if record.trace_id == trace_id)
def root_of(self, span: SpanRecord) -> SpanRecord:
def roots() -> tuple[SpanRecord, ...]:
with self.lock:
return tuple(root for root in self.roots if root.trace_id == span.trace_id)
return eventually(roots, lambda found: len(found) == 1, seconds=70)[0]
class OtlpSink:
def __init__(self) -> None:
self.lock: Final = threading.Lock()
self.spans: Final[list[SpanRecord]] = [] # mutable-ok: spans accumulate as the exporter delivers them
def respond(self, request: Request) -> Reply:
if request.target.endswith("/v1/traces"):
records: Final = _span_records(request.body)
with self.lock:
self.spans.extend(records)
return Reply(body=b"")
def root_of_request(self, marker: str) -> SpanRecord:
def roots() -> tuple[SpanRecord, ...]:
with self.lock:
snapshot: Final = tuple(self.spans)
traces: Final = {record.trace_id for record in snapshot if marker in str(tuple(record.attributes.values()))}
return tuple(record for record in snapshot if record.trace_id in traces and not record.parent_span_id)
return eventually(roots, lambda found: len(found) == 1, seconds=70)[0]
def _chat_completion(marker: str, text: str = "langfuse-otel scripted reply") -> bytes:
return json.dumps(
{
"id": marker,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 5, "completion_tokens": 4, "total_tokens": 9},
}
).encode()
def _provider(request: Request) -> Reply:
if request.method != "POST" or not request.body:
return Reply(body=b'{"object": "list", "data": []}')
body: Final = json.loads(request.body)
if request.target.endswith("/embeddings"):
return Reply(
body=json.dumps(
{
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.25, 0.5]}],
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 3, "total_tokens": 3},
}
).encode()
)
if body.get("response_format") == {"type": "json_object"}:
return Reply(body=_chat_completion("lit8281-judge-verdict", json.dumps({"overall_score": 100})))
messages: Final = body.get("messages")
first: Final = messages[0]["content"] if isinstance(messages, list) and messages else None
if isinstance(first, str) and first.startswith("fail-"):
return Reply(status=500, body=b'{"error": {"message": "scripted provider failure", "type": "server_error"}}')
return surface_reply(request)
def _langfuse_config(directory: Path, name: str, settings: Mapping[str, object], provider_url: str) -> Path:
base: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
judge: Final = {
"model_name": JUDGE_MODEL,
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_base": provider_url + "/v1",
"api_key": "synthetic-provider-key",
},
}
config: Final = {
**base,
"model_list": [*base["model_list"], judge],
"guardrails": [
{
"guardrail_name": JUDGE_GUARDRAIL,
"litellm_params": {
"guardrail": "llm_as_a_judge",
"mode": "post_call",
"judge_model": JUDGE_MODEL,
"on_failure": "log",
"criteria": [{"name": "helpful", "description": "The reply answers the user", "weight": 100}],
},
}
],
"litellm_settings": {**base["litellm_settings"], "callbacks": ["langfuse_otel"], **settings},
}
path: Final = directory / f"{name}.yaml"
path.write_text(yaml.safe_dump(config))
return path
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
sink: LangfuseSink
openai_model: str
anthropic_model: str
embedding_model: str
key: str
def _rig(
tmp_path_factory: pytest.TempPathFactory, name: str, environment: Mapping[str, str], settings: Mapping[str, object]
) -> Iterator[Rig]:
directory: Final = tmp_path_factory.mktemp(name)
sink: Final = LangfuseSink()
with (
wire_server(sink.respond) as destination,
wire_server(_provider) as provider,
gateway_from_environment() as gateway,
owned_proxy(
gateway,
directory,
{
"LANGFUSE_PUBLIC_KEY": "pk-lf-test",
"LANGFUSE_SECRET_KEY": "sk-lf-test",
"LANGFUSE_HOST": destination.url,
**environment,
},
config=_langfuse_config(directory, name, settings, provider.url),
workers=2,
) as proxy,
proxy.scenario() as scenario,
):
openai_model: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-provider-key")
anthropic_model: Final = scenario.model(
model=ANTHROPIC_MODEL, api_base=provider.url, api_key="synthetic-provider-key"
)
embedding_model: Final = scenario.model(
model="openai/text-embedding-3-small",
api_base=provider.url + "/v1",
api_key="synthetic-provider-key",
model_info={"mode": "embedding"},
)
key: Final = scenario.key(models=[openai_model, anthropic_model, embedding_model])
yield Rig(proxy, sink, openai_model, anthropic_model, embedding_model, key)
SPAN_ATTRIBUTE_LIMIT: Final = 128
@pytest.fixture(scope="module")
def v1(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
yield from _rig(
tmp_path_factory,
"langfuse-v1",
{"OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT": str(SPAN_ATTRIBUTE_LIMIT)},
{"langfuse_default_tags": ["cache_hit"]},
)
V2_ENVIRONMENT: Final = {"LITELLM_OTEL_V2": "1", "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "span_only"}
@pytest.fixture(scope="module")
def v2(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
yield from _rig(tmp_path_factory, "langfuse-v2", V2_ENVIRONMENT, {})
@pytest.fixture(scope="module")
def v2_llm_only(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
yield from _rig(
tmp_path_factory, "langfuse-v2-llm-only", {**V2_ENVIRONMENT, "LITELLM_OTEL_LANGFUSE_SPAN_SCOPE": "llm_only"}, {}
)
@dataclass(frozen=True, slots=True)
class SharedRig:
rig: Rig
otel: OtlpSink
@pytest.fixture(scope="module")
def v2_after_otel(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SharedRig]:
otel: Final = OtlpSink()
with wire_server(otel.respond) as destination:
environment: Final = {
**V2_ENVIRONMENT,
"OTEL_EXPORTER": "otlp_http",
"OTEL_ENDPOINT": destination.url + "/v1/traces",
}
for rig in _rig(
tmp_path_factory, "langfuse-v2-after-otel", environment, {"callbacks": ["otel", "langfuse_otel"]}
):
yield SharedRig(rig, otel)
def _chat(
rig: Rig,
content: str,
metadata: Mapping[str, object] | None = None,
headers: Mapping[str, str] | None = None,
guardrails: Sequence[str] = (),
) -> str:
response: Final = rig.proxy.request(
"POST",
"/v1/chat/completions",
{
"model": rig.openai_model,
"messages": [{"role": "user", "content": content}],
**({"metadata": dict(metadata)} if metadata is not None else {}),
**({"guardrails": list(guardrails)} if guardrails else {}),
},
key=rig.key,
headers=headers,
)
assert response.status_code == 200, response.text
return response.text
def _trace_fields(spans: tuple[SpanRecord, ...]) -> Iterator[str]:
for record in spans:
yield from (key for key in record.attributes if key in TRACE_FIELDS)
def _assert_derived(span: SpanRecord, call_type: str) -> None:
attributes: Final = span.attributes
assert attributes.get("langfuse.trace.name") == f"litellm-{call_type}", attributes
assert attributes.get("langfuse.trace.input") == attributes.get("langfuse.observation.input"), attributes
assert attributes.get("langfuse.trace.output") == attributes.get("langfuse.observation.output"), attributes
tags: Final = span.tags()
assert any(str(tag).startswith("User-Agent: ") for tag in tags), tags
assert len(tags) == len(set(map(str, tags))), tags
@pytest.mark.parametrize("surface", SURFACES)
def test_every_surface_derives_trace_name_io_and_tags(v1: Rig, surface: str) -> None:
marker: Final = f"lit8281-{surface}-{uuid.uuid4().hex}"
call_surface(v1.proxy, surface, v1.openai_model, v1.anthropic_model, v1.key, marker)
span: Final = v1.sink.one(marker)
_assert_derived(span, SURFACE_CALL_TYPES[surface])
assert marker in str(span.attributes["langfuse.trace.input"]), span.attributes
assert span.attributes.get("langfuse.trace.output") is not None, span.attributes
assert any(str(tag).startswith("cache_hit:") for tag in span.tags()), span.tags()
def test_inbound_traceparent_joins_the_caller_trace_without_derived_trace_fields(v1: Rig) -> None:
marker: Final = "lit8281-parent-" + uuid.uuid4().hex
trace_id: Final = uuid.uuid4().hex
parent_id: Final = uuid.uuid4().hex[:16]
_chat(v1, marker, headers={"traceparent": f"00-{trace_id}-{parent_id}-01"})
span: Final = v1.sink.one(marker)
assert (span.trace_id, span.parent_span_id) == (trace_id, parent_id), span
assert not set(TRACE_FIELDS) & set(span.attributes), span.attributes
assert marker in str(span.attributes.get("langfuse.observation.input")), span.attributes
assert span.attributes.get("langfuse.observation.output") is not None, span.attributes
def test_inbound_traceparent_still_carries_the_caller_trace_name_and_tags(v1: Rig) -> None:
marker: Final = "lit8281-parent-caller-" + uuid.uuid4().hex
trace_id: Final = uuid.uuid4().hex
_chat(
v1,
marker,
metadata={"trace_name": "caller-trace", "tags": ["caller-tag"]},
headers={"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"},
)
span: Final = v1.sink.one(marker)
assert span.trace_id == trace_id, span
assert span.attributes.get("langfuse.trace.name") == "caller-trace", span.attributes
assert span.tags() == ("caller-tag",), span.attributes
assert not {"langfuse.trace.input", "langfuse.trace.output"} & set(span.attributes), span.attributes
def test_caller_trace_name_and_tags_win_over_derived_values(v1: Rig) -> None:
marker: Final = "lit8281-caller-" + uuid.uuid4().hex
_chat(v1, marker, metadata={"trace_name": "caller-trace", "tags": ["caller-tag", "second-tag"]})
span: Final = v1.sink.one(marker)
assert span.attributes["langfuse.trace.name"] == "caller-trace", span.attributes
assert span.tags()[:2] == ("caller-tag", "second-tag"), span.tags()
assert span.attributes.get("langfuse.trace.input") == span.attributes.get("langfuse.observation.input")
@pytest.mark.parametrize(
("metadata", "headers", "expected"),
[
pytest.param({"tags": "c1, c2"}, None, ("c1", "c2"), id="body_comma_string"),
pytest.param({"tags": '["j1", "j2"]'}, None, ("j1", "j2"), id="body_json_array_string"),
pytest.param(None, {"langfuse_tags": "h1,h2"}, ("h1", "h2"), id="header_comma_string"),
],
)
def test_string_caller_tags_keep_the_tags_langfuse_split_them_into(
v1: Rig, metadata: Mapping[str, object] | None, headers: Mapping[str, str] | None, expected: tuple[str, ...]
) -> None:
marker: Final = "lit8281-strtags-" + uuid.uuid4().hex
_chat(v1, marker, metadata=metadata, headers=headers)
span: Final = v1.sink.one(marker)
assert span.tags()[: len(expected)] == expected, span.attributes
def test_trace_name_header_wins_over_the_derived_name(v1: Rig) -> None:
marker: Final = "lit8281-header-" + uuid.uuid4().hex
_chat(v1, marker, headers={"langfuse_trace_name": "header-trace"})
span: Final = v1.sink.one(marker)
assert span.attributes["langfuse.trace.name"] == "header-trace", span.attributes
def test_joined_trace_keeps_caller_tags_and_gets_no_derived_trace_fields(v1: Rig) -> None:
marker: Final = "lit8281-join-" + uuid.uuid4().hex
existing: Final = uuid.uuid4().hex
_chat(v1, marker, metadata={"existing_trace_id": existing, "tags": ["join-tag"]})
span: Final = v1.sink.one(marker)
attributes: Final = span.attributes
assert attributes.get("langfuse.trace.existing_id") == existing, attributes
assert span.tags() == ("join-tag",), attributes
assert "langfuse.trace.name" not in attributes, attributes
assert "langfuse.trace.input" not in attributes, attributes
assert "langfuse.trace.output" not in attributes, attributes
assert marker in str(attributes.get("langfuse.observation.input")), attributes
def test_joined_trace_without_caller_tags_gets_no_trace_tags(v1: Rig) -> None:
marker: Final = "lit8281-join-bare-" + uuid.uuid4().hex
_chat(v1, marker, metadata={"existing_trace_id": uuid.uuid4().hex})
attributes: Final = v1.sink.one(marker).attributes
assert not {
key for key in attributes if key.startswith("langfuse.trace.") and key != "langfuse.trace.existing_id"
}, attributes
def test_mask_flags_redact_the_derived_trace_io(v1: Rig) -> None:
marker: Final = "lit8281-mask-" + uuid.uuid4().hex
_chat(v1, marker, metadata={"mask_input": True, "mask_output": True})
attributes: Final = v1.sink.one(marker).attributes
assert attributes.get("langfuse.trace.input") == REDACTED, attributes
assert attributes.get("langfuse.trace.output") == REDACTED, attributes
assert attributes.get("langfuse.trace.name") == "litellm-acompletion", attributes
@pytest.mark.parametrize(
("headers", "masked"),
[
pytest.param({"langfuse_mask_input": "true", "langfuse_mask_output": "true"}, True, id="true"),
pytest.param({"langfuse_mask_input": "false", "langfuse_mask_output": "false"}, False, id="false"),
],
)
def test_mask_flag_headers_parse_as_booleans(v1: Rig, headers: Mapping[str, str], masked: bool) -> None:
marker: Final = "lit8281-mask-header-" + uuid.uuid4().hex
_chat(v1, marker, headers=headers)
attributes: Final = v1.sink.one(marker).attributes
expected: Final = (
(REDACTED, REDACTED)
if masked
else (attributes.get("langfuse.observation.input"), attributes.get("langfuse.observation.output"))
)
assert (attributes.get("langfuse.trace.input"), attributes.get("langfuse.trace.output")) == expected, attributes
assert marker in str(attributes.get("langfuse.observation.input")), attributes
@pytest.mark.parametrize(
"tags",
[
pytest.param(5, id="int"),
pytest.param(["ok-tag", 7, None], id="mixed_list"),
pytest.param("", id="empty_string"),
pytest.param("x" * 5000, id="five_kb_string"),
],
)
def test_malformed_caller_tags_still_log_the_generation_with_derived_fields(v1: Rig, tags: object) -> None:
marker: Final = "lit8281-badtags-" + uuid.uuid4().hex
_chat(v1, marker, metadata={"tags": tags})
span: Final = v1.sink.one(marker)
assert span.attributes.get("langfuse.trace.name") == "litellm-acompletion", span.attributes
assert span.attributes.get("langfuse.trace.input") == span.attributes.get("langfuse.observation.input")
assert all(isinstance(tag, str) for tag in span.tags()), span.tags()
def test_provider_failure_span_derives_name_and_input_without_output(v1: Rig) -> None:
marker: Final = "fail-lit8281-" + uuid.uuid4().hex
response: Final = v1.proxy.request(
"POST",
"/v1/chat/completions",
{"model": v1.openai_model, "messages": [{"role": "user", "content": marker}]},
key=v1.key,
)
assert response.status_code >= 500, response.text
span: Final = v1.sink.one(marker)
assert span.attributes.get("langfuse.trace.name") == "litellm-acompletion", span.attributes
assert span.attributes.get("langfuse.trace.input") == span.attributes.get("langfuse.observation.input")
assert "langfuse.trace.output" not in span.attributes, span.attributes
def test_embedding_call_derives_the_trace_name(v1: Rig) -> None:
marker: Final = "lit8281-embed-" + uuid.uuid4().hex
response: Final = v1.proxy.request(
"POST", "/v1/embeddings", {"model": v1.embedding_model, "input": marker, "user": marker}, key=v1.key
)
assert response.status_code == 200, response.text
span: Final = v1.sink.one(marker)
assert span.attributes.get("langfuse.trace.name") == "litellm-aembedding", span.attributes
assert span.attributes.get("langfuse.trace.output") == span.attributes.get("langfuse.observation.output")
def test_identical_requests_each_log_exactly_one_span_with_derived_fields(v1: Rig) -> None:
marker: Final = "lit8281-twice-" + uuid.uuid4().hex
_chat(v1, marker)
_chat(v1, marker)
spans: Final = eventually(lambda: v1.sink.matching(marker), lambda found: len(found) == 2, seconds=70)
for span in spans:
_assert_derived(span, "acompletion")
def test_otel_v2_names_the_request_root_and_leaves_trace_io_to_the_root_observation(v2: Rig) -> None:
derived: Final = "lit8281-v2-" + uuid.uuid4().hex
caller: Final = "lit8281-v2-caller-" + uuid.uuid4().hex
_chat(v2, derived)
_chat(v2, caller, metadata={"trace_name": "caller-trace", "tags": ["caller-tag"]})
derived_span: Final = v2.sink.one(derived)
assert not set(_trace_fields((derived_span,))), derived_span.attributes
root: Final = v2.sink.root_of(derived_span)
assert root.attributes.get("langfuse.trace.name") == "litellm-acompletion", root.attributes
assert derived in str(root.attributes.get("langfuse.observation.input")), root.attributes
assert root.attributes.get("langfuse.observation.output") is not None, root.attributes
caller_span: Final = v2.sink.one(caller)
assert caller_span.attributes.get("langfuse.trace.name") == "caller-trace", caller_span.attributes
assert caller_span.tags() == ("caller-tag",), caller_span.attributes
def test_otel_v2_inbound_traceparent_leaves_the_trace_name_to_the_caller(v2: Rig) -> None:
marker: Final = "lit8281-v2-parent-" + uuid.uuid4().hex
trace_id: Final = uuid.uuid4().hex
_chat(v2, marker, headers={"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"})
span: Final = v2.sink.one(marker)
assert span.trace_id == trace_id, span
spans: Final = eventually(
lambda: v2.sink.in_trace(trace_id),
lambda found: any(record.parent_span_id != span.parent_span_id for record in found),
seconds=70,
)
assert not set(_trace_fields(spans)), spans
@pytest.mark.parametrize(
("metadata", "headers"),
[({"trace_name": "caller-trace"}, None), (None, {"langfuse_trace_name": "caller-trace"})],
ids=["body", "header"],
)
def test_otel_v2_post_call_judge_keeps_the_caller_trace_name_on_the_root(
v2: Rig, metadata: Mapping[str, object] | None, headers: Mapping[str, str] | None
) -> None:
marker: Final = "lit8281-v2-judge-" + uuid.uuid4().hex
_chat(v2, marker, metadata=metadata, headers=headers, guardrails=[JUDGE_GUARDRAIL])
generations: Final = eventually(lambda: v2.sink.matching(marker), lambda found: len(found) == 2, seconds=70)
assert len({generation.trace_id for generation in generations}) == 1, generations
root: Final = v2.sink.root_of(generations[0])
assert root.attributes.get("langfuse.trace.name") == "caller-trace", root.attributes
def test_otel_v2_generic_otel_root_gets_no_derived_langfuse_trace_name(v2_after_otel: SharedRig) -> None:
derived: Final = "lit8281-v2-after-otel-" + uuid.uuid4().hex
caller: Final = "lit8281-v2-after-otel-caller-" + uuid.uuid4().hex
_chat(v2_after_otel.rig, derived)
_chat(v2_after_otel.rig, caller, metadata={"trace_name": "caller-trace"})
root: Final = v2_after_otel.otel.root_of_request(derived)
assert "langfuse.trace.name" not in root.attributes, root.attributes
caller_root: Final = v2_after_otel.otel.root_of_request(caller)
assert caller_root.attributes.get("langfuse.trace.name") == "caller-trace", caller_root.attributes
def test_otel_v2_llm_only_root_generation_keeps_its_span_name_as_the_trace_name(v2_llm_only: Rig) -> None:
marker: Final = "lit8281-v2-llm-only-" + uuid.uuid4().hex
_chat(v2_llm_only, marker)
span: Final = v2_llm_only.sink.one(marker)
assert span.parent_span_id == "", span
assert span.attributes.get("langfuse.trace.name") == span.name, span
assert marker in str(span.attributes.get("langfuse.observation.input")), span.attributes
def test_destination_outage_mid_burst_lands_every_span_once_with_derived_fields(v1: Rig) -> None:
marker: Final = "lit8281-chaos-" + uuid.uuid4().hex[:12]
v1.sink.outage.set()
try:
answered: Final = mixed_burst(v1.proxy, v1.openai_model, v1.anthropic_model, v1.key, marker, per_surface=4)
eventually(lambda: len(v1.sink.refusals), lambda refused: refused >= 1, seconds=70)
finally:
v1.sink.outage.clear()
assert len(answered) == 4 * len(SURFACES)
expected: Final = {f"{marker}-{surface}-{index}": surface for surface, index in product(SURFACES, range(4))}
def landed() -> Mapping[str, tuple[SpanRecord, ...]]:
return {identity: v1.sink.matching(identity) for identity in expected}
spans: Final = eventually(landed, lambda found: all(len(records) >= 1 for records in found.values()), seconds=120)
assert {identity: len(records) for identity, records in spans.items()} == dict.fromkeys(expected, 1)
for identity, records in spans.items():
_assert_derived(records[0], SURFACE_CALL_TYPES[expected[identity]])
def _tool(index: int) -> Mapping[str, object]:
return {
"type": "function",
"function": {
"name": f"lookup_{index}",
"description": f"Looks up record {index}",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
},
}
def _chat_with_tools(
rig: Rig,
content: str,
tools: int,
metadata: Mapping[str, object] | None = None,
headers: Mapping[str, str] | None = None,
) -> SpanRecord:
response: Final = rig.proxy.request(
"POST",
"/v1/chat/completions",
{
"model": rig.openai_model,
"messages": [{"role": "user", "content": content}],
"tools": [_tool(index) for index in range(tools)],
**({"metadata": dict(metadata)} if metadata is not None else {}),
},
key=rig.key,
headers=headers,
)
assert response.status_code == (500 if content.startswith("fail-") else 200), response.text
return rig.sink.one(content)
@pytest.mark.parametrize(("tools", "prefix"), product((30, 33, 34, 35, 36, 40), ("", "fail-")))
def test_trace_fields_never_evict_or_drop_another_attribute_of_a_full_span(v1: Rig, tools: int, prefix: str) -> None:
run: Final = uuid.uuid4().hex
derived: Final = _chat_with_tools(v1, f"{prefix}lit8281-full-{tools}-{run}", tools, {"trace_id": uuid.uuid4().hex})
joined: Final = _chat_with_tools(
v1,
f"{prefix}lit8281-full-joined-{tools}-{run}",
tools,
headers={"traceparent": f"00-{uuid.uuid4().hex}-{uuid.uuid4().hex[:16]}-01"},
)
assert set(joined.attributes) <= set(derived.attributes), set(joined.attributes) - set(derived.attributes)
assert derived.dropped_attributes == joined.dropped_attributes, (derived.dropped_attributes, joined.attributes)