use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use argentor_agent::debug_recorder::{DebugTrace, StepType};
const INPUT_COST_PER_1K: f64 = 0.003;
const OUTPUT_COST_PER_1K: f64 = 0.015;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceSummary {
pub trace_id: String,
pub agent_role: Option<String>,
pub session_id: Option<String>,
pub started_at: DateTime<Utc>,
pub duration_ms: u64,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub step_count: usize,
pub has_errors: bool,
pub status: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TraceFilter {
pub agent_role: Option<String>,
pub session_id: Option<String>,
pub tenant_id: Option<String>,
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub has_errors: Option<bool>,
pub min_duration_ms: Option<u64>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
impl TraceFilter {
fn effective_limit(&self) -> usize {
self.limit.unwrap_or(50)
}
fn effective_offset(&self) -> usize {
self.offset.unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostBreakdown {
pub trace_id: String,
pub total_cost_usd: f64,
pub total_tokens: u64,
pub steps: Vec<StepCost>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepCost {
pub step_name: String,
pub step_type: String,
pub tokens_in: u64,
pub tokens_out: u64,
pub cost_usd: f64,
pub duration_ms: u64,
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceTimeline {
pub trace_id: String,
pub total_duration_ms: u64,
pub lanes: Vec<TimelineLane>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineLane {
pub name: String,
pub start_offset_ms: u64,
pub duration_ms: u64,
pub step_type: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct TraceStore {
inner: Arc<RwLock<TraceStoreInner>>,
}
#[derive(Debug)]
struct TraceStoreInner {
traces: Vec<DebugTrace>,
max_traces: usize,
}
impl TraceStore {
pub fn new(max_traces: usize) -> Self {
Self {
inner: Arc::new(RwLock::new(TraceStoreInner {
traces: Vec::new(),
max_traces,
})),
}
}
pub async fn store_trace(&self, trace: DebugTrace) {
let mut inner = self.inner.write().await;
if inner.traces.len() >= inner.max_traces {
inner.traces.remove(0);
}
inner.traces.push(trace);
}
pub async fn get_trace(&self, trace_id: &str) -> Option<DebugTrace> {
let inner = self.inner.read().await;
inner
.traces
.iter()
.find(|t| t.trace_id == trace_id)
.cloned()
}
pub async fn list_traces(&self, filter: &TraceFilter) -> Vec<TraceSummary> {
let inner = self.inner.read().await;
inner
.traces
.iter()
.filter(|t| matches_filter(t, filter))
.map(to_summary)
.skip(filter.effective_offset())
.take(filter.effective_limit())
.collect()
}
pub async fn search_traces(&self, query: &TraceFilter) -> Vec<TraceSummary> {
self.list_traces(query).await
}
pub async fn get_cost_breakdown(&self, trace_id: &str) -> Option<CostBreakdown> {
let trace = self.get_trace(trace_id).await?;
Some(compute_cost_breakdown(&trace))
}
pub async fn delete_trace(&self, trace_id: &str) -> bool {
let mut inner = self.inner.write().await;
let before = inner.traces.len();
inner.traces.retain(|t| t.trace_id != trace_id);
inner.traces.len() < before
}
pub async fn len(&self) -> usize {
self.inner.read().await.traces.len()
}
pub async fn is_empty(&self) -> bool {
self.inner.read().await.traces.is_empty()
}
}
fn extract_metadata_str(trace: &DebugTrace, key: &str) -> Option<String> {
trace
.metadata
.get(key)
.and_then(|v| v.as_str())
.map(String::from)
}
fn compute_status(trace: &DebugTrace) -> String {
if trace.steps.iter().any(|s| s.step_type == StepType::Error) {
"failed".to_string()
} else if trace.ended_at.is_some() {
"completed".to_string()
} else {
"in_progress".to_string()
}
}
fn compute_total_cost(trace: &DebugTrace) -> f64 {
let input_cost = trace.total_tokens.input as f64 / 1000.0 * INPUT_COST_PER_1K;
let output_cost = trace.total_tokens.output as f64 / 1000.0 * OUTPUT_COST_PER_1K;
input_cost + output_cost
}
fn to_summary(trace: &DebugTrace) -> TraceSummary {
TraceSummary {
trace_id: trace.trace_id.clone(),
agent_role: extract_metadata_str(trace, "agent_role"),
session_id: extract_metadata_str(trace, "session_id"),
started_at: trace.started_at,
duration_ms: trace.total_duration_ms.unwrap_or(0),
total_tokens: trace.total_tokens.input + trace.total_tokens.output,
total_cost_usd: compute_total_cost(trace),
step_count: trace.steps.len(),
has_errors: trace.steps.iter().any(|s| s.step_type == StepType::Error),
status: compute_status(trace),
}
}
fn matches_filter(trace: &DebugTrace, filter: &TraceFilter) -> bool {
if let Some(ref role) = filter.agent_role {
let trace_role = extract_metadata_str(trace, "agent_role");
if trace_role.as_deref() != Some(role.as_str()) {
return false;
}
}
if let Some(ref sid) = filter.session_id {
let trace_sid = extract_metadata_str(trace, "session_id");
if trace_sid.as_deref() != Some(sid.as_str()) {
return false;
}
}
if let Some(ref tid) = filter.tenant_id {
let trace_tid = extract_metadata_str(trace, "tenant_id");
if trace_tid.as_deref() != Some(tid.as_str()) {
return false;
}
}
if let Some(from) = filter.from {
if trace.started_at < from {
return false;
}
}
if let Some(to) = filter.to {
if trace.started_at > to {
return false;
}
}
if let Some(has_errors) = filter.has_errors {
let trace_has = trace.steps.iter().any(|s| s.step_type == StepType::Error);
if trace_has != has_errors {
return false;
}
}
if let Some(min_dur) = filter.min_duration_ms {
let dur = trace.total_duration_ms.unwrap_or(0);
if dur < min_dur {
return false;
}
}
true
}
fn step_type_str(st: &StepType) -> String {
match st {
StepType::Input => "input".to_string(),
StepType::Thinking => "thinking".to_string(),
StepType::Decision => "decision".to_string(),
StepType::ToolCall => "tool_call".to_string(),
StepType::ToolResult => "tool_result".to_string(),
StepType::LlmCall => "llm_call".to_string(),
StepType::LlmResponse => "llm_response".to_string(),
StepType::CacheHit => "cache_hit".to_string(),
StepType::Error => "error".to_string(),
StepType::Output => "output".to_string(),
StepType::Custom(s) => s.clone(),
}
}
fn compute_cost_breakdown(trace: &DebugTrace) -> CostBreakdown {
let mut steps = Vec::new();
let mut total_cost = 0.0;
let mut total_tokens = 0u64;
for step in &trace.steps {
let (tokens_in, tokens_out) = step
.tokens
.as_ref()
.map(|t| (t.input, t.output))
.unwrap_or((0, 0));
let cost = tokens_in as f64 / 1000.0 * INPUT_COST_PER_1K
+ tokens_out as f64 / 1000.0 * OUTPUT_COST_PER_1K;
total_cost += cost;
total_tokens += tokens_in + tokens_out;
let model = step
.data
.as_ref()
.and_then(|d| d.get("model"))
.and_then(|v| v.as_str())
.map(String::from);
steps.push(StepCost {
step_name: step.description.clone(),
step_type: step_type_str(&step.step_type),
tokens_in,
tokens_out,
cost_usd: cost,
duration_ms: step.duration_ms.unwrap_or(0),
model,
});
}
CostBreakdown {
trace_id: trace.trace_id.clone(),
total_cost_usd: total_cost,
total_tokens,
steps,
}
}
fn compute_timeline(trace: &DebugTrace) -> TraceTimeline {
let total_duration = trace.total_duration_ms.unwrap_or(0);
let trace_start = trace.started_at;
let lanes: Vec<TimelineLane> = trace
.steps
.iter()
.map(|step| {
let offset = (step.timestamp - trace_start)
.num_milliseconds()
.unsigned_abs();
let duration = step.duration_ms.unwrap_or(0);
let status = if step.step_type == StepType::Error {
"error"
} else {
"ok"
};
TimelineLane {
name: step.description.clone(),
start_offset_ms: offset,
duration_ms: duration,
step_type: step_type_str(&step.step_type),
status: status.to_string(),
}
})
.collect();
TraceTimeline {
trace_id: trace.trace_id.clone(),
total_duration_ms: total_duration,
lanes,
}
}
#[derive(Debug, Clone)]
pub struct TraceViewerState {
pub store: TraceStore,
}
pub fn trace_viewer_router(state: TraceViewerState) -> Router {
Router::new()
.route("/api/v1/traces", get(list_traces_handler))
.route(
"/api/v1/traces/{trace_id}",
get(get_trace_handler).delete(delete_trace_handler),
)
.route("/api/v1/traces/{trace_id}/cost", get(get_cost_handler))
.route(
"/api/v1/traces/{trace_id}/timeline",
get(get_timeline_handler),
)
.with_state(state)
}
async fn list_traces_handler(
State(state): State<TraceViewerState>,
axum::extract::Query(params): axum::extract::Query<TraceFilter>,
) -> impl IntoResponse {
let summaries = state.store.list_traces(¶ms).await;
axum::Json(summaries).into_response()
}
async fn get_trace_handler(
State(state): State<TraceViewerState>,
Path(trace_id): Path<String>,
) -> impl IntoResponse {
match state.store.get_trace(&trace_id).await {
Some(trace) => axum::Json(trace).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn get_cost_handler(
State(state): State<TraceViewerState>,
Path(trace_id): Path<String>,
) -> impl IntoResponse {
match state.store.get_cost_breakdown(&trace_id).await {
Some(breakdown) => axum::Json(breakdown).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn get_timeline_handler(
State(state): State<TraceViewerState>,
Path(trace_id): Path<String>,
) -> impl IntoResponse {
match state.store.get_trace(&trace_id).await {
Some(trace) => {
let timeline = compute_timeline(&trace);
axum::Json(timeline).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn delete_trace_handler(
State(state): State<TraceViewerState>,
Path(trace_id): Path<String>,
) -> impl IntoResponse {
if state.store.delete_trace(&trace_id).await {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use argentor_agent::debug_recorder::{DebugRecorder, StepType};
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn make_trace(id: &str, agent_role: Option<&str>, session_id: Option<&str>) -> DebugTrace {
let rec = DebugRecorder::new(id);
if let Some(role) = agent_role {
rec.set_metadata("agent_role", serde_json::json!(role));
}
if let Some(sid) = session_id {
rec.set_metadata("session_id", serde_json::json!(sid));
}
rec.record(StepType::Input, "user input", None);
rec.record_with_metrics(StepType::LlmCall, "call model", 100, 500, 100);
rec.record(StepType::Output, "response", None);
rec.finalize()
}
fn make_error_trace(id: &str) -> DebugTrace {
let rec = DebugRecorder::new(id);
rec.record(StepType::Input, "input", None);
rec.record(StepType::Error, "something broke", None);
rec.finalize()
}
fn make_empty_trace(id: &str) -> DebugTrace {
let rec = DebugRecorder::new(id);
rec.finalize()
}
#[tokio::test]
async fn test_store_and_get() {
let store = TraceStore::new(100);
let trace = make_trace("t1", None, None);
store.store_trace(trace).await;
let got = store.get_trace("t1").await;
assert!(got.is_some());
assert_eq!(got.unwrap().trace_id, "t1");
}
#[tokio::test]
async fn test_get_missing() {
let store = TraceStore::new(100);
assert!(store.get_trace("nope").await.is_none());
}
#[tokio::test]
async fn test_delete_trace() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, None)).await;
assert!(store.delete_trace("t1").await);
assert!(store.get_trace("t1").await.is_none());
}
#[tokio::test]
async fn test_delete_missing() {
let store = TraceStore::new(100);
assert!(!store.delete_trace("nope").await);
}
#[tokio::test]
async fn test_eviction() {
let store = TraceStore::new(2);
store.store_trace(make_trace("t1", None, None)).await;
store.store_trace(make_trace("t2", None, None)).await;
store.store_trace(make_trace("t3", None, None)).await;
assert_eq!(store.len().await, 2);
assert!(store.get_trace("t1").await.is_none());
assert!(store.get_trace("t2").await.is_some());
assert!(store.get_trace("t3").await.is_some());
}
#[tokio::test]
async fn test_list_all() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, None)).await;
store.store_trace(make_trace("t2", None, None)).await;
let list = store.list_traces(&TraceFilter::default()).await;
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn test_filter_by_agent_role() {
let store = TraceStore::new(100);
store
.store_trace(make_trace("t1", Some("coder"), None))
.await;
store
.store_trace(make_trace("t2", Some("reviewer"), None))
.await;
store
.store_trace(make_trace("t3", Some("coder"), None))
.await;
let filter = TraceFilter {
agent_role: Some("coder".to_string()),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 2);
assert!(list
.iter()
.all(|s| s.agent_role.as_deref() == Some("coder")));
}
#[tokio::test]
async fn test_filter_by_session() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, Some("s1"))).await;
store.store_trace(make_trace("t2", None, Some("s2"))).await;
let filter = TraceFilter {
session_id: Some("s1".to_string()),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].trace_id, "t1");
}
#[tokio::test]
async fn test_filter_has_errors_true() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, None)).await;
store.store_trace(make_error_trace("t2")).await;
let filter = TraceFilter {
has_errors: Some(true),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].trace_id, "t2");
assert!(list[0].has_errors);
}
#[tokio::test]
async fn test_filter_has_errors_false() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, None)).await;
store.store_trace(make_error_trace("t2")).await;
let filter = TraceFilter {
has_errors: Some(false),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].trace_id, "t1");
}
#[tokio::test]
async fn test_pagination_limit() {
let store = TraceStore::new(100);
for i in 0..10 {
store
.store_trace(make_trace(&format!("t{i}"), None, None))
.await;
}
let filter = TraceFilter {
limit: Some(3),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 3);
}
#[tokio::test]
async fn test_pagination_offset() {
let store = TraceStore::new(100);
for i in 0..5 {
store
.store_trace(make_trace(&format!("t{i}"), None, None))
.await;
}
let filter = TraceFilter {
offset: Some(3),
limit: Some(50),
..Default::default()
};
let list = store.list_traces(&filter).await;
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn test_search_traces() {
let store = TraceStore::new(100);
store
.store_trace(make_trace("t1", Some("coder"), None))
.await;
store
.store_trace(make_trace("t2", Some("reviewer"), None))
.await;
let filter = TraceFilter {
agent_role: Some("reviewer".to_string()),
..Default::default()
};
let results = store.search_traces(&filter).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].trace_id, "t2");
}
#[tokio::test]
async fn test_summary_fields() {
let store = TraceStore::new(100);
store
.store_trace(make_trace("t1", Some("coder"), Some("s42")))
.await;
let list = store.list_traces(&TraceFilter::default()).await;
let s = &list[0];
assert_eq!(s.trace_id, "t1");
assert_eq!(s.agent_role.as_deref(), Some("coder"));
assert_eq!(s.session_id.as_deref(), Some("s42"));
assert_eq!(s.step_count, 3);
assert!(!s.has_errors);
assert_eq!(s.status, "completed");
}
#[tokio::test]
async fn test_error_trace_status() {
let store = TraceStore::new(100);
store.store_trace(make_error_trace("err")).await;
let list = store.list_traces(&TraceFilter::default()).await;
assert_eq!(list[0].status, "failed");
}
#[tokio::test]
async fn test_cost_breakdown() {
let store = TraceStore::new(100);
store.store_trace(make_trace("t1", None, None)).await;
let cb = store.get_cost_breakdown("t1").await.unwrap();
assert_eq!(cb.trace_id, "t1");
assert_eq!(cb.steps.len(), 3);
let llm_step = cb.steps.iter().find(|s| s.step_type == "llm_call").unwrap();
assert_eq!(llm_step.tokens_in, 500);
assert_eq!(llm_step.tokens_out, 100);
assert!(llm_step.cost_usd > 0.0);
assert!(cb.total_cost_usd > 0.0);
}
#[tokio::test]
async fn test_cost_breakdown_missing() {
let store = TraceStore::new(100);
assert!(store.get_cost_breakdown("nope").await.is_none());
}
#[tokio::test]
async fn test_cost_breakdown_empty() {
let store = TraceStore::new(100);
store.store_trace(make_empty_trace("empty")).await;
let cb = store.get_cost_breakdown("empty").await.unwrap();
assert_eq!(cb.total_tokens, 0);
assert!((cb.total_cost_usd - 0.0).abs() < f64::EPSILON);
}
#[tokio::test]
async fn test_timeline() {
let trace = make_trace("t1", None, None);
let tl = compute_timeline(&trace);
assert_eq!(tl.trace_id, "t1");
assert_eq!(tl.lanes.len(), 3);
assert_eq!(tl.lanes[0].step_type, "input");
assert_eq!(tl.lanes[1].step_type, "llm_call");
assert_eq!(tl.lanes[2].step_type, "output");
}
#[tokio::test]
async fn test_timeline_error_status() {
let trace = make_error_trace("e1");
let tl = compute_timeline(&trace);
let error_lane = tl.lanes.iter().find(|l| l.step_type == "error").unwrap();
assert_eq!(error_lane.status, "error");
}
#[tokio::test]
async fn test_len_and_is_empty() {
let store = TraceStore::new(100);
assert!(store.is_empty().await);
assert_eq!(store.len().await, 0);
store.store_trace(make_trace("t1", None, None)).await;
assert!(!store.is_empty().await);
assert_eq!(store.len().await, 1);
}
#[tokio::test]
async fn test_rest_list_traces() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
state.store.store_trace(make_trace("t1", None, None)).await;
let app = trace_viewer_router(state);
let req = Request::builder()
.uri("/api/v1/traces")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let summaries: Vec<TraceSummary> = serde_json::from_slice(&body).unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].trace_id, "t1");
}
#[tokio::test]
async fn test_rest_get_trace() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
state.store.store_trace(make_trace("t1", None, None)).await;
let app = trace_viewer_router(state);
let req = Request::builder()
.uri("/api/v1/traces/t1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let trace: DebugTrace = serde_json::from_slice(&body).unwrap();
assert_eq!(trace.trace_id, "t1");
}
#[tokio::test]
async fn test_rest_get_trace_not_found() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
let app = trace_viewer_router(state);
let req = Request::builder()
.uri("/api/v1/traces/nope")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_rest_cost_endpoint() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
state.store.store_trace(make_trace("t1", None, None)).await;
let app = trace_viewer_router(state);
let req = Request::builder()
.uri("/api/v1/traces/t1/cost")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let cb: CostBreakdown = serde_json::from_slice(&body).unwrap();
assert_eq!(cb.trace_id, "t1");
assert!(!cb.steps.is_empty());
}
#[tokio::test]
async fn test_rest_timeline_endpoint() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
state.store.store_trace(make_trace("t1", None, None)).await;
let app = trace_viewer_router(state);
let req = Request::builder()
.uri("/api/v1/traces/t1/timeline")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1_000_000)
.await
.unwrap();
let tl: TraceTimeline = serde_json::from_slice(&body).unwrap();
assert_eq!(tl.trace_id, "t1");
assert!(!tl.lanes.is_empty());
}
#[tokio::test]
async fn test_rest_delete_trace() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
state.store.store_trace(make_trace("t1", None, None)).await;
let app = trace_viewer_router(state);
let req = Request::builder()
.method("DELETE")
.uri("/api/v1/traces/t1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn test_rest_delete_not_found() {
let state = TraceViewerState {
store: TraceStore::new(100),
};
let app = trace_viewer_router(state);
let req = Request::builder()
.method("DELETE")
.uri("/api/v1/traces/nope")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn test_trace_summary_serializable() {
let s = TraceSummary {
trace_id: "t1".to_string(),
agent_role: Some("coder".to_string()),
session_id: None,
started_at: Utc::now(),
duration_ms: 42,
total_tokens: 600,
total_cost_usd: 0.003,
step_count: 3,
has_errors: false,
status: "completed".to_string(),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"trace_id\":\"t1\""));
let back: TraceSummary = serde_json::from_str(&json).unwrap();
assert_eq!(back.trace_id, "t1");
}
#[test]
fn test_cost_breakdown_serializable() {
let cb = CostBreakdown {
trace_id: "t1".to_string(),
total_cost_usd: 0.01,
total_tokens: 1000,
steps: vec![StepCost {
step_name: "call".to_string(),
step_type: "llm_call".to_string(),
tokens_in: 500,
tokens_out: 500,
cost_usd: 0.01,
duration_ms: 200,
model: Some("claude-3".to_string()),
}],
};
let json = serde_json::to_string(&cb).unwrap();
let back: CostBreakdown = serde_json::from_str(&json).unwrap();
assert_eq!(back.steps.len(), 1);
}
#[test]
fn test_timeline_serializable() {
let tl = TraceTimeline {
trace_id: "t1".to_string(),
total_duration_ms: 500,
lanes: vec![TimelineLane {
name: "step1".to_string(),
start_offset_ms: 0,
duration_ms: 100,
step_type: "input".to_string(),
status: "ok".to_string(),
}],
};
let json = serde_json::to_string(&tl).unwrap();
let back: TraceTimeline = serde_json::from_str(&json).unwrap();
assert_eq!(back.lanes.len(), 1);
}
#[test]
fn test_filter_defaults() {
let f = TraceFilter::default();
assert_eq!(f.effective_limit(), 50);
assert_eq!(f.effective_offset(), 0);
assert!(f.agent_role.is_none());
assert!(f.session_id.is_none());
assert!(f.has_errors.is_none());
}
}