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::{CsvBuilder, parse_time_range, resolve_export_path};
12use crate::CliConfig;
13use crate::shared::{CommandResult, RenderingHints};
14
15#[derive(Debug, Args)]
16pub struct OverviewArgs {
17 #[arg(
18 long,
19 alias = "from",
20 default_value = "24h",
21 help = "Time range (e.g., '1h', '24h', '7d')"
22 )]
23 pub since: Option<String>,
24
25 #[arg(long, alias = "to", help = "End time for range")]
26 pub until: Option<String>,
27
28 #[arg(long, help = "Export results to CSV file")]
29 pub export: Option<PathBuf>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
33pub struct OverviewOutput {
34 pub period: String,
35 pub conversations: ConversationMetrics,
36 pub agents: AgentMetrics,
37 pub requests: RequestMetrics,
38 pub tools: ToolMetrics,
39 pub sessions: SessionMetrics,
40 pub costs: CostMetrics,
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
44pub struct ConversationMetrics {
45 pub total: i64,
46 pub change_percent: Option<f64>,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
50pub struct AgentMetrics {
51 pub active_count: i64,
52 pub total_tasks: i64,
53 pub success_rate: f64,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
57pub struct RequestMetrics {
58 pub total: i64,
59 pub total_tokens: i64,
60 pub avg_latency_ms: i64,
61}
62
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
64pub struct ToolMetrics {
65 pub total_executions: i64,
66 pub success_rate: f64,
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
70pub struct SessionMetrics {
71 #[serde(rename = "currently_active")]
72 pub active: i64,
73 #[serde(rename = "created_in_period")]
74 pub total_today: i64,
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
78pub struct CostMetrics {
79 pub total_cost_microdollars: i64,
80 pub change_percent: Option<f64>,
81}
82
83pub async fn execute(
84 args: OverviewArgs,
85 _config: &CliConfig,
86) -> Result<CommandResult<OverviewOutput>> {
87 let ctx = AppContext::new().await?;
88 let repo = OverviewAnalyticsRepository::new(ctx.db_pool())?;
89 execute_internal(args, &repo).await
90}
91
92pub async fn execute_with_pool(
93 args: OverviewArgs,
94 db_ctx: &DatabaseContext,
95 _config: &CliConfig,
96) -> Result<CommandResult<OverviewOutput>> {
97 let repo = OverviewAnalyticsRepository::new(db_ctx.db_pool())?;
98 execute_internal(args, &repo).await
99}
100
101async fn execute_internal(
102 args: OverviewArgs,
103 repo: &OverviewAnalyticsRepository,
104) -> Result<CommandResult<OverviewOutput>> {
105 let (start, end) = parse_time_range(args.since.as_ref(), args.until.as_ref())?;
106 let output = fetch_overview_data(repo, start, end).await?;
107
108 if let Some(ref path) = args.export {
109 let resolved_path = resolve_export_path(path)?;
110 export_overview_csv(&output, &resolved_path)?;
111 CliService::success(&format!("Exported to {}", resolved_path.display()));
112 return Ok(CommandResult::dashboard(output).with_skip_render());
113 }
114
115 Ok(CommandResult::dashboard(output)
116 .with_title("Analytics Overview")
117 .with_hints(RenderingHints::default()))
118}
119
120async fn fetch_overview_data(
121 repo: &OverviewAnalyticsRepository,
122 start: DateTime<Utc>,
123 end: DateTime<Utc>,
124) -> Result<OverviewOutput> {
125 let period_duration = end - start;
126 let prev_start = start - period_duration;
127
128 let current_conversations = repo.get_conversation_count(start, end).await?;
129 let prev_conversations = repo.get_conversation_count(prev_start, start).await?;
130
131 let conversations = ConversationMetrics {
132 total: current_conversations,
133 change_percent: calculate_change(current_conversations, prev_conversations),
134 };
135
136 let agent_metrics = repo.get_agent_metrics(start, end).await?;
137 let success_rate = if agent_metrics.total_tasks > 0 {
138 (agent_metrics.completed_tasks as f64 / agent_metrics.total_tasks as f64) * 100.0
139 } else {
140 0.0
141 };
142
143 let agents = AgentMetrics {
144 active_count: agent_metrics.active_agents,
145 total_tasks: agent_metrics.total_tasks,
146 success_rate,
147 };
148
149 let request_metrics = repo.get_request_metrics(start, end).await?;
150 let requests = RequestMetrics {
151 total: request_metrics.total,
152 total_tokens: request_metrics.total_tokens.unwrap_or(0),
153 avg_latency_ms: request_metrics.avg_latency.map_or(0, |v| v as i64),
154 };
155
156 let tool_metrics = repo.get_tool_metrics(start, end).await?;
157 let tool_success_rate = if tool_metrics.total > 0 {
158 (tool_metrics.successful as f64 / tool_metrics.total as f64) * 100.0
159 } else {
160 0.0
161 };
162
163 let tools = ToolMetrics {
164 total_executions: tool_metrics.total,
165 success_rate: tool_success_rate,
166 };
167
168 let active_sessions = repo.get_active_session_count(start).await?;
169 let total_sessions = repo.get_total_session_count(start, end).await?;
170
171 let sessions = SessionMetrics {
172 active: active_sessions,
173 total_today: total_sessions,
174 };
175
176 let current_cost = repo.get_cost(start, end).await?;
177 let prev_cost = repo.get_cost(prev_start, start).await?;
178
179 let costs = CostMetrics {
180 total_cost_microdollars: current_cost.cost.unwrap_or(0),
181 change_percent: calculate_change(
182 current_cost.cost.unwrap_or(0),
183 prev_cost.cost.unwrap_or(0),
184 ),
185 };
186
187 Ok(OverviewOutput {
188 period: format_period(start, end),
189 conversations,
190 agents,
191 requests,
192 tools,
193 sessions,
194 costs,
195 })
196}
197
198fn calculate_change(current: i64, previous: i64) -> Option<f64> {
199 (previous != 0).then(|| ((current - previous) as f64 / previous as f64) * 100.0)
200}
201
202fn format_period(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
203 format!(
204 "{} to {}",
205 start.format("%Y-%m-%d %H:%M"),
206 end.format("%Y-%m-%d %H:%M")
207 )
208}
209
210fn export_overview_csv(output: &OverviewOutput, path: &std::path::Path) -> Result<()> {
211 let mut csv = CsvBuilder::new().headers(vec![
212 "period",
213 "conversations_total",
214 "conversations_change_pct",
215 "agents_active",
216 "agents_tasks",
217 "agents_success_rate",
218 "requests_total",
219 "requests_tokens",
220 "requests_avg_latency_ms",
221 "tools_executions",
222 "tools_success_rate",
223 "sessions_currently_active",
224 "sessions_created_in_period",
225 "costs_microdollars",
226 "costs_change_pct",
227 ]);
228
229 csv.add_row(vec![
230 output.period.clone(),
231 output.conversations.total.to_string(),
232 output
233 .conversations
234 .change_percent
235 .map_or(String::new(), |v| format!("{:.2}", v)),
236 output.agents.active_count.to_string(),
237 output.agents.total_tasks.to_string(),
238 format!("{:.2}", output.agents.success_rate),
239 output.requests.total.to_string(),
240 output.requests.total_tokens.to_string(),
241 output.requests.avg_latency_ms.to_string(),
242 output.tools.total_executions.to_string(),
243 format!("{:.2}", output.tools.success_rate),
244 output.sessions.active.to_string(),
245 output.sessions.total_today.to_string(),
246 output.costs.total_cost_microdollars.to_string(),
247 output
248 .costs
249 .change_percent
250 .map_or(String::new(), |v| format!("{:.2}", v)),
251 ]);
252
253 csv.write_to_file(path)
254}