1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use clap::Args;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7use systemprompt_analytics::OverviewAnalyticsRepository;
8use systemprompt_logging::CliService;
9use systemprompt_runtime::{AppContext, DatabaseContext};
10
11use super::shared::{
12 format_cost, format_duration_ms, format_number, format_percent, parse_time_range, CsvBuilder,
13 MetricCard,
14};
15use crate::shared::{render_result, CommandResult, RenderingHints};
16use crate::CliConfig;
17
18#[derive(Debug, Args)]
19pub struct OverviewArgs {
20 #[arg(
21 long,
22 default_value = "24h",
23 help = "Time range (e.g., '1h', '24h', '7d')"
24 )]
25 pub since: Option<String>,
26
27 #[arg(long, help = "End time for range")]
28 pub until: Option<String>,
29
30 #[arg(long, help = "Export results to CSV file")]
31 pub export: Option<PathBuf>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35pub struct OverviewOutput {
36 pub period: String,
37 pub conversations: ConversationMetrics,
38 pub agents: AgentMetrics,
39 pub requests: RequestMetrics,
40 pub tools: ToolMetrics,
41 pub sessions: SessionMetrics,
42 pub costs: CostMetrics,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
46pub struct ConversationMetrics {
47 pub total: i64,
48 pub change_percent: Option<f64>,
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
52pub struct AgentMetrics {
53 pub active_count: i64,
54 pub total_tasks: i64,
55 pub success_rate: f64,
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
59pub struct RequestMetrics {
60 pub total: i64,
61 pub total_tokens: i64,
62 pub avg_latency_ms: i64,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
66pub struct ToolMetrics {
67 pub total_executions: i64,
68 pub success_rate: f64,
69}
70
71#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
72pub struct SessionMetrics {
73 pub active: i64,
74 pub total_today: i64,
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
78pub struct CostMetrics {
79 pub total_cents: i64,
80 pub change_percent: Option<f64>,
81}
82
83pub async fn execute(args: OverviewArgs, config: &CliConfig) -> Result<()> {
84 let ctx = AppContext::new().await?;
85 let repo = OverviewAnalyticsRepository::new(ctx.db_pool())?;
86 execute_internal(args, &repo, config).await
87}
88
89pub async fn execute_with_pool(
90 args: OverviewArgs,
91 db_ctx: &DatabaseContext,
92 config: &CliConfig,
93) -> Result<()> {
94 let repo = OverviewAnalyticsRepository::new(db_ctx.db_pool())?;
95 execute_internal(args, &repo, config).await
96}
97
98async fn execute_internal(
99 args: OverviewArgs,
100 repo: &OverviewAnalyticsRepository,
101 config: &CliConfig,
102) -> Result<()> {
103 let (start, end) = parse_time_range(args.since.as_ref(), args.until.as_ref())?;
104 let output = fetch_overview_data(repo, start, end).await?;
105
106 if let Some(ref path) = args.export {
107 export_overview_csv(&output, path)?;
108 CliService::success(&format!("Exported to {}", path.display()));
109 return Ok(());
110 }
111
112 if config.is_json_output() {
113 let result = CommandResult::dashboard(output)
114 .with_title("Analytics Overview")
115 .with_hints(RenderingHints::default());
116 render_result(&result);
117 } else {
118 render_overview(&output);
119 }
120
121 Ok(())
122}
123
124async fn fetch_overview_data(
125 repo: &OverviewAnalyticsRepository,
126 start: DateTime<Utc>,
127 end: DateTime<Utc>,
128) -> Result<OverviewOutput> {
129 let period_duration = end - start;
130 let prev_start = start - period_duration;
131
132 let current_conversations = repo.get_conversation_count(start, end).await?;
133 let prev_conversations = repo.get_conversation_count(prev_start, start).await?;
134
135 let conversations = ConversationMetrics {
136 total: current_conversations,
137 change_percent: calculate_change(current_conversations, prev_conversations),
138 };
139
140 let agent_metrics = repo.get_agent_metrics(start, end).await?;
141 let success_rate = if agent_metrics.total_tasks > 0 {
142 (agent_metrics.completed_tasks as f64 / agent_metrics.total_tasks as f64) * 100.0
143 } else {
144 0.0
145 };
146
147 let agents = AgentMetrics {
148 active_count: agent_metrics.active_agents,
149 total_tasks: agent_metrics.total_tasks,
150 success_rate,
151 };
152
153 let request_metrics = repo.get_request_metrics(start, end).await?;
154 let requests = RequestMetrics {
155 total: request_metrics.total,
156 total_tokens: request_metrics.total_tokens.unwrap_or(0),
157 avg_latency_ms: request_metrics.avg_latency.map_or(0, |v| v as i64),
158 };
159
160 let tool_metrics = repo.get_tool_metrics(start, end).await?;
161 let tool_success_rate = if tool_metrics.total > 0 {
162 (tool_metrics.successful as f64 / tool_metrics.total as f64) * 100.0
163 } else {
164 0.0
165 };
166
167 let tools = ToolMetrics {
168 total_executions: tool_metrics.total,
169 success_rate: tool_success_rate,
170 };
171
172 let active_sessions = repo.get_active_session_count(start).await?;
173 let total_sessions = repo.get_total_session_count(start, end).await?;
174
175 let sessions = SessionMetrics {
176 active: active_sessions,
177 total_today: total_sessions,
178 };
179
180 let current_cost = repo.get_cost(start, end).await?;
181 let prev_cost = repo.get_cost(prev_start, start).await?;
182
183 let costs = CostMetrics {
184 total_cents: current_cost.cost.unwrap_or(0),
185 change_percent: calculate_change(
186 current_cost.cost.unwrap_or(0),
187 prev_cost.cost.unwrap_or(0),
188 ),
189 };
190
191 Ok(OverviewOutput {
192 period: format_period(start, end),
193 conversations,
194 agents,
195 requests,
196 tools,
197 sessions,
198 costs,
199 })
200}
201
202fn calculate_change(current: i64, previous: i64) -> Option<f64> {
203 (previous != 0).then(|| ((current - previous) as f64 / previous as f64) * 100.0)
204}
205
206fn format_period(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
207 format!(
208 "{} to {}",
209 start.format("%Y-%m-%d %H:%M"),
210 end.format("%Y-%m-%d %H:%M")
211 )
212}
213
214fn format_change_percent(change: Option<f64>) -> String {
215 change.map_or_else(String::new, |c| {
216 let sign = if c >= 0.0 { "+" } else { "" };
217 format!("{}{:.1}%", sign, c)
218 })
219}
220
221fn render_overview(output: &OverviewOutput) {
222 CliService::section(&format!("Analytics Overview ({})", output.period));
223
224 let cards = vec![
225 MetricCard::new("Conversations", format_number(output.conversations.total))
226 .with_change(format_change_percent(output.conversations.change_percent)),
227 MetricCard::new("Active Agents", format_number(output.agents.active_count)).with_secondary(
228 format!("{} tasks", format_number(output.agents.total_tasks)),
229 ),
230 MetricCard::new("AI Requests", format_number(output.requests.total)).with_secondary(
231 format!(
232 "avg {}ms",
233 format_duration_ms(output.requests.avg_latency_ms)
234 ),
235 ),
236 MetricCard::new(
237 "Tool Executions",
238 format_number(output.tools.total_executions),
239 )
240 .with_secondary(format!(
241 "{} success",
242 format_percent(output.tools.success_rate)
243 )),
244 MetricCard::new("Active Sessions", format_number(output.sessions.active)).with_secondary(
245 format!("{} total", format_number(output.sessions.total_today)),
246 ),
247 MetricCard::new("Total Cost", format_cost(output.costs.total_cents))
248 .with_change(format_change_percent(output.costs.change_percent)),
249 ];
250
251 for card in cards {
252 let change_str = card.change.as_deref().unwrap_or("");
253 let secondary_str = card.secondary.as_deref().unwrap_or("");
254 CliService::key_value(
255 &card.label,
256 format!("{} {} {}", card.value, change_str, secondary_str).trim(),
257 );
258 }
259}
260
261fn export_overview_csv(output: &OverviewOutput, path: &std::path::Path) -> Result<()> {
262 let mut csv = CsvBuilder::new().headers(vec![
263 "period",
264 "conversations_total",
265 "conversations_change_pct",
266 "agents_active",
267 "agents_tasks",
268 "agents_success_rate",
269 "requests_total",
270 "requests_tokens",
271 "requests_avg_latency_ms",
272 "tools_executions",
273 "tools_success_rate",
274 "sessions_active",
275 "sessions_total",
276 "costs_cents",
277 "costs_change_pct",
278 ]);
279
280 csv.add_row(vec![
281 output.period.clone(),
282 output.conversations.total.to_string(),
283 output
284 .conversations
285 .change_percent
286 .map_or(String::new(), |v| format!("{:.2}", v)),
287 output.agents.active_count.to_string(),
288 output.agents.total_tasks.to_string(),
289 format!("{:.2}", output.agents.success_rate),
290 output.requests.total.to_string(),
291 output.requests.total_tokens.to_string(),
292 output.requests.avg_latency_ms.to_string(),
293 output.tools.total_executions.to_string(),
294 format!("{:.2}", output.tools.success_rate),
295 output.sessions.active.to_string(),
296 output.sessions.total_today.to_string(),
297 output.costs.total_cents.to_string(),
298 output
299 .costs
300 .change_percent
301 .map_or(String::new(), |v| format!("{:.2}", v)),
302 ]);
303
304 csv.write_to_file(path)
305}