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::{CommandResult, RenderingHints};
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(
92    args: OverviewArgs,
93    _config: &CliConfig,
94) -> Result<CommandResult<OverviewOutput>> {
95    let ctx = AppContext::new().await?;
96    let repo = OverviewAnalyticsRepository::new(ctx.db_pool())?;
97    execute_internal(args, &repo).await
98}
99
100pub async fn execute_with_pool(
101    args: OverviewArgs,
102    db_ctx: &DatabaseContext,
103    _config: &CliConfig,
104) -> Result<CommandResult<OverviewOutput>> {
105    let repo = OverviewAnalyticsRepository::new(db_ctx.db_pool())?;
106    execute_internal(args, &repo).await
107}
108
109async fn execute_internal(
110    args: OverviewArgs,
111    repo: &OverviewAnalyticsRepository,
112) -> Result<CommandResult<OverviewOutput>> {
113    let (start, end) = parse_time_range(args.since.as_ref(), args.until.as_ref())?;
114    let output = fetch_overview_data(repo, start, end).await?;
115
116    if let Some(ref path) = args.export {
117        let resolved_path = resolve_export_path(path)?;
118        export_overview_csv(&output, &resolved_path)?;
119        CliService::success(&format!("Exported to {}", resolved_path.display()));
120        return Ok(CommandResult::dashboard(output).with_skip_render());
121    }
122
123    Ok(CommandResult::dashboard(output)
124        .with_title("Analytics Overview")
125        .with_hints(RenderingHints::default()))
126}
127
128async fn fetch_overview_data(
129    repo: &OverviewAnalyticsRepository,
130    start: DateTime<Utc>,
131    end: DateTime<Utc>,
132) -> Result<OverviewOutput> {
133    let period_duration = end - start;
134    let prev_start = start - period_duration;
135
136    let current_conversations = repo.get_conversation_count(start, end).await?;
137    let prev_conversations = repo.get_conversation_count(prev_start, start).await?;
138
139    let conversations = ConversationMetrics {
140        total: current_conversations,
141        change_percent: calculate_change(current_conversations, prev_conversations),
142    };
143
144    let agent_metrics = repo.get_agent_metrics(start, end).await?;
145    let success_rate = if agent_metrics.total_tasks > 0 {
146        (agent_metrics.completed_tasks as f64 / agent_metrics.total_tasks as f64) * 100.0
147    } else {
148        0.0
149    };
150
151    let agents = AgentMetrics {
152        active_count: agent_metrics.active_agents,
153        total_tasks: agent_metrics.total_tasks,
154        success_rate,
155    };
156
157    let request_metrics = repo.get_request_metrics(start, end).await?;
158    let requests = RequestMetrics {
159        total: request_metrics.total,
160        total_tokens: request_metrics.total_tokens.unwrap_or(0),
161        avg_latency_ms: request_metrics.avg_latency.map_or(0, |v| v as i64),
162    };
163
164    let tool_metrics = repo.get_tool_metrics(start, end).await?;
165    let tool_success_rate = if tool_metrics.total > 0 {
166        (tool_metrics.successful as f64 / tool_metrics.total as f64) * 100.0
167    } else {
168        0.0
169    };
170
171    let tools = ToolMetrics {
172        total_executions: tool_metrics.total,
173        success_rate: tool_success_rate,
174    };
175
176    let active_sessions = repo.get_active_session_count(start).await?;
177    let total_sessions = repo.get_total_session_count(start, end).await?;
178
179    let sessions = SessionMetrics {
180        active: active_sessions,
181        total_today: total_sessions,
182    };
183
184    let current_cost = repo.get_cost(start, end).await?;
185    let prev_cost = repo.get_cost(prev_start, start).await?;
186
187    let costs = CostMetrics {
188        total_cost_microdollars: current_cost.cost.unwrap_or(0),
189        change_percent: calculate_change(
190            current_cost.cost.unwrap_or(0),
191            prev_cost.cost.unwrap_or(0),
192        ),
193    };
194
195    Ok(OverviewOutput {
196        period: format_period(start, end),
197        conversations,
198        agents,
199        requests,
200        tools,
201        sessions,
202        costs,
203    })
204}
205
206fn calculate_change(current: i64, previous: i64) -> Option<f64> {
207    (previous != 0).then(|| ((current - previous) as f64 / previous as f64) * 100.0)
208}
209
210fn format_period(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
211    format!(
212        "{} to {}",
213        start.format("%Y-%m-%d %H:%M"),
214        end.format("%Y-%m-%d %H:%M")
215    )
216}
217
218fn export_overview_csv(output: &OverviewOutput, path: &std::path::Path) -> Result<()> {
219    let mut csv = CsvBuilder::new().headers(vec![
220        "period",
221        "conversations_total",
222        "conversations_change_pct",
223        "agents_active",
224        "agents_tasks",
225        "agents_success_rate",
226        "requests_total",
227        "requests_tokens",
228        "requests_avg_latency_ms",
229        "tools_executions",
230        "tools_success_rate",
231        "sessions_currently_active",
232        "sessions_created_in_period",
233        "costs_microdollars",
234        "costs_change_pct",
235    ]);
236
237    csv.add_row(vec![
238        output.period.clone(),
239        output.conversations.total.to_string(),
240        output
241            .conversations
242            .change_percent
243            .map_or(String::new(), |v| format!("{:.2}", v)),
244        output.agents.active_count.to_string(),
245        output.agents.total_tasks.to_string(),
246        format!("{:.2}", output.agents.success_rate),
247        output.requests.total.to_string(),
248        output.requests.total_tokens.to_string(),
249        output.requests.avg_latency_ms.to_string(),
250        output.tools.total_executions.to_string(),
251        format!("{:.2}", output.tools.success_rate),
252        output.sessions.active.to_string(),
253        output.sessions.total_today.to_string(),
254        output.costs.total_cost_microdollars.to_string(),
255        output
256            .costs
257            .change_percent
258            .map_or(String::new(), |v| format!("{:.2}", v)),
259    ]);
260
261    csv.write_to_file(path)
262}