mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
* feat(lens): own trace reads behind a cached TraceStore port Move storage-independent trace reads into litellm-traces-cache behind a TraceStore port that ClickHouse implements. One keyset pager drives the span, list span and spend reads, and a run list batch reads spend once. Trace opens, pages and list summaries share one resolved read per trace in an in-process cache with single-flight loading. Live traces and reads with unknown spend expire after 5s, quiet traces after 10 minutes, failed reads are never cached, and the accepted list page size is remembered per scope. Trace read failures map to their own status and code (400, 409, 413, 503 with Retry-After), and the trace drawer retries temporary failures while offering only a refresh for changed or oversized traces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(lens): seed large profiles with server-side copies and long sessions Replay one copy through the proxy, then copy it inside ClickHouse and PostgreSQL with INSERT ... SELECT, rewriting trace, span and call IDs so every copy keeps its own spend. Add three long single-trace sessions for drawer paging and the oversized read path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chores * style(lens): float the investigation setup badge on the tab edge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(lens): restructure the trace drawer and polish its layout Split the 547-line TraceDrawer into run/, tree/, span/, content/ and conversation/ modules. Step rows now sit on one line with colored span family tiles, and the per-row timing bar moved into an optional Waterfall layout with a time axis. The steps and details panes are separated by the shadcn Resizable handle, with the split remembered per orientation. Span payloads go through one pure classifier (payloadView) that picks messages, a tool result, a nested field tree or text. JSON-encoded field values unfold into a tree, prose renders as markdown, repr and tracebacks stay monospace, and every section offers a Raw view. LangChain's serialized messages now render as conversation cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * typesafety * wip * fmt --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
724 lines
33 KiB
Python
724 lines
33 KiB
Python
import base64
|
|
import gzip
|
|
import json
|
|
import math
|
|
import re
|
|
import time
|
|
from collections.abc import Generator, Iterator
|
|
from contextlib import closing
|
|
from dataclasses import dataclass
|
|
from itertools import chain
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import httpx
|
|
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 Trace, 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,
|
|
Copies,
|
|
FixtureReplay,
|
|
bulk_span_rows,
|
|
copied_trace_id,
|
|
copy_clickhouse,
|
|
fixture_capture,
|
|
fixture_replays,
|
|
long_sessions,
|
|
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 (
|
|
TRACE_FIXTURES,
|
|
fixture_replays,
|
|
rebase_spend,
|
|
)
|
|
|
|
spends: Final = dict(spend_fixtures())["openai_agents_swarm"]
|
|
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 == "openai_agents_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, ...]
|
|
) -> Generator[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 == ("fixture_capture", "name") 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}
|
|
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)
|
|
|
|
|
|
def _captured_replays(
|
|
namespace: str,
|
|
) -> tuple[tuple[FixtureReplay, ...], tuple[tuple[str, tuple[SpendLogRecord, ...]], ...]]:
|
|
captures: Final = spend_fixtures()
|
|
pattern: Final = response_pattern(tuple(chain.from_iterable(rows for _, rows in captures)))
|
|
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, namespace, pattern)
|
|
by_name: Final = MappingProxyType(dict(captures))
|
|
return replays, tuple(
|
|
(
|
|
replay.name,
|
|
tuple(
|
|
_stamp(row) for row in rebase_spend(by_name[replay.name], replay.offset_ms, replay.namespace, pattern)
|
|
),
|
|
)
|
|
for replay in replays
|
|
if replay.name in by_name
|
|
)
|
|
|
|
|
|
def _stamp(row: SpendLogRecord) -> SpendLogRecord:
|
|
return {**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def captured_trace_api() -> Iterator[SeededTraceAPI]:
|
|
replays, paired = _captured_replays("captured-api")
|
|
with clickhouse_service() as url:
|
|
yield from _fixture_trace_api(url, replays, tuple(chain.from_iterable(rows for _, rows in paired)))
|
|
|
|
|
|
@pytest.mark.parametrize("name", tuple(name for name, _ in spend_fixtures()))
|
|
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 and capture.spend_complete:
|
|
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)
|
|
|
|
|
|
def test_server_side_copies_keep_every_capture_linked_to_its_spend() -> None:
|
|
replays, paired = _captured_replays("copied-api")
|
|
copies: Final = Copies(
|
|
trace_ids=tuple(sorted(frozenset(str(span["TraceId"]) for span in bulk_span_rows(replays, Tenant("", ""))))),
|
|
request_ids=tuple(row["request_id"] for _, rows in paired for row in rows),
|
|
numbers=range(1, 3),
|
|
step_ms=60_000,
|
|
source="seed-copied-api-",
|
|
target="seed-copied-api-c",
|
|
)
|
|
(session,) = long_sessions(replays, paired, "seed-copied-api-", "seed-copied-api-c", (3,))
|
|
session_spend: Final = sum(row["spend"] or 0 for row in dict(paired)["openai_agents_swarm"])
|
|
session_spans: Final = len(
|
|
span_rows((TRACE_FIXTURES / "openai_agents_swarm.json").read_bytes(), "application/json")
|
|
)
|
|
with (
|
|
clickhouse_service() as url,
|
|
closing(_fixture_trace_api(url, replays, tuple(chain.from_iterable(rows for _, rows in paired)))) as seeded,
|
|
):
|
|
api: Final = next(seeded)
|
|
assert api.client.portal is not None
|
|
for plan in (copies, session):
|
|
api.client.portal.call(_copy_clickhouse, url, plan)
|
|
for name, rows in paired:
|
|
_assert_capture(api, name, rows, fixture_capture(name, rows[0]).trace_id)
|
|
_assert_capture(api, name, rows, copied_trace_id(fixture_capture(name, rows[0]).trace_id, "2"))
|
|
trace: Final = _trace(api, copied_trace_id(session.trace_ids[0], session.session))
|
|
assert trace["summary"]["span_count"] == 1 + 3 * (session_spans - 1)
|
|
(root,) = (span for span in trace["spans"] if not span["parent_span_id"])
|
|
assert {span["parent_span_id"] for span in trace["spans"] if span["parent_span_id"]} <= {
|
|
span["span_id"] for span in trace["spans"]
|
|
}
|
|
assert root["start_offset_ms"] == min(span["start_offset_ms"] for span in trace["spans"])
|
|
assert root["start_offset_ms"] + root["duration_ms"] >= max(
|
|
span["start_offset_ms"] + span["duration_ms"] for span in trace["spans"]
|
|
)
|
|
assert trace["summary"]["spend"] == pytest.approx(3 * session_spend)
|
|
|
|
|
|
def _trace(api: SeededTraceAPI, trace_id: str) -> Trace:
|
|
response: Final = api.client.get(f"/v1/traces/{trace_id}")
|
|
assert response.status_code == 200, response.text
|
|
return TRACE.validate_json(response.content)
|
|
|
|
|
|
def _assert_capture(api: SeededTraceAPI, name: str, rows: tuple[SpendLogRecord, ...], trace_id: str) -> None:
|
|
capture: Final = fixture_capture(name, rows[0])
|
|
summary: Final = _trace(api, trace_id)["summary"]
|
|
assert summary["span_count"] == len(span_rows((TRACE_FIXTURES / f"{name}.json").read_bytes(), "application/json"))
|
|
assert summary["spend"] == (
|
|
pytest.approx(sum(row["spend"] or 0 for row in rows))
|
|
if capture.spend_linked and capture.spend_complete
|
|
else None
|
|
)
|
|
|
|
|
|
async def _copy_clickhouse(url: str, copies: Copies) -> None:
|
|
async with httpx.AsyncClient(base_url=url, params={"database": "trace_test"}) as client:
|
|
await copy_clickhouse(client, "trace_test", copies)
|