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    pub async fn get_profile_usage(
49        &self,
50        user_id: &UserId,
51        now: DateTime<Utc>,
52    ) -> Result<BridgeProfileUsage> {
53        let repo = &self.cost_repo;
54        let d30_start = now - Duration::days(30);
55
56        let (d1, d7, d30) = tokio::try_join!(
57            self.get_usage_window(user_id, now, Duration::days(1)),
58            self.get_usage_window(user_id, now, Duration::days(7)),
59            self.get_usage_window(user_id, now, Duration::days(30)),
60        )?;
61
62        let top_models = self.list_top_models(user_id, d30_start, now).await?;
63
64        let total = repo
65            .get_context_summary_for_user(user_id, d30_start, now)
66            .await?;
67        let by_model = repo
68            .get_contexts_by_model_for_user(user_id, d30_start, now, TOP_GROUPS_LIMIT)
69            .await?;
70        let by_agent = repo
71            .get_contexts_by_agent_for_user(user_id, d30_start, now, TOP_GROUPS_LIMIT)
72            .await?;
73        let recent = repo
74            .get_recent_contexts_for_user(user_id, now, RECENT_LIMIT)
75            .await?;
76
77        let to_group = |r: crate::models::ContextGroupRow| ConversationGroup {
78            name: r.name,
79            conversations: r.conversations,
80            ai_requests: r.ai_requests,
81        };
82
83        Ok(BridgeProfileUsage {
84            d1,
85            d7,
86            d30,
87            top_models,
88            conversations: ConversationSummary {
89                total_conversations: total.conversations,
90                total_ai_requests: total.ai_requests,
91                by_model: by_model.into_iter().map(to_group).collect(),
92                by_agent: by_agent.into_iter().map(to_group).collect(),
93                recent: recent
94                    .into_iter()
95                    .map(|r| RecentConversationSummary {
96                        context_id: r.context_id,
97                        last_activity: r.last_activity,
98                        ai_requests: r.ai_requests,
99                        model: r.model,
100                        agent_name: r.agent_name,
101                        context_name: r.context_name,
102                    })
103                    .collect(),
104            },
105        })
106    }
107
108    pub async fn get_usage_window(
109        &self,
110        user_id: &UserId,
111        now: DateTime<Utc>,
112        span: Duration,
113    ) -> Result<UsageWindow> {
114        let start = now - span;
115        let summary = self
116            .cost_repo
117            .get_summary_for_user(user_id, start, now)
118            .await?;
119        let prev = self
120            .cost_repo
121            .get_previous_cost_for_user(user_id, start - span, start)
122            .await?;
123        Ok(UsageWindow {
124            requests: summary.requests,
125            tokens: summary.tokens.unwrap_or(0),
126            cost_microdollars: summary.cost.unwrap_or(0),
127            previous_cost_microdollars: prev.cost,
128        })
129    }
130
131    pub async fn list_top_models(
132        &self,
133        user_id: &UserId,
134        start: DateTime<Utc>,
135        end: DateTime<Utc>,
136    ) -> Result<Vec<ModelShare>> {
137        let rows = self
138            .cost_repo
139            .get_breakdown_by_model_for_user(user_id, start, end, TOP_MODELS_LIMIT)
140            .await?;
141
142        let total_tokens: i64 = rows.iter().map(|r| r.tokens).sum();
143        Ok(rows
144            .into_iter()
145            .map(|r| ModelShare {
146                token_share: if total_tokens > 0 {
147                    r.tokens as f64 / total_tokens as f64
148                } else {
149                    0.0
150                },
151                model: r.name,
152                requests: r.requests,
153                tokens: r.tokens,
154                cost_microdollars: r.cost,
155            })
156            .collect())
157    }
158}