From 5713454d632412af2b2362aa6f5b39517ee714ad Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 20:38:23 +0100 Subject: [PATCH 01/15] feat: add model_pricing table with seed data --- .../migrations/002_model_pricing.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/tracevault-server/migrations/002_model_pricing.sql diff --git a/crates/tracevault-server/migrations/002_model_pricing.sql b/crates/tracevault-server/migrations/002_model_pricing.sql new file mode 100644 index 0000000..435140b --- /dev/null +++ b/crates/tracevault-server/migrations/002_model_pricing.sql @@ -0,0 +1,21 @@ +CREATE TABLE model_pricing ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + model TEXT NOT NULL, + input_per_mtok DOUBLE PRECISION NOT NULL, + output_per_mtok DOUBLE PRECISION NOT NULL, + cache_read_per_mtok DOUBLE PRECISION NOT NULL, + cache_write_per_mtok DOUBLE PRECISION NOT NULL, + effective_from TIMESTAMPTZ NOT NULL, + effective_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_model_pricing_lookup + ON model_pricing(model, effective_from); + +-- Seed with current Anthropic rates +INSERT INTO model_pricing (model, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, effective_from) +VALUES + ('opus', 15.00, 75.00, 1.50, 18.75, '2025-01-01T00:00:00Z'), + ('sonnet', 3.00, 15.00, 0.30, 3.75, '2025-01-01T00:00:00Z'), + ('haiku', 0.80, 4.00, 0.08, 1.00, '2025-01-01T00:00:00Z'); From bc7fa895dc3d0bc3a5a97bb8d6f9efb8d4c8cfb7 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 20:45:58 +0100 Subject: [PATCH 02/15] refactor: pricing module to support DB-backed rates with fallback --- crates/tracevault-server/src/pricing.rs | 170 +++++++++++++++++++----- 1 file changed, 136 insertions(+), 34 deletions(-) diff --git a/crates/tracevault-server/src/pricing.rs b/crates/tracevault-server/src/pricing.rs index 5c830d6..f7b4cb1 100644 --- a/crates/tracevault-server/src/pricing.rs +++ b/crates/tracevault-server/src/pricing.rs @@ -1,43 +1,107 @@ -struct ModelPricing { - input_per_m: f64, - output_per_m: f64, - cache_write_per_m: f64, - cache_read_per_m: f64, -} +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; -const OPUS: ModelPricing = ModelPricing { - input_per_m: 15.0, - output_per_m: 75.0, - cache_write_per_m: 18.75, - cache_read_per_m: 1.50, -}; +#[derive(Debug, Clone, Serialize)] +pub struct ModelPricing { + pub input_per_m: f64, + pub output_per_m: f64, + pub cache_write_per_m: f64, + pub cache_read_per_m: f64, +} -const SONNET: ModelPricing = ModelPricing { +// Hardcoded fallbacks (Sonnet rates) — used when DB lookup fails +const FALLBACK_PRICING: ModelPricing = ModelPricing { input_per_m: 3.0, output_per_m: 15.0, cache_write_per_m: 3.75, cache_read_per_m: 0.30, }; -const HAIKU: ModelPricing = ModelPricing { - input_per_m: 0.80, - output_per_m: 4.0, - cache_write_per_m: 1.00, - cache_read_per_m: 0.08, -}; +/// Look up pricing from model_pricing table. +/// Uses substring matching: "opus" matches "claude-opus-4-6". +/// Uses effective_from/until to match the given timestamp. +/// Falls back to Sonnet rates if no match found. +pub async fn fetch_pricing_for_model( + pool: &PgPool, + model: &str, + at: Option>, +) -> ModelPricing { + let at = at.unwrap_or_else(Utc::now); + let lower = model.to_lowercase(); + + // Determine canonical name for DB lookup + let canonical = if lower.contains("opus") { + "opus" + } else if lower.contains("haiku") { + "haiku" + } else { + "sonnet" + }; + + let row = sqlx::query_as::<_, (f64, f64, f64, f64)>( + "SELECT input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok + FROM model_pricing + WHERE model = $1 + AND effective_from <= $2 + AND (effective_until IS NULL OR effective_until > $2) + ORDER BY effective_from DESC + LIMIT 1", + ) + .bind(canonical) + .bind(at) + .fetch_optional(pool) + .await; + + match row { + Ok(Some((input, output, cache_read, cache_write))) => ModelPricing { + input_per_m: input, + output_per_m: output, + cache_read_per_m: cache_read, + cache_write_per_m: cache_write, + }, + _ => fallback_pricing_for_model(model), + } +} -fn pricing_for_model(model: &str) -> &'static ModelPricing { +/// Synchronous fallback using hardcoded rates. Used by existing code paths +/// that don't have async context (e.g., cost_from_model_usage during ingest). +pub fn fallback_pricing_for_model(model: &str) -> ModelPricing { let lower = model.to_lowercase(); if lower.contains("opus") { - &OPUS + ModelPricing { + input_per_m: 15.0, + output_per_m: 75.0, + cache_write_per_m: 18.75, + cache_read_per_m: 1.50, + } } else if lower.contains("haiku") { - &HAIKU + ModelPricing { + input_per_m: 0.80, + output_per_m: 4.0, + cache_write_per_m: 1.00, + cache_read_per_m: 0.08, + } } else { - &SONNET // default + FALLBACK_PRICING } } -/// Estimate cost in USD from token counts for a single model. +/// Estimate cost in USD from token counts using given pricing. +pub fn estimate_cost_with_pricing( + pricing: &ModelPricing, + input_tokens: i64, + output_tokens: i64, + cache_read_tokens: i64, + cache_write_tokens: i64, +) -> f64 { + (input_tokens as f64 / 1_000_000.0) * pricing.input_per_m + + (output_tokens as f64 / 1_000_000.0) * pricing.output_per_m + + (cache_write_tokens as f64 / 1_000_000.0) * pricing.cache_write_per_m + + (cache_read_tokens as f64 / 1_000_000.0) * pricing.cache_read_per_m +} + +/// Backward-compatible wrapper: estimate cost by model name (uses hardcoded fallback). pub fn estimate_cost( model: &str, input_tokens: i64, @@ -45,22 +109,28 @@ pub fn estimate_cost( cache_read_tokens: i64, cache_write_tokens: i64, ) -> f64 { - let p = pricing_for_model(model); - (input_tokens as f64 / 1_000_000.0) * p.input_per_m - + (output_tokens as f64 / 1_000_000.0) * p.output_per_m - + (cache_write_tokens as f64 / 1_000_000.0) * p.cache_write_per_m - + (cache_read_tokens as f64 / 1_000_000.0) * p.cache_read_per_m + let p = fallback_pricing_for_model(model); + estimate_cost_with_pricing( + &p, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + ) } -/// Estimate how much was saved by cache reads vs full input price. +/// Estimate gross savings from cache reads vs full input price. pub fn estimate_cache_savings(model: &str, cache_read_tokens: i64) -> f64 { - let p = pricing_for_model(model); + let p = fallback_pricing_for_model(model); (cache_read_tokens as f64 / 1_000_000.0) * (p.input_per_m - p.cache_read_per_m) } -/// Compute total cost from model_usage JSONB array. -/// Each element: {"model": "...", "input_tokens": N, "output_tokens": N, "cache_read_tokens": N, "cache_creation_tokens": N} -/// Falls back to session-level totals if model_usage is absent. +/// Estimate overhead from cache writes vs base input price. +pub fn estimate_cache_write_overhead(pricing: &ModelPricing, cache_write_tokens: i64) -> f64 { + (cache_write_tokens as f64 / 1_000_000.0) * (pricing.cache_write_per_m - pricing.input_per_m) +} + +/// Compute total cost from model_usage JSONB array (backward compatible). pub fn cost_from_model_usage( model_usage: Option<&serde_json::Value>, fallback_model: Option<&str>, @@ -105,3 +175,35 @@ pub fn cost_from_model_usage( ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_estimate_cost_sonnet() { + let cost = estimate_cost("claude-sonnet-4-6", 1_000_000, 1_000_000, 0, 0); + assert!((cost - 18.0).abs() < 0.001); // 3 + 15 + } + + #[test] + fn test_estimate_cost_opus_with_cache() { + let cost = estimate_cost("claude-opus-4-6", 0, 0, 1_000_000, 1_000_000); + // cache_read: 1.50 + cache_write: 18.75 = 20.25 + assert!((cost - 20.25).abs() < 0.001); + } + + #[test] + fn test_cache_write_overhead() { + let p = fallback_pricing_for_model("opus"); + let overhead = estimate_cache_write_overhead(&p, 1_000_000); + // 18.75 - 15.0 = 3.75 + assert!((overhead - 3.75).abs() < 0.001); + } + + #[test] + fn test_fallback_defaults_to_sonnet() { + let p = fallback_pricing_for_model("unknown-model-xyz"); + assert!((p.input_per_m - 3.0).abs() < 0.001); + } +} From d482badfc315825aba51e7c53e91c74683c547a1 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:17:23 +0100 Subject: [PATCH 03/15] feat: add session detail transcript parser with per-call breakdown --- crates/tracevault-server/src/api/mod.rs | 1 + .../src/api/session_detail.rs | 603 ++++++++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 crates/tracevault-server/src/api/session_detail.rs diff --git a/crates/tracevault-server/src/api/mod.rs b/crates/tracevault-server/src/api/mod.rs index eb23492..c18af40 100644 --- a/crates/tracevault-server/src/api/mod.rs +++ b/crates/tracevault-server/src/api/mod.rs @@ -9,4 +9,5 @@ pub mod github; pub mod orgs; pub mod policies; pub mod repos; +pub mod session_detail; pub mod traces; diff --git a/crates/tracevault-server/src/api/session_detail.rs b/crates/tracevault-server/src/api/session_detail.rs new file mode 100644 index 0000000..3d02677 --- /dev/null +++ b/crates/tracevault-server/src/api/session_detail.rs @@ -0,0 +1,603 @@ +use axum::extract::{Path, State}; +use axum::Json; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +use crate::extractors::OrgAuth; +use crate::pricing::{self, ModelPricing}; +use crate::AppState; + +#[derive(Serialize)] +pub struct SessionDetailResponse { + pub session_id: String, + pub model: Option, + pub started_at: Option>, + pub ended_at: Option>, + pub duration_ms: Option, + pub total_tokens: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub estimated_cost_usd: f64, + pub api_calls: i32, + pub user_messages: i32, + pub assistant_messages: i32, + pub total_tool_calls: i32, + pub compactions: i32, + pub cache_savings: CacheSavings, + pub per_call: Vec, + pub cost_breakdown: CostBreakdown, + pub token_distribution: TokenDistribution, + pub transcript_records: Vec, +} + +#[derive(Serialize)] +pub struct CacheSavings { + pub gross_savings_usd: f64, + pub cache_write_overhead_usd: f64, + pub net_savings_usd: f64, + pub cache_hit_percentage: f64, +} + +#[derive(Serialize)] +pub struct PerCallUsage { + pub index: u32, + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub cost_usd: f64, + pub cumulative_cost_usd: f64, + pub model: String, +} + +#[derive(Serialize)] +pub struct CostBreakdown { + pub input_cost: f64, + pub output_cost: f64, + pub cache_read_cost: f64, + pub cache_write_cost: f64, + pub total_cost: f64, +} + +#[derive(Serialize)] +pub struct TokenDistribution { + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, +} + +#[derive(Serialize)] +pub struct RecordUsage { + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub cache_write_tokens: i64, + pub cost_usd: f64, +} + +#[derive(Serialize)] +pub struct TranscriptRecord { + pub record_type: String, + pub timestamp: Option, + pub content_types: Vec, + pub tool_name: Option, + pub text: Option, + pub usage: Option, + pub model: Option, +} + +fn parse_record(record: &serde_json::Value, pricing: &ModelPricing) -> Option { + let record_type = record.get("type")?.as_str()?.to_string(); + let timestamp = record + .get("timestamp") + .and_then(|v| v.as_str()) + .map(String::from); + + match record_type.as_str() { + "assistant" => { + let msg = record.get("message")?; + let model = msg.get("model").and_then(|v| v.as_str()).map(String::from); + + let mut content_types = Vec::new(); + let mut texts = Vec::new(); + if let Some(content) = msg.get("content").and_then(|v| v.as_array()) { + for block in content { + if let Some(ct) = block.get("type").and_then(|v| v.as_str()) { + if !content_types.contains(&ct.to_string()) { + content_types.push(ct.to_string()); + } + } + if let Some(text) = block.get("text").and_then(|v| v.as_str()) { + texts.push(text.to_string()); + } + if let Some(thinking) = block.get("thinking").and_then(|v| v.as_str()) { + texts.push(format!("[thinking] {}", thinking)); + } + } + } + + let usage = msg.get("usage").map(|u| { + let input = u.get("input_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("output_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let cache_read = u + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let cache_write = u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let cost = pricing::estimate_cost_with_pricing( + pricing, + input, + output, + cache_read, + cache_write, + ); + RecordUsage { + input_tokens: input, + output_tokens: output, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + cost_usd: cost, + } + }); + + let tool_name = msg + .get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find(|b| b.get("type").and_then(|v| v.as_str()) == Some("tool_use")) + }) + .and_then(|b| b.get("name").and_then(|v| v.as_str()).map(String::from)); + + Some(TranscriptRecord { + record_type, + timestamp, + content_types, + tool_name, + text: if texts.is_empty() { + None + } else { + Some(texts.join("\n\n")) + }, + usage, + model, + }) + } + "user" => { + let msg = record.get("message")?; + let mut content_types = Vec::new(); + let mut text = None; + let mut tool_name = None; + + match msg.get("content") { + Some(serde_json::Value::String(s)) => { + content_types.push("text".to_string()); + text = Some(s.clone()); + } + Some(serde_json::Value::Array(arr)) => { + for block in arr { + if let Some(ct) = block.get("type").and_then(|v| v.as_str()) { + if !content_types.contains(&ct.to_string()) { + content_types.push(ct.to_string()); + } + if ct == "tool_result" { + if let Some(content) = block.get("content").and_then(|v| v.as_str()) + { + text = Some(content.to_string()); + } + } else if ct == "text" { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + text = Some(t.to_string()); + } + } + } + } + } + _ => {} + } + + if let Some(tur) = record.get("toolUseResult") { + if let Some(file) = tur + .get("file") + .and_then(|f| f.get("filePath").and_then(|v| v.as_str())) + { + tool_name = Some(format!("Read: {}", file)); + } else if tur.get("filenames").is_some() { + tool_name = Some("Glob".to_string()); + } else if tur.get("stdout").is_some() { + tool_name = Some("Bash".to_string()); + } + } + + Some(TranscriptRecord { + record_type, + timestamp, + content_types, + tool_name, + text, + usage: None, + model: None, + }) + } + "progress" => { + let data = record.get("data"); + let hook_name = data + .and_then(|d| d.get("hookName").and_then(|v| v.as_str())) + .map(String::from); + let hook_event = data.and_then(|d| d.get("hookEvent").and_then(|v| v.as_str())); + let text = hook_event.map(|e| { + if let Some(ref name) = hook_name { + format!("{}: {}", e, name) + } else { + e.to_string() + } + }); + + Some(TranscriptRecord { + record_type, + timestamp, + content_types: vec![], + tool_name: hook_name, + text, + usage: None, + model: None, + }) + } + "system" => { + let subtype = record + .get("subtype") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let text = match subtype { + "turn_duration" => { + let ms = record + .get("durationMs") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + Some(format!("turn_duration: {:.1}s", ms / 1000.0)) + } + "stop_hook_summary" => { + let count = record + .get("hookCount") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + Some(format!("stop_hook_summary: {} hooks", count)) + } + _ => Some(subtype.to_string()), + }; + + Some(TranscriptRecord { + record_type, + timestamp, + content_types: vec![subtype.to_string()], + tool_name: None, + text, + usage: None, + model: None, + }) + } + _ => Some(TranscriptRecord { + record_type, + timestamp, + content_types: vec![], + tool_name: None, + text: None, + usage: None, + model: None, + }), + } +} + +pub fn parse_transcript( + transcript: &serde_json::Value, + pricing: &ModelPricing, +) -> ( + Vec, + Vec, + TokenDistribution, + CostBreakdown, + CacheSavings, +) { + let records = transcript.as_array().map(|a| a.as_slice()).unwrap_or(&[]); + + let mut per_call = Vec::new(); + let mut transcript_records = Vec::new(); + let mut cumulative_cost = 0.0; + let mut call_index: u32 = 0; + + let mut total_input: i64 = 0; + let mut total_output: i64 = 0; + let mut total_cache_read: i64 = 0; + let mut total_cache_write: i64 = 0; + + for record in records { + if let Some(tr) = parse_record(record, pricing) { + if tr.record_type == "assistant" { + if let Some(ref usage) = tr.usage { + let model = tr.model.as_deref().unwrap_or("unknown"); + if model != "" + && (usage.input_tokens > 0 + || usage.output_tokens > 0 + || usage.cache_read_tokens > 0 + || usage.cache_write_tokens > 0) + { + call_index += 1; + cumulative_cost += usage.cost_usd; + + total_input += usage.input_tokens; + total_output += usage.output_tokens; + total_cache_read += usage.cache_read_tokens; + total_cache_write += usage.cache_write_tokens; + + per_call.push(PerCallUsage { + index: call_index, + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + cost_usd: usage.cost_usd, + cumulative_cost_usd: cumulative_cost, + model: model.to_string(), + }); + } + } + } + transcript_records.push(tr); + } + } + + let token_distribution = TokenDistribution { + input_tokens: total_input, + output_tokens: total_output, + cache_read_tokens: total_cache_read, + cache_write_tokens: total_cache_write, + }; + + let cost_breakdown = CostBreakdown { + input_cost: (total_input as f64 / 1_000_000.0) * pricing.input_per_m, + output_cost: (total_output as f64 / 1_000_000.0) * pricing.output_per_m, + cache_read_cost: (total_cache_read as f64 / 1_000_000.0) * pricing.cache_read_per_m, + cache_write_cost: (total_cache_write as f64 / 1_000_000.0) * pricing.cache_write_per_m, + total_cost: cumulative_cost, + }; + + let total_input_side = total_cache_read + total_cache_write + total_input; + let gross_savings = + (total_cache_read as f64 / 1_000_000.0) * (pricing.input_per_m - pricing.cache_read_per_m); + let write_overhead = pricing::estimate_cache_write_overhead(pricing, total_cache_write); + + let cache_savings = CacheSavings { + gross_savings_usd: gross_savings, + cache_write_overhead_usd: write_overhead, + net_savings_usd: gross_savings - write_overhead, + cache_hit_percentage: if total_input_side > 0 { + (total_cache_read as f64 / total_input_side as f64) * 100.0 + } else { + 0.0 + }, + }; + + ( + per_call, + transcript_records, + token_distribution, + cost_breakdown, + cache_savings, + ) +} + +#[derive(sqlx::FromRow)] +struct SessionRow { + session_id: String, + model: Option, + started_at: Option>, + ended_at: Option>, + duration_ms: Option, + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + cache_read_tokens: Option, + cache_write_tokens: Option, + estimated_cost_usd: Option, + api_calls: Option, + user_messages: Option, + assistant_messages: Option, + total_tool_calls: Option, + compactions: Option, + transcript: Option, +} + +pub async fn get_session_detail( + State(state): State, + auth: OrgAuth, + Path((_slug, session_uuid)): Path<(String, Uuid)>, +) -> Result, axum::http::StatusCode> { + let org_id = auth.org_id; + + let row = sqlx::query_as::<_, SessionRow>( + "SELECT s.session_id, s.model, s.started_at, s.ended_at, s.duration_ms, + s.total_tokens, s.input_tokens, s.output_tokens, + s.cache_read_tokens, s.cache_write_tokens, + s.estimated_cost_usd, s.api_calls, + s.user_messages, s.assistant_messages, + s.total_tool_calls, s.compactions, s.transcript + FROM sessions s + JOIN commits c ON s.commit_id = c.id + JOIN repos r ON c.repo_id = r.id + WHERE s.id = $1 AND r.org_id = $2", + ) + .bind(session_uuid) + .bind(org_id) + .fetch_optional(&state.pool) + .await + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(axum::http::StatusCode::NOT_FOUND)?; + + let pricing = pricing::fetch_pricing_for_model( + &state.pool, + row.model.as_deref().unwrap_or("sonnet"), + row.started_at, + ) + .await; + + let empty = serde_json::Value::Array(vec![]); + let transcript_ref = row.transcript.as_ref().unwrap_or(&empty); + let (per_call, transcript_records, token_distribution, cost_breakdown, cache_savings) = + parse_transcript(transcript_ref, &pricing); + + Ok(Json(SessionDetailResponse { + session_id: row.session_id, + model: row.model, + started_at: row.started_at, + ended_at: row.ended_at, + duration_ms: row.duration_ms, + total_tokens: row.total_tokens.unwrap_or(0), + input_tokens: row.input_tokens.unwrap_or(0), + output_tokens: row.output_tokens.unwrap_or(0), + cache_read_tokens: row.cache_read_tokens.unwrap_or(0), + cache_write_tokens: row.cache_write_tokens.unwrap_or(0), + estimated_cost_usd: row.estimated_cost_usd.unwrap_or(0.0), + api_calls: row.api_calls.unwrap_or(0), + user_messages: row.user_messages.unwrap_or(0), + assistant_messages: row.assistant_messages.unwrap_or(0), + total_tool_calls: row.total_tool_calls.unwrap_or(0), + compactions: row.compactions.unwrap_or(0), + cache_savings, + per_call, + cost_breakdown, + token_distribution, + transcript_records, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_pricing() -> ModelPricing { + ModelPricing { + input_per_m: 15.0, + output_per_m: 75.0, + cache_write_per_m: 18.75, + cache_read_per_m: 1.50, + } + } + + #[test] + fn test_parse_empty_transcript() { + let transcript = serde_json::json!([]); + let (per_call, records, dist, cost, savings) = + parse_transcript(&transcript, &test_pricing()); + assert!(per_call.is_empty()); + assert!(records.is_empty()); + assert_eq!(dist.input_tokens, 0); + assert_eq!(cost.total_cost, 0.0); + assert_eq!(savings.cache_hit_percentage, 0.0); + } + + #[test] + fn test_parse_assistant_with_usage() { + let transcript = serde_json::json!([ + { + "type": "assistant", + "timestamp": "2026-03-23T13:17:16Z", + "message": { + "model": "claude-opus-4-6", + "content": [{"type": "text", "text": "Hello"}], + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 1000, + "cache_creation_input_tokens": 500 + } + } + } + ]); + let (per_call, records, dist, _cost, _savings) = + parse_transcript(&transcript, &test_pricing()); + assert_eq!(per_call.len(), 1); + assert_eq!(per_call[0].index, 1); + assert_eq!(per_call[0].cache_read_tokens, 1000); + assert_eq!(records.len(), 1); + assert_eq!(dist.output_tokens, 50); + } + + #[test] + fn test_skips_synthetic_records() { + let transcript = serde_json::json!([ + { + "type": "assistant", + "timestamp": "2026-03-23T13:17:16Z", + "message": { + "model": "", + "content": [], + "usage": { "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0 } + } + } + ]); + let (per_call, records, _dist, _cost, _savings) = + parse_transcript(&transcript, &test_pricing()); + assert!(per_call.is_empty()); + assert_eq!(records.len(), 1); + } + + #[test] + fn test_cache_savings_calculation() { + let transcript = serde_json::json!([ + { + "type": "assistant", + "timestamp": "2026-03-23T13:17:16Z", + "message": { + "model": "claude-opus-4-6", + "content": [{"type": "text", "text": "test"}], + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 1_000_000, + "cache_creation_input_tokens": 100_000 + } + } + } + ]); + let pricing = test_pricing(); + let (_per_call, _records, _dist, _cost, savings) = parse_transcript(&transcript, &pricing); + assert!((savings.gross_savings_usd - 13.5).abs() < 0.001); + assert!((savings.cache_write_overhead_usd - 0.375).abs() < 0.001); + assert!((savings.net_savings_usd - 13.125).abs() < 0.001); + } + + #[test] + fn test_cumulative_cost_accumulates() { + let transcript = serde_json::json!([ + { + "type": "assistant", + "timestamp": "2026-03-23T13:17:16Z", + "message": { + "model": "claude-opus-4-6", + "content": [{"type": "text", "text": "a"}], + "usage": { "input_tokens": 1_000_000, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0 } + } + }, + { + "type": "assistant", + "timestamp": "2026-03-23T13:17:20Z", + "message": { + "model": "claude-opus-4-6", + "content": [{"type": "text", "text": "b"}], + "usage": { "input_tokens": 1_000_000, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0 } + } + } + ]); + let (per_call, _, _, _, _) = parse_transcript(&transcript, &test_pricing()); + assert_eq!(per_call.len(), 2); + assert!((per_call[0].cumulative_cost_usd - 15.0).abs() < 0.001); + assert!((per_call[1].cumulative_cost_usd - 30.0).abs() < 0.001); + } +} From ddffcf9ba710f3a79495d3320a93a71fda9070d8 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:20:19 +0100 Subject: [PATCH 04/15] feat: register session detail API route --- crates/tracevault-server/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tracevault-server/src/main.rs b/crates/tracevault-server/src/main.rs index dbe7160..bcc0fd7 100644 --- a/crates/tracevault-server/src/main.rs +++ b/crates/tracevault-server/src/main.rs @@ -260,6 +260,10 @@ async fn main() { "/api/v1/orgs/{slug}/analytics/sessions", get(api::analytics::get_sessions), ) + .route( + "/api/v1/orgs/{slug}/analytics/sessions/{id}/detail", + get(api::session_detail::get_session_detail), + ) .route( "/api/v1/orgs/{slug}/analytics/cost", get(api::analytics::get_cost), From 131a596886fbb0ae6db15a1414077288776f3d02 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:23:15 +0100 Subject: [PATCH 05/15] feat: add session detail frontend components (stats, charts, transcript) --- .../session-detail/SessionCharts.svelte | 176 ++++++++++++++++++ .../session-detail/SessionSummaryStats.svelte | 128 +++++++++++++ .../session-detail/SessionTranscript.svelte | 92 +++++++++ .../session-detail/TranscriptRecord.svelte | 161 ++++++++++++++++ 4 files changed, 557 insertions(+) create mode 100644 web/src/lib/components/session-detail/SessionCharts.svelte create mode 100644 web/src/lib/components/session-detail/SessionSummaryStats.svelte create mode 100644 web/src/lib/components/session-detail/SessionTranscript.svelte create mode 100644 web/src/lib/components/session-detail/TranscriptRecord.svelte diff --git a/web/src/lib/components/session-detail/SessionCharts.svelte b/web/src/lib/components/session-detail/SessionCharts.svelte new file mode 100644 index 0000000..0f9d61b --- /dev/null +++ b/web/src/lib/components/session-detail/SessionCharts.svelte @@ -0,0 +1,176 @@ + + +
+
+

