Skip to main content

systemprompt_cli/commands/analytics/
overview.rs

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