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