litellm/tests/test_litellm/tracing/test_decode.py
yujonglee ec605826d4
feat: improve trace ingestion and trace details (#43975)
* refactor: separate OTLP HTTP decoding from trace codec

* feat: complete trace ingestion and read paths

* fix: encode OTLP protobuf errors in Rust

* fix: raise OTLP body limit to 16 MiB

* test: cover OTLP auth body parsing boundary

* refactor: parse OTLP media type into enum

* fix: enforce OTLP body size at HTTP boundary

* perf: preserve shared OTLP metadata across ingestion

* bench: compare owned and shared trace resource fanout

* refactor: extract shared storage and Python conversion caches

* refactor: keep shared storage owned by traces

* test: keep trace loopback coverage in Rust

* test(proxy): adapt trace coverage to injected access context

* fix(tracing): satisfy stacked branch lint checks

* refactor(tracing): use immutable ingestion payloads

* fix(tracing): declare native error encoder export

* test(proxy): resolve trace access through dependency

* fix(tracing): align merged normalizer types and bridge tests

* fix(tracing): address ingestion and diagnostic review findings

* fix(proxy): preserve body parsing for partial request scopes

* test(proxy): use valid HTTP scopes in request fixtures

* test(proxy): complete auth request flow scopes
2026-10-01 13:45:33 -07:00

500 lines
20 KiB
Python

"""
Tests for OTLP decode + normalization (litellm/tracing/decode.py).
The fixture is a trimmed real export from a Deep Agents run (LangSmith OTEL mode):
deep_research_agent -> task (tool) -> researcher (subagent) -> search_docs (tool).
"""
import base64
import gzip
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from google.protobuf.json_format import ParseDict
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
from opentelemetry.proto.trace.v1.trace_pb2 import ResourceSpans, ScopeSpans, Span, Status
from litellm.tracing import decode
from litellm.tracing.decode import decode_otlp, encode_otlp_response
pytestmark = pytest.mark.requires_rust_extension
FIXTURE = Path(__file__).parent / "fixtures" / "langsmith_deep_agent_export.json"
TRACE_ID = "4bad42b84e9de3ba46fc870185f8f023"
def _fixture_json() -> bytes:
return FIXTURE.read_bytes()
def _fixture_protobuf() -> bytes:
request = ExportTraceServiceRequest()
payload = json.loads(_fixture_json())
for resource in payload["resourceSpans"]:
for scope in resource["scopeSpans"]:
for span in scope["spans"]:
for field in ("traceId", "spanId", "parentSpanId"):
if field in span:
span[field] = base64.b64encode(bytes.fromhex(span[field])).decode()
ParseDict(payload, request)
return request.SerializeToString()
@pytest.fixture
def rows_by_name() -> dict:
rows = decode_otlp(_fixture_json(), "application/json")
return {r["SpanName"]: r for r in rows}
def _kv(key: str, value: str | int) -> KeyValue:
if isinstance(value, int):
return KeyValue(key=key, value=AnyValue(int_value=value))
return KeyValue(key=key, value=AnyValue(string_value=value))
def _export(*spans: Span, service: str = "svc", scope: str = "test") -> bytes:
resource_spans = ResourceSpans(scope_spans=[ScopeSpans(spans=list(spans))])
resource_spans.resource.attributes.append(_kv("service.name", service))
resource_spans.scope_spans[0].scope.name = scope
return ExportTraceServiceRequest(resource_spans=[resource_spans]).SerializeToString()
def _span(name: str, span_id: bytes, parent: bytes = b"", **attributes: str | int) -> Span:
return Span(
trace_id=bytes.fromhex(TRACE_ID),
span_id=span_id,
parent_span_id=parent,
name=name,
start_time_unix_nano=1_000,
end_time_unix_nano=5_000,
attributes=[_kv(k.replace("__", "."), v) for k, v in attributes.items()],
)
# ---------------------------------------------------------------- LangSmith / Deep Agents fixture
def test_classifies_every_langsmith_span(rows_by_name):
assert {name: r["ObservationType"] for name, r in rows_by_name.items()} == {
"deep_research_agent": "agent",
"ChatOpenAI": "llm",
"FilesystemMiddleware.wrap_model_call": "framework",
"task": "tool",
"researcher": "agent",
"search_docs": "tool",
}
def test_agent_name_is_the_enclosing_agent(rows_by_name):
assert rows_by_name["task"]["AgentName"] == "deep_research_agent"
assert rows_by_name["ChatOpenAI"]["AgentName"] == "deep_research_agent"
assert rows_by_name["researcher"]["AgentName"] == "researcher"
assert rows_by_name["search_docs"]["AgentName"] == "researcher"
def test_subagent_is_nested_under_task_tool(rows_by_name):
assert rows_by_name["researcher"]["ParentSpanId"] == rows_by_name["task"]["SpanId"]
assert rows_by_name["deep_research_agent"]["ParentSpanId"] == ""
def test_llm_span_carries_litellm_request_id_model_and_tokens(rows_by_name):
llm = rows_by_name["ChatOpenAI"]
assert llm["LiteLLMRequestId"] == "chatcmpl-4077bb36-9380-4a3b-9481-245700cef09a"
assert llm["Model"] == "claude-sonnet-4-5"
assert (llm["InputTokens"], llm["OutputTokens"]) == (3332, 467)
def test_llm_input_output_are_normalized_messages(rows_by_name):
llm = rows_by_name["ChatOpenAI"]
messages = json.loads(llm["Input"])
assert [m["role"] for m in messages][:2] == ["system", "user"]
assert "research lead" in messages[0]["content"]
output = json.loads(llm["Output"])
assert output["role"] == "assistant"
assert output["tool_calls"][0]["name"]
@pytest.mark.parametrize("completion", ["{}", '{"generations": []}', '{"generations": [[{}]]}'])
def test_incomplete_langsmith_completion_preserves_the_export(completion):
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt='{"messages": [[{"kwargs": {"type": "human", "content": "hi"}}]]}',
gen_ai__completion=completion,
)
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
assert len(rows) == 1
assert json.loads(rows[0]["Input"])[0]["content"] == "hi"
assert rows[0]["Output"] == completion
def test_llm_block_list_content_keeps_only_text():
reasoning = {"type": "reasoning", "summary": [], "encrypted_content": "gAAAAB-opaque"}
history = [reasoning, {"type": "text", "text": "Earlier answer", "annotations": []}]
answer = [reasoning, {"type": "text", "text": "Part one"}, {"type": "text", "text": "Part two"}]
prompt = {
"messages": [
[
{"kwargs": {"type": "human", "content": "refund please"}},
{"kwargs": {"type": "ai", "content": history}},
{"kwargs": {"type": "ai", "content": [reasoning]}},
]
]
}
completion = {"generations": [[{"message": {"kwargs": {"type": "ai", "content": answer}}}]]}
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt=json.dumps(prompt),
gen_ai__completion=json.dumps(completion),
)
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
assert [m["content"] for m in json.loads(rows[0]["Input"])] == ["refund please", "Earlier answer", ""]
assert json.loads(rows[0]["Output"])["content"] == "Part one\n\nPart two"
assert "encrypted_content" not in rows[0]["Input"] + rows[0]["Output"]
def test_llm_unrecognized_list_content_is_kept_as_json():
content = [{"type": "image_url", "image_url": {"url": "https://x.test/a.png"}}]
completion = {"generations": [[{"message": {"kwargs": {"type": "ai", "content": content}}}]]}
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt='{"messages": [[{"kwargs": {"type": "human", "content": "hi"}}]]}',
gen_ai__completion=json.dumps(completion),
)
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
assert json.loads(json.loads(rows[0]["Output"])["content"]) == content
def test_task_tool_output_is_subagent_final_message_text(rows_by_name):
task = rows_by_name["task"]
assert json.loads(task["Input"])["subagent_type"] == "researcher"
assert task["Output"].startswith("Based on my research")
assert not task["Output"].startswith("{")
def test_agent_input_output(rows_by_name):
root = rows_by_name["deep_research_agent"]
assert json.loads(root["Input"]) == [
{"role": "user", "content": "Should we store OTEL agent spans in ClickHouse or Postgres at 50k spans/sec?"}
]
assert json.loads(root["Output"])["role"] == "assistant"
def test_plain_tool_input_output(rows_by_name):
tool = rows_by_name["search_docs"]
assert json.loads(tool["Input"]) == {"query": "ClickHouse Postgres OpenTelemetry OTEL spans performance comparison"}
assert tool["Output"].startswith("ClickHouse ingests")
def test_heavy_attributes_are_lifted_out_of_span_attributes(rows_by_name):
for row in rows_by_name.values():
assert not set(row["SpanAttributes"]) & {"gen_ai.prompt", "gen_ai.completion"}
assert rows_by_name["ChatOpenAI"]["SpanAttributes"]["langsmith.span.kind"] == "llm"
def test_ids_are_hex_and_resource_is_kept(rows_by_name):
root = rows_by_name["deep_research_agent"]
assert root["TraceId"] == TRACE_ID
assert root["SpanId"] == "5e79f3b5b504985e"
assert root["ServiceName"] == "agent-demo"
assert root["ScopeName"] == "langsmith"
assert root["SpanKind"] == "SPAN_KIND_INTERNAL"
assert root["StatusCode"] == "STATUS_CODE_OK"
assert root["Duration"] > 0
def test_protobuf_and_json_decode_identically():
from_json = decode_otlp(_fixture_json(), "application/json")
from_protobuf = decode_otlp(_fixture_protobuf(), "application/x-protobuf")
assert from_json == from_protobuf
assert len(from_json) == 6
def test_content_type_defaults_to_protobuf():
assert len(decode_otlp(_fixture_protobuf(), None)) == 6
def test_gzip_body_by_header():
rows = decode_otlp(gzip.compress(_fixture_protobuf()), "application/x-protobuf", "gzip")
assert len(rows) == 6
def test_gzip_requires_content_encoding_header():
with pytest.raises(decode.InvalidOTLPPayloadError):
decode_otlp(gzip.compress(_fixture_protobuf()), "application/x-protobuf")
def test_invalid_gzip_body_is_rejected():
with pytest.raises(decode.InvalidOTLPPayloadError):
decode_otlp(b"not gzip", "application/x-protobuf", "gzip")
def test_gzip_expansion_respects_body_limit():
with patch.object(decode, "OTLP_MAX_BODY_BYTES", 1024):
with pytest.raises(decode.OTLPPayloadTooLargeError):
decode_otlp(gzip.compress(b" " * 16384), "application/json", "gzip")
def test_concatenated_gzip_members_are_decoded():
body = _fixture_json()
midpoint = len(body) // 2
compressed = gzip.compress(body[:midpoint]) + gzip.compress(body[midpoint:])
assert len(decode_otlp(compressed, "application/json", "gzip")) == 6
@pytest.mark.parametrize("encoding", ["br", "gzip, identity"])
def test_unsupported_content_encoding_is_rejected(encoding):
with pytest.raises(decode.InvalidOTLPPayloadError):
decode_otlp(_fixture_protobuf(), "application/x-protobuf", encoding)
def test_long_values_are_truncated_with_marker():
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 100):
rows = {r["SpanName"]: r for r in decode_otlp(_fixture_json(), "application/json")}
task = rows["task"]
assert "…[truncated " in task["Input"]
assert task["Input"].encode().startswith(task["Input"].split("…")[0].encode())
assert len(task["Input"].split("…")[0].encode()) <= 100
def test_long_message_history_drops_middle_messages_and_stays_valid_json():
history = [{"kwargs": {"type": "human", "content": f"turn {i} " + "x" * 60}} for i in range(12)]
prompt = json.dumps({"messages": [[{"kwargs": {"type": "system", "content": "be brief"}}, *history]]})
completion = json.dumps({"generations": [[{"message": {"kwargs": {"type": "ai", "content": "ok"}}}]]})
span = _span(
"ChatOpenAI",
b"\x03" * 8,
b"\x02" * 8,
langsmith__span__kind="llm",
gen_ai__prompt=prompt,
gen_ai__completion=completion,
)
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
rows = decode_otlp(_export(span, scope="langsmith"), "application/x-protobuf")
messages = json.loads(rows[0]["Input"])
assert len(rows[0]["Input"].encode()) <= 400
assert messages[0]["content"] == "be brief"
assert "earlier messages truncated" in messages[1]["content"]
assert messages[-1]["content"].startswith("turn 11 ")
kept = int(messages[1]["content"].split("[")[1].split()[0])
assert kept + len(messages) - 2 == 12
@pytest.mark.parametrize(
"messages",
[
[{"role": "system", "content": "s" * 2000}, {"role": "user", "content": "short question"}],
[{"role": "user", "content": "a" * 900}, {"role": "assistant", "content": "b" * 900}],
[
{"role": "system", "content": "s" * 900},
{"role": "user", "content": "middle"},
{"role": "user", "content": "q" * 900},
],
],
ids=["huge-first-message", "two-messages", "huge-first-and-last"],
)
def test_oversized_message_arrays_are_shortened_not_cut(messages):
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
out = decode._truncate_payload(json.dumps(messages))
assert len(out.encode()) <= 400
kept = json.loads(out)
assert kept[0]["role"] == messages[0]["role"]
assert kept[-1]["role"] == messages[-1]["role"]
assert all(isinstance(m["content"], str) for m in kept)
def test_oversized_non_content_fields_still_fit_the_limit():
heavy = {"role": "assistant", "content": "x", "tool_calls": [{"name": "t", "args": {"blob": "z" * 3000}}]}
messages = [heavy, {"role": "user", "content": "—" * 900}]
with patch.object(decode, "OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 400):
out = decode._truncate_payload(json.dumps(messages))
kept = json.loads(out)
assert len(out.encode()) <= 400
assert [m["role"] for m in kept] == ["assistant", "user"]
assert kept[0]["content"].startswith("x")
assert kept[1]["content"].startswith("\u2014")
# ---------------------------------------------------------------- status / exceptions
def test_exception_event_fills_status_message():
span = _span("get_customer_plan", b"\x01" * 8, b"\x02" * 8)
span.status.CopyFrom(Status(code=Status.STATUS_CODE_ERROR))
event = span.events.add()
event.name = "exception"
event.attributes.extend(
[_kv("exception.type", "KeyError"), _kv("exception.message", "customer acme-404 not found")]
)
(row,) = decode_otlp(_export(span))
assert row["StatusCode"] == "STATUS_CODE_ERROR"
assert row["StatusMessage"] == "customer acme-404 not found"
def test_status_message_wins_over_exception_event():
span = _span("tool", b"\x01" * 8, b"\x02" * 8)
span.status.CopyFrom(Status(code=Status.STATUS_CODE_ERROR, message="boom"))
event = span.events.add()
event.name = "exception"
event.attributes.append(_kv("exception.message", "other"))
(row,) = decode_otlp(_export(span))
assert row["StatusMessage"] == "boom"
# ---------------------------------------------------------------- GenAI semconv / OpenInference
def test_genai_semconv_spans():
root = _span(
"invoke_agent planner", b"\x01" * 8, gen_ai__operation__name="invoke_agent", gen_ai__agent__name="planner"
)
chat = _span(
"chat gpt-4o",
b"\x02" * 8,
b"\x01" * 8,
gen_ai__operation__name="chat",
gen_ai__agent__name="planner",
gen_ai__request__model="gpt-4o",
gen_ai__response__id="chatcmpl-abc",
gen_ai__usage__input_tokens=12,
gen_ai__usage__output_tokens=3,
gen_ai__input__messages='[{"role":"user","content":"hi"}]',
gen_ai__output__messages='[{"role":"assistant","content":"hello"}]',
)
tool = _span(
"execute_tool search",
b"\x03" * 8,
b"\x01" * 8,
gen_ai__operation__name="execute_tool",
gen_ai__tool__call__arguments='{"q":"x"}',
gen_ai__tool__call__result="found",
)
rows = {r["SpanName"]: r for r in decode_otlp(_export(root, chat, tool))}
assert rows["invoke_agent planner"]["ObservationType"] == "agent"
assert rows["invoke_agent planner"]["AgentName"] == "planner"
llm = rows["chat gpt-4o"]
assert (llm["ObservationType"], llm["Model"], llm["LiteLLMRequestId"]) == ("llm", "gpt-4o", "chatcmpl-abc")
assert (llm["InputTokens"], llm["OutputTokens"]) == (12, 3)
assert json.loads(llm["Input"])[0]["content"] == "hi"
assert "gen_ai.input.messages" not in llm["SpanAttributes"]
assert (rows["execute_tool search"]["ObservationType"], rows["execute_tool search"]["Output"]) == ("tool", "found")
def test_openinference_spans():
root = _span("agent", b"\x01" * 8, openinference__span__kind="AGENT", agent__name="writer", input__value="task")
llm = _span(
"llm",
b"\x02" * 8,
b"\x01" * 8,
openinference__span__kind="LLM",
llm__model_name="claude-sonnet-4-5",
llm__token_count__prompt=40,
llm__token_count__completion=8,
input__value="prompt",
output__value="answer",
)
chain = _span("retriever", b"\x03" * 8, b"\x01" * 8, openinference__span__kind="RETRIEVER")
rows = {r["SpanName"]: r for r in decode_otlp(_export(root, llm, chain))}
assert (rows["agent"]["ObservationType"], rows["agent"]["AgentName"], rows["agent"]["Input"]) == (
"agent",
"writer",
"task",
)
assert rows["llm"]["ObservationType"] == "llm"
assert (rows["llm"]["Model"], rows["llm"]["InputTokens"], rows["llm"]["OutputTokens"]) == (
"claude-sonnet-4-5",
40,
8,
)
assert (rows["llm"]["Input"], rows["llm"]["Output"]) == ("prompt", "answer")
assert "input.value" not in rows["llm"]["SpanAttributes"]
assert rows["retriever"]["ObservationType"] == "chain"
def test_non_string_attribute_values_are_stringified():
span = _span("root", b"\x01" * 8)
span.attributes.extend(
[
KeyValue(key="flag", value=AnyValue(bool_value=True)),
KeyValue(key="ratio", value=AnyValue(double_value=0.5)),
KeyValue(key="raw", value=AnyValue(bytes_value=b"abc")),
]
)
array = KeyValue(key="list")
array.value.array_value.values.extend([AnyValue(string_value="a"), AnyValue(int_value=1)])
span.attributes.append(array)
(row,) = decode_otlp(_export(span))
assert row["SpanAttributes"]["flag"] == "true"
assert row["SpanAttributes"]["ratio"] == "0.5"
assert row["SpanAttributes"]["raw"] == "abc"
assert json.loads(row["SpanAttributes"]["list"]) == ["a", 1]
# ---------------------------------------------------------------- helpers
def test_encode_otlp_response_matches_request_encoding():
assert encode_otlp_response("application/json") == (b"{}", "application/json")
assert encode_otlp_response("application/x-protobuf") == (b"", "application/x-protobuf")
assert encode_otlp_response(None) == (b"", "application/x-protobuf")
body, media_type = encode_otlp_response("application/x-protobuf", "invalid trace")
assert media_type == "application/x-protobuf"
from google.rpc.status_pb2 import Status
assert Status.FromString(body).message == "invalid trace"
@pytest.mark.parametrize(
"attributes, expected",
[
({"langsmith__span__kind": "llm"}, "llm"),
({"langsmith__span__kind": "tool"}, "tool"),
({"gen_ai__operation__name": "chat"}, "llm"),
({"gen_ai__operation__name": "execute_tool"}, "tool"),
({"openinference__span__kind": "LLM"}, "llm"),
],
)
def test_explicit_root_span_semantics_and_response_id_are_preserved(attributes, expected):
exported = _span("root", b"\x01" * 8, gen_ai__response__id="response-123", **attributes)
(row,) = decode_otlp(_export(exported))
assert (row["ObservationType"], row["LiteLLMRequestId"]) == (expected, "response-123")
@pytest.mark.parametrize(
"payload",
[
'{"messages": 7}',
'{"messages": {"0": "wrong"}}',
'{"messages": [{"kwargs": []}]}',
'{"messages": [{"role": "assistant", "tool_calls": [1]}]}',
],
)
def test_malformed_framework_messages_preserve_raw_content_without_rejecting_the_batch(payload):
exported = _span("agent", b"\x01" * 8, langsmith__span__kind="chain", gen_ai__prompt=payload)
(row,) = decode_otlp(_export(exported))
assert row["Input"] == payload
def test_unrecognized_heavy_attributes_are_retained():
exported = _span("root", b"\x01" * 8, gen_ai__prompt="unknown convention", gen_ai__tool__definitions="tools")
(row,) = decode_otlp(_export(exported))
assert row["SpanAttributes"]["gen_ai.prompt"] == "unknown convention"
assert row["SpanAttributes"]["gen_ai.tool.definitions"] == "tools"
@pytest.mark.parametrize("count", [-1, 1 << 32])
def test_token_counts_outside_storage_range_are_rejected(count):
exported = _span("root", b"\x01" * 8, gen_ai__usage__input_tokens=count)
with pytest.raises(decode.InvalidOTLPPayloadError, match="storage range"):
decode_otlp(_export(exported))