agent-orchestrator/agor-pro/src/analytics.rs
Hibryda 03fe2e2237 feat(pro): add analytics, export, and multi-account commercial features
3 new agor-pro modules: analytics.rs (usage dashboard queries),
export.rs (session/project Markdown report generation),
profiles.rs (multi-account switching via accounts.json).
9 Tauri plugin commands. Frontend IPC bridge (pro-bridge.ts).
168 cargo tests, 14 commercial vitest tests.
2026-03-17 01:52:46 +01:00

176 lines
5.7 KiB
Rust

// SPDX-License-Identifier: LicenseRef-Commercial
// Analytics Dashboard — historical cost tracking, model usage, token trends.
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsSummary {
pub total_sessions: i64,
pub total_cost_usd: f64,
pub total_tokens: i64,
pub total_turns: i64,
pub total_tool_calls: i64,
pub avg_cost_per_session: f64,
pub avg_tokens_per_session: f64,
pub period_days: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyStats {
pub date: String,
pub session_count: i64,
pub cost_usd: f64,
pub tokens: i64,
pub turns: i64,
pub tool_calls: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBreakdown {
pub model: String,
pub session_count: i64,
pub total_cost_usd: f64,
pub total_tokens: i64,
pub avg_cost_per_session: f64,
}
#[tauri::command]
pub fn pro_analytics_summary(project_id: String, days: Option<i64>) -> Result<AnalyticsSummary, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
).map_err(|e| format!("Query failed: {e}"))?;
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
let count: i64 = row.get(0)?;
let cost: f64 = row.get(1)?;
let tokens: i64 = row.get(2)?;
let turns: i64 = row.get(3)?;
let tools: i64 = row.get(4)?;
Ok(AnalyticsSummary {
total_sessions: count,
total_cost_usd: cost,
total_tokens: tokens,
total_turns: turns,
total_tool_calls: tools,
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
avg_tokens_per_session: if count > 0 { tokens as f64 / count as f64 } else { 0.0 },
period_days: period,
})
}).map_err(|e| format!("Query failed: {e}"))?;
Ok(result)
}
#[tauri::command]
pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<DailyStats>, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT date(end_time, 'unixepoch') as day,
COUNT(*), COALESCE(SUM(cost_usd), 0), COALESCE(SUM(peak_tokens), 0),
COALESCE(SUM(turn_count), 0), COALESCE(SUM(tool_call_count), 0)
FROM session_metrics
WHERE project_id = ?1 AND end_time >= ?2
GROUP BY day ORDER BY day ASC"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
Ok(DailyStats {
date: row.get(0)?,
session_count: row.get(1)?,
cost_usd: row.get(2)?,
tokens: row.get(3)?,
turns: row.get(4)?,
tool_calls: row.get(5)?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_analytics_model_breakdown(project_id: String, days: Option<i64>) -> Result<Vec<ModelBreakdown>, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT COALESCE(model, 'unknown'), COUNT(*), COALESCE(SUM(cost_usd), 0),
COALESCE(SUM(peak_tokens), 0)
FROM session_metrics
WHERE project_id = ?1 AND end_time >= ?2
GROUP BY model ORDER BY SUM(cost_usd) DESC"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
let count: i64 = row.get(1)?;
let cost: f64 = row.get(2)?;
Ok(ModelBreakdown {
model: row.get(0)?,
session_count: count,
total_cost_usd: cost,
total_tokens: row.get(3)?,
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
pub(crate) fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_analytics_summary_struct() {
let s = AnalyticsSummary {
total_sessions: 5,
total_cost_usd: 1.25,
total_tokens: 50000,
total_turns: 30,
total_tool_calls: 100,
avg_cost_per_session: 0.25,
avg_tokens_per_session: 10000.0,
period_days: 30,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("totalSessions"));
assert!(json.contains("avgCostPerSession"));
}
#[test]
fn test_model_breakdown_serializes_camel_case() {
let m = ModelBreakdown {
model: "opus".into(),
session_count: 3,
total_cost_usd: 0.75,
total_tokens: 30000,
avg_cost_per_session: 0.25,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("sessionCount"));
assert!(json.contains("totalCostUsd"));
}
}