Skip to main content

systemprompt_cli/commands/analytics/
overview.rs

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}