Tokens per API Call

+
+ +
+
+ +
+

Cumulative Cost

+
+ +
+
+ +
+

Token Distribution

+
+ +
+
+
diff --git a/web/src/lib/components/session-detail/SessionSummaryStats.svelte b/web/src/lib/components/session-detail/SessionSummaryStats.svelte new file mode 100644 index 0000000..5b80c19 --- /dev/null +++ b/web/src/lib/components/session-detail/SessionSummaryStats.svelte @@ -0,0 +1,128 @@ + + +
+
+ +
Total Tokens
+
{fmtNum(totalTokens)}
+
+ + +
Total Cost
+
{fmtCost(estimatedCostUsd)}
+
+ + +
Output Tokens
+
{fmtNum(outputTokens)}
+
+ + +
API Calls
+
{apiCalls}
+
+ + +
+ Cache Saved + + + ? + + + + Percentage of input-side tokens served from cache instead of being freshly processed. Higher is better. + + + +
+
+ {cacheSavings.cache_hit_percentage.toFixed(1)}% +
+
+ + + -{fmtCost(cacheSavings.gross_savings_usd)} gross + + + + Amount saved by serving tokens from cache at the reduced cache-read rate instead of the full input rate. + + + + {' · '} + + + -{fmtCost(cacheSavings.net_savings_usd)} net + + + + Gross savings minus the overhead of writing tokens to cache, which costs more than base input. This is your true savings. + + + +
+
+ + +
Compactions
+
{compactions}
+
+
+ +
+ Cost breakdown: + Input {fmtCost(costBreakdown.input_cost)} + Output {fmtCost(costBreakdown.output_cost)} + Cache Read {fmtCost(costBreakdown.cache_read_cost)} + Cache Write {fmtCost(costBreakdown.cache_write_cost)} +
+
diff --git a/web/src/lib/components/session-detail/SessionTranscript.svelte b/web/src/lib/components/session-detail/SessionTranscript.svelte new file mode 100644 index 0000000..952cc67 --- /dev/null +++ b/web/src/lib/components/session-detail/SessionTranscript.svelte @@ -0,0 +1,92 @@ + + +
+
+ Transcript ({records.length} records) +
+ +
+ {#each Object.entries(typeCounts) as [type, count]} + {@const color = TYPE_COLORS[type] || '#6b7594'} + {@const isActive = activeFilters.size === 0 || activeFilters.has(type)} + + {/each} +
+ +
+ {#each filteredRecords as record} + + {/each} + + {#if filteredRecords.length === 0} +
No matching records
+ {/if} +
+
diff --git a/web/src/lib/components/session-detail/TranscriptRecord.svelte b/web/src/lib/components/session-detail/TranscriptRecord.svelte new file mode 100644 index 0000000..01e8bcc --- /dev/null +++ b/web/src/lib/components/session-detail/TranscriptRecord.svelte @@ -0,0 +1,161 @@ + + +
+ + + {#if expanded} +
+ {#if record.text} +
{record.text}
+ {/if} + + {#if record.usage} +
+ cache_read +
+
+
+ {fmtNum(record.usage.cache_read_tokens)} + + cache_write +
+
+
+ {fmtNum(record.usage.cache_write_tokens)} + + output +
+
+
+ {fmtNum(record.usage.output_tokens)} + + input +
+
+
+ {fmtNum(record.usage.input_tokens)} +
+ {/if} +
+ {/if} +
From eebca5ac4d7570a3b806aeadda44c7b3ea4b24da Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:23:52 +0100 Subject: [PATCH 06/15] feat: add SessionDetailPanel orchestrator with progressive disclosure --- .../session-detail/SessionDetailPanel.svelte | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 web/src/lib/components/session-detail/SessionDetailPanel.svelte diff --git a/web/src/lib/components/session-detail/SessionDetailPanel.svelte b/web/src/lib/components/session-detail/SessionDetailPanel.svelte new file mode 100644 index 0000000..2ea9bc1 --- /dev/null +++ b/web/src/lib/components/session-detail/SessionDetailPanel.svelte @@ -0,0 +1,76 @@ + + +
+ {#if loading} +
+ + Loading session detail... +
+ {:else if error} +
{error}
+ {:else if data} + + + {#if !showDetail} +
+ +
+ {:else} +
+ + +
+ {/if} + {/if} +
From 57037e75da15528d9869ef2387df5da0a5168454 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:26:00 +0100 Subject: [PATCH 07/15] feat: wire session detail expansion into sessions analytics page --- .../session-detail/SessionCharts.svelte | 2 +- .../[slug]/analytics/sessions/+page.svelte | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/session-detail/SessionCharts.svelte b/web/src/lib/components/session-detail/SessionCharts.svelte index 0f9d61b..b723f72 100644 --- a/web/src/lib/components/session-detail/SessionCharts.svelte +++ b/web/src/lib/components/session-detail/SessionCharts.svelte @@ -133,7 +133,7 @@ scales: { y: { ticks: { - callback: (value: number) => `$${value.toFixed(3)}` + callback: (value: string | number) => `$${Number(value).toFixed(3)}` } } }, diff --git a/web/src/routes/orgs/[slug]/analytics/sessions/+page.svelte b/web/src/routes/orgs/[slug]/analytics/sessions/+page.svelte index 4e5b75b..e38df78 100644 --- a/web/src/routes/orgs/[slug]/analytics/sessions/+page.svelte +++ b/web/src/routes/orgs/[slug]/analytics/sessions/+page.svelte @@ -5,6 +5,7 @@ import * as Table from '$lib/components/ui/table/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; import Chart from '$lib/components/chart.svelte'; + import SessionDetailPanel from '$lib/components/session-detail/SessionDetailPanel.svelte'; import { Chart as ChartJS, CategoryScale, @@ -66,6 +67,11 @@ let error = $state(''); let sortCol = $state('started_at'); let sortDir = $state<'asc' | 'desc'>('desc'); + let expandedSessionId = $state(null); + + function toggleExpand(id: string) { + expandedSessionId = expandedSessionId === id ? null : id; + } const slug = $derived($page.params.slug); @@ -282,7 +288,10 @@ {#each sortedSessions(data.sessions) as session} - + toggleExpand(session.id)} + > {session.session_id.slice(0, 8)} {session.repo_name} {session.author} @@ -301,6 +310,13 @@ {fmtRelativeTime(session.started_at)} + {#if expandedSessionId === session.id} + + + + + + {/if} {/each} From 0c67424f82c1c1d5d2ca6786bc3f2f345104b78a Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:28:27 +0100 Subject: [PATCH 08/15] chore: add .superpowers/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 009bca7..7117e58 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ docs/plans/ screenshots/ .idea/ docs/superpowers/ +.superpowers/ PROJECT_DESCRIPTION.md From c5cb5da9cfd1bf6da1651f026682e74866de01b6 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:41:17 +0100 Subject: [PATCH 09/15] fix: remove audit log from login to avoid nil org_id FK violation --- crates/tracevault-server/src/api/auth.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/tracevault-server/src/api/auth.rs b/crates/tracevault-server/src/api/auth.rs index 3bd37d4..b73cfb8 100644 --- a/crates/tracevault-server/src/api/auth.rs +++ b/crates/tracevault-server/src/api/auth.rs @@ -237,19 +237,7 @@ pub async fn login( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Audit with nil org_id since login is org-agnostic now - crate::audit::log( - &state.pool, - crate::audit::user_action( - Uuid::nil(), - user_id, - "user.login", - "user", - Some(user_id), - None, - ), - ) - .await; + // Login is org-agnostic — skip audit log (audit_log requires a valid org_id) Ok(Json(LoginResponse { token: raw_token, From 506b94096c0cc0cf774df70cde9820ea4e588319 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 23 Mar 2026 21:48:23 +0100 Subject: [PATCH 10/15] feat: add session detail analytics panel to traces page --- .../orgs/[slug]/traces/[id]/+page.svelte | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte b/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte index a6ecd84..ca254b7 100644 --- a/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte +++ b/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte @@ -5,6 +5,7 @@ import * as Card from '$lib/components/ui/card/index.js'; import * as Table from '$lib/components/ui/table/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; + import SessionDetailPanel from '$lib/components/session-detail/SessionDetailPanel.svelte'; interface SessionDetail { id: string; @@ -613,46 +614,9 @@ {#if expandedSessions.has(session.id)} - {#if tx.entries.length > 0} -
-
-

Total Tokens

-

{fmtTokens(tx.stats.totalInputTokens + tx.stats.totalOutputTokens)}

-

- {fmtTokens(tx.stats.totalInputTokens)} in / {fmtTokens(tx.stats.totalOutputTokens)} out -

-
-
-

Cache

-

{fmtTokens(tx.stats.totalCacheReadTokens + tx.stats.totalCacheCreationTokens)}

-
-
-

Turns

-

{tx.stats.userMessageCount}

-

{tx.stats.turnCount} events

-
-
-

Duration

-

{fmtDuration(tx.stats.totalDurationMs)}

-
- {#each tx.stats.byModel as m} - {m.model} ({m.count}) - {/each} -
-
-
- - {#if Object.keys(tx.stats.toolUsageCounts).length > 0} -
-

Tool Usage

-
- {#each Object.entries(tx.stats.toolUsageCounts).sort((a, b) => b[1] - a[1]) as [tool, count]} - {tool} ({count}) - {/each} -
-
- {/if} + + {#if tx.entries.length > 0} From 74222cb22924543bab76d9250d92385ac5bf84d5 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 24 Mar 2026 07:03:24 +0100 Subject: [PATCH 11/15] refactor: replace old transcript table with SessionDetailPanel on traces page --- .../orgs/[slug]/traces/[id]/+page.svelte | 308 +----------------- 1 file changed, 5 insertions(+), 303 deletions(-) diff --git a/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte b/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte index ca254b7..9215f57 100644 --- a/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte +++ b/web/src/routes/orgs/[slug]/traces/[id]/+page.svelte @@ -3,8 +3,7 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import * as Card from '$lib/components/ui/card/index.js'; - import * as Table from '$lib/components/ui/table/index.js'; - import { Badge } from '$lib/components/ui/badge/index.js'; +import { Badge } from '$lib/components/ui/badge/index.js'; import SessionDetailPanel from '$lib/components/session-detail/SessionDetailPanel.svelte'; interface SessionDetail { @@ -34,45 +33,7 @@ sessions: SessionDetail[]; } - interface TokenUsage { - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - } - - interface TranscriptEntry { - index: number; - timestamp: string | null; - type: string; - subtype: string | null; - summary: string; - model: string | null; - usage: TokenUsage | null; - prompt: string | null; - toolNames: string[]; - raw: unknown; - } - - interface ModelStats { - model: string; - tokens: number; - count: number; - } - - interface TranscriptStats { - totalInputTokens: number; - totalOutputTokens: number; - totalCacheReadTokens: number; - totalCacheCreationTokens: number; - byModel: ModelStats[]; - toolUsageCounts: Record; - turnCount: number; - userMessageCount: number; - totalDurationMs: number; - } - - interface DiffLine { +interface DiffLine { kind: 'add' | 'delete' | 'context'; content: string; new_line_number: number | null; @@ -117,200 +78,12 @@ }; } - function truncate(s: string, max: number): string { - if (s.length <= max) return s; - return s.slice(0, max) + '…'; - } - - function parseTranscript(raw: unknown[]): TranscriptEntry[] { - const entries: TranscriptEntry[] = []; - - for (let i = 0; i < raw.length; i++) { - const e = raw[i] as Record; - const type = (e.type as string) ?? 'unknown'; - const dataObj = e.data as Record | undefined; - const subtype = (e.subtype as string) ?? (dataObj?.type as string) ?? null; - const timestamp = (e.timestamp as string) ?? null; - const msg = e.message as Record | undefined; - - let summary = type; - let model: string | null = null; - let usage: TokenUsage | null = null; - let prompt: string | null = null; - const toolNames: string[] = []; - - if (type === 'assistant' && msg) { - model = (msg.model as string) ?? null; - const rawUsage = msg.usage as Record | undefined; - if (rawUsage) { - usage = { - input_tokens: rawUsage.input_tokens ?? 0, - output_tokens: rawUsage.output_tokens ?? 0, - cache_creation_input_tokens: rawUsage.cache_creation_input_tokens ?? 0, - cache_read_input_tokens: rawUsage.cache_read_input_tokens ?? 0 - }; - } - const content = msg.content; - if (Array.isArray(content)) { - const textParts: string[] = []; - for (const block of content) { - const b = block as Record; - if (b.type === 'text') textParts.push(b.text as string); - else if (b.type === 'tool_use') toolNames.push(b.name as string); - } - if (textParts.length > 0) { - summary = truncate(textParts.join(' ').replace(/\s+/g, ' '), 120); - } else if (toolNames.length > 0) { - summary = `tool calls: ${toolNames.join(', ')}`; - } else { - summary = 'assistant response'; - } - } - } else if (type === 'user') { - const content = msg?.content; - if (typeof content === 'string') { - prompt = content; - summary = truncate(content.replace(/\s+/g, ' '), 120); - } else if (Array.isArray(content)) { - const toolResults = content.filter( - (b: Record) => (b as Record).type === 'tool_result' - ); - summary = `${toolResults.length} tool result${toolResults.length !== 1 ? 's' : ''}`; - } else { - summary = 'user message'; - } - } else if (type === 'progress' && subtype === 'agent_progress') { - const data = e.data as Record | undefined; - if (data) { - prompt = (data.prompt as string) ?? null; - const nestedMsg = data.message as Record | undefined; - const innerMsg = nestedMsg?.message as Record | undefined; - if (innerMsg) { - model = (innerMsg.model as string) ?? null; - const rawUsage = innerMsg.usage as Record | undefined; - if (rawUsage) { - usage = { - input_tokens: rawUsage.input_tokens ?? 0, - output_tokens: rawUsage.output_tokens ?? 0, - cache_creation_input_tokens: rawUsage.cache_creation_input_tokens ?? 0, - cache_read_input_tokens: rawUsage.cache_read_input_tokens ?? 0 - }; - } - } - summary = prompt ? truncate(prompt.replace(/\s+/g, ' '), 120) : 'agent progress'; - } - } else if (type === 'system' && subtype === 'turn_duration') { - const data = e.data as Record | undefined; - const durationMs = (data?.durationMs as number) ?? 0; - summary = `turn duration: ${fmtDuration(durationMs)}`; - } - - entries.push({ - index: i, - timestamp, - type, - subtype: subtype, - summary, - model, - usage, - prompt, - toolNames, - raw: e - }); - } - - return entries; - } - - function computeStats(entries: TranscriptEntry[]): TranscriptStats { - let totalInputTokens = 0; - let totalOutputTokens = 0; - let totalCacheReadTokens = 0; - let totalCacheCreationTokens = 0; - let userMessageCount = 0; - let totalDurationMs = 0; - let minTime = Infinity; - let maxTime = -Infinity; - const modelMap = new Map(); - const toolCounts: Record = {}; - - for (const entry of entries) { - if (entry.timestamp) { - const t = new Date(entry.timestamp).getTime(); - if (t < minTime) minTime = t; - if (t > maxTime) maxTime = t; - } - if (entry.usage) { - totalInputTokens += entry.usage.input_tokens; - totalOutputTokens += entry.usage.output_tokens; - totalCacheReadTokens += entry.usage.cache_read_input_tokens; - totalCacheCreationTokens += entry.usage.cache_creation_input_tokens; - } - if (entry.model && entry.usage) { - const existing = modelMap.get(entry.model) ?? { tokens: 0, count: 0 }; - existing.tokens += - entry.usage.input_tokens + - entry.usage.output_tokens + - entry.usage.cache_read_input_tokens + - entry.usage.cache_creation_input_tokens; - existing.count++; - modelMap.set(entry.model, existing); - } - if (entry.type === 'user' && entry.prompt) { - userMessageCount++; - } - for (const tool of entry.toolNames) { - toolCounts[tool] = (toolCounts[tool] ?? 0) + 1; - } - if (entry.type === 'system' && entry.subtype === 'turn_duration') { - const data = (entry.raw as Record).data as - | Record - | undefined; - totalDurationMs += (data?.durationMs as number) ?? 0; - } - } - - // Prefer timestamp range over turn_duration sum - if (minTime !== Infinity && maxTime !== -Infinity && maxTime > minTime) { - totalDurationMs = maxTime - minTime; - } - - const byModel: ModelStats[] = Array.from(modelMap.entries()) - .map(([model, { tokens, count }]) => ({ model, tokens, count })) - .sort((a, b) => b.tokens - a.tokens); - - return { - totalInputTokens, - totalOutputTokens, - totalCacheReadTokens, - totalCacheCreationTokens, - byModel, - toolUsageCounts: toolCounts, - turnCount: entries.length, - userMessageCount, - totalDurationMs - }; - } - function fmtTokens(n: number | undefined | null): string { if (n == null || n === 0) return '-'; if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); } - function fmtTime(iso: string | null): string { - if (!iso) return '-'; - return new Date(iso).toLocaleTimeString(); - } - - function fmtDuration(ms: number): string { - if (ms === 0) return '-'; - if (ms < 1000) return `${Math.round(ms)}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; - return `${Math.floor(ms / 3_600_000)}h ${Math.floor((ms % 3_600_000) / 60_000)}m`; - } - interface VerifyResponse { commit_id: string; record_hash_valid: boolean; @@ -323,7 +96,6 @@ let loading = $state(true); let error = $state(''); let expandedSessions: Set = $state(new Set()); - let expandedEntries: Set = $state(new Set()); let verification: VerifyResponse | null = $state(null); let verifyLoading = $state(false); @@ -375,29 +147,14 @@ return { totalTokens, totalInput, totalOutput }; }); - // Per-session transcript data - function sessionTranscript(session: SessionDetail) { - if (!session.transcript) return { entries: [] as TranscriptEntry[], stats: computeStats([]) }; - const entries = parseTranscript(session.transcript); - const stats = computeStats(entries); - return { entries, stats }; - } - - function toggleSession(sessionId: string) { +function toggleSession(sessionId: string) { const next = new Set(expandedSessions); if (next.has(sessionId)) next.delete(sessionId); else next.add(sessionId); expandedSessions = next; } - function toggleEntry(key: string) { - const next = new Set(expandedEntries); - if (next.has(key)) next.delete(key); - else next.add(key); - expandedEntries = next; - } - - let expandedFiles: Set = $state(new Set()); +let expandedFiles: Set = $state(new Set()); const diffFiles = $derived.by(() => { if (!commit?.diff_data) return [] as FileDiff[]; @@ -586,7 +343,6 @@ {#if commit.sessions.length > 0}

Sessions

{#each commit.sessions as session (session.id)} - {@const tx = sessionTranscript(session)} - - {#if expandedSessions.has(session.id)} - - - - {/if} - - {/each} + {#if expandedSessions.has(session.id)} +
+ +
+ {/if} + + {/each} + {/if} + {#if diffFiles.length > 0} - - - - Changes - {diffSummary.fileCount} file{diffSummary.fileCount !== 1 ? 's' : ''} - - +{diffSummary.totalAdded} - -{diffSummary.totalDeleted} - - {#if attrData} - - {attrData.summary.ai_percentage.toFixed(0)}% AI - - {/if} - - - - {#if !attrData} -
- Attribution data not available. Install git-ai to track which lines were written by AI agents vs humans. -
+
+
+

Changes

+ {diffSummary.fileCount} file{diffSummary.fileCount !== 1 ? 's' : ''} + + +{diffSummary.totalAdded} + -{diffSummary.totalDeleted} + + {#if attrData} + {attrData.summary.ai_percentage.toFixed(0)}% AI {/if} +
+ + {#if !attrData} +
+ Attribution data not available. Install git-ai to track which lines were written by AI agents vs humans. +
+ {/if} + +
{#each diffFiles as file} -
+
{#if expandedFiles.has(file.path)}
{#each file.hunks as hunk} -
+
@@ -{hunk.old_start},{hunk.old_count} +{hunk.new_start},{hunk.new_count} @@
{#each hunk.lines as line} {@const isAi = line.kind === 'add' && line.new_line_number != null && isAiLine(file.path, line.new_line_number)}
- + {line.old_line_number ?? ''} - + {line.new_line_number ?? ''} - {line.kind === 'add' ? '+' : line.kind === 'delete' ? '-' : ' '} @@ -459,26 +426,34 @@ let expandedFiles: Set = $state(new Set()); {/if}
{/each} - - +
+
{/if} - {#if commit.attribution} -
- - Attribution Details - -
{formatJson(commit.attribution)}
-
+ + {#if commit.attribution || commit.sessions.some((s) => s.session_data)} +
+

Raw Data

+
+ {#if commit.attribution} +
+ + Attribution Details + +
{formatJson(commit.attribution)}
+
+ {/if} + {#each commit.sessions.filter((s) => s.session_data) as session (session.id)} +
+ + Session Data + {session.session_id} + +
{formatJson(session.session_data)}
+
+ {/each} +
+
{/if} - - {#each commit.sessions.filter((s) => s.session_data) as session (session.id)} -
- - Session Data — {session.session_id} - -
{formatJson(session.session_data)}
-
- {/each} {/if}
From 1e01f7bed6712f22b9ef0e36a997463c833b4285 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 24 Mar 2026 07:35:34 +0100 Subject: [PATCH 14/15] refactor: modernize all pages to match session detail design style --- web/src/routes/orgs/+page.svelte | 8 +- .../routes/orgs/[slug]/analytics/+page.svelte | 339 ++++++++---------- .../[slug]/analytics/attribution/+page.svelte | 141 ++++---- .../[slug]/analytics/authors/+page.svelte | 135 ++++--- .../orgs/[slug]/analytics/cost/+page.svelte | 149 ++++---- .../orgs/[slug]/analytics/models/+page.svelte | 189 +++++----- .../[slug]/analytics/sessions/+page.svelte | 228 ++++++------ .../orgs/[slug]/analytics/tokens/+page.svelte | 198 +++++----- .../orgs/[slug]/compliance/+page.svelte | 113 +++--- .../[slug]/compliance/audit-log/+page.svelte | 44 ++- .../[slug]/compliance/settings/+page.svelte | 29 +- web/src/routes/orgs/[slug]/repos/+page.svelte | 22 +- .../orgs/[slug]/repos/[id]/+page.svelte | 116 +++--- .../[slug]/repos/[id]/settings/+page.svelte | 97 +++-- .../routes/orgs/[slug]/settings/+page.svelte | 32 +- .../[slug]/settings/api-keys/+page.svelte | 27 +- .../orgs/[slug]/settings/llm/+page.svelte | 53 ++- .../orgs/[slug]/settings/members/+page.svelte | 82 +++-- .../orgs/[slug]/settings/org/+page.svelte | 23 +- .../routes/orgs/[slug]/traces/+page.svelte | 16 +- 20 files changed, 954 insertions(+), 1087 deletions(-) diff --git a/web/src/routes/orgs/+page.svelte b/web/src/routes/orgs/+page.svelte index 3a4de24..cd74973 100644 --- a/web/src/routes/orgs/+page.svelte +++ b/web/src/routes/orgs/+page.svelte @@ -100,7 +100,7 @@ {#if loading}
-

Loading...

+
Loading...
{:else if showSigningKey}
@@ -163,14 +163,14 @@
@@ -192,7 +192,7 @@ {#if showCreateForm || orgs.length === 0}
{ e.preventDefault(); createOrg(); }} class="space-y-4 border-t pt-4"> -

Create Organization

+

Create Organization

import { page } from '$app/stores'; import { api } from '$lib/api'; - import * as Card from '$lib/components/ui/card/index.js'; import * as Table from '$lib/components/ui/table/index.js'; - import { Badge } from '$lib/components/ui/badge/index.js'; import Chart from '$lib/components/chart.svelte'; import { Chart as ChartJS, @@ -221,185 +219,138 @@
-

Analytics Overview

+

Analytics Overview

{#if loading} -

Loading...

+
+ + Loading... +
{:else if error}

{error}

{:else if data} -
- - - Total Commits - - -

{fmtNum(data.total_commits)}

-
-
- - - Sessions - - -

{fmtNum(data.total_sessions)}

-
-
- - - Total Tokens - - -

{fmtNum(data.total_tokens)}

-
-
- - - Active Authors - - -

{data.active_authors}

-
-
- - - AI % - - -

+

+
+
+
Total Commits
+
{fmtNum(data.total_commits)}
+
+
+
Sessions
+
{fmtNum(data.total_sessions)}
+
+
+
Total Tokens
+
{fmtNum(data.total_tokens)}
+
+
+
Active Authors
+
{data.active_authors}
+
+
+
AI %
+
{data.ai_percentage != null ? `${data.ai_percentage.toFixed(1)}%` : 'N/A'} -

- - - - - Estimated Cost - - -

{fmtCost(data.estimated_cost_usd)}

-
-
+
+
+
+
Estimated Cost
+
{fmtCost(data.estimated_cost_usd)}
+
+
-
- - - Avg Session Duration - - -

{fmtDuration(data.avg_session_duration_ms)}

-
-
- - - Total Tool Calls - - -

{fmtNum(data.total_tool_calls)}

-
-
- - - Cache Savings - - -

{fmtCost(data.cache_savings_usd)}

-
-
+
+
+
+
Avg Session Duration
+
{fmtDuration(data.avg_session_duration_ms)}
+
+
+
Total Tool Calls
+
{fmtNum(data.total_tool_calls)}
+
+
+
Cache Savings
+
{fmtCost(data.cache_savings_usd)}
+
+
- - - - Tokens Over Time - - - - {#if data.tokens_over_time.length > 0} - - {:else} -

No data

- {/if} -
-
+
+

+ Tokens Over Time +

+ {#if data.tokens_over_time.length > 0} + + {:else} +

No data

+ {/if} +
- - - - Top Repos by Tokens - - - - {#if data.top_repos.length > 0} - - {:else} -

No data

- {/if} -
-
+
+

+ Top Repos by Tokens +

+ {#if data.top_repos.length > 0} + + {:else} +

No data

+ {/if} +
- - - - Hourly Activity - - - - {#if data.hourly_activity.length > 0} - - {:else} -

No data

- {/if} -
-
+
+

+ Hourly Activity +

+ {#if data.hourly_activity.length > 0} + + {:else} +

No data

+ {/if} +
- - - - Sessions Over Time - - - - {#if data.sessions_over_time.length > 0} - - {:else} -

No data

- {/if} -
-
+
+

+ Sessions Over Time +

+ {#if data.sessions_over_time.length > 0} + + {:else} +

No data

+ {/if} +
- - - - Model Distribution - - - +
+

+ Model Distribution +

+
{#if data.model_distribution.length > 0}
No data

{/if} - - +
+
- - - Recent Commits - - - {#if data.recent_commits.length > 0} - - - - Commit - Author - Sessions - Tokens - Date +
+

Recent Commits

+ {#if data.recent_commits.length > 0} + + + + Commit + Author + Sessions + Tokens + Date + + + + {#each data.recent_commits as commit} + + {commit.commit_sha.slice(0, 8)} + {commit.author} + {commit.session_count} + {fmtNum(commit.total_tokens)} + {fmtDate(commit.created_at)} - - - {#each data.recent_commits as commit} - - {commit.commit_sha.slice(0, 8)} - {commit.author} - {commit.session_count} - {fmtNum(commit.total_tokens)} - {fmtDate(commit.created_at)} - - {/each} - - - {:else} -

No commits

- {/if} - - + {/each} + + + {:else} +

No commits

+ {/if} +
{/if}
diff --git a/web/src/routes/orgs/[slug]/analytics/attribution/+page.svelte b/web/src/routes/orgs/[slug]/analytics/attribution/+page.svelte index 8bb13c9..279f9b8 100644 --- a/web/src/routes/orgs/[slug]/analytics/attribution/+page.svelte +++ b/web/src/routes/orgs/[slug]/analytics/attribution/+page.svelte @@ -1,7 +1,6 @@ @@ -172,7 +187,7 @@ {/if}
-

Members

+

Members

{#if isAdmin} @@ -229,35 +244,37 @@
{#if loading} -

Loading...

+
+ + Loading... +
{:else if members.length === 0} -

No members found.

+

No members found.

{:else} - Email - Name - Role - Joined + Email + Name + Role + Joined {#if isOwner} - Actions + Actions {/if} {#each members as member} - - {member.email} - {member.name ?? '-'} - - - {member.role} - + + {member.email} + {member.name ?? '-'} + + {@const rc = roleColor(member.role)} + {member.role} - {formatDate(member.created_at)} + {formatDate(member.created_at)} {#if isOwner} - + {#if member.role !== 'owner' && member.id !== authState.user?.user_id}
{ if (v) changeRole(member.id, v); }}> @@ -287,29 +304,28 @@ {#if isAdmin && invRequests.length > 0}
-

Invitation Requests

+

Invitation Requests

- Email - Name - Status - Requested - Actions + Email + Name + Status + Requested + Actions {#each invRequests as req} - - {req.email} - {req.name ?? '-'} - - - {req.status} - + + {req.email} + {req.name ?? '-'} + + {@const sc = statusColor(req.status)} + {req.status} - {formatDate(req.created_at)} - + {formatDate(req.created_at)} + {#if req.status === 'pending'}
diff --git a/web/src/routes/orgs/[slug]/settings/org/+page.svelte b/web/src/routes/orgs/[slug]/settings/org/+page.svelte index 78910a2..ea23b19 100644 --- a/web/src/routes/orgs/[slug]/settings/org/+page.svelte +++ b/web/src/routes/orgs/[slug]/settings/org/+page.svelte @@ -3,7 +3,6 @@ import { page } from '$app/stores'; import { api } from '$lib/api'; import { orgStore } from '$lib/stores/org'; - import * as Card from '$lib/components/ui/card/index.js'; import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; @@ -89,14 +88,12 @@ {/if} {#if org} - - - General - - + diff --git a/web/src/routes/orgs/[slug]/traces/+page.svelte b/web/src/routes/orgs/[slug]/traces/+page.svelte index 875022d..da929e3 100644 --- a/web/src/routes/orgs/[slug]/traces/+page.svelte +++ b/web/src/routes/orgs/[slug]/traces/+page.svelte @@ -3,7 +3,6 @@ import { page } from '$app/stores'; import { api } from '$lib/api'; import * as Table from '$lib/components/ui/table/index.js'; - import { Badge } from '$lib/components/ui/badge/index.js'; interface CommitListItem { id: string; @@ -51,13 +50,16 @@

Commits

{#if loading} -

Loading...

+
+ + Loading... +
{:else if error}

{error}

{:else if commits.length === 0}

No commits yet. Push traces using the CLI.

{:else} - + Commit @@ -70,16 +72,18 @@ {#each commits as commit} - +
{commit.commit_sha.slice(0, 8)} - {commit.author} + + {commit.author} + {#if commit.branch} - {commit.branch} + {commit.branch} {:else} - {/if} From 977f2d42b3dcdc9784435ddf863279b3ccbc98f8 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 24 Mar 2026 07:36:16 +0100 Subject: [PATCH 15/15] update cargo.lock --- Cargo.lock | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index dbac54f..f73a0d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2718,8 +2718,21 @@ dependencies = [ name = "tracevault-enterprise" version = "0.1.0" dependencies = [ + "aes-gcm", "async-trait", + "base64", + "chrono", + "ed25519-dalek", + "glob-match", + "hex", + "rand", + "reqwest", + "serde", + "serde_json", + "sha2", "tracevault-core", + "tracing", + "uuid", ] [[package]]