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//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use anyhow::Result;
13use chrono::{DateTime, Utc};
14use clap::Args;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18use systemprompt_analytics::OverviewAnalyticsRepository;
19use systemprompt_logging::CliService;
20use systemprompt_runtime::DatabaseContext;
21
22use super::shared::{CsvBuilder, parse_time_range, resolve_export_path};
23use crate::CliConfig;
24use crate::shared::CommandOutput;
25
26#[derive(Debug, Args)]
27pub struct OverviewArgs {
28    #[arg(
29        long,
30        alias = "from",
31        default_value = "24h",
32        help = "Time range (e.g., '1h', '24h', '7d')"
33    )]
34    pub since: Option<String>,
35
36    #[arg(long, alias = "to", help = "End time for range")]
37    pub until: Option<String>,
38
39    #[arg(long, help = "Export results to CSV file")]
40    pub export: Option<PathBuf>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct OverviewOutput {
45    pub period: String,
46    pub conversations: ConversationMetrics,
47    pub agents: AgentMetrics,
48    pub requests: RequestMetrics,
49    pub tools: ToolMetrics,
50    pub sessions: SessionMetrics,
51    pub costs: CostMetrics,
52}
53
54#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
55pub struct ConversationMetrics {
56    pub total: i64,
57    pub change_percent: Option<f64>,
58}
59
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
61pub struct AgentMetrics {
62    pub active_count: i64,
63    pub total_tasks: i64,
64    pub success_rate: f64,
65}
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
68pub struct RequestMetrics {
69    pub total: i64,
70    pub total_tokens: i64,
71    pub avg_latency_ms: i64,
72}
73
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
75pub struct ToolMetrics {
76    pub total_executions: i64,
77    pub success_rate: f64,
78}
79
80#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
81pub struct SessionMetrics {
82    #[serde(rename = "currently_active")]
83    pub active: i64,
84    #[serde(rename = "created_in_period")]
85    pub total_today: i64,
86}
87
88#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
89pub struct CostMetrics {
90    pub total_cost_microdollars: i64,
91    pub change_percent: Option<f64>,
92}
93
94pub async fn execute_with_pool(
95    args: OverviewArgs,
96    db_ctx: &DatabaseContext,
97    _config: &CliConfig,
98) -> Result<CommandOutput> {
99    let repo = OverviewAnalyticsRepository::new(db_ctx.db_pool())?;
100    execute_internal(args, &repo).await
101}
102
103async fn execute_internal(
104    args: OverviewArgs,
105    repo: &OverviewAnalyticsRepository,
106) -> Result<CommandOutput> {
107    let (start, end) = parse_time_range(args.since.as_ref(), args.until.as_ref())?;
108    let output = fetch_overview_data(repo, start, end).await?;
109
110    if let Some(ref path) = args.export {
111        let resolved_path = resolve_export_path(path)?;
112        export_overview_csv(&output, &resolved_path)?;
113        CliService::success(&format!("Exported to {}", resolved_path.display()));
114        return Ok(CommandOutput::card_value("Analytics Overview", &output).with_skip_render());
115    }
116
117    Ok(CommandOutput::card_value("Analytics Overview", &output))
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
198/// Percentage change from `previous` to `current`; `None` when the previous
199/// period had no data.
200#[must_use]
201pub fn calculate_change(current: i64, previous: i64) -> Option<f64> {
202    (previous != 0).then(|| ((current - previous) as f64 / previous as f64) * 100.0)
203}
204
205#[must_use]
206pub fn 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
214pub fn export_overview_csv(output: &OverviewOutput, path: &std::path::Path) -> Result<()> {
215    let mut csv = CsvBuilder::new().headers(vec![
216        "period",
217        "conversations_total",
218        "conversations_change_pct",
219        "agents_active",
220        "agents_tasks",
221        "agents_success_rate",
222        "requests_total",
223        "requests_tokens",
224        "requests_avg_latency_ms",
225        "tools_executions",
226        "tools_success_rate",
227        "sessions_currently_active",
228        "sessions_created_in_period",
229        "costs_microdollars",
230        "costs_change_pct",
231    ]);
232
233    csv.add_row(vec![
234        output.period.clone(),
235        output.conversations.total.to_string(),
236        output
237            .conversations
238            .change_percent
239            .map_or(String::new(), |v| format!("{:.2}", v)),
240        output.agents.active_count.to_string(),
241        output.agents.total_tasks.to_string(),
242        format!("{:.2}", output.agents.success_rate),
243        output.requests.total.to_string(),
244        output.requests.total_tokens.to_string(),
245        output.requests.avg_latency_ms.to_string(),
246        output.tools.total_executions.to_string(),
247        format!("{:.2}", output.tools.success_rate),
248        output.sessions.active.to_string(),
249        output.sessions.total_today.to_string(),
250        output.costs.total_cost_microdollars.to_string(),
251        output
252            .costs
253            .change_percent
254            .map_or(String::new(), |v| format!("{:.2}", v)),
255    ]);
256
257    csv.write_to_file(path)
258}