Skip to main content

systemprompt_analytics/services/
profile_usage.rs

1//! Per-user token usage and conversation summary for the profile surfaces.
2//!
3//! Single source of truth for the rolling 24h / 7d / 30d usage windows, the
4//! top models by token share, and the conversation summary. Both the
5//! `/v1/bridge/profile/usage` route and the server-rendered admin profile page
6//! read through here, so the two surfaces cannot drift apart.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::sync::Arc;
12
13use chrono::{DateTime, Duration, Utc};
14use sqlx::PgPool;
15use systemprompt_identifiers::UserId;
16use systemprompt_models::api::cloud::{
17    BridgeProfileUsage, ConversationGroup, ConversationSummary, ModelShare,
18    RecentConversationSummary, UsageWindow,
19};
20
21use crate::error::Result;
22use crate::repository::CostAnalyticsRepository;
23
24const TOP_MODELS_LIMIT: i64 = 5;
25const TOP_GROUPS_LIMIT: i64 = 10;
26const RECENT_LIMIT: i64 = 10;
27
28/// Reads the profile usage windows for one user.
29///
30/// Cheap to clone — it holds the shared pool handle, so it can be built once
31/// at a router's composition root and injected into handlers.
32#[derive(Debug, Clone)]
33pub struct ProfileUsageService {
34    cost_repo: CostAnalyticsRepository,
35}
36
37impl ProfileUsageService {
38    #[must_use]
39    pub const fn new(cost_repo: CostAnalyticsRepository) -> Self {
40        Self { cost_repo }
41    }
42
43    #[must_use]
44    pub const fn from_pool(pool: Arc<PgPool>) -> Self {
45        Self::new(CostAnalyticsRepository::from_pool(pool))
46    }
47
48    // Why: Everything the profile surfaces render, for one user.
49    //
50    // `now` is a parameter rather than `Utc::now()` so all three windows are
51    // computed against a single instant, and so the result is testable.
52    pub async fn get_profile_usage(
53        &self,
54        user_id: &UserId,
55        now: DateTime<Utc>,
56    ) -> Result<BridgeProfileUsage> {
57        let repo = &self.cost_repo;
58        let d30_start = now - Duration::days(30);
59
60        let (d1, d7, d30) = tokio::try_join!(
61            self.get_usage_window(user_id, now, Duration::days(1)),
62            self.get_usage_window(user_id, now, Duration::days(7)),
63            self.get_usage_window(user_id, now, Duration::days(30)),
64        )?;
65
66        let top_models = self.list_top_models(user_id, d30_start, now).await?;
67
68        let total = repo
69            .get_context_summary_for_user(user_id, d30_start, now)
70            .await?;
71        let by_model = repo
72            .get_contexts_by_model_for_user(user_id, d30_start, now, TOP_GROUPS_LIMIT)
73            .await?;
74        let by_agent = repo
75            .get_contexts_by_agent_for_user(user_id, d30_start, now, TOP_GROUPS_LIMIT)
76            .await?;
77        let recent = repo
78            .get_recent_contexts_for_user(user_id, now, RECENT_LIMIT)
79            .await?;
80
81        let to_group = |r: crate::models::ContextGroupRow| ConversationGroup {
82            name: r.name,
83            conversations: r.conversations,
84            ai_requests: r.ai_requests,
85        };
86
87        Ok(BridgeProfileUsage {
88            d1,
89            d7,
90            d30,
91            top_models,
92            conversations: ConversationSummary {
93                total_conversations: total.conversations,
94                total_ai_requests: total.ai_requests,
95                by_model: by_model.into_iter().map(to_group).collect(),
96                by_agent: by_agent.into_iter().map(to_group).collect(),
97                recent: recent
98                    .into_iter()
99                    .map(|r| RecentConversationSummary {
100                        context_id: r.context_id,
101                        last_activity: r.last_activity,
102                        ai_requests: r.ai_requests,
103                        model: r.model,
104                        agent_name: r.agent_name,
105                        context_name: r.context_name,
106                    })
107                    .collect(),
108            },
109        })
110    }
111
112    // Why: One rolling window ending at `now`, carrying the preceding window's
113    // cost so a caller can render a delta.
114    pub async fn get_usage_window(
115        &self,
116        user_id: &UserId,
117        now: DateTime<Utc>,
118        span: Duration,
119    ) -> Result<UsageWindow> {
120        let start = now - span;
121        let summary = self
122            .cost_repo
123            .get_summary_for_user(user_id, start, now)
124            .await?;
125        let prev = self
126            .cost_repo
127            .get_previous_cost_for_user(user_id, start - span, start)
128            .await?;
129        Ok(UsageWindow {
130            requests: summary.requests,
131            tokens: summary.tokens.unwrap_or(0),
132            cost_microdollars: summary.cost.unwrap_or(0),
133            previous_cost_microdollars: prev.cost,
134        })
135    }
136
137    pub async fn list_top_models(
138        &self,
139        user_id: &UserId,
140        start: DateTime<Utc>,
141        end: DateTime<Utc>,
142    ) -> Result<Vec<ModelShare>> {
143        let rows = self
144            .cost_repo
145            .get_breakdown_by_model_for_user(user_id, start, end, TOP_MODELS_LIMIT)
146            .await?;
147
148        let total_tokens: i64 = rows.iter().map(|r| r.tokens).sum();
149        Ok(rows
150            .into_iter()
151            .map(|r| ModelShare {
152                token_share: if total_tokens > 0 {
153                    r.tokens as f64 / total_tokens as f64
154                } else {
155                    0.0
156                },
157                model: r.name,
158                requests: r.requests,
159                tokens: r.tokens,
160                cost_microdollars: r.cost,
161            })
162            .collect())
163    }
164}