litellm/tests/test_litellm_rust/test_traces.py
devin-ai-integration[bot] e340e546e2
feat(traces): tracing development seed (#44363)
* feat(dev): seed linked tracing and spend fixtures

* chore(dev): use OpenAI model in tracing config

* chore(dev): align tracing credentials with UI E2E

* fix(dev): update fixture seeder query scope

* feat(dev): seed linked tracing and spend fixtures

* chore(dev): use OpenAI model in tracing config

* chore(dev): align tracing credentials with UI E2E

* fix(dev): update fixture seeder query scope

* wip

* wip

* wip

* chore(trace): checkpoint ongoing Rust migration

* refactor(trace): group Python bridge under trace package

* refactor(traces): read span conventions through a Convention trait

Each span format (Claude Code, LangSmith, OpenInference, gen_ai) now lives under
normalize/convention/ as a unit struct implementing Convention, owning both its
detection and its extraction. Precedence is one ordered registry instead of an
if-chain in mod.rs that reached into each module differently.

The modules now share one way to read attributes: present() for the first
non-empty key and Payload for a text that also reports the key it consumed,
replacing three different idioms and the &mut Vec threaded through payload
readers. Instrumentation::adjust returns a new Extraction instead of mutating
one, with each SDK rule as its own function, and the LangChain middleware
suffix list exists once.

* feat(trace): export Rust-owned wire schemas and enforce contract bounds

* fix(trace): bound quoted counts in ClickHouse wire schemas

* feat(trace): generate Python wire contracts with datamodel-code-generator

* test(trace): validate migrated callers and generated contracts at the native boundary

* refactor(traces): rename normalization convention to format

* fix(traces): reconcile spend evidence and preserve unknown costs

* feat(traces): normalize additional telemetry formats

* test(traces): cover captured normalization fixtures

* refactor(traces): isolate SDK normalization rules

* feat(tracing): seed all trace exports for local dashboard

* fix(clickhouse): preserve custom LiteLLM request metadata

* docs(traces): define normalization module boundaries

* docs(traces): define resolution and OTLP boundaries

* fix(ui): normalize nullable trace message names

* refactor(traces): split resolver modules and cover resolution behavior

* test(traces): replace normalization snapshots with behavior assertions

* fix(ui): align dashboard API contracts with generated types

* refactor(traces): type normalization and storage boundaries

* fix(traces): seed captured SDK spend and preserve provider identities

* wip

* test(traces): verify guide discovery and content ordering

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 09:59:51 +00:00

651 lines
30 KiB
Python

import base64
import gzip
import json
import math
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from itertools import chain
from types import MappingProxyType
from typing import Final
from urllib.parse import parse_qs, urlsplit
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
from litellm.constants import OTLP_MAX_ATTRIBUTE_VALUE_BYTES
from litellm.rust_bridge._native import NativeTraceConfig, NativeTraceStorage
from litellm.rust_bridge.trace.generated.models import ActivityAvailability, LensAccessParams, TraceQueryHelp
from litellm.rust_bridge.trace.generated.types import TraceScope
from litellm.rust_bridge.trace.storage import ClickHouseStorage, TraceStorageConfig, span_rows
from litellm.tracing import Tenant, TraceReceiver, TracingPayloadTooLargeError
from litellm.tracing.types import SpendLogRecord
from scripts.seed_tracing_fixtures import (
TRACE,
TRACE_FIXTURES,
FixtureReplay,
fixture_capture,
fixture_replays,
rebase_spend,
response_pattern,
spend_fixtures,
)
from tests.test_litellm_rust.support.clickhouse import clickhouse_service
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
pytestmark = pytest.mark.requires_rust_extension
QUERY_ROWS: Final = TypeAdapter(tuple[dict[str, JsonValue], ...])
class CapturedSpendRow(BaseModel):
model_config = ConfigDict(frozen=True)
request_id: str
spend: float
prompt_tokens: int
completion_tokens: int
class CapturedSpendQuery(BaseModel):
model_config = ConfigDict(frozen=True)
data: tuple[CapturedSpendRow, ...]
def _native_storage(database: str, url: str, retention_days: int = 14) -> NativeTraceStorage:
return NativeTraceStorage(NativeTraceConfig(database, url, retention_days, OTLP_MAX_ATTRIBUTE_VALUE_BYTES))
@pytest.fixture
def span_row() -> dict[str, JsonValue]:
return {
"span_id": "span-1",
"parent_span_id": "",
"name": "root",
"type": "agent",
"agent": "",
"framework": "",
"status": "STATUS_CODE_OK",
"status_message": "",
"error_truncated": 0,
"start_ns": "1000000000",
"duration_ns": "1000",
"service": "test",
"input_preview": "hello",
"model": "",
"input_tokens": 0,
"output_tokens": 0,
"litellm_request_id": "",
"team_id": "",
"api_key_hash": "",
"user_id": "",
}
@pytest.fixture
def span_params() -> dict[str, str | int | list[str]]:
return {"trace_id": "trace-1", "trace_ref": "", "all_teams": 1, "user_id": "", "team_ids": []}
@pytest.mark.asyncio
async def test_trace_reader_projects_connection_and_parameters(
recording_server: RecordingServer, span_row: dict[str, JsonValue], span_params: dict[str, str | int | list[str]]
) -> None:
recording_server.enqueue(ResponseSpec(body={"data": [span_row]}))
url: Final = recording_server.base_url.replace("http://", "http://reader:p%40ss%2Fword%25@")
storage: Final = _native_storage("trace_test", url + "?database=wrong")
rows: Final = json.loads(await storage.query("trace_spans", span_params))
request: Final = recording_server.requests[0]
parameters: Final = parse_qs(urlsplit(request.path).query)
assert rows == {"data": [span_row]}
assert b"o.TraceId = {trace_id:String}" in request.raw_body
assert parameters["database"] == ["trace_test"]
assert parameters["param_trace_id"] == ["trace-1"]
assert parameters["readonly"] == ["1"]
assert "user" not in parameters
assert "password" not in parameters
assert request.headers["authorization"] == "Basic " + base64.b64encode(b"reader:p@ss/word%").decode()
@pytest.mark.asyncio
async def test_trace_reader_rejects_success_status_with_embedded_error(
recording_server: RecordingServer, span_params: dict[str, str | int | list[str]]
) -> None:
recording_server.enqueue(ResponseSpec(body={"data": [], "exception": "query failed"}))
storage: Final = _native_storage("trace_test", recording_server.base_url)
with pytest.raises(RuntimeError, match="invalid or failed JSON"):
await storage.query("trace_spans", span_params)
@pytest.mark.asyncio
async def test_reader_rejects_arbitrary_sql_before_sending(recording_server: RecordingServer) -> None:
recording_server.expected_requests = 0
storage: Final = _native_storage("trace_test", recording_server.base_url)
with pytest.raises(ValueError, match="unknown ClickHouse read query"):
await storage.query("SELECT 1", {})
@pytest.mark.asyncio
async def test_schema_binding_rejects_invalid_database() -> None:
with pytest.raises(ValueError, match=r"database.*retention"):
NativeTraceConfig("db; DROP DATABASE default", "http://localhost:8123", 14, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
@pytest.mark.asyncio
async def test_schema_binding_rejects_non_positive_retention() -> None:
with pytest.raises(ValueError, match=r"database.*retention"):
NativeTraceConfig("traces", "http://localhost:8123", 0, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
def test_invalid_url_error_does_not_expose_credentials() -> None:
with pytest.raises(RuntimeError, match="invalid ClickHouse HTTP URL") as error:
NativeTraceConfig("traces", "secret://writer:password@example.com", 7, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
assert "password" not in str(error.value)
@pytest.mark.asyncio
async def test_from_env_reads_with_clickhouse_url(
recording_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
) -> None:
recording_server.enqueue(ResponseSpec(body={"data": []}))
monkeypatch.setenv("CLICKHOUSE_URL", recording_server.base_url)
monkeypatch.delenv("CLICKHOUSE_READER_URL", raising=False)
scope: Final[TraceScope] = {"all_teams": 1, "user_id": "", "team_ids": ()}
page: Final = await TraceReceiver.from_env().list_traces(scope, 0, 1)
assert page == {"data": (), "next_cursor": None}
assert len(recording_server.requests) == 1
@pytest.mark.asyncio
async def test_schema_setup_uses_configured_retention(recording_server: RecordingServer) -> None:
recording_server.expected_requests = None
storage: Final = _native_storage("trace_test", recording_server.base_url, 7)
await storage.ensure_schema()
ttl_statements: Final = tuple(
request.raw_body for request in recording_server.requests if b"MODIFY TTL" in request.raw_body
)
assert len(ttl_statements) == 3
assert all(b"INTERVAL 7 DAY" in statement for statement in ttl_statements)
@pytest.mark.asyncio
async def test_schema_setup_uses_writer_credentials_and_rejects_failed_statement(
recording_server: RecordingServer,
) -> None:
recording_server.expected_requests = 2
recording_server.enqueue(ResponseSpec(body=""))
recording_server.enqueue(ResponseSpec(status=403, body="denied"))
writer_url: Final = recording_server.base_url.replace("http://", "http://writer:p%40ss%2Fword%25@")
storage: Final = _native_storage("trace_test", writer_url + "?database=wrong&readonly=1", 7)
with pytest.raises(RuntimeError, match="schema setup failed with HTTP status 403"):
await storage.ensure_schema()
assert len(recording_server.requests) == 2
assert recording_server.requests[0].raw_body.startswith(b"CREATE DATABASE IF NOT EXISTS")
assert recording_server.requests[1].raw_body.startswith(b"CREATE TABLE IF NOT EXISTS")
assert "readonly" not in parse_qs(urlsplit(recording_server.requests[0].path).query)
assert (
recording_server.requests[0].headers["authorization"]
== "Basic " + base64.b64encode(b"writer:p@ss/word%").decode()
)
@pytest.mark.asyncio
async def test_insert_encodes_and_sends_rows(recording_server: RecordingServer) -> None:
recording_server.enqueue(ResponseSpec(body=""))
storage: Final = _native_storage("trace_test", recording_server.base_url)
before: Final = time.time_ns() // 1_000_000
await storage.insert_rows("otel_traces", [{"Timestamp": 1_234_567_890, "Input": "hello", "EngineReceivedMs": -1}])
after: Final = time.time_ns() // 1_000_000
request: Final = recording_server.requests[0]
row: Final = json.loads(gzip.decompress(request.raw_body))
assert before <= row["EngineReceivedMs"] <= after
assert row == {
"Input": "hello",
"Timestamp": "1970-01-01T00:00:01.23456789Z",
"EngineReceivedMs": row["EngineReceivedMs"],
}
assert parse_qs(urlsplit(request.path).query)["query"] == [
"INSERT INTO `trace_test`.otel_traces FORMAT JSONEachRow"
]
assert request.headers["content-encoding"] == "gzip"
def _resource_export(attribute_bytes: int, span_count: int, groups: int = 1) -> bytes:
span: Final = {
"traceId": "01" * 16,
"spanId": "02" * 8,
"name": "shared-resource",
"startTimeUnixNano": "1",
"endTimeUnixNano": "2",
}
resource: Final = {
"resource": {
"attributes": [
{"key": "shared", "value": {"stringValue": "x" * attribute_bytes}},
{"key": "litellm.team_id", "value": {"stringValue": "spoofed"}},
]
},
"scopeSpans": [
{
"scope": {"name": "scope-" * 32, "version": "v" * 128},
"spans": [{**span, "spanId": f"{index + 1:016x}"} for index in range(span_count)],
}
],
}
return json.dumps({"resourceSpans": [resource] * groups}).encode()
@pytest.mark.asyncio
async def test_resource_fanout_reaches_insert_with_identical_values(recording_server: RecordingServer) -> None:
body: Final = _resource_export(16 * 1024, 1024)
receiver: Final = TraceReceiver(ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test")))
tenant: Final = Tenant("team-a", "key-a", "org-a")
assert await receiver.ingest(body, "application/json", None, tenant) == 1024
encoded: Final = gzip.decompress(recording_server.requests[0].raw_body)
actual: Final = tuple(json.loads(line) for line in encoded.splitlines())
expected: Final = span_rows(body, "application/json", tenant)
assert len(encoded) < 64 * 1024 * 1024
assert tuple({key: value for key, value in row.items() if key != "EngineReceivedMs"} for row in actual) == tuple(
{**row, "Timestamp": "1970-01-01T00:00:00.000000001Z"} for row in expected
)
assert len({row["EngineReceivedMs"] for row in actual}) == 1
@pytest.mark.asyncio
async def test_shared_resource_still_hits_insert_limit_before_transport(recording_server: RecordingServer) -> None:
recording_server.expected_requests = 0
body: Final = _resource_export(64 * 1024, 1024)
receiver: Final = TraceReceiver(ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test")))
with pytest.raises(TracingPayloadTooLargeError, match="encoded size limit"):
await receiver.ingest(body, "application/json", None, Tenant("team-a", "key-a"))
assert recording_server.requests == []
@pytest.mark.asyncio
async def test_insert_validates_values_without_pydantic_copy(recording_server: RecordingServer) -> None:
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
invalid: Final = object()
with pytest.raises(ValueError, match=type(invalid).__name__):
await storage.insert_rows("otel_traces", [{"ResourceAttributes": invalid}])
attributes: Final = MappingProxyType({"service.name": "trace-test"})
await storage.insert_rows(
"otel_traces",
(MappingProxyType({"Timestamp": 1, "ResourceAttributes": attributes, "SpanAttributes": attributes}),),
)
stored: Final = json.loads(gzip.decompress(recording_server.requests[0].raw_body))
assert stored["Timestamp"] == "1970-01-01T00:00:00.000000001Z"
assert stored["ResourceAttributes"] == attributes
assert stored["SpanAttributes"] == attributes
@pytest.mark.parametrize(
("role", "user_id", "expected_status"),
(
("proxy_admin", None, 200),
("proxy_admin_viewer", None, 200),
("internal_user", "user", 200),
("internal_user", None, 403),
),
)
def test_trace_sql_endpoint_enforces_ownership_and_preserves_clickhouse_envelope(
recording_server: RecordingServer, role: str, user_id: str | None, expected_status: int
) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
envelope: Final = {
"meta": [{"name": "answer", "type": "UInt8"}],
"data": [{"answer": 42}],
"rows": 1,
"statistics": {"elapsed": 0.01, "rows_read": 1, "bytes_read": 1},
}
recording_server.expected_requests = 12 if expected_status == 200 else 0
if expected_status == 200:
for _ in range(11):
recording_server.enqueue(ResponseSpec(body=""))
recording_server.enqueue(ResponseSpec(body=envelope))
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=role, user_id=user_id, token="test")
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
async def permitted_teams(auth: UserAPIKeyAuth) -> tuple[str, ...]:
return ()
app.dependency_overrides[get_log_team_lookup] = lambda: permitted_teams
with TestClient(app) as client:
result: Final = client.post("/v1/traces/query", json={"sql": "SELECT 42 AS answer"})
assert result.status_code == expected_status, result.text
if expected_status == 403:
assert result.json() == {"detail": "Not allowed to view logs"}
return
assert result.json() == envelope
assert recording_server.requests[-1].raw_body == b"SELECT 42 AS answer"
assert client.post("/v1/traces/query", json={"sql": " "}).status_code == 400
assert client.post("/v1/traces/query", json={}).status_code == 422
@pytest.mark.parametrize("discovery_fails", (False, True))
def test_trace_help_endpoint_runs_native_schema_and_metadata_discovery(
recording_server: RecordingServer, discovery_fails: bool
) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
recording_server.expected_requests = 17
for _ in range(11):
recording_server.enqueue(ResponseSpec(body=""))
for response in (
{"data": [{"name": "Model", "type": "String"}]},
{"data": []},
{"data": []},
):
recording_server.enqueue(ResponseSpec(body=response))
metadata: Final = (
ResponseSpec(status=503, body="discovery failed")
if discovery_fails
else ResponseSpec(body={"data": [{"metadata": '{"custom": {"label": "hello"}}'}]})
)
recording_server.enqueue(metadata)
recording_server.enqueue(ResponseSpec(body={"data": [{"key": "custom.span"}]}))
recording_server.enqueue(ResponseSpec(body={"data": [{"key": "custom.resource"}]}))
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin", token="test")
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
with TestClient(app) as client:
result: Final = client.get("/v1/traces/query/help")
assert result.status_code == 200, result.text
body: Final = result.json()
assert body["guide"].startswith("Trace SQL query guide")
assert body["tables"][0]["columns"] == [{"name": "Model", "type": "String"}]
if discovery_fails:
assert body["metadata"]["fields"] == []
assert "503" in body["metadata"]["error"]
else:
assert "JSONExtractRaw(metadata, 'custom', 'label')" in body["guide"]
assert body["metadata"]["fields"][1] == {
"path": ["custom", "label"],
"types": ["string"],
"expression": "JSONExtractRaw(metadata, 'custom', 'label')",
}
assert body["attributes"][0]["fields"][0]["expression"] == "SpanAttributes['custom.span']"
assert body["attributes"][1]["fields"][0]["expression"] == "ResourceAttributes['custom.resource']"
@pytest.mark.parametrize(
("clickhouse_status", "body", "expected_status"),
(
(400, b"ClickHouse rejected the query", 400),
(404, b"ClickHouse rejected the query", 400),
(500, b"ClickHouse rejected the query", 503),
(503, b"ClickHouse rejected the query", 503),
(200, b'{"data":[]}', 503),
),
)
def test_trace_sql_endpoint_distinguishes_query_errors_from_reader_failures(
recording_server: RecordingServer, clickhouse_status: int, body: bytes, expected_status: int
) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
recording_server.expected_requests = 13
for _ in range(11):
recording_server.enqueue(ResponseSpec(body=""))
recording_server.enqueue(ResponseSpec(status=clickhouse_status, body=body))
envelope: Final = {
"meta": [{"name": "answer", "type": "UInt8"}],
"data": [{"answer": 42}],
"rows": 1,
"statistics": {"elapsed": 0.01, "rows_read": 1, "bytes_read": 1},
}
recording_server.enqueue(ResponseSpec(body=envelope))
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin", token="test")
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
with TestClient(app) as client:
failed: Final = client.post("/v1/traces/query", json={"sql": "SELEC 42"})
assert failed.status_code == expected_status, failed.text
recovered: Final = client.post("/v1/traces/query", json={"sql": "SELECT 42 AS answer"})
assert recovered.status_code == 200, recovered.text
assert recovered.json() == envelope
assert recording_server.requests[-2].raw_body == b"SELEC 42"
@pytest.mark.asyncio
async def test_trace_receiver_reads_with_only_one_clickhouse_url(
recording_server: RecordingServer,
monkeypatch: pytest.MonkeyPatch,
span_row: dict[str, JsonValue],
span_params: dict[str, str | int | list[str]],
) -> None:
monkeypatch.setenv("CLICKHOUSE_URL", recording_server.base_url)
monkeypatch.setenv("CLICKHOUSE_DATABASE", "trace_test")
monkeypatch.delenv("CLICKHOUSE_READER_URL", raising=False)
recording_server.enqueue(ResponseSpec(body={"data": [span_row]}))
receiver: Final = TraceReceiver.from_env()
trace: Final = await receiver.get_trace("trace-1", {"all_teams": 1, "user_id": "", "team_ids": ()}, "ref")
assert trace is not None
assert trace["spans"][0]["span_id"] == span_row["span_id"]
assert trace["spans"][0]["duration_ms"] == int(str(span_row["duration_ns"])) / 1_000_000
parameters: Final = parse_qs(urlsplit(recording_server.requests[0].path).query)
assert parameters["database"] == ["trace_test"]
assert parameters["readonly"] == ["1"]
@pytest.mark.asyncio
async def test_lens_read_uses_the_shared_native_query_and_returns_typed_rows(
recording_server: RecordingServer,
) -> None:
recording_server.enqueue(ResponseSpec(body={"data": [{"traces": 0, "requests": 1}]}))
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
rows: Final = await storage.lens_availability(LensAccessParams(all_teams=0, team="team-a", key_hash="key-a"))
assert rows == (ActivityAvailability(traces=False, requests=True),)
parameters: Final = parse_qs(urlsplit(recording_server.requests[0].path).query)
assert parameters["param_all_teams"] == ["0"]
assert parameters["param_team"] == ["team-a"]
assert parameters["param_key_hash"] == ["key-a"]
@dataclass(frozen=True, slots=True)
class SeededTraceAPI:
client: TestClient
storage: ClickHouseStorage
spends: tuple[SpendLogRecord, ...]
help: TraceQueryHelp
def query_example(self, name: str) -> tuple[dict[str, JsonValue], ...]:
example: Final = next(example for example in self.help.examples if example.name == name)
response: Final = self.client.post("/v1/traces/query", json={"sql": example.sql})
assert response.status_code == 200, response.text
return QUERY_ROWS.validate_python(response.json()["data"])
@pytest.fixture
def seeded_trace_api(clickhouse_url: str) -> Iterator[SeededTraceAPI]:
from scripts.seed_tracing_fixtures import (
SPEND_FIXTURE,
SPEND_ROWS,
TRACE_FIXTURES,
fixture_replays,
rebase_spend,
)
spends: Final = SPEND_ROWS.validate_python(
tuple(json.loads(line) for line in SPEND_FIXTURE.read_text().splitlines())
)
pattern: Final = re.compile("|".join(re.escape(row["response_id"]) for row in spends))
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, "query-api", pattern)
swarm: Final = next(replay for replay in replays if replay.name == "deeplite_swarm")
rebased: Final = rebase_spend(spends, swarm.offset_ms, swarm.namespace, pattern)
stamped: Final[tuple[SpendLogRecord, ...]] = tuple(
{**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"} for row in rebased
)
yield from _fixture_trace_api(clickhouse_url, replays, stamped)
def _fixture_trace_api(
clickhouse_url: str, replays: tuple[FixtureReplay, ...], stamped: tuple[SpendLogRecord, ...]
) -> Iterator[SeededTraceAPI]:
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
storage: Final = ClickHouseStorage(TraceStorageConfig(clickhouse_url, "trace_test"))
app: Final = FastAPI()
app.include_router(router)
app.dependency_overrides[provide_trace_query_secret] = lambda: "fixture-secret"
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, team_id="team-a", token="fixture-key", user_id="fixture-user"
)
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
with TestClient(app) as client:
assert client.portal is not None
client.portal.call(storage.ensure_schema)
ingested: Final = tuple(client.post("/v1/traces", json=replay.export) for replay in replays)
for result in ingested:
assert result.status_code == 200, result.text
client.portal.call(storage.insert_rows, "spend_logs", stamped)
response: Final = client.get("/v1/traces/query/help")
assert response.status_code == 200, response.text
yield SeededTraceAPI(client, storage, stamped, TraceQueryHelp.model_validate(response.json()))
def test_fixture_backed_help_examples_execute_through_query_api(seeded_trace_api: SeededTraceAPI) -> None:
api: Final = seeded_trace_api
assert {table.name for table in api.help.tables} == {"otel_traces", "spend_logs", "agent_traces_by_key"}
assert api.help.metadata.error is None
assert api.help.metadata.sampled_rows == len(api.spends)
assert any(field.path == ("synthetic_spend",) for field in api.help.metadata.fields)
for example in api.help.examples:
api.query_example(example.name)
records: Final = api.query_example("Recent spend records")
assert {str(row["request_id"]) for row in records} == {row["request_id"] for row in api.spends}
assert all(bool(row["synthetic_spend"]) for row in records)
total: Final = sum(row["spend"] or 0 for row in api.spends)
recorded: Final = api.query_example("Recorded spend by trace")
assert len(recorded) == 1
assert recorded[0]["trace_id"] == api.spends[0]["trace_id"]
assert int(str(recorded[0]["requests"])) == len(api.spends)
assert math.isclose(float(str(recorded[0]["recorded_spend"])), total)
detail: Final = api.client.get(f"/v1/traces/{api.spends[0]['trace_id']}")
assert detail.status_code == 200, detail.text
assert math.isclose(TRACE.validate_json(detail.content)["summary"]["spend"] or 0, total)
unmatched: Final = api.query_example("LLM spans without a direct spend match")
assert unmatched
assert all(row["TraceId"] != api.spends[0]["trace_id"] for row in unmatched)
unpriced: Final = api.client.get(f"/v1/traces/{unmatched[0]['TraceId']}")
assert unpriced.status_code == 200, unpriced.text
assert unpriced.json()["summary"]["spend"] is None
@pytest.mark.parametrize("spend", (None, 0.0, 0.125), ids=("unknown", "free", "paid"))
def test_query_model_totals_deduplicate_and_preserve_unknown_cost(
seeded_trace_api: SeededTraceAPI, spend: float | None
) -> None:
api: Final = seeded_trace_api
original: Final = api.spends[0]
replacement: Final[SpendLogRecord] = {**original, "end_time": original["end_time"] + 1, "spend": spend}
assert api.client.portal is not None
api.client.portal.call(api.storage.insert_rows, "spend_logs", (replacement,))
totals: Final = api.query_example("Spend and tokens by model")
row: Final = next(row for row in totals if row["model"] == original["model"])
model_spends: Final = tuple(row for row in api.spends if row["model"] == original["model"])
assert int(str(row["requests"])) == len(model_spends)
assert int(str(row["input_tokens"])) == sum(row["prompt_tokens"] for row in model_spends)
assert int(str(row["output_tokens"])) == sum(row["completion_tokens"] for row in model_spends)
assert int(str(row["unknown_cost_requests"])) == int(spend is None)
if spend is None:
assert row["spend"] is None
else:
assert math.isclose(
float(str(row["spend"])), sum(row["spend"] or 0 for row in model_spends) - (original["spend"] or 0) + spend
)
def test_query_correlation_requires_key_or_user_ownership_within_a_team(seeded_trace_api: SeededTraceAPI) -> None:
api: Final = seeded_trace_api
original: Final = api.spends[0]
unrelated: Final[SpendLogRecord] = {
**original,
"request_id": "unrelated-request",
"api_key": "other-key",
"user": "other-user",
}
assert api.client.portal is not None
api.client.portal.call(api.storage.insert_rows, "spend_logs", (unrelated,))
matches: Final = api.query_example("Traces correlated with LLM call metadata")
assert {str(row["request_id"]) for row in matches} == {row["request_id"] for row in api.spends}
assert all(row["request_id"] != unrelated["request_id"] for row in matches)
@pytest.fixture(scope="module")
def captured_trace_api() -> Iterator[SeededTraceAPI]:
captures: Final = spend_fixtures()
originals: Final = tuple(chain.from_iterable(rows for _, rows in captures))
pattern: Final = response_pattern(originals)
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, "captured-api", pattern)
by_name: Final = MappingProxyType(dict(captures))
paired: Final = tuple(
rebase_spend(by_name[replay.name], replay.offset_ms, replay.namespace, pattern)
for replay in replays
if replay.name in by_name
)
stamped: Final[tuple[SpendLogRecord, ...]] = tuple(
{**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"}
for row in chain.from_iterable(paired)
)
with clickhouse_service() as url:
yield from _fixture_trace_api(url, replays, stamped)
@pytest.mark.parametrize("name", tuple(name for name, _ in spend_fixtures() if name != "deeplite_swarm"))
def test_captured_sdk_cost_survives_seeding_and_is_queryable(name: str, captured_trace_api: SeededTraceAPI) -> None:
api: Final = captured_trace_api
rows: Final = tuple(row for row in api.spends if fixture_capture("", row).name == name)
assert rows
capture: Final = fixture_capture(name, rows[0])
response: Final = api.client.get(f"/v1/traces/{capture.trace_id}")
assert response.status_code == 200, response.text
detail: Final = TRACE.validate_json(response.content)
original: Final = span_rows((TRACE_FIXTURES / f"{name}.json").read_bytes(), "application/json")
assert detail["summary"]["span_count"] == len(original)
if capture.spend_linked:
assert detail["summary"]["spend"] is not None
assert math.isclose(detail["summary"]["spend"], sum(row["spend"] or 0 for row in rows))
else:
assert detail["summary"]["spend"] is None
query: Final = api.client.post(
"/v1/traces/query",
json={
"sql": "SELECT request_id, spend, prompt_tokens, completion_tokens FROM spend_logs FINAL "
f"WHERE JSONExtractString(metadata, 'fixture_capture', 'name') = '{name}' LIMIT 100"
},
)
assert query.status_code == 200, query.text
records: Final = CapturedSpendQuery.model_validate_json(query.content).data
assert {row.request_id for row in records} == {row["request_id"] for row in rows}
assert math.isclose(sum(row.spend for row in records), sum(row["spend"] or 0 for row in rows))
assert sum(row.prompt_tokens for row in records) == sum(row["prompt_tokens"] for row in rows)
assert sum(row.completion_tokens for row in records) == sum(row["completion_tokens"] for row in rows)