mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-01 02:02:20 +00:00
feat(tracing): add OTLP trace ingestion and reads (#43915)
This commit is contained in:
parent
f285229b51
commit
268eb4d6e6
55 changed files with 4240 additions and 270 deletions
4
.github/workflows/test-unit.yml
vendored
4
.github/workflows/test-unit.yml
vendored
|
|
@ -79,7 +79,9 @@ jobs:
|
|||
|
||||
- shard: integrations
|
||||
artifact-name: integrations
|
||||
test-path: ""
|
||||
test-path: >-
|
||||
tests/test_litellm/integrations
|
||||
tests/test_litellm/tracing
|
||||
unit-flag: integrations
|
||||
workers: 2
|
||||
reruns: 3
|
||||
|
|
|
|||
67
litellm-rust/Cargo.lock
generated
67
litellm-rust/Cargo.lock
generated
|
|
@ -1274,6 +1274,18 @@ version = "0.4.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "const-hex"
|
||||
version = "1.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"proptest",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
|
|
@ -2372,9 +2384,9 @@ dependencies = [
|
|||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"lazy_static",
|
||||
"opentelemetry",
|
||||
"opentelemetry 0.32.0",
|
||||
"opentelemetry-semantic-conventions",
|
||||
"opentelemetry_sdk",
|
||||
"opentelemetry_sdk 0.32.1",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
|
|
@ -4356,7 +4368,11 @@ dependencies = [
|
|||
name = "litellm-traces"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"litellm-http",
|
||||
"opentelemetry-proto",
|
||||
"prost",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -4776,6 +4792,33 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"js-sys",
|
||||
"pin-project-lite",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"const-hex",
|
||||
"opentelemetry 0.33.0",
|
||||
"opentelemetry_sdk 0.33.0",
|
||||
"prost",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.32.1"
|
||||
|
|
@ -4791,7 +4834,23 @@ dependencies = [
|
|||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"opentelemetry",
|
||||
"opentelemetry 0.32.0",
|
||||
"percent-encoding",
|
||||
"portable-atomic",
|
||||
"rand 0.9.5",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"opentelemetry 0.33.0",
|
||||
"percent-encoding",
|
||||
"portable-atomic",
|
||||
"rand 0.9.5",
|
||||
|
|
@ -7524,7 +7583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"opentelemetry",
|
||||
"opentelemetry 0.32.0",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ mod _native {
|
|||
#[pymodule_export]
|
||||
use crate::routes::token_counter::TokenCounter;
|
||||
#[pymodule_export]
|
||||
use crate::routes::traces::{trace_encode_rows, trace_ensure_schema, trace_query};
|
||||
use crate::routes::traces::{NativeTraceStorage, trace_decode_otlp};
|
||||
#[cfg(feature = "huggingface")]
|
||||
#[pymodule_export]
|
||||
use crate::tokenizer::HuggingFaceEncoding;
|
||||
|
|
@ -109,9 +109,8 @@ mod tests {
|
|||
"aresponses",
|
||||
"ResponsesWebSocketConnection",
|
||||
"NativeDiagnosticProcessor",
|
||||
"trace_encode_rows",
|
||||
"trace_ensure_schema",
|
||||
"trace_query",
|
||||
"NativeTraceStorage",
|
||||
"trace_decode_otlp",
|
||||
"TokenCounter",
|
||||
"Tokenizer",
|
||||
"gil_stats",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use litellm_http::ClientVariant;
|
||||
use litellm_traces::{Connection, Error, Parameter};
|
||||
use litellm_traces::{Connection, Error, InsertTable, Parameter};
|
||||
use pyo3::{
|
||||
exceptions::{PyRuntimeError, PyValueError},
|
||||
exceptions::{PyOverflowError, PyRuntimeError, PyValueError},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
fn map_error(error: Error) -> PyErr {
|
||||
match error {
|
||||
Error::InvalidRow | Error::InvalidSchema | Error::EmptySql => {
|
||||
Error::InvalidRow | Error::InvalidTable | Error::InvalidSchema | Error::EmptySql => {
|
||||
PyValueError::new_err(error.to_string())
|
||||
}
|
||||
Error::InsertTooLarge => PyOverflowError::new_err(error.to_string()),
|
||||
Error::InvalidUrl
|
||||
| Error::QueryFailed(_)
|
||||
| Error::InsertFailed(_)
|
||||
| Error::SchemaFailed(_)
|
||||
| Error::ResponseTooLarge
|
||||
| Error::InvalidResponse
|
||||
|
|
@ -21,63 +23,115 @@ fn map_error(error: Error) -> PyErr {
|
|||
}
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn trace_ensure_schema<'py>(
|
||||
py: Python<'py>,
|
||||
url: &str,
|
||||
#[pyclass]
|
||||
pub struct NativeTraceStorage {
|
||||
database: String,
|
||||
user: &str,
|
||||
password: &str,
|
||||
trace_retention_days: u32,
|
||||
spend_log_retention_days: u32,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let connection = Connection::writer(url, user, password).map_err(map_error)?;
|
||||
let client = crate::http::host_client(py, ClientVariant::NoRedirect)?;
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
async move {
|
||||
litellm_traces::ensure_schema(
|
||||
&client,
|
||||
&connection,
|
||||
&database,
|
||||
trace_retention_days,
|
||||
spend_log_retention_days,
|
||||
)
|
||||
.await
|
||||
},
|
||||
map_error,
|
||||
)
|
||||
writer: Connection,
|
||||
reader: Option<Connection>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl NativeTraceStorage {
|
||||
#[new]
|
||||
fn new(database: String, url: &str, reader_url: Option<&str>) -> PyResult<Self> {
|
||||
litellm_traces::schema_statements(&database, 1, 1).map_err(map_error)?;
|
||||
Ok(Self {
|
||||
writer: Connection::writer(url).map_err(map_error)?,
|
||||
reader: reader_url
|
||||
.map(|value| Connection::reader(value, &database))
|
||||
.transpose()
|
||||
.map_err(map_error)?,
|
||||
database,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_schema<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
trace_retention_days: u32,
|
||||
spend_log_retention_days: u32,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let client = crate::http::host_client(py, ClientVariant::NoRedirect)?;
|
||||
let connection = self.writer.clone();
|
||||
let database = self.database.clone();
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
async move {
|
||||
litellm_traces::ensure_schema(
|
||||
&client,
|
||||
&connection,
|
||||
&database,
|
||||
trace_retention_days,
|
||||
spend_log_retention_days,
|
||||
)
|
||||
.await
|
||||
},
|
||||
map_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_rows<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
table: &str,
|
||||
#[pyo3(from_py_with = litellm_host_python::from_py_argument)] rows: Vec<
|
||||
BTreeMap<String, serde_json::Value>,
|
||||
>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let table = InsertTable::parse(table).map_err(map_error)?;
|
||||
let client = crate::http::host_client(py, ClientVariant::NoRedirect)?;
|
||||
let connection = self.writer.clone();
|
||||
let database = self.database.clone();
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
async move {
|
||||
litellm_traces::insert_rows(&client, &connection, &database, table, rows).await
|
||||
},
|
||||
map_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn query<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
sql: String,
|
||||
#[pyo3(from_py_with = litellm_host_python::from_py_argument)] parameters: BTreeMap<
|
||||
String,
|
||||
Parameter,
|
||||
>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let connection = self.reader.clone().ok_or_else(|| {
|
||||
PyRuntimeError::new_err("Trace reads require a separate ClickHouse reader URL")
|
||||
})?;
|
||||
let client = crate::http::host_client(py, ClientVariant::NoRedirect)?;
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
async move { litellm_traces::execute_read(&client, &connection, &sql, ¶meters).await },
|
||||
map_error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn trace_query<'py>(
|
||||
pub fn trace_decode_otlp<'py>(
|
||||
py: Python<'py>,
|
||||
url: &str,
|
||||
database: &str,
|
||||
user: &str,
|
||||
password: &str,
|
||||
sql: String,
|
||||
#[pyo3(from_py_with = litellm_host_python::from_py_argument)] parameters: BTreeMap<
|
||||
String,
|
||||
Parameter,
|
||||
>,
|
||||
body: &[u8],
|
||||
content_type: Option<&str>,
|
||||
content_encoding: Option<&str>,
|
||||
max_decompressed_bytes: usize,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let connection = Connection::configured(url, database, user, password).map_err(map_error)?;
|
||||
let client = crate::http::host_client(py, ClientVariant::NoRedirect)?;
|
||||
crate::execution::run_async(
|
||||
py,
|
||||
async move { litellm_traces::execute_read(&client, &connection, &sql, ¶meters).await },
|
||||
map_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn trace_encode_rows(
|
||||
py: Python<'_>,
|
||||
#[pyo3(from_py_with = litellm_host_python::from_py_argument)] rows: Vec<
|
||||
BTreeMap<String, serde_json::Value>,
|
||||
>,
|
||||
) -> PyResult<String> {
|
||||
py.detach(|| litellm_traces::encode_rows(rows))
|
||||
.map_err(map_error)
|
||||
let spans = py
|
||||
.detach(|| {
|
||||
litellm_traces::decode_otlp(
|
||||
body,
|
||||
content_type,
|
||||
content_encoding,
|
||||
max_decompressed_bytes,
|
||||
)
|
||||
})
|
||||
.map_err(|error| match error {
|
||||
litellm_traces::DecodeError::TooLarge => PyOverflowError::new_err(error.to_string()),
|
||||
_ => PyValueError::new_err(error.to_string()),
|
||||
})?;
|
||||
litellm_host_python::Pythonized(spans).into_pyobject(py)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
- Keep this crate independent of Python; PyO3 conversion and public Python exceptions belong in `python-bridge`
|
||||
- Keep the SQL migrations here as the only ClickHouse schema definition
|
||||
- Use typed query parameters and a dedicated SELECT-only reader with server-side limits
|
||||
- Keep `config/reader.xml` grants on the database the schema is created in (CLICKHOUSE_DATABASE, default `litellm`)
|
||||
- Bound insert time and encoded bytes; make retry deduplication behavior explicit for supported ClickHouse versions
|
||||
- Test storage behavior through the crate's public API against ClickHouse
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
flate2.workspace = true
|
||||
opentelemetry-proto = { version = "0.33.0", default-features = false, features = ["gen-tonic-messages", "trace", "with-serde"] }
|
||||
prost = "0.14.4"
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
litellm-http.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<profile>litellm_traces_reader</profile>
|
||||
<grants>
|
||||
<query>GRANT SELECT ON litellm.otel_traces</query>
|
||||
<query>GRANT SELECT ON litellm.agent_traces</query>
|
||||
<query>GRANT SELECT ON litellm.agent_traces_by_key</query>
|
||||
<query>GRANT SELECT ON litellm.spend_logs</query>
|
||||
</grants>
|
||||
</litellm_traces_reader>
|
||||
|
|
|
|||
|
|
@ -44,4 +44,4 @@ CREATE TABLE IF NOT EXISTS {database}.otel_traces
|
|||
ENGINE = MergeTree
|
||||
PARTITION BY toDate(Timestamp)
|
||||
ORDER BY (TeamId, ServiceName, toDateTime(Timestamp), TraceId)
|
||||
SETTINGS ttl_only_drop_parts = 1
|
||||
SETTINGS ttl_only_drop_parts = 1, non_replicated_deduplication_window = 1000
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS {database}.agent_traces
|
||||
CREATE TABLE IF NOT EXISTS {database}.agent_traces_by_key
|
||||
(
|
||||
TeamId LowCardinality(String),
|
||||
ApiKeyHash String,
|
||||
TraceId String,
|
||||
StartTs SimpleAggregateFunction(min, DateTime64(9)),
|
||||
EndTs SimpleAggregateFunction(max, DateTime64(9)),
|
||||
|
|
@ -20,4 +21,5 @@ CREATE TABLE IF NOT EXISTS {database}.agent_traces
|
|||
RequestIds SimpleAggregateFunction(groupArrayArray, Array(String))
|
||||
)
|
||||
ENGINE = AggregatingMergeTree
|
||||
ORDER BY (TeamId, TraceId)
|
||||
ORDER BY (TeamId, ApiKeyHash, TraceId)
|
||||
SETTINGS non_replicated_deduplication_window = 1000
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
CREATE MATERIALIZED VIEW IF NOT EXISTS {database}.agent_traces_mv TO {database}.agent_traces AS
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS {database}.agent_traces_by_key_mv
|
||||
TO {database}.agent_traces_by_key AS
|
||||
SELECT
|
||||
TeamId, TraceId,
|
||||
TeamId, ApiKeyHash, TraceId,
|
||||
min(Timestamp) AS StartTs,
|
||||
max(Timestamp + toIntervalNanosecond(Duration)) AS EndTs,
|
||||
any(ServiceName) AS ServiceName,
|
||||
|
|
@ -18,4 +19,4 @@ SELECT
|
|||
groupUniqArrayIf(SpanName, ObservationType = 'agent') AS AgentNames,
|
||||
groupArrayIf(LiteLLMRequestId, LiteLLMRequestId != '') AS RequestIds
|
||||
FROM {database}.otel_traces
|
||||
GROUP BY TeamId, TraceId
|
||||
GROUP BY TeamId, ApiKeyHash, TraceId
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ALTER TABLE {database}.agent_traces MODIFY TTL toDateTime(StartTs) + INTERVAL {trace_retention_days} DAY
|
||||
ALTER TABLE {database}.agent_traces_by_key MODIFY TTL toDateTime(StartTs) + INTERVAL {trace_retention_days} DAY
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
pub enum Error {
|
||||
#[error("invalid ClickHouse insert row")]
|
||||
InvalidRow,
|
||||
#[error("invalid ClickHouse insert table")]
|
||||
InvalidTable,
|
||||
#[error("invalid ClickHouse HTTP URL")]
|
||||
InvalidUrl,
|
||||
#[error("database must be a nonempty SQL identifier and retention must be positive")]
|
||||
|
|
@ -10,6 +12,10 @@ pub enum Error {
|
|||
EmptySql,
|
||||
#[error("ClickHouse query failed with HTTP status {0}")]
|
||||
QueryFailed(u16),
|
||||
#[error("ClickHouse insert failed with HTTP status {0}")]
|
||||
InsertFailed(u16),
|
||||
#[error("ClickHouse insert exceeds the encoded size limit")]
|
||||
InsertTooLarge,
|
||||
#[error("ClickHouse schema setup failed with HTTP status {0}")]
|
||||
SchemaFailed(u16),
|
||||
#[error("ClickHouse query exceeded the response size limit")]
|
||||
|
|
@ -19,3 +25,11 @@ pub enum Error {
|
|||
#[error("ClickHouse query transport failed")]
|
||||
Transport,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DecodeError {
|
||||
#[error("invalid OTLP trace payload")]
|
||||
InvalidPayload,
|
||||
#[error("OTLP trace payload exceeds the decompressed size limit")]
|
||||
TooLarge,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,109 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, io::Write, time::Duration};
|
||||
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use litellm_http::Client;
|
||||
use serde_json::Value;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use crate::Error;
|
||||
use crate::{Connection, Error};
|
||||
|
||||
const MAX_INSERT_BYTES: usize = 64 * 1024 * 1024;
|
||||
const INSERT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub enum InsertTable {
|
||||
OtelTraces,
|
||||
SpendLogs,
|
||||
}
|
||||
|
||||
impl InsertTable {
|
||||
pub fn parse(value: &str) -> Result<Self, Error> {
|
||||
match value {
|
||||
"otel_traces" => Ok(Self::OtelTraces),
|
||||
"spend_logs" => Ok(Self::SpendLogs),
|
||||
_ => Err(Error::InvalidTable),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OtelTraces => "otel_traces",
|
||||
Self::SpendLogs => "spend_logs",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert_rows(
|
||||
client: &Client,
|
||||
connection: &Connection,
|
||||
database: &str,
|
||||
table: InsertTable,
|
||||
rows: Vec<BTreeMap<String, Value>>,
|
||||
) -> Result<(), Error> {
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let encoded = encode_rows_with_limit(rows, MAX_INSERT_BYTES)?;
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder
|
||||
.write_all(encoded.as_bytes())
|
||||
.map_err(|_| Error::InvalidRow)?;
|
||||
let body = encoder.finish().map_err(|_| Error::InvalidRow)?;
|
||||
let mut url = connection.url().clone();
|
||||
url.query_pairs_mut()
|
||||
.append_pair(
|
||||
"query",
|
||||
&format!(
|
||||
"INSERT INTO `{database}`.{} FORMAT JSONEachRow",
|
||||
table.name()
|
||||
),
|
||||
)
|
||||
.append_pair("async_insert", "1")
|
||||
.append_pair("async_insert_deduplicate", "1")
|
||||
.append_pair("wait_for_async_insert", "1")
|
||||
.append_pair("date_time_input_format", "best_effort");
|
||||
let response = client
|
||||
.post(url)
|
||||
.timeout(INSERT_TIMEOUT)
|
||||
.header("Content-Encoding", "gzip")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| Error::Transport)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::InsertFailed(response.status().as_u16()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn encode_rows(rows: Vec<BTreeMap<String, Value>>) -> Result<String, Error> {
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let encoded = row
|
||||
.into_iter()
|
||||
.map(|(name, value)| insert_value(&name, value).map(|value| (name, value)))
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?;
|
||||
serde_json::to_string(&encoded).map_err(|_| Error::InvalidRow)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(|rows| rows.join("\n"))
|
||||
encode_rows_with_limit(rows, usize::MAX)
|
||||
}
|
||||
|
||||
fn encode_rows_with_limit(
|
||||
rows: Vec<BTreeMap<String, Value>>,
|
||||
limit: usize,
|
||||
) -> Result<String, Error> {
|
||||
let mut body = Vec::new();
|
||||
for row in rows {
|
||||
let encoded = row
|
||||
.into_iter()
|
||||
.map(|(name, value)| insert_value(&name, value).map(|value| (name, value)))
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()?;
|
||||
let record = serde_json::to_vec(&encoded).map_err(|_| Error::InvalidRow)?;
|
||||
let size = body
|
||||
.len()
|
||||
.checked_add(record.len())
|
||||
.and_then(|size| size.checked_add(usize::from(!body.is_empty())))
|
||||
.ok_or(Error::InsertTooLarge)?;
|
||||
if size > limit {
|
||||
return Err(Error::InsertTooLarge);
|
||||
}
|
||||
if !body.is_empty() {
|
||||
body.push(b'\n');
|
||||
}
|
||||
body.extend_from_slice(&record);
|
||||
}
|
||||
String::from_utf8(body).map_err(|_| Error::InvalidRow)
|
||||
}
|
||||
|
||||
fn insert_value(name: &str, value: Value) -> Result<Value, Error> {
|
||||
|
|
@ -35,3 +123,29 @@ fn insert_value(name: &str, value: Value) -> Result<Value, Error> {
|
|||
.map(Value::String)
|
||||
.map_err(|_| Error::InvalidRow)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rstest::rstest;
|
||||
use serde_json::json;
|
||||
|
||||
use super::encode_rows_with_limit;
|
||||
use crate::Error;
|
||||
|
||||
#[rstest]
|
||||
fn encoded_limit_counts_utf8_bytes_across_rows() {
|
||||
let rows = vec![
|
||||
BTreeMap::from([("Input".to_owned(), json!("雪"))]),
|
||||
BTreeMap::from([("Input".to_owned(), json!("雪"))]),
|
||||
];
|
||||
let encoded = encode_rows_with_limit(rows.clone(), usize::MAX).expect("valid rows");
|
||||
|
||||
assert!(encode_rows_with_limit(rows.clone(), encoded.len()).is_ok());
|
||||
assert!(matches!(
|
||||
encode_rows_with_limit(rows, encoded.len() - 1),
|
||||
Err(Error::InsertTooLarge)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
mod error;
|
||||
mod insert;
|
||||
mod otlp;
|
||||
mod schema;
|
||||
mod sql;
|
||||
|
||||
pub use error::Error;
|
||||
pub use insert::encode_rows;
|
||||
pub use error::{DecodeError, Error};
|
||||
pub use insert::{InsertTable, encode_rows, insert_rows};
|
||||
pub use otlp::{DecodedSpan, decode_otlp};
|
||||
pub use schema::{ensure_schema, schema_statements};
|
||||
pub use sql::{Parameter, execute_read};
|
||||
use url::Url;
|
||||
|
|
@ -53,17 +55,32 @@ impl Connection {
|
|||
Ok(connection)
|
||||
}
|
||||
|
||||
pub fn writer(url: &str, user: &str, password: &str) -> Result<Self, Error> {
|
||||
pub fn writer(url: &str) -> Result<Self, Error> {
|
||||
let mut connection = Self::parse(url)?;
|
||||
let pairs: Vec<_> = connection
|
||||
.url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| !matches!(key.as_ref(), "database" | "readonly" | "query"))
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
connection.url.query_pairs_mut().clear().extend_pairs(pairs);
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
pub fn reader(url: &str, database: &str) -> Result<Self, Error> {
|
||||
let mut connection = Self::parse(url)?;
|
||||
let pairs: Vec<_> = connection
|
||||
.url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key != "database")
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
connection
|
||||
.url
|
||||
.set_username(user)
|
||||
.map_err(|_| Error::InvalidUrl)?;
|
||||
connection
|
||||
.url
|
||||
.set_password(Some(password))
|
||||
.map_err(|_| Error::InvalidUrl)?;
|
||||
connection.url.set_query(None);
|
||||
.query_pairs_mut()
|
||||
.clear()
|
||||
.extend_pairs(pairs)
|
||||
.append_pair("database", database);
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
|
|
|
|||
221
litellm-rust/crates/traces/src/otlp.rs
Normal file
221
litellm-rust/crates/traces/src/otlp.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
use std::{collections::BTreeMap, io::Read};
|
||||
|
||||
use base64::Engine;
|
||||
use flate2::read::GzDecoder;
|
||||
use opentelemetry_proto::tonic::{
|
||||
collector::trace::v1::ExportTraceServiceRequest,
|
||||
common::v1::{AnyValue, KeyValue, any_value::Value as AttributeValue},
|
||||
trace::v1::{Span, span::SpanKind, status::StatusCode},
|
||||
};
|
||||
use prost::Message;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::DecodeError;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DecodedEvent {
|
||||
pub name: String,
|
||||
pub attributes: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DecodedSpan {
|
||||
pub trace_id: String,
|
||||
pub span_id: String,
|
||||
pub parent_span_id: String,
|
||||
pub trace_state: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub resource_attributes: BTreeMap<String, String>,
|
||||
pub scope_name: String,
|
||||
pub scope_version: String,
|
||||
pub attributes: BTreeMap<String, String>,
|
||||
pub start_ns: u64,
|
||||
pub end_ns: u64,
|
||||
pub status_code: String,
|
||||
pub status_message: String,
|
||||
pub events: Vec<DecodedEvent>,
|
||||
}
|
||||
|
||||
pub fn decode_otlp(
|
||||
body: &[u8],
|
||||
content_type: Option<&str>,
|
||||
content_encoding: Option<&str>,
|
||||
max_decompressed_bytes: usize,
|
||||
) -> Result<Vec<DecodedSpan>, DecodeError> {
|
||||
let payload = if content_encoding == Some("gzip") || body.starts_with(&[0x1f, 0x8b]) {
|
||||
let limit = u64::try_from(max_decompressed_bytes).map_err(|_| DecodeError::TooLarge)?;
|
||||
let mut decoded = Vec::new();
|
||||
GzDecoder::new(body)
|
||||
.take(limit + 1)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|_| DecodeError::InvalidPayload)?;
|
||||
decoded
|
||||
} else {
|
||||
body.to_vec()
|
||||
};
|
||||
if payload.len() > max_decompressed_bytes {
|
||||
return Err(DecodeError::TooLarge);
|
||||
}
|
||||
let request = if content_type.is_some_and(|value| value.contains("json")) {
|
||||
let value: Value =
|
||||
serde_json::from_slice(&payload).map_err(|_| DecodeError::InvalidPayload)?;
|
||||
serde_json::from_value(normalize_json_ids(value)?)
|
||||
.map_err(|_| DecodeError::InvalidPayload)?
|
||||
} else {
|
||||
ExportTraceServiceRequest::decode(payload.as_slice())
|
||||
.map_err(|_| DecodeError::InvalidPayload)?
|
||||
};
|
||||
Ok(request
|
||||
.resource_spans
|
||||
.into_iter()
|
||||
.flat_map(|resource_spans| {
|
||||
let resource_attributes = attributes(
|
||||
resource_spans
|
||||
.resource
|
||||
.map(|resource| resource.attributes)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
resource_spans
|
||||
.scope_spans
|
||||
.into_iter()
|
||||
.flat_map(move |scope_spans| {
|
||||
let scope = scope_spans.scope.unwrap_or_default();
|
||||
let resource_attributes = resource_attributes.clone();
|
||||
scope_spans.spans.into_iter().map(move |span| {
|
||||
decoded_span(span, &resource_attributes, &scope.name, &scope.version)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn normalize_json_ids(value: Value) -> Result<Value, DecodeError> {
|
||||
match value {
|
||||
Value::Object(fields) => fields
|
||||
.into_iter()
|
||||
.map(|(name, value)| {
|
||||
let normalized = if matches!(name.as_str(), "traceId" | "spanId" | "parentSpanId") {
|
||||
let encoded = value.as_str().ok_or(DecodeError::InvalidPayload)?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| DecodeError::InvalidPayload)?;
|
||||
Value::String(hex_bytes(&bytes))
|
||||
} else if name == "kind" && value.is_string() {
|
||||
let kind = SpanKind::from_str_name(value.as_str().unwrap_or_default())
|
||||
.ok_or(DecodeError::InvalidPayload)?;
|
||||
Value::from(kind as i32)
|
||||
} else if name == "code" && value.is_string() {
|
||||
let code = StatusCode::from_str_name(value.as_str().unwrap_or_default())
|
||||
.ok_or(DecodeError::InvalidPayload)?;
|
||||
Value::from(code as i32)
|
||||
} else {
|
||||
normalize_json_ids(value)?
|
||||
};
|
||||
Ok((name, normalized))
|
||||
})
|
||||
.collect::<Result<serde_json::Map<_, _>, _>>()
|
||||
.map(Value::Object),
|
||||
Value::Array(values) => values
|
||||
.into_iter()
|
||||
.map(normalize_json_ids)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Value::Array),
|
||||
value => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_bytes(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn decoded_span(
|
||||
span: Span,
|
||||
resource_attributes: &BTreeMap<String, String>,
|
||||
scope_name: &str,
|
||||
scope_version: &str,
|
||||
) -> DecodedSpan {
|
||||
let status = span.status.unwrap_or_default();
|
||||
DecodedSpan {
|
||||
trace_id: hex_bytes(&span.trace_id),
|
||||
span_id: hex_bytes(&span.span_id),
|
||||
parent_span_id: hex_bytes(&span.parent_span_id),
|
||||
trace_state: span.trace_state,
|
||||
name: span.name,
|
||||
kind: SpanKind::try_from(span.kind)
|
||||
.unwrap_or(SpanKind::Unspecified)
|
||||
.as_str_name()
|
||||
.to_owned(),
|
||||
resource_attributes: resource_attributes.clone(),
|
||||
scope_name: scope_name.to_owned(),
|
||||
scope_version: scope_version.to_owned(),
|
||||
attributes: attributes(span.attributes),
|
||||
start_ns: span.start_time_unix_nano,
|
||||
end_ns: span.end_time_unix_nano,
|
||||
status_code: StatusCode::try_from(status.code)
|
||||
.unwrap_or(StatusCode::Unset)
|
||||
.as_str_name()
|
||||
.to_owned(),
|
||||
status_message: status.message,
|
||||
events: span
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| DecodedEvent {
|
||||
name: event.name,
|
||||
attributes: attributes(event.attributes),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn attributes(values: Vec<KeyValue>) -> BTreeMap<String, String> {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.key,
|
||||
entry.value.as_ref().map(attribute_text).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn attribute_text(value: &AnyValue) -> String {
|
||||
match value.value.as_ref() {
|
||||
Some(AttributeValue::StringValue(value)) => value.clone(),
|
||||
Some(AttributeValue::BoolValue(value)) => value.to_string(),
|
||||
Some(AttributeValue::IntValue(value)) => value.to_string(),
|
||||
Some(AttributeValue::DoubleValue(value)) => {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
}
|
||||
Some(AttributeValue::BytesValue(value)) => String::from_utf8_lossy(value).into_owned(),
|
||||
Some(AttributeValue::ArrayValue(value)) => format!(
|
||||
"[{}]",
|
||||
value
|
||||
.values
|
||||
.iter()
|
||||
.map(|value| serde_json::to_string(&attribute_text(value)).unwrap_or_default())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Some(AttributeValue::KvlistValue(value)) => format!(
|
||||
"{{{}}}",
|
||||
value
|
||||
.values
|
||||
.iter()
|
||||
.map(|entry| format!(
|
||||
"{}: {}",
|
||||
serde_json::to_string(&entry.key).unwrap_or_default(),
|
||||
serde_json::to_string(
|
||||
&entry.value.as_ref().map(attribute_text).unwrap_or_default()
|
||||
)
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Some(AttributeValue::StringValueStrindex(value)) => value.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ use std::time::Duration;
|
|||
use crate::Connection;
|
||||
use crate::Error;
|
||||
|
||||
const SCHEMA_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
const MIGRATIONS: [&str; 7] = [
|
||||
include_str!("../migrations/0001_otel_traces.sql"),
|
||||
include_str!("../migrations/0002_agent_traces.sql"),
|
||||
|
|
@ -49,11 +51,30 @@ pub async fn ensure_schema(
|
|||
database: &str,
|
||||
trace_retention_days: u32,
|
||||
spend_log_retention_days: u32,
|
||||
) -> Result<(), Error> {
|
||||
ensure_schema_with_timeout(
|
||||
client,
|
||||
connection,
|
||||
database,
|
||||
trace_retention_days,
|
||||
spend_log_retention_days,
|
||||
SCHEMA_REQUEST_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ensure_schema_with_timeout(
|
||||
client: &Client,
|
||||
connection: &Connection,
|
||||
database: &str,
|
||||
trace_retention_days: u32,
|
||||
spend_log_retention_days: u32,
|
||||
request_timeout: Duration,
|
||||
) -> Result<(), Error> {
|
||||
for statement in schema_statements(database, trace_retention_days, spend_log_retention_days)? {
|
||||
let response = client
|
||||
.post(connection.url().clone())
|
||||
.timeout(Duration::from_secs(10))
|
||||
.timeout(request_timeout)
|
||||
.body(statement)
|
||||
.send()
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ async fn database() -> Result<Database, Box<dyn std::error::Error>> {
|
|||
"CREATE DATABASE litellm",
|
||||
"CREATE TABLE litellm.otel_traces (n UInt8) ENGINE = Memory",
|
||||
"INSERT INTO litellm.otel_traces VALUES (1)",
|
||||
"CREATE TABLE litellm.agent_traces_by_key (n UInt8) ENGINE = Memory",
|
||||
"INSERT INTO litellm.agent_traces_by_key VALUES (4)",
|
||||
"CREATE TABLE litellm.spend_logs (n UInt8) ENGINE = Memory",
|
||||
"INSERT INTO litellm.spend_logs VALUES (3)",
|
||||
"CREATE TABLE litellm.private_traces (n UInt8) ENGINE = Memory",
|
||||
"CREATE TABLE private_traces (n UInt8) ENGINE = Memory",
|
||||
] {
|
||||
client
|
||||
.post(&admin_url)
|
||||
|
|
@ -81,6 +86,15 @@ async fn admin_sql_reads_rows_with_enforced_settings(
|
|||
let json: Value = serde_json::from_str(&result)?;
|
||||
assert_eq!(json["data"][0]["answer"], 1);
|
||||
|
||||
let result = read(
|
||||
&database.client,
|
||||
&connection,
|
||||
"SELECT n AS answer FROM agent_traces_by_key",
|
||||
)
|
||||
.await?;
|
||||
let json: Value = serde_json::from_str(&result)?;
|
||||
assert_eq!(json["data"][0]["answer"], 4);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::{collections::BTreeMap, time::Duration};
|
|||
|
||||
use litellm_http::Client;
|
||||
use litellm_traces::{
|
||||
Connection, Error, encode_rows, ensure_schema, execute_read, schema_statements,
|
||||
Connection, Error, InsertTable, encode_rows, ensure_schema, execute_read, schema_statements,
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use testcontainers_modules::{
|
||||
|
|
@ -107,7 +107,7 @@ async fn schema_supports_span_rollups_and_spend_joins(
|
|||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url, "default", "")?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
let timestamp = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64;
|
||||
|
|
@ -144,7 +144,7 @@ async fn schema_supports_span_rollups_and_spend_joins(
|
|||
let body = read_json(
|
||||
&database,
|
||||
"SELECT toUInt32(sum(SpanCount)) AS spans, toUInt32(sum(InputTokens)) AS tokens \
|
||||
FROM trace_test.agent_traces WHERE TeamId = 'team-1' AND TraceId = 'trace-1'",
|
||||
FROM trace_test.agent_traces_by_key WHERE TeamId = 'team-1' AND TraceId = 'trace-1'",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
|
|
@ -154,13 +154,92 @@ async fn schema_supports_span_rollups_and_spend_joins(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn retried_trace_insert_does_not_inflate_rollup(
|
||||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
let row: BTreeMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
|
||||
"Timestamp": time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64,
|
||||
"TraceId": "retried-trace", "SpanId": "span-1", "ParentSpanId": "",
|
||||
"TeamId": "team-1", "ApiKeyHash": "key-1", "SpanName": "root", "InputTokens": 7
|
||||
}))?;
|
||||
for _ in 0..2 {
|
||||
litellm_traces::insert_rows(
|
||||
&database.client,
|
||||
&writer,
|
||||
"trace_test",
|
||||
InsertTable::OtelTraces,
|
||||
vec![row.clone()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let counts = read_json(
|
||||
&database,
|
||||
"SELECT toUInt32(sum(SpanCount)) AS spans, toUInt32(sum(InputTokens)) AS tokens \
|
||||
FROM trace_test.agent_traces_by_key WHERE TraceId = 'retried-trace'",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(table_rows(&database, "otel_traces").await?, 1);
|
||||
assert_eq!(counts["data"][0]["spans"], 1);
|
||||
assert_eq!(counts["data"][0]["tokens"], 7);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn keyed_rollup_keeps_same_trace_ids_separate_by_api_key(
|
||||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
let timestamp = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64;
|
||||
let rows = vec![
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"Timestamp": timestamp, "TraceId": "shared-id", "SpanId": "root-one",
|
||||
"ParentSpanId": "", "SpanName": "root-one", "Input": "private-one",
|
||||
"ResourceAttributes": {"litellm.api_key_hash": "key-one"}
|
||||
}))?,
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"Timestamp": timestamp, "TraceId": "shared-id", "SpanId": "root-two",
|
||||
"ParentSpanId": "", "SpanName": "root-two", "Input": "private-two",
|
||||
"ResourceAttributes": {"litellm.api_key_hash": "key-two"}
|
||||
}))?,
|
||||
];
|
||||
insert_rows(&database, "otel_traces", rows).await?;
|
||||
execute_write(
|
||||
&database,
|
||||
"OPTIMIZE TABLE trace_test.agent_traces_by_key FINAL",
|
||||
)
|
||||
.await?;
|
||||
let rows = read_json(
|
||||
&database,
|
||||
"SELECT ApiKeyHash, any(RootInput) AS RootInput \
|
||||
FROM trace_test.agent_traces_by_key WHERE TraceId = 'shared-id' \
|
||||
GROUP BY ApiKeyHash ORDER BY ApiKeyHash",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
rows["data"],
|
||||
serde_json::json!([
|
||||
{"ApiKeyHash": "key-one", "RootInput": "private-one"},
|
||||
{"ApiKeyHash": "key-two", "RootInput": "private-two"}
|
||||
])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn rollup_merges_spans_across_days_without_losing_root_fields(
|
||||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url, "default", "")?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
let day_start = time::OffsetDateTime::now_utc()
|
||||
.replace_time(time::Time::MIDNIGHT)
|
||||
|
|
@ -179,12 +258,16 @@ async fn rollup_merges_spans_across_days_without_losing_root_fields(
|
|||
"ResourceAttributes": {"litellm.team_id": "team-1"}
|
||||
}))?;
|
||||
insert_rows(&database, "otel_traces", vec![child]).await?;
|
||||
execute_write(&database, "OPTIMIZE TABLE trace_test.agent_traces FINAL").await?;
|
||||
execute_write(
|
||||
&database,
|
||||
"OPTIMIZE TABLE trace_test.agent_traces_by_key FINAL",
|
||||
)
|
||||
.await?;
|
||||
let response = read_json(
|
||||
&database,
|
||||
"SELECT count() AS rows, any(RootName) AS RootName, any(RootInput) AS RootInput, \
|
||||
any(RootStatus) AS RootStatus, sum(SpanCount) AS SpanCount \
|
||||
FROM trace_test.agent_traces",
|
||||
FROM trace_test.agent_traces_by_key",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
|
|
@ -203,7 +286,7 @@ async fn spend_deduplication_preserves_subsecond_requests_and_retries(
|
|||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url, "default", "")?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 7, 14).await?;
|
||||
let now_ms = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000_000;
|
||||
let base_start_time = now_ms / 1000 * 1000;
|
||||
|
|
@ -255,7 +338,7 @@ async fn retention_changes_materialize_existing_rows_and_remain_idempotent(
|
|||
#[future(awt)] database: TestResult<ClickHouseDatabase>,
|
||||
) -> TestResult {
|
||||
let database = database?;
|
||||
let writer = Connection::writer(&database.url, "default", "")?;
|
||||
let writer = Connection::writer(&database.url)?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 30, 30).await?;
|
||||
let old_time = time::OffsetDateTime::now_utc() - time::Duration::days(20);
|
||||
let old_timestamp_ns = old_time.unix_timestamp_nanos() as i64;
|
||||
|
|
@ -271,6 +354,7 @@ async fn retention_changes_materialize_existing_rows_and_remain_idempotent(
|
|||
}))?;
|
||||
insert_rows(&database, "otel_traces", vec![span]).await?;
|
||||
insert_rows(&database, "spend_logs", vec![spend]).await?;
|
||||
assert_eq!(table_rows(&database, "agent_traces_by_key").await?, 1);
|
||||
ensure_schema(&database.client, &writer, "trace_test", 14, 14).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
|
|
@ -293,10 +377,14 @@ async fn retention_changes_materialize_existing_rows_and_remain_idempotent(
|
|||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
execute_write(&database, "OPTIMIZE TABLE trace_test.otel_traces FINAL").await?;
|
||||
execute_write(&database, "OPTIMIZE TABLE trace_test.agent_traces FINAL").await?;
|
||||
execute_write(
|
||||
&database,
|
||||
"OPTIMIZE TABLE trace_test.agent_traces_by_key FINAL",
|
||||
)
|
||||
.await?;
|
||||
execute_write(&database, "OPTIMIZE TABLE trace_test.spend_logs FINAL").await?;
|
||||
assert_eq!(table_rows(&database, "otel_traces").await?, 0);
|
||||
assert_eq!(table_rows(&database, "agent_traces").await?, 0);
|
||||
assert_eq!(table_rows(&database, "agent_traces_by_key").await?, 0);
|
||||
assert_eq!(table_rows(&database, "spend_logs").await?, 0);
|
||||
let mutation_count = mutation_rows(&database).await?;
|
||||
ensure_schema(&database.client, &writer, "trace_test", 14, 14).await?;
|
||||
|
|
@ -315,9 +403,9 @@ async fn schema_statement_timeout_maps_to_transport_error() -> TestResult {
|
|||
});
|
||||
let client = Client::no_redirect_for_test();
|
||||
let url = format!("http://{address}");
|
||||
let writer = Connection::writer(&url, "default", "")?;
|
||||
let writer = Connection::writer(&url)?;
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(12),
|
||||
Duration::from_secs(35),
|
||||
ensure_schema(&client, &writer, "trace_test", 7, 14),
|
||||
)
|
||||
.await;
|
||||
|
|
|
|||
47
litellm-rust/crates/traces/tests/otlp.rs
Normal file
47
litellm-rust/crates/traces/tests/otlp.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use flate2::{Compression, write::GzEncoder};
|
||||
use litellm_traces::decode_otlp;
|
||||
use rstest::rstest;
|
||||
use std::io::Write;
|
||||
|
||||
const FIXTURE: &[u8] = include_bytes!(
|
||||
"../../../../tests/test_litellm/tracing/fixtures/langsmith_deep_agent_export.json"
|
||||
);
|
||||
|
||||
#[rstest]
|
||||
#[case::json(FIXTURE, Some("application/json"), None)]
|
||||
#[case::gzip_json(FIXTURE, Some("application/json"), Some("gzip"))]
|
||||
fn decodes_neutral_spans(
|
||||
#[case] body: &[u8],
|
||||
#[case] content_type: Option<&str>,
|
||||
#[case] content_encoding: Option<&str>,
|
||||
) {
|
||||
let payload = if content_encoding == Some("gzip") {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(body).expect("gzip input");
|
||||
encoder.finish().expect("gzip payload")
|
||||
} else {
|
||||
body.to_vec()
|
||||
};
|
||||
let spans = decode_otlp(&payload, content_type, content_encoding, 8 * 1024 * 1024)
|
||||
.expect("valid OTLP export");
|
||||
assert_eq!(spans.len(), 6);
|
||||
assert_eq!(spans[0].trace_id, "4bad42b84e9de3ba46fc870185f8f023");
|
||||
assert_eq!(spans[0].resource_attributes["service.name"], "agent-demo");
|
||||
assert_eq!(spans[0].scope_name, "langsmith");
|
||||
assert!(
|
||||
spans
|
||||
.iter()
|
||||
.any(|span| span.attributes.contains_key("gen_ai.prompt"))
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::invalid(b"not protobuf", None, 8 * 1024 * 1024)]
|
||||
#[case::too_large(FIXTURE, Some("application/json"), 1)]
|
||||
fn rejects_invalid_or_oversized_payload(
|
||||
#[case] body: &[u8],
|
||||
#[case] content_type: Option<&str>,
|
||||
#[case] limit: usize,
|
||||
) {
|
||||
assert!(decode_otlp(body, content_type, None, limit).is_err());
|
||||
}
|
||||
|
|
@ -46,6 +46,19 @@ ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG: Final[frozenset[str]] = frozenset(
|
|||
)
|
||||
DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512))
|
||||
DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5))
|
||||
# Agent tracing / ClickHouse
|
||||
CLICKHOUSE_BATCH_SIZE: Final = get_env_int("CLICKHOUSE_BATCH_SIZE", 10_000)
|
||||
CLICKHOUSE_FLUSH_INTERVAL_SECONDS: Final = float(os.getenv("CLICKHOUSE_FLUSH_INTERVAL_SECONDS", "1.0"))
|
||||
CLICKHOUSE_MAX_BUFFERED_ROWS: Final = get_env_int("CLICKHOUSE_MAX_BUFFERED_ROWS", 200_000)
|
||||
CLICKHOUSE_MAX_RETRIES: Final = get_env_int("CLICKHOUSE_MAX_RETRIES", 3)
|
||||
AGENT_TRACING_RETENTION_DAYS: Final = get_env_int("AGENT_TRACING_RETENTION_DAYS", 30)
|
||||
AGENT_TRACING_SPEND_LOG_RETENTION_DAYS: Final = get_env_int("AGENT_TRACING_SPEND_LOG_RETENTION_DAYS", 90)
|
||||
OTLP_MAX_BODY_BYTES: Final = get_env_int("OTLP_MAX_BODY_BYTES", 8 * 1024 * 1024)
|
||||
OTLP_MAX_ATTRIBUTE_VALUE_BYTES: Final = get_env_int("OTLP_MAX_ATTRIBUTE_VALUE_BYTES", 64 * 1024)
|
||||
OTLP_RETRY_AFTER_SECONDS: Final = get_env_int("OTLP_RETRY_AFTER_SECONDS", 2)
|
||||
OTLP_OFFLOAD_DECODE_BYTES: Final = get_env_int("OTLP_OFFLOAD_DECODE_BYTES", 256 * 1024)
|
||||
AGENT_TRACING_INPUT_PREVIEW_CHARS: Final = get_env_int("AGENT_TRACING_INPUT_PREVIEW_CHARS", 240)
|
||||
AGENT_TRACING_LIST_PAGE_SIZE: Final = get_env_int("AGENT_TRACING_LIST_PAGE_SIZE", 50)
|
||||
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
|
||||
DEFAULT_S3_BATCH_SIZE: Final = int(os.getenv("DEFAULT_S3_BATCH_SIZE", 512))
|
||||
DEFAULT_S3_MAX_CONCURRENT_UPLOADS: Final = int(os.getenv("DEFAULT_S3_MAX_CONCURRENT_UPLOADS", "16"))
|
||||
|
|
|
|||
100
litellm/integrations/clickhouse/clickhouse_batch_logger.py
Normal file
100
litellm/integrations/clickhouse/clickhouse_batch_logger.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""
|
||||
Shared base for everything LiteLLM writes to ClickHouse.
|
||||
|
||||
Built on `CustomBatchLogger`: rows accumulate in `log_queue` and are flushed as one
|
||||
gzip JSONEachRow insert, either every `CLICKHOUSE_FLUSH_INTERVAL_SECONDS` or as soon as
|
||||
`batch_size` rows are queued. Subclasses only pick the table and build rows:
|
||||
|
||||
- `ClickHouseSpendLogger` -> spend_logs (LiteLLM requests, via the `clickhouse` callback)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import (
|
||||
CLICKHOUSE_BATCH_SIZE,
|
||||
CLICKHOUSE_FLUSH_INTERVAL_SECONDS,
|
||||
CLICKHOUSE_MAX_BUFFERED_ROWS,
|
||||
CLICKHOUSE_MAX_RETRIES,
|
||||
)
|
||||
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||||
from litellm.rust_bridge.traces import TraceStorage
|
||||
|
||||
|
||||
def clickhouse_storage_from_env() -> TraceStorage:
|
||||
return TraceStorage(
|
||||
database=os.getenv("CLICKHOUSE_DATABASE", "litellm"),
|
||||
url=os.getenv("CLICKHOUSE_URL", ""),
|
||||
)
|
||||
|
||||
|
||||
class ClickHouseBatchLogger(CustomBatchLogger):
|
||||
table: ClassVar[str]
|
||||
|
||||
def __init__(self, storage: TraceStorage | None = None) -> None:
|
||||
self.storage = storage or clickhouse_storage_from_env()
|
||||
self.rows_written = 0
|
||||
self.rows_dropped = 0
|
||||
self._failed_attempts = 0
|
||||
super().__init__(
|
||||
flush_lock=asyncio.Lock(),
|
||||
batch_size=CLICKHOUSE_BATCH_SIZE,
|
||||
flush_interval=CLICKHOUSE_FLUSH_INTERVAL_SECONDS,
|
||||
)
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(self.periodic_flush())
|
||||
except RuntimeError: # no loop yet (e.g. sync config load); proxy startup calls start()
|
||||
pass
|
||||
|
||||
def start(self) -> None:
|
||||
asyncio.get_running_loop().create_task(self.periodic_flush())
|
||||
|
||||
def is_full(self) -> bool:
|
||||
"""Backpressure signal: producers should reject (429) instead of enqueueing."""
|
||||
return len(self.log_queue) >= CLICKHOUSE_MAX_BUFFERED_ROWS
|
||||
|
||||
def enqueue(self, rows: list[dict[str, Any]]) -> None:
|
||||
"""Never awaits ClickHouse. Kicks off an early flush once a full batch is queued."""
|
||||
self.log_queue.extend(rows)
|
||||
if len(self.log_queue) >= self.batch_size:
|
||||
asyncio.get_running_loop().create_task(self.flush_queue())
|
||||
|
||||
async def flush_queue(self) -> None:
|
||||
# Swap the queue under the lock so rows enqueued during the insert are kept.
|
||||
if self.flush_lock is None:
|
||||
return
|
||||
async with self.flush_lock:
|
||||
while self.log_queue:
|
||||
batch = self.log_queue[: self.batch_size]
|
||||
self.log_queue = self.log_queue[len(batch) :]
|
||||
if not await self._insert(batch):
|
||||
break
|
||||
|
||||
async def async_send_batch(self) -> None:
|
||||
await self.flush_queue()
|
||||
|
||||
async def _insert(self, batch: list[dict[str, Any]]) -> bool:
|
||||
try:
|
||||
await self.storage.insert_rows(self.table, batch)
|
||||
self.rows_written += len(batch)
|
||||
self._failed_attempts = 0
|
||||
return True
|
||||
except Exception as e:
|
||||
self._failed_attempts += 1
|
||||
if self._failed_attempts >= CLICKHOUSE_MAX_RETRIES:
|
||||
self.rows_dropped += len(batch)
|
||||
self._failed_attempts = 0
|
||||
verbose_logger.error(
|
||||
"ClickHouse: dropped %s rows for %s after %s attempts: %s",
|
||||
len(batch),
|
||||
self.table,
|
||||
CLICKHOUSE_MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
# put it back; the next periodic flush retries it
|
||||
self.log_queue = batch + self.log_queue
|
||||
verbose_logger.warning("ClickHouse: insert into %s failed, will retry: %s", self.table, e)
|
||||
return False
|
||||
11
litellm/integrations/clickhouse/schema.py
Normal file
11
litellm/integrations/clickhouse/schema.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from typing import Final
|
||||
|
||||
from litellm.rust_bridge.traces import TraceStorage
|
||||
|
||||
OTEL_TRACES_TABLE: Final = "otel_traces"
|
||||
AGENT_TRACES_BY_KEY_TABLE: Final = "agent_traces_by_key"
|
||||
SPEND_LOGS_TABLE: Final = "spend_logs"
|
||||
|
||||
|
||||
async def ensure_schema(storage: TraceStorage, trace_retention_days: int, spend_log_retention_days: int) -> None:
|
||||
await storage.ensure_schema(trace_retention_days, spend_log_retention_days)
|
||||
|
|
@ -27,7 +27,7 @@ class CustomBatchLogger(CustomLogger):
|
|||
self,
|
||||
flush_lock: asyncio.Lock | None = None,
|
||||
batch_size: int | None = None,
|
||||
flush_interval: int | None = None,
|
||||
flush_interval: float | None = None,
|
||||
max_queue_size: int | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -520,6 +520,10 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v1/rag/ingest",
|
||||
"/rag/query",
|
||||
"/v1/rag/query",
|
||||
# agent tracing: OTLP ingest + reads (scoped to the caller's team in the handler)
|
||||
"/v1/traces",
|
||||
"/v1/traces/{trace_id}",
|
||||
"/v1/traces/{trace_id}/spans/{span_id}",
|
||||
]
|
||||
|
||||
anthropic_routes = [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ from litellm.proxy.common_utils.callback_utils import (
|
|||
from litellm.types.router import Deployment
|
||||
|
||||
_FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-urlencoded", "multipart/form-data"})
|
||||
# Binary bodies (e.g. OTLP trace exports on POST /v1/traces) are not JSON: arbitrary bytes used to
|
||||
# hit the JSON surrogate-repair path and fail auth with a 400. JSON under these types still parses.
|
||||
_BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobuf", "application/protobuf"})
|
||||
|
||||
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
|
||||
|
||||
|
|
@ -153,6 +156,17 @@ def coerce_numeric_form_fields(
|
|||
}
|
||||
|
||||
|
||||
def _parse_binary_body(body: bytes) -> dict:
|
||||
"""JSON sent under a binary content type still parses; real binary (protobuf) carries no params -> {}."""
|
||||
try:
|
||||
parsed: Final = orjson.loads(body)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except orjson.JSONDecodeError:
|
||||
pass
|
||||
return {} # mutable-ok: auth parser returns a fresh dict per request
|
||||
|
||||
|
||||
async def _read_request_body(request: Request | None) -> dict:
|
||||
"""
|
||||
Safely read the request body and parse it as JSON.
|
||||
|
|
@ -175,7 +189,13 @@ async def _read_request_body(request: Request | None) -> dict:
|
|||
_request_headers: Final[dict] = _safe_get_request_headers(request=request)
|
||||
content_type: Final = _request_headers.get("content-type", "")
|
||||
|
||||
if _is_form_content_type(content_type):
|
||||
if _normalize_media_type(content_type) in _BINARY_CONTENT_TYPES or (
|
||||
request.scope.get("path") == "/v1/traces"
|
||||
and request.scope.get("method") == "POST"
|
||||
and _request_headers.get("content-encoding", "").lower() == "gzip"
|
||||
):
|
||||
parsed_body = _parse_binary_body(await request.body())
|
||||
elif _is_form_content_type(content_type):
|
||||
try:
|
||||
form_data: Final = await request.form()
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -714,6 +714,7 @@ try:
|
|||
except ImportError:
|
||||
build_billing_metrics_recorder = None
|
||||
shutdown_billing_metrics_recorder = None
|
||||
from litellm.proxy import tracing_endpoints
|
||||
from litellm.proxy.middleware.admission_control_middleware import (
|
||||
AdmissionControlMiddleware,
|
||||
admission_control_state,
|
||||
|
|
@ -845,6 +846,7 @@ from litellm.secret_managers.main import (
|
|||
secret_manager_would_be_consulted,
|
||||
str_to_bool,
|
||||
)
|
||||
from litellm.tracing import TraceReceiver
|
||||
from litellm.types.integrations.slack_alerting import AlertType, SlackAlertingArgs
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthropicMessagesRequest,
|
||||
|
|
@ -1521,6 +1523,9 @@ async def proxy_startup_event(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
_tagged.strategy._state_loaded = True
|
||||
asyncio.create_task(_adaptive_router_flusher_loop())
|
||||
|
||||
## [Optional] Initialize agent tracing
|
||||
asyncio.create_task(ProxyStartupEvent.init_tracing(general_settings))
|
||||
|
||||
## [Optional] Initialize dd tracer
|
||||
ProxyStartupEvent._init_dd_tracer()
|
||||
|
||||
|
|
@ -11310,6 +11315,28 @@ class ProxyStartupEvent:
|
|||
)
|
||||
return connected_client
|
||||
|
||||
@classmethod
|
||||
async def init_tracing(cls, general_settings: dict) -> None:
|
||||
"""
|
||||
Enable agent tracing (`POST/GET /v1/traces`) when configured:
|
||||
|
||||
general_settings:
|
||||
tracing:
|
||||
store: clickhouse # CLICKHOUSE_URL / _USER / _PASSWORD / _DATABASE
|
||||
"""
|
||||
settings: Final = general_settings.get("tracing")
|
||||
if not isinstance(settings, dict) or settings.get("store") != "clickhouse":
|
||||
return
|
||||
try:
|
||||
tracing: Final = TraceReceiver.from_env()
|
||||
await tracing.start()
|
||||
except (KeyError, OSError, RuntimeError, ValueError) as error:
|
||||
tracing_endpoints.receiver = None
|
||||
verbose_proxy_logger.warning("Agent tracing unavailable: %s", error)
|
||||
return
|
||||
tracing_endpoints.receiver = tracing
|
||||
verbose_proxy_logger.info("Agent tracing enabled (store=clickhouse)")
|
||||
|
||||
@classmethod
|
||||
def _init_dd_tracer(cls):
|
||||
"""
|
||||
|
|
@ -19849,6 +19876,7 @@ app.include_router(rag_router)
|
|||
app.include_router(video_router)
|
||||
app.include_router(container_router)
|
||||
app.include_router(search_router)
|
||||
app.include_router(tracing_endpoints.router)
|
||||
app.include_router(image_router)
|
||||
app.include_router(fine_tuning_router)
|
||||
app.include_router(credential_router)
|
||||
|
|
|
|||
141
litellm/proxy/tracing_endpoints.py
Normal file
141
litellm/proxy/tracing_endpoints.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
Agent tracing endpoints. Thin wrappers over `TraceReceiver`: auth -> tenant/scope -> one call.
|
||||
|
||||
POST /v1/traces OTLP/HTTP trace export (protobuf or JSON)
|
||||
GET /v1/traces TracePage
|
||||
GET /v1/traces/{trace_id} Trace
|
||||
GET /v1/traces/{trace_id}/spans/{span_id} SpanDetail
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
|
||||
from litellm.constants import OTLP_MAX_BODY_BYTES, OTLP_RETRY_AFTER_SECONDS
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.tracing import (
|
||||
Tenant,
|
||||
TraceReceiver,
|
||||
TracingPayloadTooLargeError,
|
||||
)
|
||||
from litellm.tracing.decode import InvalidOTLPPayloadError, encode_otlp_response
|
||||
from litellm.tracing.types import SpanDetail, Trace, TracePage, TraceScope
|
||||
|
||||
router = APIRouter(tags=["agent tracing"]) # mutable-ok: FastAPI copies the mutable tags list
|
||||
|
||||
MS_PER_DAY: Final = 24 * 60 * 60 * 1000
|
||||
_ADMIN_ROLES: Final = (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
|
||||
|
||||
receiver: TraceReceiver | None = None
|
||||
|
||||
|
||||
def get_receiver() -> TraceReceiver:
|
||||
if receiver is None:
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Agent tracing is not enabled. Set `tracing:` in general_settings and CLICKHOUSE_URL.",
|
||||
)
|
||||
return receiver
|
||||
|
||||
|
||||
def tenant_for(user_api_key_dict: UserAPIKeyAuth) -> Tenant:
|
||||
return Tenant(
|
||||
team_id=user_api_key_dict.team_id or "",
|
||||
api_key_hash=user_api_key_dict.token or "",
|
||||
org_id=user_api_key_dict.org_id or "",
|
||||
)
|
||||
|
||||
|
||||
def scope_for(user_api_key_dict: UserAPIKeyAuth) -> TraceScope:
|
||||
"""Admins see everything; team members see their team; team-less keys see their own traces."""
|
||||
if user_api_key_dict.user_role in _ADMIN_ROLES:
|
||||
return TraceScope(team_ids=(), api_key_hash="")
|
||||
if user_api_key_dict.team_id:
|
||||
return TraceScope(team_ids=(user_api_key_dict.team_id,), api_key_hash="")
|
||||
if not user_api_key_dict.token:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to view agent traces")
|
||||
return TraceScope(team_ids=("",), api_key_hash=user_api_key_dict.token)
|
||||
|
||||
|
||||
async def _read_otlp_body(request: Request) -> bytes:
|
||||
body: Final = bytearray()
|
||||
async for chunk in request.stream():
|
||||
if len(body) + len(chunk) > OTLP_MAX_BODY_BYTES:
|
||||
raise TracingPayloadTooLargeError(f"OTLP body exceeds {OTLP_MAX_BODY_BYTES} bytes")
|
||||
body.extend(chunk)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
@router.post("/v1/traces", include_in_schema=False)
|
||||
async def ingest_otlp_traces(
|
||||
request: Request,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> Response:
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to ingest agent traces")
|
||||
tracing: Final = get_receiver()
|
||||
content_type: Final = request.headers.get("content-type")
|
||||
try:
|
||||
await tracing.ingest(
|
||||
body=await _read_otlp_body(request),
|
||||
content_type=content_type,
|
||||
content_encoding=request.headers.get("content-encoding"),
|
||||
tenant=tenant_for(user_api_key_dict),
|
||||
)
|
||||
except TracingPayloadTooLargeError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e))
|
||||
except InvalidOTLPPayloadError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
except RuntimeError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(OTLP_RETRY_AFTER_SECONDS)}, # mutable-ok: FastAPI requires dict headers
|
||||
)
|
||||
body, media_type = encode_otlp_response(content_type)
|
||||
return Response(content=body, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/v1/traces", response_model=None)
|
||||
async def list_agent_traces(
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
start_ms: Annotated[int | None, Query(description="Window start, unix ms. Default: 24h ago")] = None,
|
||||
end_ms: Annotated[int | None, Query(description="Window end, unix ms. Default: now")] = None,
|
||||
cursor: Annotated[str | None, Query()] = None,
|
||||
) -> TracePage:
|
||||
now_ms: Final = int(time.time() * 1000)
|
||||
try:
|
||||
return await get_receiver().list_traces(
|
||||
scope=scope_for(user_api_key_dict),
|
||||
start_ms=start_ms if start_ms is not None else now_ms - MS_PER_DAY,
|
||||
end_ms=end_ms if end_ms is not None else now_ms,
|
||||
cursor=cursor,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get("/v1/traces/{trace_id}", response_model=None)
|
||||
async def get_agent_trace(
|
||||
trace_id: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
trace_ref: Annotated[str, Query()] = "",
|
||||
) -> Trace:
|
||||
trace: Final = await get_receiver().get_trace(trace_id, scope_for(user_api_key_dict), trace_ref)
|
||||
if trace is None:
|
||||
raise HTTPException(status_code=404, detail=f"Trace {trace_id} not found")
|
||||
return trace
|
||||
|
||||
|
||||
@router.get("/v1/traces/{trace_id}/spans/{span_id}", response_model=None)
|
||||
async def get_agent_trace_span(
|
||||
trace_id: str,
|
||||
span_id: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
trace_ref: Annotated[str, Query()] = "",
|
||||
) -> SpanDetail:
|
||||
span: Final = await get_receiver().get_span(trace_id, span_id, scope_for(user_api_key_dict), trace_ref)
|
||||
if span is None:
|
||||
raise HTTPException(status_code=404, detail=f"Span {span_id} not found")
|
||||
return span
|
||||
|
|
@ -11,6 +11,7 @@ from litellm.rust_bridge.embeddings.entrypoints import LiteLLMEmbeddingRequest
|
|||
from litellm.rust_bridge.messages.entrypoints import LiteLLMMessagesRequest
|
||||
from litellm.rust_bridge.ocr.entrypoints import LiteLLMOcrRequest
|
||||
from litellm.rust_bridge.responses.entrypoints import LiteLLMResponsesRequest
|
||||
from litellm.rust_bridge.traces import DecodedSpan
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.utils import EmbeddingResponse, ModelResponse
|
||||
|
|
@ -20,18 +21,16 @@ class RustUpstreamError(Exception): ...
|
|||
class ForkedAfterNativeRuntimeStarted(RuntimeError): ...
|
||||
class ProcessReservedForForking(RuntimeError): ...
|
||||
|
||||
def trace_encode_rows(rows: Sequence[Mapping[str, JsonValue]]) -> str: ...
|
||||
def trace_ensure_schema(
|
||||
url: str, database: str, user: str, password: str, trace_retention_days: int, spend_log_retention_days: int
|
||||
) -> Future[None]: ...
|
||||
def trace_query(
|
||||
url: str,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str,
|
||||
sql: str,
|
||||
parameters: Mapping[str, str | int | Sequence[str]],
|
||||
) -> Future[str]: ...
|
||||
def trace_decode_otlp(
|
||||
body: bytes, content_type: str | None, content_encoding: str | None, max_decompressed_bytes: int
|
||||
) -> list[DecodedSpan]: ...
|
||||
|
||||
@final
|
||||
class NativeTraceStorage:
|
||||
def __new__(cls, database: str, url: str, reader_url: str | None = None) -> NativeTraceStorage: ...
|
||||
def ensure_schema(self, trace_retention_days: int, spend_log_retention_days: int) -> Future[None]: ...
|
||||
def insert_rows(self, table: str, rows: Sequence[Mapping[str, JsonValue]]) -> Future[None]: ...
|
||||
def query(self, sql: str, parameters: Mapping[str, str | int | Sequence[str]]) -> Future[str]: ...
|
||||
|
||||
@final
|
||||
class NativeDiagnosticProcessor:
|
||||
|
|
@ -327,6 +326,7 @@ __all__ = [
|
|||
"ForkedAfterNativeRuntimeStarted",
|
||||
"HuggingFaceEncoding",
|
||||
"NativeDiagnosticProcessor",
|
||||
"NativeTraceStorage",
|
||||
"ProcessReservedForForking",
|
||||
"ResponsesWebSocketConnection",
|
||||
"RustBridgeDeclined",
|
||||
|
|
@ -351,9 +351,7 @@ __all__ = [
|
|||
"process_state_started",
|
||||
"reserve_process_for_forking",
|
||||
"responses",
|
||||
"trace_encode_rows",
|
||||
"trace_ensure_schema",
|
||||
"trace_query",
|
||||
"trace_decode_otlp",
|
||||
"transcription",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,56 @@
|
|||
from collections.abc import Awaitable, Mapping, Sequence
|
||||
from typing import Final, Protocol, cast
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
from litellm.rust_bridge.loader import get_native_bridge
|
||||
|
||||
|
||||
class DecodedEvent(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
attributes: ReadOnly[dict[str, str]]
|
||||
|
||||
|
||||
class DecodedSpan(TypedDict):
|
||||
trace_id: ReadOnly[str]
|
||||
span_id: ReadOnly[str]
|
||||
parent_span_id: ReadOnly[str]
|
||||
trace_state: ReadOnly[str]
|
||||
name: ReadOnly[str]
|
||||
kind: ReadOnly[str]
|
||||
resource_attributes: ReadOnly[dict[str, str]]
|
||||
scope_name: ReadOnly[str]
|
||||
scope_version: ReadOnly[str]
|
||||
attributes: ReadOnly[dict[str, str]]
|
||||
start_ns: ReadOnly[int]
|
||||
end_ns: ReadOnly[int]
|
||||
status_code: ReadOnly[str]
|
||||
status_message: ReadOnly[str]
|
||||
events: ReadOnly[list[DecodedEvent]]
|
||||
|
||||
|
||||
class NativeStore(Protocol):
|
||||
def __init__(self, database: str, url: str, reader_url: str | None = None) -> None: ...
|
||||
|
||||
def ensure_schema(self, trace_retention_days: int, spend_log_retention_days: int) -> Awaitable[None]: ...
|
||||
|
||||
def insert_rows(self, table: str, rows: Sequence[Mapping[str, JsonValue]]) -> Awaitable[None]: ...
|
||||
|
||||
def query(self, sql: str, parameters: Mapping[str, str | int | Sequence[str]]) -> Awaitable[str]: ...
|
||||
|
||||
|
||||
class NativeTraces(Protocol):
|
||||
def trace_encode_rows(self, rows: Sequence[Mapping[str, JsonValue]]) -> str: ...
|
||||
NativeTraceStorage: type[NativeStore]
|
||||
|
||||
def trace_ensure_schema(
|
||||
def trace_decode_otlp(
|
||||
self,
|
||||
url: str,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str,
|
||||
trace_retention_days: int,
|
||||
spend_log_retention_days: int,
|
||||
) -> Awaitable[None]: ...
|
||||
|
||||
def trace_query(
|
||||
self,
|
||||
url: str,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str,
|
||||
sql: str,
|
||||
parameters: Mapping[str, str | int | Sequence[str]],
|
||||
) -> Awaitable[str]: ...
|
||||
body: bytes,
|
||||
content_type: str | None,
|
||||
content_encoding: str | None,
|
||||
max_decompressed_bytes: int,
|
||||
) -> list[DecodedSpan]: ...
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
|
|
@ -35,6 +58,10 @@ class QueryResponse(BaseModel):
|
|||
data: list[dict[str, JsonValue]]
|
||||
|
||||
|
||||
INSERT_ROWS: Final = TypeAdapter(list[dict[str, JsonValue]])
|
||||
QUERY_PARAMETERS: Final = TypeAdapter(dict[str, str | int | list[str]])
|
||||
|
||||
|
||||
def _native() -> NativeTraces:
|
||||
native: Final = get_native_bridge()
|
||||
if native is None:
|
||||
|
|
@ -42,28 +69,24 @@ def _native() -> NativeTraces:
|
|||
return cast(NativeTraces, native) # cast-ok: the native extension is validated against this protocol at call sites
|
||||
|
||||
|
||||
async def ensure_schema(
|
||||
url: str,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str,
|
||||
trace_retention_days: int,
|
||||
spend_log_retention_days: int,
|
||||
) -> None:
|
||||
await _native().trace_ensure_schema(url, database, user, password, trace_retention_days, spend_log_retention_days)
|
||||
def decode_otlp(
|
||||
body: bytes, content_type: str | None, content_encoding: str | None, max_decompressed_bytes: int
|
||||
) -> list[DecodedSpan]:
|
||||
return _native().trace_decode_otlp(body, content_type, content_encoding, max_decompressed_bytes)
|
||||
|
||||
|
||||
async def query(
|
||||
url: str,
|
||||
database: str,
|
||||
user: str,
|
||||
password: str,
|
||||
sql: str,
|
||||
parameters: Mapping[str, str | int | Sequence[str]],
|
||||
) -> list[dict[str, JsonValue]]:
|
||||
result: Final = await _native().trace_query(url, database, user, password, sql, parameters)
|
||||
return QueryResponse.model_validate_json(result).data
|
||||
class TraceStorage:
|
||||
def __init__(self, database: str, url: str, reader_url: str | None = None) -> None:
|
||||
self._native: Final = _native().NativeTraceStorage(database, url, reader_url)
|
||||
|
||||
async def ensure_schema(self, trace_retention_days: int, spend_log_retention_days: int) -> None:
|
||||
await self._native.ensure_schema(trace_retention_days, spend_log_retention_days)
|
||||
|
||||
def encode_rows(rows: Sequence[Mapping[str, JsonValue]]) -> bytes:
|
||||
return _native().trace_encode_rows(rows).encode("utf-8")
|
||||
async def insert_rows(self, table: str, rows: Sequence[Mapping[str, object]]) -> None:
|
||||
await self._native.insert_rows(table, INSERT_ROWS.validate_python(rows))
|
||||
|
||||
async def query(self, sql: str, parameters: Mapping[str, object] | None = None) -> list[dict[str, JsonValue]]:
|
||||
result: Final = await self._native.query(
|
||||
sql, QUERY_PARAMETERS.validate_python(parameters or MappingProxyType({}))
|
||||
)
|
||||
return QueryResponse.model_validate_json(result).data
|
||||
|
|
|
|||
6
litellm/tracing/AGENTS.md
Normal file
6
litellm/tracing/AGENTS.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
- Python owns tracing endpoints, authenticated tenant scope, framework normalization and API response shaping
|
||||
- Trace ingestion awaits `TraceStorage.insert_rows` before returning success; propagate storage failures so OTLP exporters can retry
|
||||
- Spend logging keeps its separate batch queue in `litellm/integrations/clickhouse`
|
||||
- Use `litellm.rust_bridge.traces.TraceStorage` for ClickHouse; keep schema, SQL, encoding and transport in `litellm-traces`
|
||||
- Derive tenant fields from authentication and overwrite matching fields supplied by the exporter
|
||||
- Test confirmed writes, failures, tenant isolation and read behavior through public functions
|
||||
16
litellm/tracing/__init__.py
Normal file
16
litellm/tracing/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
LiteLLM agent tracing: OTLP traces from agents, joined to LiteLLM spend logs, in ClickHouse.
|
||||
|
||||
"""
|
||||
|
||||
from litellm.tracing.receiver import (
|
||||
Tenant,
|
||||
TraceReceiver,
|
||||
TracingPayloadTooLargeError,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"Tenant",
|
||||
"TraceReceiver",
|
||||
"TracingPayloadTooLargeError",
|
||||
)
|
||||
284
litellm/tracing/decode.py
Normal file
284
litellm/tracing/decode.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"""
|
||||
OTLP/HTTP trace export -> `SpanRow`s.
|
||||
|
||||
Pure functions, no I/O. Two steps:
|
||||
1. `decode_otlp()` protobuf / JSON / gzip `ExportTraceServiceRequest` -> flat spans
|
||||
2. `normalize()` framework conventions -> LiteLLM columns (type, agent, input/output,
|
||||
LiteLLM request id). Supported: LangSmith (LangChain, LangGraph,
|
||||
Deep Agents), OTEL GenAI semconv, OpenInference.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.constants import OTLP_MAX_ATTRIBUTE_VALUE_BYTES, OTLP_MAX_BODY_BYTES
|
||||
from litellm.rust_bridge.traces import DecodedSpan
|
||||
from litellm.rust_bridge.traces import decode_otlp as native_decode_otlp
|
||||
from litellm.tracing.types import SpanRow, SpanType
|
||||
|
||||
# attributes whose content we lift into Input/Output and drop from SpanAttributes
|
||||
_HEAVY_ATTRIBUTES: Final = frozenset(
|
||||
{
|
||||
"gen_ai.prompt",
|
||||
"gen_ai.completion",
|
||||
"gen_ai.tool.definitions",
|
||||
"gen_ai.input.messages",
|
||||
"gen_ai.output.messages",
|
||||
"input.value",
|
||||
"output.value",
|
||||
}
|
||||
)
|
||||
# LangChain / Deep Agents middleware wrappers: real spans, but noise in the UI
|
||||
_FRAMEWORK_SUFFIXES: Final = (
|
||||
".wrap_model_call",
|
||||
".wrap_tool_call",
|
||||
".before_agent",
|
||||
".after_agent",
|
||||
".before_model",
|
||||
".after_model",
|
||||
)
|
||||
_LLM_OPERATIONS: Final = frozenset({"chat", "text_completion", "generate_content"})
|
||||
_LC_ROLES: Final = MappingProxyType({"human": "user", "ai": "assistant", "system": "system", "tool": "tool"})
|
||||
_OPENINFERENCE_TYPES: Final[Mapping[str, SpanType]] = MappingProxyType({"AGENT": "agent", "LLM": "llm", "TOOL": "tool"})
|
||||
|
||||
|
||||
class InvalidOTLPPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class OTLPPayloadTooLargeError(OverflowError):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- decode
|
||||
|
||||
|
||||
def _truncate(value: str) -> str:
|
||||
size = len(value.encode("utf-8"))
|
||||
if size <= OTLP_MAX_ATTRIBUTE_VALUE_BYTES:
|
||||
return value
|
||||
kept = value.encode("utf-8")[:OTLP_MAX_ATTRIBUTE_VALUE_BYTES].decode("utf-8", "ignore")
|
||||
return f"{kept}…[truncated {size - OTLP_MAX_ATTRIBUTE_VALUE_BYTES} bytes]"
|
||||
|
||||
|
||||
def decode_otlp(
|
||||
body: bytes, content_type: str | None = None, content_encoding: str | None = None
|
||||
) -> tuple[SpanRow, ...]:
|
||||
"""Decode an OTLP trace export and normalize every span."""
|
||||
try:
|
||||
spans: Final = native_decode_otlp(body, content_type, content_encoding, OTLP_MAX_BODY_BYTES)
|
||||
except OverflowError as error:
|
||||
raise OTLPPayloadTooLargeError(str(error)) from error
|
||||
except ValueError as error:
|
||||
raise InvalidOTLPPayloadError(str(error)) from error
|
||||
return tuple(_span_row(span) for span in spans)
|
||||
|
||||
|
||||
def _exception_message(span: DecodedSpan) -> str:
|
||||
"""`span.record_exception()` writes an `exception` event; surface it when status.message is empty."""
|
||||
for event in span["events"]:
|
||||
if event["name"] == "exception":
|
||||
attributes = event["attributes"]
|
||||
return attributes.get("exception.message") or attributes.get("exception.type", "")
|
||||
return ""
|
||||
|
||||
|
||||
def _span_row(span: DecodedSpan) -> SpanRow:
|
||||
attributes = span["attributes"]
|
||||
resource = span["resource_attributes"]
|
||||
row = SpanRow(
|
||||
Timestamp=span["start_ns"],
|
||||
TraceId=span["trace_id"],
|
||||
SpanId=span["span_id"],
|
||||
ParentSpanId=span["parent_span_id"],
|
||||
TraceState=span["trace_state"],
|
||||
SpanName=span["name"],
|
||||
SpanKind=span["kind"],
|
||||
ServiceName=resource.get("service.name", ""),
|
||||
ResourceAttributes=resource,
|
||||
ScopeName=span["scope_name"],
|
||||
ScopeVersion=span["scope_version"],
|
||||
SpanAttributes=attributes,
|
||||
Duration=max(span["end_ns"] - span["start_ns"], 0),
|
||||
StatusCode=span["status_code"],
|
||||
StatusMessage=span["status_message"] or _exception_message(span),
|
||||
TeamId="",
|
||||
ApiKeyHash="",
|
||||
ObservationType="chain",
|
||||
AgentName="",
|
||||
LiteLLMRequestId="",
|
||||
Model="",
|
||||
InputTokens=0,
|
||||
OutputTokens=0,
|
||||
Input="",
|
||||
Output="",
|
||||
)
|
||||
normalize(row, attributes)
|
||||
row["SpanAttributes"] = { # mutable-ok: the Rust JSON bridge requires a plain dict for span attributes
|
||||
k: _truncate(v) for k, v in attributes.items() if k not in _HEAVY_ATTRIBUTES
|
||||
}
|
||||
row["Input"], row["Output"] = _truncate(row["Input"]), _truncate(row["Output"])
|
||||
return row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- normalize
|
||||
|
||||
|
||||
def _loads(value: str) -> object:
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _lc_message(message: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""LangChain serialized message (or plain {role, content}) -> {role, content, tool_calls?}."""
|
||||
kwargs = message.get("kwargs", message)
|
||||
role = _LC_ROLES.get(kwargs.get("type") or kwargs.get("role"), kwargs.get("role") or kwargs.get("type") or "")
|
||||
content = kwargs.get("content", "")
|
||||
out: dict[str, Any] = { # mutable-ok: the framework message is built for JSON serialization
|
||||
"role": role,
|
||||
"content": content if isinstance(content, str) else json.dumps(content),
|
||||
}
|
||||
if kwargs.get("tool_calls"):
|
||||
out["tool_calls"] = tuple(
|
||||
{"name": t.get("name"), "args": t.get("args")} # mutable-ok: JSON tool calls need object payloads
|
||||
for t in kwargs["tool_calls"]
|
||||
)
|
||||
if role == "tool" and kwargs.get("name"):
|
||||
out["name"] = kwargs["name"]
|
||||
return out
|
||||
|
||||
|
||||
def _langsmith_type(row: SpanRow, attributes: Mapping[str, str]) -> SpanType:
|
||||
kind = attributes.get("langsmith.span.kind", "chain")
|
||||
name = row["SpanName"]
|
||||
if not row["ParentSpanId"] or name == attributes.get("langsmith.metadata.lc_agent_name"):
|
||||
return "agent"
|
||||
if kind in ("llm", "tool"):
|
||||
return kind
|
||||
if name.endswith(_FRAMEWORK_SUFFIXES):
|
||||
return "framework"
|
||||
return "chain"
|
||||
|
||||
|
||||
def _langsmith_io(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
prompt = _loads(attributes.get("gen_ai.prompt", ""))
|
||||
completion = _loads(attributes.get("gen_ai.completion", ""))
|
||||
prompt_payload = prompt if isinstance(prompt, dict) else MappingProxyType({})
|
||||
if row["ObservationType"] == "llm" and isinstance(completion, dict):
|
||||
messages = prompt_payload.get("messages") or ((),)
|
||||
batch = messages[0] if messages and isinstance(messages[0], list) else messages
|
||||
row["Input"] = (
|
||||
json.dumps(tuple(_lc_message(m) for m in batch if isinstance(m, dict)))
|
||||
if isinstance(batch, (list, tuple))
|
||||
else ""
|
||||
)
|
||||
generations: Final = completion.get("generations")
|
||||
first: Final = generations[0] if isinstance(generations, list) and generations else None
|
||||
item: Final = first[0] if isinstance(first, list) and first else None
|
||||
message: Final = item.get("message") if isinstance(item, dict) else None
|
||||
generation: Final = message.get("kwargs") if isinstance(message, dict) else None
|
||||
if isinstance(generation, dict):
|
||||
row["Output"] = json.dumps(_lc_message(generation))
|
||||
metadata: Final = generation.get("response_metadata")
|
||||
row["LiteLLMRequestId"] = metadata.get("id", "") if isinstance(metadata, dict) else ""
|
||||
else:
|
||||
row["Output"] = attributes.get("gen_ai.completion", "")
|
||||
return
|
||||
if row["ObservationType"] == "tool":
|
||||
output = completion.get("output", completion) if isinstance(completion, dict) else completion
|
||||
if isinstance(output, dict) and "update" in output: # LangGraph Command, e.g. Deep Agents `task`
|
||||
update: Final = output.get("update")
|
||||
update_messages = update.get("messages") or () if isinstance(update, dict) else ()
|
||||
output = update_messages[-1] if update_messages else output
|
||||
if isinstance(output, dict):
|
||||
output = output.get("content", output)
|
||||
row["Input"] = attributes.get("gen_ai.prompt", "")
|
||||
row["Output"] = output if isinstance(output, str) else json.dumps(output)
|
||||
return
|
||||
if row["ObservationType"] == "agent":
|
||||
input_messages = prompt.get("messages") if isinstance(prompt, dict) else None
|
||||
output_messages = completion.get("messages") if isinstance(completion, dict) else None
|
||||
# agents built with @traceable take arbitrary args, not a message list: keep the raw payload then
|
||||
row["Input"] = (
|
||||
json.dumps(tuple(_lc_message(m) for m in input_messages if isinstance(m, dict)))
|
||||
if input_messages
|
||||
else attributes.get("gen_ai.prompt", "")
|
||||
)
|
||||
row["Output"] = (
|
||||
json.dumps(_lc_message(output_messages[-1]))
|
||||
if output_messages and isinstance(output_messages[-1], dict)
|
||||
else attributes.get("gen_ai.completion", "")
|
||||
)
|
||||
return
|
||||
row["Input"] = attributes.get("gen_ai.prompt", "")
|
||||
row["Output"] = attributes.get("gen_ai.completion", "")
|
||||
|
||||
|
||||
def normalize_langsmith(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
row["ObservationType"] = _langsmith_type(row, attributes)
|
||||
row["AgentName"] = attributes.get("langsmith.metadata.lc_agent_name", "")
|
||||
row["Model"] = attributes.get("gen_ai.request.model", "")
|
||||
_langsmith_io(row, attributes)
|
||||
|
||||
|
||||
def normalize_genai(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
operation = attributes.get("gen_ai.operation.name", "")
|
||||
if operation == "invoke_agent" or not row["ParentSpanId"]:
|
||||
row["ObservationType"] = "agent"
|
||||
elif operation in _LLM_OPERATIONS:
|
||||
row["ObservationType"] = "llm"
|
||||
elif operation == "execute_tool":
|
||||
row["ObservationType"] = "tool"
|
||||
row["AgentName"] = attributes.get("gen_ai.agent.name", "")
|
||||
row["Model"] = attributes.get("gen_ai.request.model") or attributes.get("gen_ai.response.model", "")
|
||||
row["LiteLLMRequestId"] = attributes.get("gen_ai.response.id", "")
|
||||
row["Input"] = attributes.get("gen_ai.input.messages") or attributes.get("gen_ai.tool.call.arguments", "")
|
||||
row["Output"] = attributes.get("gen_ai.output.messages") or attributes.get("gen_ai.tool.call.result", "")
|
||||
|
||||
|
||||
def normalize_openinference(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
kind = attributes.get("openinference.span.kind", "").upper()
|
||||
row["ObservationType"] = _OPENINFERENCE_TYPES.get(kind, "agent" if not row["ParentSpanId"] else "chain")
|
||||
row["AgentName"] = attributes.get("agent.name", "")
|
||||
row["Model"] = attributes.get("llm.model_name", "")
|
||||
row["Input"] = attributes.get("input.value", "")
|
||||
row["Output"] = attributes.get("output.value", "")
|
||||
row["InputTokens"] = _to_int(attributes.get("llm.token_count.prompt"))
|
||||
row["OutputTokens"] = _to_int(attributes.get("llm.token_count.completion"))
|
||||
|
||||
|
||||
def _set_tokens(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
row["InputTokens"] = _to_int(attributes.get("gen_ai.usage.input_tokens"))
|
||||
row["OutputTokens"] = _to_int(attributes.get("gen_ai.usage.output_tokens"))
|
||||
|
||||
|
||||
def _to_int(value: str | None) -> int:
|
||||
try:
|
||||
return int(value) if value else 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def select_normalizer(scope_name: str, attributes: Mapping[str, str]) -> Callable[[SpanRow, Mapping[str, str]], None]:
|
||||
if scope_name == "langsmith" or "langsmith.span.kind" in attributes:
|
||||
return normalize_langsmith
|
||||
if "openinference.span.kind" in attributes:
|
||||
return normalize_openinference
|
||||
return normalize_genai
|
||||
|
||||
|
||||
def normalize(row: SpanRow, attributes: Mapping[str, str]) -> None:
|
||||
select_normalizer(row["ScopeName"], attributes)(row, attributes)
|
||||
if not row["InputTokens"] and not row["OutputTokens"]:
|
||||
_set_tokens(row, attributes)
|
||||
|
||||
|
||||
def encode_otlp_response(content_type: str | None) -> tuple[bytes, str]:
|
||||
"""Empty ExportTraceServiceResponse in the caller's encoding."""
|
||||
if content_type and "json" in content_type:
|
||||
return b"{}", "application/json"
|
||||
return b"", "application/x-protobuf"
|
||||
120
litellm/tracing/receiver.py
Normal file
120
litellm/tracing/receiver.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""
|
||||
`TraceReceiver`: the one entry point for agent tracing.
|
||||
|
||||
tracing = TraceReceiver.from_env() # or TraceReceiver(store=...)
|
||||
await tracing.start() # create tables if missing
|
||||
|
||||
tracing.ingest(otlp_body, content_type, content_encoding, tenant) # POST /v1/traces
|
||||
await tracing.list_traces(scope, start_ms, end_ms, cursor) # GET /v1/traces
|
||||
await tracing.get_trace(trace_id, scope) # GET /v1/traces/{id}
|
||||
await tracing.get_span(trace_id, span_id, scope) # GET /v1/traces/{id}/spans/{span_id}
|
||||
|
||||
The proxy endpoints are thin wrappers: auth -> build tenant/scope -> call one method.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Final
|
||||
|
||||
from litellm.constants import (
|
||||
AGENT_TRACING_RETENTION_DAYS,
|
||||
AGENT_TRACING_SPEND_LOG_RETENTION_DAYS,
|
||||
OTLP_MAX_BODY_BYTES,
|
||||
OTLP_OFFLOAD_DECODE_BYTES,
|
||||
)
|
||||
from litellm.integrations.clickhouse.schema import ensure_schema
|
||||
from litellm.rust_bridge.traces import TraceStorage
|
||||
from litellm.tracing.decode import OTLPPayloadTooLargeError, decode_otlp
|
||||
from litellm.tracing.store import ClickHouseTraceStore
|
||||
from litellm.tracing.types import (
|
||||
SpanDetail,
|
||||
SpanRow,
|
||||
Trace,
|
||||
TracePage,
|
||||
TraceScope,
|
||||
)
|
||||
|
||||
|
||||
class TracingPayloadTooLargeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Tenant:
|
||||
"""Who sent the spans. Always taken from auth, never from span attributes."""
|
||||
|
||||
def __init__(self, team_id: str, api_key_hash: str, org_id: str = "") -> None:
|
||||
self.team_id = team_id
|
||||
self.api_key_hash = api_key_hash
|
||||
self.org_id = org_id
|
||||
|
||||
def stamp(self, row: SpanRow) -> SpanRow:
|
||||
row["TeamId"] = self.team_id
|
||||
row["ApiKeyHash"] = self.api_key_hash
|
||||
row["ResourceAttributes"] = { # mutable-ok: the Rust JSON bridge requires a plain dict
|
||||
**row["ResourceAttributes"],
|
||||
"litellm.team_id": self.team_id,
|
||||
"litellm.api_key_hash": self.api_key_hash,
|
||||
"litellm.org_id": self.org_id,
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
class TraceReceiver:
|
||||
def __init__(self, store: ClickHouseTraceStore) -> None:
|
||||
self.store = store
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "TraceReceiver":
|
||||
return cls(
|
||||
store=ClickHouseTraceStore(
|
||||
TraceStorage(
|
||||
database=os.getenv("CLICKHOUSE_DATABASE", "litellm"),
|
||||
url=os.environ["CLICKHOUSE_URL"],
|
||||
reader_url=os.environ["CLICKHOUSE_READER_URL"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
await ensure_schema(
|
||||
self.store.storage,
|
||||
trace_retention_days=AGENT_TRACING_RETENTION_DAYS,
|
||||
spend_log_retention_days=AGENT_TRACING_SPEND_LOG_RETENTION_DAYS,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ write
|
||||
|
||||
async def ingest(
|
||||
self,
|
||||
body: bytes,
|
||||
content_type: str | None,
|
||||
content_encoding: str | None,
|
||||
tenant: Tenant,
|
||||
) -> int:
|
||||
"""Decode an OTLP trace export and store its authenticated spans."""
|
||||
if len(body) > OTLP_MAX_BODY_BYTES:
|
||||
raise TracingPayloadTooLargeError(f"OTLP body exceeds {OTLP_MAX_BODY_BYTES} bytes")
|
||||
try:
|
||||
rows: Final = (
|
||||
await asyncio.to_thread(decode_otlp, body, content_type, content_encoding)
|
||||
if len(body) > OTLP_OFFLOAD_DECODE_BYTES
|
||||
else decode_otlp(body, content_type, content_encoding)
|
||||
)
|
||||
except OTLPPayloadTooLargeError as error:
|
||||
raise TracingPayloadTooLargeError(str(error)) from error
|
||||
try:
|
||||
await self.store.insert_spans(tuple(tenant.stamp(r) for r in rows))
|
||||
except OverflowError as error:
|
||||
raise TracingPayloadTooLargeError(str(error)) from error
|
||||
return len(rows)
|
||||
|
||||
# ------------------------------------------------------------ read
|
||||
|
||||
async def list_traces(self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None = None) -> TracePage:
|
||||
return await self.store.list_traces(scope, start_ms, end_ms, cursor)
|
||||
|
||||
async def get_trace(self, trace_id: str, scope: TraceScope, trace_ref: str = "") -> Trace | None:
|
||||
return await self.store.get_trace(trace_id, scope, trace_ref)
|
||||
|
||||
async def get_span(self, trace_id: str, span_id: str, scope: TraceScope, trace_ref: str = "") -> SpanDetail | None:
|
||||
return await self.store.get_span(trace_id, span_id, scope, trace_ref)
|
||||
286
litellm/tracing/store.py
Normal file
286
litellm/tracing/store.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""ClickHouse-backed trace store: batched span writes and scoped reads."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.constants import AGENT_TRACING_LIST_PAGE_SIZE
|
||||
from litellm.integrations.clickhouse.schema import (
|
||||
AGENT_TRACES_BY_KEY_TABLE,
|
||||
OTEL_TRACES_TABLE,
|
||||
)
|
||||
from litellm.rust_bridge.traces import TraceStorage
|
||||
from litellm.tracing.types import (
|
||||
AgentNode,
|
||||
Span,
|
||||
SpanDetail,
|
||||
SpanRow,
|
||||
SpanStatus,
|
||||
Trace,
|
||||
TracePage,
|
||||
TraceScope,
|
||||
TraceSummary,
|
||||
)
|
||||
|
||||
NANOS_PER_MS: Final = 1_000_000
|
||||
_STATUS: Final = MappingProxyType({"STATUS_CODE_OK": "ok", "STATUS_CODE_ERROR": "error"})
|
||||
|
||||
_SCOPE_OTEL: Final = (
|
||||
"(empty({team_ids:Array(String)}) OR TeamId IN {team_ids:Array(String)})"
|
||||
" AND ({api_key_hash:String} = '' OR ApiKeyHash = {api_key_hash:String})"
|
||||
)
|
||||
_TRACE_REF_SQL: Final = "hex(SHA256(concat(TeamId, char(0), ApiKeyHash, char(0), TraceId)))"
|
||||
LIST_TRACES_SQL: Final = f"""
|
||||
SELECT TraceId AS trace_id, {_TRACE_REF_SQL} AS trace_ref,
|
||||
ifNull(any(RootName), '') AS name, any(ServiceName) AS service,
|
||||
ifNull(any(RootInput), '') AS input_preview, ifNull(any(RootStatus), '') AS status,
|
||||
toUnixTimestamp64Milli(min(StartTs)) AS start_ms,
|
||||
dateDiff('millisecond', min(StartTs), max(EndTs)) AS duration_ms,
|
||||
sum(SpanCount) AS span_count, length(groupUniqArrayArray(AgentNames)) AS agent_count,
|
||||
sum(AgentCount) AS agent_invocations,
|
||||
sum(LlmCount) AS llm_calls, sum(ToolCount) AS tool_calls,
|
||||
sum(InputTokens) AS input_tokens, sum(OutputTokens) AS output_tokens,
|
||||
groupUniqArrayArray(Models) AS models, sum(ErrorCount) AS error_count
|
||||
FROM {AGENT_TRACES_BY_KEY_TABLE}
|
||||
WHERE (empty({{team_ids:Array(String)}}) OR TeamId IN {{team_ids:Array(String)}})
|
||||
AND ({{api_key_hash:String}} = '' OR ApiKeyHash = {{api_key_hash:String}})
|
||||
GROUP BY TeamId, ApiKeyHash, TraceId
|
||||
HAVING min(StartTs) >= fromUnixTimestamp64Milli({{start_ms:Int64}})
|
||||
AND min(StartTs) < fromUnixTimestamp64Milli({{end_ms:Int64}})
|
||||
AND ({{cursor_ms:Int64}} = 0 OR (toUnixTimestamp64Milli(min(StartTs)), trace_ref)
|
||||
< ({{cursor_ms:Int64}}, {{cursor_trace_id:String}}))
|
||||
ORDER BY start_ms DESC, trace_ref DESC
|
||||
LIMIT {{limit:UInt32}}
|
||||
"""
|
||||
|
||||
TRACE_SPANS_SQL: Final = f"""
|
||||
SELECT o.SpanId AS span_id, o.ParentSpanId AS parent_span_id, o.SpanName AS name,
|
||||
o.ObservationType AS type, o.AgentName AS agent, o.StatusCode AS status,
|
||||
o.StatusMessage AS status_message,
|
||||
toUnixTimestamp64Nano(o.Timestamp) AS start_ns, o.Duration AS duration_ns,
|
||||
o.ServiceName AS service, o.InputPreview AS input_preview, o.Model AS model,
|
||||
o.InputTokens AS input_tokens, o.OutputTokens AS output_tokens,
|
||||
o.LiteLLMRequestId AS litellm_request_id
|
||||
FROM {OTEL_TRACES_TABLE} AS o
|
||||
WHERE o.TraceId = {{trace_id:String}} AND {_SCOPE_OTEL}
|
||||
AND ({{trace_ref:String}} = '' OR {_TRACE_REF_SQL} = {{trace_ref:String}})
|
||||
ORDER BY o.Timestamp
|
||||
LIMIT 1 BY o.SpanId
|
||||
"""
|
||||
|
||||
SPAN_DETAIL_SQL: Final = f"""
|
||||
SELECT SpanId AS span_id, Input AS input, Output AS output, SpanAttributes AS attributes
|
||||
FROM {OTEL_TRACES_TABLE}
|
||||
WHERE TraceId = {{trace_id:String}} AND SpanId = {{span_id:String}} AND {_SCOPE_OTEL}
|
||||
AND ({{trace_ref:String}} = '' OR {_TRACE_REF_SQL} = {{trace_ref:String}})
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
def encode_cursor(start_ms: int, trace_id: str) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps((start_ms, trace_id)).encode()).decode()
|
||||
|
||||
|
||||
def decode_cursor(cursor: str | None) -> tuple[int, str]:
|
||||
if not cursor:
|
||||
return 0, ""
|
||||
try:
|
||||
value: Final = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 2
|
||||
or not isinstance(value[0], int)
|
||||
or isinstance(value[0], bool)
|
||||
or value[0] <= 0
|
||||
or not isinstance(value[1], str)
|
||||
or not value[1]
|
||||
):
|
||||
raise ValueError("Invalid trace cursor")
|
||||
return value[0], value[1]
|
||||
except (ValueError, UnicodeError, binascii.Error) as error:
|
||||
raise ValueError("Invalid trace cursor") from error
|
||||
|
||||
|
||||
def _iso(ms: int) -> str:
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _status(code: str) -> SpanStatus:
|
||||
return _STATUS.get(code, "unset")
|
||||
|
||||
|
||||
def trace_summary_from_row(row: dict[str, Any]) -> TraceSummary:
|
||||
return TraceSummary(
|
||||
trace_id=row["trace_id"],
|
||||
trace_ref=row.get("trace_ref", ""),
|
||||
name=row["name"],
|
||||
service=row["service"],
|
||||
input_preview=row["input_preview"],
|
||||
start_time=_iso(int(row["start_ms"])),
|
||||
duration_ms=float(row["duration_ms"]),
|
||||
status=_status(row["status"]),
|
||||
span_count=int(row["span_count"]),
|
||||
agent_count=int(row["agent_count"]),
|
||||
agent_invocations=int(row.get("agent_invocations") or row["agent_count"]),
|
||||
llm_calls=int(row["llm_calls"]),
|
||||
tool_calls=int(row["tool_calls"]),
|
||||
error_count=int(row.get("error_count") or 0),
|
||||
input_tokens=int(row["input_tokens"]),
|
||||
output_tokens=int(row["output_tokens"]),
|
||||
models=tuple(row["models"]),
|
||||
)
|
||||
|
||||
|
||||
def span_from_row(row: dict[str, Any], trace_start_ns: int) -> Span:
|
||||
return Span(
|
||||
span_id=row["span_id"],
|
||||
parent_span_id=row["parent_span_id"] or None,
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
agent=row["agent"],
|
||||
start_offset_ms=(int(row["start_ns"]) - trace_start_ns) / NANOS_PER_MS,
|
||||
duration_ms=int(row["duration_ns"]) / NANOS_PER_MS,
|
||||
status=_status(row["status"]),
|
||||
error=row.get("status_message") or None,
|
||||
input_preview=row["input_preview"],
|
||||
model=row["model"] or None,
|
||||
input_tokens=int(row["input_tokens"]),
|
||||
output_tokens=int(row["output_tokens"]),
|
||||
litellm_request_id=row["litellm_request_id"] or None,
|
||||
)
|
||||
|
||||
|
||||
def _parent_agent_of(span: Span, by_id: Mapping[str, Span]) -> str | None:
|
||||
parent_id = span["parent_span_id"]
|
||||
for _ in by_id:
|
||||
if parent_id is None or parent_id not in by_id or parent_id == span["span_id"]:
|
||||
return None
|
||||
parent = by_id[parent_id]
|
||||
if parent["type"] == "agent" and parent["name"] != span["name"]:
|
||||
return parent["name"]
|
||||
parent_id = parent["parent_span_id"]
|
||||
return None
|
||||
|
||||
|
||||
def agent_nodes(spans: Sequence[Span]) -> tuple[AgentNode, ...]:
|
||||
"""One node per distinct agent name (200 `researcher` invocations = 1 node), with who invoked it."""
|
||||
by_id: Final = MappingProxyType({s["span_id"]: s for s in spans})
|
||||
agents: dict[str, AgentNode] = {} # mutable-ok: linear-time aggregation updates counters per agent
|
||||
for span in spans:
|
||||
if span["type"] != "agent":
|
||||
continue
|
||||
node = agents.setdefault(
|
||||
span["name"],
|
||||
AgentNode(
|
||||
name=span["name"],
|
||||
parent_agent=_parent_agent_of(span, by_id),
|
||||
invocations=0,
|
||||
llm_calls=0,
|
||||
tool_calls=0,
|
||||
duration_ms=0.0,
|
||||
),
|
||||
)
|
||||
node["invocations"] += 1
|
||||
node["duration_ms"] += span["duration_ms"]
|
||||
for span in spans:
|
||||
owner = agents.get(span["agent"])
|
||||
if owner is None:
|
||||
continue
|
||||
if span["type"] == "llm":
|
||||
owner["llm_calls"] += 1
|
||||
elif span["type"] == "tool":
|
||||
owner["tool_calls"] += 1
|
||||
return tuple(agents.values())
|
||||
|
||||
|
||||
def trace_from_rows(trace_id: str, rows: list[dict[str, Any]], trace_ref: str = "") -> Trace | None:
|
||||
if not rows:
|
||||
return None
|
||||
trace_start_ns: Final = min(int(r["start_ns"]) for r in rows)
|
||||
trace_end_ns: Final = max(int(r["start_ns"]) + int(r["duration_ns"]) for r in rows)
|
||||
spans: Final = tuple(span_from_row(r, trace_start_ns) for r in rows)
|
||||
root: Final = next((s for s in spans if s["parent_span_id"] is None), spans[0])
|
||||
agents: Final = agent_nodes(spans)
|
||||
llm_spans: Final = tuple(s for s in spans if s["type"] == "llm")
|
||||
return Trace(
|
||||
summary=TraceSummary(
|
||||
trace_id=trace_id,
|
||||
trace_ref=trace_ref,
|
||||
name=root["name"],
|
||||
service=rows[0]["service"],
|
||||
input_preview=root["input_preview"],
|
||||
start_time=_iso(trace_start_ns // NANOS_PER_MS),
|
||||
duration_ms=(trace_end_ns - trace_start_ns) / NANOS_PER_MS,
|
||||
status=root["status"],
|
||||
span_count=len(spans),
|
||||
agent_count=len(agents),
|
||||
agent_invocations=sum(a["invocations"] for a in agents),
|
||||
llm_calls=len(llm_spans),
|
||||
tool_calls=sum(1 for s in spans if s["type"] == "tool"),
|
||||
error_count=sum(1 for s in spans if s["status"] == "error"),
|
||||
input_tokens=sum(s["input_tokens"] for s in spans),
|
||||
output_tokens=sum(s["output_tokens"] for s in spans),
|
||||
models=tuple(sorted(frozenset(s["model"] for s in llm_spans if s["model"]))),
|
||||
),
|
||||
agents=agents,
|
||||
spans=spans,
|
||||
)
|
||||
|
||||
|
||||
class ClickHouseTraceStore:
|
||||
"""Stores spans and runs scoped trace reads."""
|
||||
|
||||
def __init__(self, storage: TraceStorage) -> None:
|
||||
self.storage = storage
|
||||
|
||||
async def insert_spans(self, rows: Sequence[SpanRow]) -> None:
|
||||
await self.storage.insert_rows(OTEL_TRACES_TABLE, tuple(rows))
|
||||
|
||||
async def list_traces(
|
||||
self,
|
||||
scope: TraceScope,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
cursor: str | None = None,
|
||||
limit: int = AGENT_TRACING_LIST_PAGE_SIZE,
|
||||
) -> TracePage:
|
||||
cursor_ms, cursor_trace_id = decode_cursor(cursor)
|
||||
rows = await self.storage.query(
|
||||
LIST_TRACES_SQL,
|
||||
MappingProxyType(
|
||||
{
|
||||
**scope,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
"cursor_ms": cursor_ms,
|
||||
"cursor_trace_id": cursor_trace_id,
|
||||
"limit": limit,
|
||||
}
|
||||
),
|
||||
)
|
||||
next_cursor = encode_cursor(int(rows[-1]["start_ms"]), rows[-1]["trace_ref"]) if len(rows) == limit else None
|
||||
return TracePage(data=tuple(trace_summary_from_row(r) for r in rows), next_cursor=next_cursor)
|
||||
|
||||
async def get_trace(self, trace_id: str, scope: TraceScope, trace_ref: str = "") -> Trace | None:
|
||||
rows = await self.storage.query(
|
||||
TRACE_SPANS_SQL, MappingProxyType({**scope, "trace_id": trace_id, "trace_ref": trace_ref})
|
||||
)
|
||||
return trace_from_rows(trace_id, rows, trace_ref)
|
||||
|
||||
async def get_span(self, trace_id: str, span_id: str, scope: TraceScope, trace_ref: str = "") -> SpanDetail | None:
|
||||
rows = await self.storage.query(
|
||||
SPAN_DETAIL_SQL,
|
||||
MappingProxyType({**scope, "trace_id": trace_id, "span_id": span_id, "trace_ref": trace_ref}),
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
return SpanDetail(
|
||||
span_id=rows[0]["span_id"],
|
||||
input=rows[0]["input"],
|
||||
output=rows[0]["output"],
|
||||
attributes=rows[0]["attributes"],
|
||||
)
|
||||
120
litellm/tracing/types.py
Normal file
120
litellm/tracing/types.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""
|
||||
Agent tracing types.
|
||||
|
||||
A trace is one agent run. It's made of spans (agent / llm / tool / chain / framework).
|
||||
Trace
|
||||
├── summary: TraceSummary
|
||||
├── agents: list[AgentNode] one per distinct agent name (for the agent graph)
|
||||
└── spans: list[Span] flat, linked by parent_span_id
|
||||
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
SpanType = Literal["agent", "llm", "tool", "chain", "framework"]
|
||||
SpanStatus = Literal["ok", "error", "unset"]
|
||||
|
||||
|
||||
class Span(TypedDict):
|
||||
span_id: ReadOnly[str]
|
||||
parent_span_id: ReadOnly[str | None]
|
||||
name: ReadOnly[str]
|
||||
type: ReadOnly[SpanType]
|
||||
agent: ReadOnly[str] # the agent this span runs inside, e.g. "researcher"
|
||||
start_offset_ms: ReadOnly[float] # relative to trace start
|
||||
duration_ms: ReadOnly[float]
|
||||
status: ReadOnly[SpanStatus]
|
||||
error: ReadOnly[str | None] # exception message when status == "error"
|
||||
input_preview: ReadOnly[str]
|
||||
model: ReadOnly[str | None]
|
||||
input_tokens: ReadOnly[int]
|
||||
output_tokens: ReadOnly[int]
|
||||
litellm_request_id: ReadOnly[str | None]
|
||||
|
||||
|
||||
class AgentNode(TypedDict):
|
||||
"""One distinct agent in a trace. 200 invocations of `researcher` = one node."""
|
||||
|
||||
name: ReadOnly[str]
|
||||
parent_agent: ReadOnly[str | None]
|
||||
invocations: int
|
||||
llm_calls: int
|
||||
tool_calls: int
|
||||
duration_ms: float
|
||||
|
||||
|
||||
class TraceSummary(TypedDict):
|
||||
trace_id: ReadOnly[str]
|
||||
trace_ref: ReadOnly[NotRequired[str]]
|
||||
name: ReadOnly[str]
|
||||
service: ReadOnly[str]
|
||||
input_preview: ReadOnly[str]
|
||||
start_time: ReadOnly[str] # ISO 8601
|
||||
duration_ms: ReadOnly[float]
|
||||
status: ReadOnly[SpanStatus]
|
||||
span_count: ReadOnly[int]
|
||||
agent_count: ReadOnly[int] # distinct agent names (researcher x200 counts once)
|
||||
agent_invocations: ReadOnly[int] # agent spans (researcher x200 counts 200)
|
||||
llm_calls: ReadOnly[int]
|
||||
tool_calls: ReadOnly[int]
|
||||
error_count: ReadOnly[int] # spans with an error status; > 0 means the run shows as failed
|
||||
input_tokens: ReadOnly[int]
|
||||
output_tokens: ReadOnly[int]
|
||||
models: ReadOnly[tuple[str, ...]]
|
||||
|
||||
|
||||
class Trace(TypedDict):
|
||||
summary: ReadOnly[TraceSummary]
|
||||
agents: ReadOnly[tuple[AgentNode, ...]]
|
||||
spans: ReadOnly[tuple[Span, ...]]
|
||||
|
||||
|
||||
class TracePage(TypedDict):
|
||||
data: ReadOnly[tuple[TraceSummary, ...]]
|
||||
next_cursor: ReadOnly[str | None]
|
||||
|
||||
|
||||
class SpanDetail(TypedDict):
|
||||
span_id: ReadOnly[str]
|
||||
input: ReadOnly[str]
|
||||
output: ReadOnly[str]
|
||||
attributes: ReadOnly[dict[str, str]]
|
||||
|
||||
|
||||
class TraceScope(TypedDict):
|
||||
"""Who is asking. Empty team_ids = all teams (admins only)."""
|
||||
|
||||
team_ids: ReadOnly[tuple[str, ...]]
|
||||
api_key_hash: ReadOnly[str]
|
||||
|
||||
|
||||
class SpanRow(TypedDict):
|
||||
"""One stored span (ClickHouse `otel_traces` row). Produced by `litellm.tracing.decode`."""
|
||||
|
||||
Timestamp: ReadOnly[int] # unix ns
|
||||
TraceId: ReadOnly[str]
|
||||
SpanId: ReadOnly[str]
|
||||
ParentSpanId: ReadOnly[str]
|
||||
TraceState: ReadOnly[str]
|
||||
SpanName: ReadOnly[str]
|
||||
SpanKind: ReadOnly[str]
|
||||
ServiceName: ReadOnly[str]
|
||||
ResourceAttributes: dict[str, str]
|
||||
ScopeName: ReadOnly[str]
|
||||
ScopeVersion: ReadOnly[str]
|
||||
SpanAttributes: dict[str, str]
|
||||
Duration: ReadOnly[int] # ns
|
||||
StatusCode: ReadOnly[str]
|
||||
StatusMessage: ReadOnly[str]
|
||||
TeamId: str
|
||||
ApiKeyHash: str
|
||||
ObservationType: SpanType
|
||||
AgentName: str
|
||||
LiteLLMRequestId: str
|
||||
Model: str
|
||||
InputTokens: int
|
||||
OutputTokens: int
|
||||
Input: str
|
||||
Output: str
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Tests for the CustomBatchLogger-based ClickHouse base logger.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.clickhouse import clickhouse_batch_logger as module
|
||||
from litellm.integrations.clickhouse.clickhouse_batch_logger import ClickHouseBatchLogger
|
||||
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||||
|
||||
|
||||
class _TestLogger(ClickHouseBatchLogger):
|
||||
table = "test_table"
|
||||
|
||||
|
||||
def _logger(insert: AsyncMock) -> _TestLogger:
|
||||
storage = MagicMock()
|
||||
storage.insert_rows = insert
|
||||
return _TestLogger(storage=storage)
|
||||
|
||||
|
||||
def test_is_a_custom_batch_logger():
|
||||
assert issubclass(ClickHouseBatchLogger, CustomBatchLogger)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_splits_into_batches_and_empties_queue():
|
||||
insert = AsyncMock()
|
||||
logger = _logger(insert)
|
||||
logger.batch_size = 2
|
||||
logger.log_queue.extend([{"i": i} for i in range(5)])
|
||||
|
||||
await logger.flush_queue()
|
||||
|
||||
assert [len(c.args[1]) for c in insert.await_args_list] == [2, 2, 1]
|
||||
assert all(c.args[0] == "test_table" for c in insert.await_args_list)
|
||||
assert logger.log_queue == []
|
||||
assert logger.rows_written == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_full_signals_backpressure():
|
||||
logger = _logger(AsyncMock())
|
||||
with patch.object(module, "CLICKHOUSE_MAX_BUFFERED_ROWS", 3):
|
||||
logger.log_queue.extend([{}, {}])
|
||||
assert logger.is_full() is False
|
||||
logger.log_queue.append({})
|
||||
assert logger.is_full() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_insert_is_requeued_then_dropped():
|
||||
insert = AsyncMock(side_effect=RuntimeError("clickhouse down"))
|
||||
logger = _logger(insert)
|
||||
logger.log_queue.extend([{"request_id": "a"}, {"request_id": "b"}])
|
||||
|
||||
with patch.object(module, "CLICKHOUSE_MAX_RETRIES", 2):
|
||||
await logger.flush_queue()
|
||||
assert len(logger.log_queue) == 2 # kept for retry
|
||||
await logger.flush_queue()
|
||||
|
||||
assert insert.await_count == 2
|
||||
assert logger.rows_dropped == 2
|
||||
assert logger.rows_written == 0
|
||||
assert logger.log_queue == []
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import gzip
|
||||
import io
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
|
|
@ -31,12 +32,14 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
)
|
||||
|
||||
|
||||
def _starlette_request(body: bytes, content_type: str) -> Request:
|
||||
def _starlette_request(
|
||||
body: bytes, content_type: str, path: str = "/v1/messages", content_encoding: str = ""
|
||||
) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"headers": [(b"content-type", content_type.encode())],
|
||||
"path": path,
|
||||
"headers": [(b"content-type", content_type.encode()), (b"content-encoding", content_encoding.encode())],
|
||||
"query_string": b"",
|
||||
}
|
||||
chunks = iter((body,))
|
||||
|
|
@ -72,6 +75,26 @@ async def test_read_raw_json_body_is_none_for_form_bodies():
|
|||
assert await read_raw_json_body(request) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content_type", ["application/x-protobuf", "application/protobuf; charset=binary"])
|
||||
async def test_protobuf_body_is_not_parsed_as_json(content_type):
|
||||
# OTLP trace exports (POST /v1/traces) are binary protobuf; arbitrary bytes like these
|
||||
# used to hit the JSON surrogate-repair path and fail auth with a 400.
|
||||
body = b"\n\xa2\x01\n\x1c\n\x0cservice.name\x12\x0c\n\nswarm\xed\xa0\x80\xff"
|
||||
request = _starlette_request(body, content_type)
|
||||
|
||||
assert await _read_request_body(request) == {}
|
||||
assert await request.body() == body # body is still readable by the endpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gzipped_json_trace_body_survives_auth_pre_read():
|
||||
body = gzip.compress(b'{"resourceSpans": []}')
|
||||
request = _starlette_request(body, "application/json", "/v1/traces", "gzip")
|
||||
assert await _read_request_body(request) == {}
|
||||
assert await request.body() == body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_raw_json_body_is_none_for_a_request_that_only_mocks_the_parsed_body_path():
|
||||
mock_request = MagicMock()
|
||||
|
|
|
|||
180
tests/test_litellm/proxy/test_tracing_endpoints.py
Normal file
180
tests/test_litellm/proxy/test_tracing_endpoints.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Tests for the agent tracing endpoints (litellm/proxy/tracing_endpoints.py).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy import tracing_endpoints
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.tracing import TracingPayloadTooLargeError
|
||||
|
||||
TEAM_KEY = UserAPIKeyAuth(
|
||||
token="hashed-key", team_id="team-research", org_id="org-1", user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scope / tenant
|
||||
|
||||
|
||||
def test_scope_for_admin_sees_everything():
|
||||
for role in (LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
|
||||
auth = UserAPIKeyAuth(token="k", team_id="team-a", user_role=role)
|
||||
assert tracing_endpoints.scope_for(auth) == {"team_ids": (), "api_key_hash": ""}
|
||||
|
||||
|
||||
def test_scope_for_team_key_sees_its_team():
|
||||
assert tracing_endpoints.scope_for(TEAM_KEY) == {"team_ids": ("team-research",), "api_key_hash": ""}
|
||||
|
||||
|
||||
def test_scope_for_teamless_key_sees_only_its_own_traces():
|
||||
auth = UserAPIKeyAuth(token="hashed-key", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
assert tracing_endpoints.scope_for(auth) == {"team_ids": ("",), "api_key_hash": "hashed-key"}
|
||||
|
||||
|
||||
def test_scope_for_no_team_no_token_is_forbidden():
|
||||
with pytest.raises(HTTPException) as e:
|
||||
tracing_endpoints.scope_for(UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER))
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
def test_tenant_for_comes_from_auth():
|
||||
tenant = tracing_endpoints.tenant_for(TEAM_KEY)
|
||||
assert (tenant.team_id, tenant.api_key_hash, tenant.org_id) == ("team-research", "hashed-key", "org-1")
|
||||
blank = tracing_endpoints.tenant_for(UserAPIKeyAuth())
|
||||
assert (blank.team_id, blank.api_key_hash, blank.org_id) == ("", "", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def receiver(monkeypatch) -> MagicMock:
|
||||
fake = MagicMock()
|
||||
fake.ingest = AsyncMock(return_value=1)
|
||||
fake.list_traces = AsyncMock(return_value={"data": [], "next_cursor": None})
|
||||
fake.get_trace = AsyncMock(return_value=None)
|
||||
fake.get_span = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(tracing_endpoints, "receiver", fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(tracing_endpoints.router)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: TEAM_KEY
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_501_when_tracing_not_enabled(client, monkeypatch):
|
||||
monkeypatch.setattr(tracing_endpoints, "receiver", None)
|
||||
assert client.post("/v1/traces", content=b"").status_code == 501
|
||||
assert client.get("/v1/traces").status_code == 501
|
||||
|
||||
|
||||
def test_post_protobuf_returns_empty_protobuf(client, receiver):
|
||||
response = client.post(
|
||||
"/v1/traces",
|
||||
content=b"\x0a\x00",
|
||||
headers={"content-type": "application/x-protobuf", "content-encoding": "gzip"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.content == b""
|
||||
assert response.headers["content-type"] == "application/x-protobuf"
|
||||
kwargs = receiver.ingest.call_args.kwargs
|
||||
assert kwargs["body"] == b"\x0a\x00"
|
||||
assert kwargs["content_type"] == "application/x-protobuf"
|
||||
assert kwargs["content_encoding"] == "gzip"
|
||||
assert kwargs["tenant"].team_id == "team-research"
|
||||
|
||||
|
||||
def test_post_json_returns_empty_json(client, receiver):
|
||||
response = client.post("/v1/traces", content=b"{}", headers={"content-type": "application/json"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
|
||||
|
||||
def test_post_clickhouse_failure_is_503_with_retry_after(client, receiver):
|
||||
receiver.ingest.side_effect = RuntimeError("ClickHouse unavailable")
|
||||
response = client.post("/v1/traces", content=b"", headers={"content-type": "application/x-protobuf"})
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == str(tracing_endpoints.OTLP_RETRY_AFTER_SECONDS)
|
||||
|
||||
|
||||
def test_post_too_large_is_413(client, receiver):
|
||||
receiver.ingest.side_effect = TracingPayloadTooLargeError("OTLP body exceeds 10 bytes")
|
||||
response = client.post("/v1/traces", content=b"x" * 20)
|
||||
assert response.status_code == 413
|
||||
assert "exceeds" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_list_traces_passes_scope_window_and_cursor(client, receiver):
|
||||
response = client.get("/v1/traces", params={"start_ms": 1, "end_ms": 2, "cursor": "abc"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"data": [], "next_cursor": None}
|
||||
receiver.list_traces.assert_awaited_once_with(
|
||||
scope={"team_ids": ("team-research",), "api_key_hash": ""}, start_ms=1, end_ms=2, cursor="abc"
|
||||
)
|
||||
|
||||
|
||||
def test_list_traces_defaults_to_last_24h(client, receiver):
|
||||
client.get("/v1/traces")
|
||||
kwargs = receiver.list_traces.call_args.kwargs
|
||||
assert kwargs["end_ms"] - kwargs["start_ms"] == tracing_endpoints.MS_PER_DAY
|
||||
assert kwargs["cursor"] is None
|
||||
|
||||
|
||||
def test_get_trace_404_and_200(client, receiver):
|
||||
assert client.get("/v1/traces/missing").status_code == 404
|
||||
trace = {"summary": {"trace_id": "t1"}, "agents": [], "spans": []}
|
||||
receiver.get_trace.return_value = trace
|
||||
response = client.get("/v1/traces/t1")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == trace
|
||||
receiver.get_trace.assert_awaited_with("t1", {"team_ids": ("team-research",), "api_key_hash": ""}, "")
|
||||
|
||||
|
||||
def test_get_span_404_and_200(client, receiver):
|
||||
assert client.get("/v1/traces/t1/spans/s1").status_code == 404
|
||||
receiver.get_span.return_value = {"span_id": "s1", "input": "", "output": "", "attributes": {}}
|
||||
response = client.get("/v1/traces/t1/spans/s1")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["span_id"] == "s1"
|
||||
receiver.get_span.assert_awaited_with("t1", "s1", {"team_ids": ("team-research",), "api_key_hash": ""}, "")
|
||||
|
||||
|
||||
def test_trace_detail_passes_scoped_reference(client, receiver):
|
||||
receiver.get_trace.return_value = {"summary": {"trace_id": "t1"}, "agents": [], "spans": []}
|
||||
assert client.get("/v1/traces/t1?trace_ref=run-one").status_code == 200
|
||||
receiver.get_trace.assert_awaited_with("t1", {"team_ids": ("team-research",), "api_key_hash": ""}, "run-one")
|
||||
|
||||
|
||||
def test_invalid_export_and_cursor_are_client_errors(client, receiver):
|
||||
from litellm.tracing.decode import InvalidOTLPPayloadError
|
||||
|
||||
receiver.ingest.side_effect = InvalidOTLPPayloadError("invalid OTLP trace payload")
|
||||
assert client.post("/v1/traces", content=b"broken").status_code == 400
|
||||
receiver.list_traces.side_effect = ValueError("Invalid trace cursor")
|
||||
assert client.get("/v1/traces?cursor=broken").status_code == 400
|
||||
|
||||
|
||||
def test_teamless_key_without_token_gets_403_on_reads(client, receiver):
|
||||
client.app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER
|
||||
)
|
||||
assert client.get("/v1/traces").status_code == 403
|
||||
receiver.list_traces.assert_not_called()
|
||||
|
||||
|
||||
def test_view_only_admin_cannot_ingest_traces(client, receiver):
|
||||
client.app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
token="admin-key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
|
||||
)
|
||||
response = client.post("/v1/traces", content=b"{}")
|
||||
assert response.status_code == 403
|
||||
receiver.ingest.assert_not_called()
|
||||
File diff suppressed because one or more lines are too long
313
tests/test_litellm/tracing/test_decode.py
Normal file
313
tests/test_litellm/tracing/test_decode.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""
|
||||
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 gzip
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from google.protobuf.json_format import Parse
|
||||
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()
|
||||
Parse(_fixture_json().decode(), 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_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"]) & decode._HEAVY_ATTRIBUTES
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_encoding", ["gzip", None])
|
||||
def test_gzip_body_by_header_or_magic_bytes(content_encoding):
|
||||
rows = decode_otlp(gzip.compress(_fixture_protobuf()), "application/x-protobuf", content_encoding)
|
||||
assert len(rows) == 6
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 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")
|
||||
117
tests/test_litellm/tracing/test_receiver.py
Normal file
117
tests/test_litellm/tracing/test_receiver.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""
|
||||
Tests for TraceReceiver.ingest (litellm/tracing/receiver.py) with a fake store.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
from litellm.tracing import Tenant, TraceReceiver, TracingPayloadTooLargeError
|
||||
from litellm.tracing import receiver as receiver_module
|
||||
from litellm.tracing.types import TraceScope
|
||||
|
||||
pytestmark = pytest.mark.requires_rust_extension
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "langsmith_deep_agent_export.json"
|
||||
TENANT = Tenant(team_id="team-research", api_key_hash="hashed-key", org_id="org-1")
|
||||
|
||||
|
||||
def _fake_store() -> MagicMock:
|
||||
store = MagicMock()
|
||||
store.insert_spans = AsyncMock()
|
||||
store.get_trace = AsyncMock(return_value=None)
|
||||
return store
|
||||
|
||||
|
||||
def _spoofed_export() -> bytes:
|
||||
"""A client that tries to claim another team via resource attributes."""
|
||||
resource_spans = ResourceSpans(scope_spans=[ScopeSpans(spans=[Span(trace_id=b"\x01" * 16, span_id=b"\x02" * 8)])])
|
||||
resource_spans.resource.attributes.extend(
|
||||
[
|
||||
KeyValue(key="service.name", value=AnyValue(string_value="svc")),
|
||||
KeyValue(key="litellm.team_id", value=AnyValue(string_value="someone-elses-team")),
|
||||
KeyValue(key="litellm.api_key_hash", value=AnyValue(string_value="someone-elses-key")),
|
||||
]
|
||||
)
|
||||
return ExportTraceServiceRequest(resource_spans=[resource_spans]).SerializeToString()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_returns_span_count_and_writes_stamped_rows():
|
||||
store = _fake_store()
|
||||
count = await TraceReceiver(store).ingest(FIXTURE.read_bytes(), "application/json", None, TENANT)
|
||||
assert count == 6
|
||||
(rows,) = store.insert_spans.await_args.args
|
||||
assert len(rows) == 6
|
||||
for row in rows:
|
||||
assert (row["TeamId"], row["ApiKeyHash"]) == ("team-research", "hashed-key")
|
||||
assert row["ResourceAttributes"]["litellm.org_id"] == "org-1"
|
||||
assert row["ResourceAttributes"]["service.name"] == "agent-demo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_overwrites_client_supplied_tenant_attributes():
|
||||
store = _fake_store()
|
||||
await TraceReceiver(store).ingest(_spoofed_export(), "application/x-protobuf", None, TENANT)
|
||||
((row,),) = store.insert_spans.await_args.args
|
||||
assert row["TeamId"] == "team-research"
|
||||
assert row["ResourceAttributes"]["litellm.team_id"] == "team-research"
|
||||
assert row["ResourceAttributes"]["litellm.api_key_hash"] == "hashed-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_does_not_acknowledge_failed_clickhouse_write():
|
||||
store = _fake_store()
|
||||
store.insert_spans.side_effect = RuntimeError("ClickHouse unavailable")
|
||||
with pytest.raises(RuntimeError, match="ClickHouse unavailable"):
|
||||
await TraceReceiver(store).ingest(FIXTURE.read_bytes(), "application/json", None, TENANT)
|
||||
store.insert_spans.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_rejects_oversized_encoded_batch():
|
||||
store = _fake_store()
|
||||
store.insert_spans.side_effect = OverflowError("ClickHouse insert exceeds the encoded size limit")
|
||||
with pytest.raises(TracingPayloadTooLargeError, match="encoded size limit"):
|
||||
await TraceReceiver(store).ingest(FIXTURE.read_bytes(), "application/json", None, TENANT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_rejects_oversized_body():
|
||||
store = _fake_store()
|
||||
with patch.object(receiver_module, "OTLP_MAX_BODY_BYTES", 10):
|
||||
with pytest.raises(TracingPayloadTooLargeError):
|
||||
await TraceReceiver(store).ingest(FIXTURE.read_bytes(), "application/json", None, TENANT)
|
||||
store.insert_spans.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_body_is_decoded_off_the_event_loop():
|
||||
store = _fake_store()
|
||||
with (
|
||||
patch.object(receiver_module, "OTLP_OFFLOAD_DECODE_BYTES", 0),
|
||||
patch.object(receiver_module.asyncio, "to_thread", wraps=receiver_module.asyncio.to_thread) as to_thread,
|
||||
):
|
||||
count = await TraceReceiver(store).ingest(FIXTURE.read_bytes(), "application/json", None, TENANT)
|
||||
assert count == 6
|
||||
to_thread.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_export_writes_nothing():
|
||||
store = _fake_store()
|
||||
assert await TraceReceiver(store).ingest(b"", "application/x-protobuf", None, TENANT) == 0
|
||||
store.insert_spans.assert_awaited_once_with(())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_delegate_to_store():
|
||||
store = _fake_store()
|
||||
tracing = TraceReceiver(store)
|
||||
scope: TraceScope = {"team_ids": ("team-research",), "api_key_hash": ""}
|
||||
assert await tracing.get_trace("t1", scope) is None
|
||||
store.get_trace.assert_awaited_once_with("t1", scope, "")
|
||||
311
tests/test_litellm/tracing/test_store.py
Normal file
311
tests/test_litellm/tracing/test_store.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""
|
||||
Tests for the pure read-side helpers in litellm/tracing/store.py (no ClickHouse needed).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.tracing.store import (
|
||||
ClickHouseTraceStore,
|
||||
agent_nodes,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
span_from_row,
|
||||
trace_from_rows,
|
||||
trace_summary_from_row,
|
||||
)
|
||||
from litellm.tracing.types import TraceScope
|
||||
|
||||
T0 = 1_790_742_989_000_000_000 # ns
|
||||
MS = 1_000_000
|
||||
|
||||
|
||||
def _row(
|
||||
span_id: str,
|
||||
parent: str,
|
||||
name: str,
|
||||
type_: str,
|
||||
agent: str,
|
||||
start_ms: float = 0,
|
||||
duration_ms: float = 10,
|
||||
status: str = "STATUS_CODE_OK",
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"span_id": span_id,
|
||||
"parent_span_id": parent,
|
||||
"name": name,
|
||||
"type": type_,
|
||||
"agent": agent,
|
||||
"status": status,
|
||||
"start_ns": T0 + int(start_ms * MS),
|
||||
"duration_ns": int(duration_ms * MS),
|
||||
"service": "agent-demo",
|
||||
"input_preview": f"input of {name}",
|
||||
"model": "",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"litellm_request_id": "",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _llm_row(span_id: str, parent: str, agent: str, request_id: str, start_ms: float = 1) -> dict:
|
||||
return _row(
|
||||
span_id,
|
||||
parent,
|
||||
"ChatOpenAI",
|
||||
"llm",
|
||||
agent,
|
||||
start_ms=start_ms,
|
||||
duration_ms=100,
|
||||
model="claude-sonnet-4-5",
|
||||
input_tokens=100,
|
||||
output_tokens=20,
|
||||
litellm_request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
def _deep_agent_rows(researcher_invocations: int = 1) -> list[dict[str, Any]]:
|
||||
"""root agent -> llm, task tool -> researcher subagent (N times) -> llm + search_docs tool."""
|
||||
rows = [
|
||||
_row("root", "", "deep_research_agent", "agent", "deep_research_agent", duration_ms=1000),
|
||||
_llm_row("llm-root", "root", "deep_research_agent", "chatcmpl-root"),
|
||||
_row("task", "root", "task", "tool", "deep_research_agent", start_ms=200, duration_ms=700),
|
||||
]
|
||||
for i in range(researcher_invocations):
|
||||
rows += [
|
||||
_row(f"res-{i}", "task", "researcher", "agent", "researcher", start_ms=201, duration_ms=5),
|
||||
_llm_row(f"res-llm-{i}", f"res-{i}", "researcher", f"chatcmpl-res-{i}", start_ms=202),
|
||||
_row(f"res-tool-{i}", f"res-{i}", "search_docs", "tool", "researcher", start_ms=203, duration_ms=1),
|
||||
_row(f"res-mw-{i}", f"res-{i}", "FilesystemMiddleware.wrap_model_call", "framework", "researcher"),
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- trace_from_rows
|
||||
|
||||
|
||||
def test_empty_rows_is_none():
|
||||
assert trace_from_rows("abc", []) is None
|
||||
|
||||
|
||||
def test_llm_response_id_is_preserved_without_spend_enrichment():
|
||||
trace = trace_from_rows("t1", _deep_agent_rows())
|
||||
assert trace is not None
|
||||
spans = {span["span_id"]: span for span in trace["spans"]}
|
||||
assert spans["llm-root"]["litellm_request_id"] == "chatcmpl-root"
|
||||
assert spans["task"]["litellm_request_id"] is None
|
||||
assert "spend" not in trace["summary"]
|
||||
|
||||
|
||||
def test_summary_totals():
|
||||
trace = trace_from_rows("t1", _deep_agent_rows())
|
||||
assert trace is not None
|
||||
summary = trace["summary"]
|
||||
assert summary["trace_id"] == "t1"
|
||||
assert summary["name"] == "deep_research_agent"
|
||||
assert summary["service"] == "agent-demo"
|
||||
assert summary["input_preview"] == "input of deep_research_agent"
|
||||
assert summary["status"] == "ok"
|
||||
assert summary["span_count"] == 7
|
||||
assert summary["agent_count"] == 2
|
||||
assert summary["llm_calls"] == 2
|
||||
assert summary["tool_calls"] == 2
|
||||
assert summary["error_count"] == 0
|
||||
assert (summary["input_tokens"], summary["output_tokens"]) == (200, 40)
|
||||
assert summary["models"] == ("claude-sonnet-4-5",)
|
||||
assert summary["duration_ms"] == 1000
|
||||
assert summary["start_time"].startswith("2026-09-30T")
|
||||
|
||||
|
||||
def test_error_count_counts_error_spans():
|
||||
rows = _deep_agent_rows()
|
||||
rows[2]["status"] = "STATUS_CODE_ERROR"
|
||||
trace = trace_from_rows("t1", rows)
|
||||
assert trace is not None
|
||||
assert trace["summary"]["error_count"] == 1
|
||||
assert trace["summary"]["status"] == "ok" # root span status; the UI uses error_count for "failed"
|
||||
assert trace["spans"][2]["status"] == "error"
|
||||
|
||||
|
||||
def test_offsets_are_relative_to_trace_start_in_ms():
|
||||
trace = trace_from_rows("t1", _deep_agent_rows())
|
||||
assert trace is not None
|
||||
spans = {s["span_id"]: s for s in trace["spans"]}
|
||||
assert spans["root"]["start_offset_ms"] == 0
|
||||
assert spans["task"]["start_offset_ms"] == 200
|
||||
assert spans["task"]["duration_ms"] == 700
|
||||
assert spans["root"]["parent_span_id"] is None
|
||||
assert spans["task"]["parent_span_id"] == "root"
|
||||
|
||||
|
||||
def test_span_from_row_optional_fields():
|
||||
span = span_from_row(_row("s", "", "x", "chain", "a", status="STATUS_CODE_UNSET"), T0)
|
||||
assert (span["model"], span["parent_span_id"], span["status"], span["litellm_request_id"]) == (
|
||||
None,
|
||||
None,
|
||||
"unset",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_nodes_parent_and_per_agent_counts():
|
||||
trace = trace_from_rows("t1", _deep_agent_rows())
|
||||
assert trace is not None
|
||||
assert trace["agents"] == (
|
||||
{
|
||||
"name": "deep_research_agent",
|
||||
"parent_agent": None,
|
||||
"invocations": 1,
|
||||
"llm_calls": 1,
|
||||
"tool_calls": 1,
|
||||
"duration_ms": 1000,
|
||||
},
|
||||
{
|
||||
"name": "researcher",
|
||||
"parent_agent": "deep_research_agent",
|
||||
"invocations": 1,
|
||||
"llm_calls": 1,
|
||||
"tool_calls": 1,
|
||||
"duration_ms": 5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_200_subagent_invocations_aggregate_into_one_node():
|
||||
trace = trace_from_rows("t1", _deep_agent_rows(researcher_invocations=200))
|
||||
assert trace is not None
|
||||
assert [a["name"] for a in trace["agents"]] == ["deep_research_agent", "researcher"]
|
||||
researcher = trace["agents"][1]
|
||||
assert researcher["parent_agent"] == "deep_research_agent"
|
||||
assert researcher["invocations"] == 200
|
||||
assert researcher["llm_calls"] == 200
|
||||
assert researcher["tool_calls"] == 200
|
||||
assert researcher["duration_ms"] == pytest.approx(1000)
|
||||
assert trace["summary"]["agent_count"] == 2
|
||||
assert trace["summary"]["span_count"] == 3 + 4 * 200
|
||||
|
||||
|
||||
def test_parent_agent_skips_same_name_ancestors():
|
||||
"""A recursive agent (researcher -> researcher) still reports the nearest *different* agent."""
|
||||
rows = [
|
||||
_row("root", "", "lead", "agent", "lead"),
|
||||
_row("r1", "root", "researcher", "agent", "researcher"),
|
||||
_row("r2", "r1", "researcher", "agent", "researcher"),
|
||||
]
|
||||
spans = [span_from_row(r, T0) for r in rows]
|
||||
nodes = {n["name"]: n for n in agent_nodes(spans)}
|
||||
assert nodes["researcher"]["parent_agent"] == "lead"
|
||||
assert nodes["researcher"]["invocations"] == 2
|
||||
|
||||
|
||||
def test_parent_agent_stops_at_cyclic_parents():
|
||||
rows = [
|
||||
_row("self", "self", "researcher", "agent", "researcher"),
|
||||
_row("first", "second", "researcher", "agent", "researcher"),
|
||||
_row("second", "first", "researcher", "agent", "researcher"),
|
||||
]
|
||||
spans = [span_from_row(row, T0) for row in rows]
|
||||
assert agent_nodes(spans)[0]["parent_agent"] is None
|
||||
|
||||
|
||||
def test_agent_nodes_ignores_spans_of_unknown_agents():
|
||||
spans = [span_from_row(_row("t", "", "tool", "tool", "ghost"), T0)]
|
||||
assert agent_nodes(spans) == ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- list helpers
|
||||
|
||||
|
||||
def test_cursor_round_trip():
|
||||
cursor = encode_cursor(1790742989377, "4bad42b84e9de3ba46fc870185f8f023")
|
||||
assert decode_cursor(cursor) == (1790742989377, "4bad42b84e9de3ba46fc870185f8f023")
|
||||
assert decode_cursor(None) == (0, "")
|
||||
assert decode_cursor("") == (0, "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cursor", ["abc", "bm90LWpzb24=", "WzEsIDJd", "WzAsICJ0Il0="])
|
||||
def test_invalid_cursor_is_rejected(cursor):
|
||||
with pytest.raises(ValueError, match="Invalid trace cursor"):
|
||||
decode_cursor(cursor)
|
||||
|
||||
|
||||
def test_trace_summary_from_row():
|
||||
summary = trace_summary_from_row(
|
||||
{
|
||||
"trace_id": "t1",
|
||||
"name": "deep_research_agent",
|
||||
"service": "agent-demo",
|
||||
"input_preview": "hi",
|
||||
"start_ms": 1790742989377,
|
||||
"duration_ms": 51385,
|
||||
"status": "STATUS_CODE_OK",
|
||||
"span_count": "126",
|
||||
"agent_count": "2",
|
||||
"llm_calls": "7",
|
||||
"tool_calls": "26",
|
||||
"error_count": "1",
|
||||
"input_tokens": "30175",
|
||||
"output_tokens": "2620",
|
||||
"models": ["claude-sonnet-4-5"],
|
||||
}
|
||||
)
|
||||
assert summary["status"] == "ok"
|
||||
assert (summary["span_count"], summary["error_count"]) == (126, 1)
|
||||
assert summary["start_time"] == "2026-09-30T04:36:29.377000+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_traces_sets_next_cursor_on_full_page():
|
||||
client = MagicMock()
|
||||
row = {
|
||||
"trace_id": "t2",
|
||||
"trace_ref": "ref2",
|
||||
"name": "a",
|
||||
"service": "s",
|
||||
"input_preview": "",
|
||||
"start_ms": 1000,
|
||||
"duration_ms": 1,
|
||||
"status": "STATUS_CODE_OK",
|
||||
"span_count": 1,
|
||||
"agent_count": 1,
|
||||
"llm_calls": 0,
|
||||
"tool_calls": 0,
|
||||
"error_count": 0,
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"models": [],
|
||||
}
|
||||
client.query = AsyncMock(return_value=[row, {**row, "trace_id": "t1", "trace_ref": "ref1", "start_ms": 900}])
|
||||
store = ClickHouseTraceStore(client)
|
||||
scope: TraceScope = {"team_ids": ("team-a",), "api_key_hash": ""}
|
||||
|
||||
page = await store.list_traces(scope, 0, 2000, limit=2)
|
||||
assert [t["trace_id"] for t in page["data"]] == ["t2", "t1"]
|
||||
assert page["next_cursor"] is not None
|
||||
assert decode_cursor(page["next_cursor"]) == (900, "ref1")
|
||||
params = client.query.call_args.args[1]
|
||||
assert params["team_ids"] == ("team-a",) and params["limit"] == 2 and params["cursor_ms"] == 0
|
||||
|
||||
page = await store.list_traces(scope, 0, 2000, cursor=page["next_cursor"], limit=3)
|
||||
assert page["next_cursor"] is None
|
||||
assert client.query.call_args.args[1]["cursor_trace_id"] == "ref1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_span_not_found_and_found():
|
||||
client = MagicMock()
|
||||
client.query = AsyncMock(return_value=[])
|
||||
store = ClickHouseTraceStore(client)
|
||||
scope: TraceScope = {"team_ids": (), "api_key_hash": ""}
|
||||
assert await store.get_span("t", "s", scope) is None
|
||||
client.query = AsyncMock(return_value=[{"span_id": "s", "input": "i", "output": "o", "attributes": {"k": "v"}}])
|
||||
assert await store.get_span("t", "s", scope) == {
|
||||
"span_id": "s",
|
||||
"input": "i",
|
||||
"output": "o",
|
||||
"attributes": {"k": "v"},
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import base64
|
||||
import gzip
|
||||
import json
|
||||
from typing import Final
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.rust_bridge.traces import encode_rows, ensure_schema, query
|
||||
from litellm.rust_bridge._native import NativeTraceStorage
|
||||
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
|
||||
|
||||
pytestmark = pytest.mark.requires_rust_extension
|
||||
|
|
@ -14,14 +15,9 @@ pytestmark = pytest.mark.requires_rust_extension
|
|||
@pytest.mark.asyncio
|
||||
async def test_trace_reader_projects_connection_and_parameters(recording_server: RecordingServer) -> None:
|
||||
recording_server.enqueue(ResponseSpec(body={"data": [{"trace_id": "trace-1"}]}))
|
||||
rows: Final = await query(
|
||||
recording_server.base_url + "?database=wrong&user=wrong&password=wrong",
|
||||
"trace_test",
|
||||
"reader",
|
||||
"p@ss/word%",
|
||||
"SELECT {trace_id:String} AS trace_id",
|
||||
{"trace_id": "trace-1"},
|
||||
)
|
||||
reader_url: Final = recording_server.base_url.replace("http://", "http://reader:p%40ss%2Fword%25@")
|
||||
storage: Final = NativeTraceStorage("trace_test", recording_server.base_url, reader_url + "?database=wrong")
|
||||
rows: Final = json.loads(await storage.query("SELECT {trace_id:String} AS trace_id", {"trace_id": "trace-1"}))
|
||||
request: Final = recording_server.requests[0]
|
||||
parameters: Final = parse_qs(urlsplit(request.path).query)
|
||||
assert rows == [{"trace_id": "trace-1"}]
|
||||
|
|
@ -37,15 +33,22 @@ async def test_trace_reader_projects_connection_and_parameters(recording_server:
|
|||
@pytest.mark.asyncio
|
||||
async def test_trace_reader_rejects_success_status_with_embedded_error(recording_server: RecordingServer) -> None:
|
||||
recording_server.enqueue(ResponseSpec(body={"data": [], "exception": "query failed"}))
|
||||
storage: Final = NativeTraceStorage("trace_test", recording_server.base_url, recording_server.base_url)
|
||||
with pytest.raises(RuntimeError, match="invalid or failed JSON"):
|
||||
await query(recording_server.base_url, "trace_test", "reader", "password", "SELECT 1", {})
|
||||
await storage.query("SELECT 1", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("database,retention", [("db; DROP DATABASE default", 7), ("traces", 0)])
|
||||
async def test_schema_binding_preserves_configuration_validation(database: str, retention: int) -> None:
|
||||
async def test_schema_binding_rejects_invalid_database() -> None:
|
||||
with pytest.raises(ValueError, match=r"database.*retention"):
|
||||
await ensure_schema("http://localhost:8123", database, "writer", "password", retention, 14)
|
||||
NativeTraceStorage("db; DROP DATABASE default", "http://localhost:8123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schema_binding_rejects_non_positive_retention() -> None:
|
||||
storage: Final = NativeTraceStorage("traces", "http://localhost:8123")
|
||||
with pytest.raises(ValueError, match=r"database.*retention"):
|
||||
await storage.ensure_schema(0, 14)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -53,15 +56,10 @@ async def test_schema_setup_uses_writer_credentials_and_rejects_failed_statement
|
|||
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 = NativeTraceStorage("trace_test", writer_url + "?database=wrong&readonly=1")
|
||||
with pytest.raises(RuntimeError, match="schema setup failed with HTTP status 403"):
|
||||
await ensure_schema(
|
||||
recording_server.base_url + "?database=wrong&readonly=1",
|
||||
"trace_test",
|
||||
"writer",
|
||||
"p@ss/word%",
|
||||
7,
|
||||
14,
|
||||
)
|
||||
await storage.ensure_schema(7, 14)
|
||||
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")
|
||||
|
|
@ -71,8 +69,15 @@ async def test_schema_setup_uses_writer_credentials_and_rejects_failed_statement
|
|||
).decode()
|
||||
|
||||
|
||||
def test_insert_encoding_preserves_nanoseconds_through_bridge() -> None:
|
||||
assert json.loads(encode_rows([{"Timestamp": 1_234_567_890, "Input": "hello"}])) == {
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_encodes_and_sends_rows(recording_server: RecordingServer) -> None:
|
||||
recording_server.enqueue(ResponseSpec(body=""))
|
||||
storage: Final = NativeTraceStorage("trace_test", recording_server.base_url)
|
||||
await storage.insert_rows("otel_traces", [{"Timestamp": 1_234_567_890, "Input": "hello"}])
|
||||
request: Final = recording_server.requests[0]
|
||||
assert json.loads(gzip.decompress(request.raw_body)) == {
|
||||
"Input": "hello",
|
||||
"Timestamp": "1970-01-01T00:00:01.23456789Z",
|
||||
}
|
||||
assert parse_qs(urlsplit(request.path).query)["query"] == ["INSERT INTO `trace_test`.otel_traces FORMAT JSONEachRow"]
|
||||
assert request.headers["content-encoding"] == "gzip"
|
||||
|
|
|
|||
82
ui/litellm-dashboard/package-lock.json
generated
82
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
|||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.31.0",
|
||||
"next": "16.3.3",
|
||||
"next": "16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.9.4",
|
||||
"openai": "4.104.0",
|
||||
|
|
@ -2061,9 +2061,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.3.tgz",
|
||||
"integrity": "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz",
|
||||
"integrity": "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
|
|
@ -2078,9 +2078,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.3.tgz",
|
||||
"integrity": "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz",
|
||||
"integrity": "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2094,9 +2094,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.3.tgz",
|
||||
"integrity": "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz",
|
||||
"integrity": "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2110,9 +2110,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.3.tgz",
|
||||
"integrity": "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz",
|
||||
"integrity": "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2129,9 +2129,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.3.tgz",
|
||||
"integrity": "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz",
|
||||
"integrity": "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2148,9 +2148,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.3.tgz",
|
||||
"integrity": "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz",
|
||||
"integrity": "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2167,9 +2167,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.3.tgz",
|
||||
"integrity": "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz",
|
||||
"integrity": "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2186,9 +2186,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.3.tgz",
|
||||
"integrity": "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz",
|
||||
"integrity": "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2202,9 +2202,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.3.tgz",
|
||||
"integrity": "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz",
|
||||
"integrity": "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -9712,12 +9712,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.3.tgz",
|
||||
"integrity": "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==",
|
||||
"version": "16.3.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.6.tgz",
|
||||
"integrity": "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.3.3",
|
||||
"@next/env": "16.3.6",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
|
|
@ -9731,15 +9731,15 @@
|
|||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.3.3",
|
||||
"@next/swc-darwin-x64": "16.3.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.3",
|
||||
"@next/swc-linux-arm64-musl": "16.3.3",
|
||||
"@next/swc-linux-x64-gnu": "16.3.3",
|
||||
"@next/swc-linux-x64-musl": "16.3.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.3",
|
||||
"@next/swc-win32-x64-msvc": "16.3.3",
|
||||
"sharp": "^0.35.3"
|
||||
"@next/swc-darwin-arm64": "16.3.6",
|
||||
"@next/swc-darwin-x64": "16.3.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.6",
|
||||
"@next/swc-linux-arm64-musl": "16.3.6",
|
||||
"@next/swc-linux-x64-gnu": "16.3.6",
|
||||
"@next/swc-linux-x64-musl": "16.3.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.6",
|
||||
"@next/swc-win32-x64-msvc": "16.3.6",
|
||||
"sharp": "^0.35.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.31.0",
|
||||
"next": "16.3.3",
|
||||
"next": "16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.9.4",
|
||||
"openai": "4.104.0",
|
||||
|
|
|
|||
|
|
@ -2121,12 +2121,21 @@ export const agentTraceListCall = async ({
|
|||
return apiClient.get<TracePage>(`/v1/traces`, { accessToken, query });
|
||||
};
|
||||
|
||||
export const agentTraceCall = async (accessToken: string, traceId: string): Promise<Trace> =>
|
||||
apiClient.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`, { accessToken });
|
||||
export const agentTraceCall = async (accessToken: string, traceId: string, traceRef?: string): Promise<Trace> =>
|
||||
apiClient.get<Trace>(`/v1/traces/${encodeURIComponent(traceId)}`, {
|
||||
accessToken,
|
||||
query: { trace_ref: traceRef || undefined },
|
||||
});
|
||||
|
||||
export const agentTraceSpanCall = async (accessToken: string, traceId: string, spanId: string): Promise<SpanDetail> =>
|
||||
export const agentTraceSpanCall = async (
|
||||
accessToken: string,
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
traceRef?: string,
|
||||
): Promise<SpanDetail> =>
|
||||
apiClient.get<SpanDetail>(`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`, {
|
||||
accessToken,
|
||||
query: { trace_ref: traceRef || undefined },
|
||||
});
|
||||
|
||||
export const adminSpendLogsCall = async (accessToken: string) => {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function AgentTracesSection({
|
|||
timeControls,
|
||||
onRunOpenChange,
|
||||
}: AgentTracesSectionProps) {
|
||||
const [openTraceId, setOpenTraceId] = useState<string | null>(null);
|
||||
const [openTrace, setOpenTrace] = useState<TraceSummary | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [service, setService] = useState(ALL_SERVICES);
|
||||
const [status, setStatus] = useState<RunStatusFilter>("all");
|
||||
|
|
@ -96,9 +96,9 @@ export function AgentTracesSection({
|
|||
apply(hours);
|
||||
};
|
||||
|
||||
const openRun = (traceId: string | null) => {
|
||||
setOpenTraceId(traceId);
|
||||
onRunOpenChange?.(traceId !== null);
|
||||
const openRun = (trace: TraceSummary | null) => {
|
||||
setOpenTrace(trace);
|
||||
onRunOpenChange?.(trace !== null);
|
||||
};
|
||||
|
||||
if (traces.notEnabledDetail !== null) return <TracingSetupCard detail={traces.notEnabledDetail} />;
|
||||
|
|
@ -120,8 +120,15 @@ export function AgentTracesSection({
|
|||
);
|
||||
}
|
||||
|
||||
if (openTraceId !== null) {
|
||||
return <RunView traceId={openTraceId} accessToken={accessToken} onBack={() => openRun(null)} />;
|
||||
if (openTrace !== null) {
|
||||
return (
|
||||
<RunView
|
||||
traceId={openTrace.trace_id}
|
||||
traceRef={openTrace.trace_ref}
|
||||
accessToken={accessToken}
|
||||
onBack={() => openRun(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface AgentTracesTableProps {
|
|||
error: Error | null;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
onOpenTrace: (traceId: string) => void;
|
||||
onOpenTrace: (trace: TraceSummary) => void;
|
||||
}
|
||||
|
||||
/** Spend is only on summaries once the spend-enrichment PR lands; show Cost when it's there. */
|
||||
|
|
@ -82,9 +82,9 @@ export function AgentTracesTable({
|
|||
<tbody>
|
||||
{traces.map((run) => (
|
||||
<tr
|
||||
key={run.trace_id}
|
||||
key={run.trace_ref || run.trace_id}
|
||||
data-testid="agent-trace-row"
|
||||
onClick={() => onOpenTrace(run.trace_id)}
|
||||
onClick={() => onOpenTrace(run)}
|
||||
className="h-9 cursor-pointer border-b border-border/60 text-[12px] hover:bg-accent/50"
|
||||
>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ export const errorHeadline = (error: string): string =>
|
|||
const errorReason = (headline: string): string => /^([A-Za-z_][\w.]*)\(/.exec(headline)?.[1] ?? "error";
|
||||
|
||||
/** Shared lazy fetch of one span's full input / output / attributes. */
|
||||
export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null) {
|
||||
export function useSpanDetail(accessToken: string, traceId: string, spanId: string | null, traceRef?: string) {
|
||||
const queryOptions: UseQueryOptions<SpanDetail, Error> = {
|
||||
queryKey: ["agentTraceSpan", traceId, spanId, accessToken],
|
||||
queryFn: () => agentTraceSpanCall(accessToken, traceId, spanId as string),
|
||||
queryKey: ["agentTraceSpan", traceId, traceRef, spanId, accessToken],
|
||||
queryFn: () => agentTraceSpanCall(accessToken, traceId, spanId as string, traceRef),
|
||||
enabled: spanId !== null,
|
||||
staleTime: Infinity,
|
||||
};
|
||||
|
|
@ -121,12 +121,13 @@ function Payload({ label, value, mono }: { label: string; value: string; mono: b
|
|||
interface DetailContentProps {
|
||||
accessToken: string;
|
||||
traceId: string;
|
||||
traceRef?: string;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
/** Content tab: the error first (if any), then what went in and what came out. */
|
||||
export function DetailContent({ accessToken, traceId, span }: DetailContentProps) {
|
||||
const detailQuery = useSpanDetail(accessToken, traceId, span.span_id);
|
||||
export function DetailContent({ accessToken, traceId, traceRef, span }: DetailContentProps) {
|
||||
const detailQuery = useSpanDetail(accessToken, traceId, span.span_id, traceRef);
|
||||
const detail = detailQuery.data;
|
||||
const isTool = span.type === "tool";
|
||||
const empty = detail && !detail.input && !detail.output;
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ describe("DetailPane", () => {
|
|||
expect(screen.getByRole("tab", { name: "Attributes" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("You are a LiteLLM support agent.")).toBeInTheDocument();
|
||||
expect(screen.getByText("get_customer_plan")).toBeInTheDocument();
|
||||
expect(vi.mocked(agentTraceSpanCall)).toHaveBeenCalledWith("sk-test", "t1", "llm1");
|
||||
expect(vi.mocked(agentTraceSpanCall)).toHaveBeenCalledWith("sk-test", "t1", "llm1", undefined);
|
||||
});
|
||||
|
||||
it("shows a tool failure as 'Tool · <reason>' with the exception line and no traceback", async () => {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,12 @@ function SpanPane({
|
|||
}) {
|
||||
const [tab, setTab] = useState<Tab>("content");
|
||||
const traceId = trace.summary.trace_id;
|
||||
const detailQuery = useSpanDetail(accessToken, traceId, tab === "attributes" ? span.span_id : null);
|
||||
const detailQuery = useSpanDetail(
|
||||
accessToken,
|
||||
traceId,
|
||||
tab === "attributes" ? span.span_id : null,
|
||||
trace.summary.trace_ref,
|
||||
);
|
||||
const tokens = span.input_tokens + span.output_tokens;
|
||||
return (
|
||||
<aside className="flex h-full min-w-0 flex-col bg-background" aria-label="Span details">
|
||||
|
|
@ -103,7 +108,9 @@ function SpanPane({
|
|||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{tab === "content" && <DetailContent accessToken={accessToken} traceId={traceId} span={span} />}
|
||||
{tab === "content" && (
|
||||
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
|
||||
)}
|
||||
{tab === "request" && (
|
||||
<RequestDetail span={span} accessToken={accessToken} traceStartMs={Date.parse(trace.summary.start_time)} />
|
||||
)}
|
||||
|
|
@ -117,7 +124,11 @@ function SpanPane({
|
|||
)}
|
||||
</div>
|
||||
<PaneFooter>
|
||||
<CopyButton value={agentHandoffText(traceId, span.span_id)} label="Copy step" copiedLabel="Command copied" />
|
||||
<CopyButton
|
||||
value={agentHandoffText(traceId, span.span_id, trace.summary.trace_ref)}
|
||||
label="Copy step"
|
||||
copiedLabel="Command copied"
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-3 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<Meta label="time" value={fmtMs(span.duration_ms)} />
|
||||
{tokens > 0 && <Meta label="tokens" value={fmtTok(tokens)} />}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import {
|
|||
} from "./traceUtils";
|
||||
|
||||
/** What "Copy for agent" puts on the clipboard: a one-liner Claude Code / Codex can run. */
|
||||
export const agentHandoffText = (traceId: string, spanId?: string | null): string => {
|
||||
const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}`;
|
||||
export const agentHandoffText = (traceId: string, spanId?: string | null, traceRef?: string): string => {
|
||||
const url = `${getProxyBaseUrl().replace(/\/$/, "")}/v1/traces/${traceId}?format=md${spanId ? `&span_id=${spanId}` : ""}${traceRef ? `&trace_ref=${traceRef}` : ""}`;
|
||||
const what = spanId ? "this step of a LiteLLM agent trace" : "this LiteLLM agent trace";
|
||||
return `Read ${what} and explain what happened and why it failed:\ncurl -s -H "Authorization: Bearer $LITELLM_API_KEY" "${url}"`;
|
||||
};
|
||||
|
|
@ -60,7 +60,7 @@ const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
|
|||
return next;
|
||||
};
|
||||
|
||||
function CopyForAgent({ traceId }: { traceId: string }) {
|
||||
function CopyForAgent({ traceId, traceRef }: { traceId: string; traceRef?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
|
|
@ -72,7 +72,9 @@ function CopyForAgent({ traceId }: { traceId: string }) {
|
|||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 gap-1.5 rounded-[4px] font-mono text-[10px] shadow-none"
|
||||
onClick={async () => setCopied(await copyToClipboard(agentHandoffText(traceId), "Command copied"))}
|
||||
onClick={async () =>
|
||||
setCopied(await copyToClipboard(agentHandoffText(traceId, null, traceRef), "Command copied"))
|
||||
}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? "Command copied" : "Copy for agent"}
|
||||
|
|
@ -121,7 +123,7 @@ function RunHeader({ trace, onBack }: { trace: Trace; onBack: () => void }) {
|
|||
{failed && <Stat label="failed" value={summary.error_count.toLocaleString()} error />}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<CopyForAgent traceId={summary.trace_id} />
|
||||
<CopyForAgent traceId={summary.trace_id} traceRef={summary.trace_ref} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
@ -222,15 +224,16 @@ function RunBody({ trace, accessToken }: { trace: Trace; accessToken: string })
|
|||
|
||||
interface RunViewProps {
|
||||
traceId: string;
|
||||
traceRef?: string;
|
||||
accessToken: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** One agent run: header with totals and "Copy for agent", span tree on the left, span details on the right. */
|
||||
export function RunView({ traceId, accessToken, onBack }: RunViewProps) {
|
||||
export function RunView({ traceId, traceRef, accessToken, onBack }: RunViewProps) {
|
||||
const traceQuery = useQuery({
|
||||
queryKey: ["agentTrace", traceId, accessToken],
|
||||
queryFn: () => agentTraceCall(accessToken, traceId),
|
||||
queryKey: ["agentTrace", traceId, traceRef, accessToken],
|
||||
queryFn: () => agentTraceCall(accessToken, traceId, traceRef),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const trace = traceQuery.data;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface AgentNode {
|
|||
|
||||
export interface TraceSummary {
|
||||
trace_id: string;
|
||||
trace_ref?: string;
|
||||
name: string;
|
||||
service: string;
|
||||
input_preview: string;
|
||||
|
|
|
|||
170
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
170
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -21262,6 +21262,58 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/traces": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Agent Traces */
|
||||
get: operations["list_agent_traces_v1_traces_get"];
|
||||
put?: never;
|
||||
/** Ingest Otlp Traces */
|
||||
post: operations["ingest_otlp_traces_v1_traces_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/traces/{trace_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Agent Trace */
|
||||
get: operations["get_agent_trace_v1_traces__trace_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/traces/{trace_id}/spans/{span_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Agent Trace Span */
|
||||
get: operations["get_agent_trace_span_v1_traces__trace_id__spans__span_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/unified_access_group": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -74649,6 +74701,124 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_agent_traces_v1_traces_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Window start, unix ms. Default: 24h ago */
|
||||
start_ms?: number | null;
|
||||
/** @description Window end, unix ms. Default: now */
|
||||
end_ms?: number | null;
|
||||
cursor?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ingest_otlp_traces_v1_traces_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_agent_trace_v1_traces__trace_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
trace_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_agent_trace_span_v1_traces__trace_id__spans__span_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_access_groups_v1_unified_access_group_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
12
uv.lock
generated
12
uv.lock
generated
|
|
@ -7857,14 +7857,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.14.0"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -10196,11 +10196,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue