Skip to main content

systemprompt_analytics/repository/costs/
per_user.rs

1//! Per-user cost queries for `CostAnalyticsRepository`.
2//!
3//! Scopes spend, token, and conversation reads from `ai_requests` to a single
4//! [`UserId`], including model/agent breakdowns and recent-context summaries
5//! used for per-user billing and usage views.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use super::CostAnalyticsRepository;
11use crate::Result;
12use chrono::{DateTime, Utc};
13use systemprompt_identifiers::{ContextId, UserId};
14
15use crate::models::reporting::{
16    ContextGroupRow, ContextSummaryRow, CostBreakdownRow, CostSummaryRow, PreviousCostRow,
17    RecentContextRow,
18};
19
20impl CostAnalyticsRepository {
21    pub async fn get_summary_for_user(
22        &self,
23        user_id: &UserId,
24        start: DateTime<Utc>,
25        end: DateTime<Utc>,
26    ) -> Result<CostSummaryRow> {
27        sqlx::query_as!(
28            CostSummaryRow,
29            r#"
30            SELECT
31                COUNT(*)::bigint as "requests!",
32                SUM(cost_microdollars)::bigint as "cost",
33                SUM(tokens_used)::bigint as "tokens",
34                SUM(reasoning_tokens)::bigint as "reasoning_tokens",
35                SUM(cache_read_tokens)::bigint as "cache_read_tokens",
36                SUM(cache_creation_tokens)::bigint as "cache_creation_tokens"
37            FROM ai_requests
38            WHERE created_at >= $1 AND created_at < $2
39              AND NOT synthetic AND user_id = $3
40            "#,
41            start,
42            end,
43            user_id.as_str()
44        )
45        .fetch_one(&*self.pool)
46        .await
47        .map_err(Into::into)
48    }
49
50    pub async fn get_previous_cost_for_user(
51        &self,
52        user_id: &UserId,
53        start: DateTime<Utc>,
54        end: DateTime<Utc>,
55    ) -> Result<PreviousCostRow> {
56        sqlx::query_as!(
57            PreviousCostRow,
58            r#"
59            SELECT SUM(cost_microdollars)::bigint as "cost"
60            FROM ai_requests
61            WHERE created_at >= $1 AND created_at < $2
62              AND NOT synthetic AND user_id = $3
63            "#,
64            start,
65            end,
66            user_id.as_str()
67        )
68        .fetch_one(&*self.pool)
69        .await
70        .map_err(Into::into)
71    }
72
73    pub async fn get_breakdown_by_model_for_user(
74        &self,
75        user_id: &UserId,
76        start: DateTime<Utc>,
77        end: DateTime<Utc>,
78        limit: i64,
79    ) -> Result<Vec<CostBreakdownRow>> {
80        sqlx::query_as!(
81            CostBreakdownRow,
82            r#"
83            SELECT
84                model as "name!",
85                COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
86                COUNT(*)::bigint as "requests!",
87                COALESCE(SUM(tokens_used), 0)::bigint as "tokens!"
88            FROM ai_requests
89            WHERE created_at >= $1 AND created_at < $2
90              AND NOT synthetic AND user_id = $4
91              AND model IS NOT NULL
92            GROUP BY model
93            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
94            LIMIT $3
95            "#,
96            start,
97            end,
98            limit,
99            user_id.as_str()
100        )
101        .fetch_all(&*self.pool)
102        .await
103        .map_err(Into::into)
104    }
105
106    pub async fn get_context_summary_for_user(
107        &self,
108        user_id: &UserId,
109        start: DateTime<Utc>,
110        end: DateTime<Utc>,
111    ) -> Result<ContextSummaryRow> {
112        sqlx::query_as!(
113            ContextSummaryRow,
114            r#"
115            SELECT
116                COUNT(DISTINCT context_id)::bigint as "conversations!",
117                COUNT(*)::bigint as "ai_requests!"
118            FROM ai_requests
119            WHERE created_at >= $1 AND created_at < $2
120              AND NOT synthetic
121              AND user_id = $3
122              AND context_id IS NOT NULL
123            "#,
124            start,
125            end,
126            user_id.as_str()
127        )
128        .fetch_one(&*self.pool)
129        .await
130        .map_err(Into::into)
131    }
132
133    pub async fn get_contexts_by_model_for_user(
134        &self,
135        user_id: &UserId,
136        start: DateTime<Utc>,
137        end: DateTime<Utc>,
138        limit: i64,
139    ) -> Result<Vec<ContextGroupRow>> {
140        sqlx::query_as!(
141            ContextGroupRow,
142            r#"
143            SELECT
144                model as "name!",
145                COUNT(DISTINCT context_id)::bigint as "conversations!",
146                COUNT(*)::bigint as "ai_requests!"
147            FROM ai_requests
148            WHERE created_at >= $1 AND created_at < $2
149              AND NOT synthetic
150              AND user_id = $3
151              AND context_id IS NOT NULL
152              AND model IS NOT NULL
153            GROUP BY model
154            ORDER BY COUNT(DISTINCT context_id) DESC
155            LIMIT $4
156            "#,
157            start,
158            end,
159            user_id.as_str(),
160            limit
161        )
162        .fetch_all(&*self.pool)
163        .await
164        .map_err(Into::into)
165    }
166
167    pub async fn get_contexts_by_agent_for_user(
168        &self,
169        user_id: &UserId,
170        start: DateTime<Utc>,
171        end: DateTime<Utc>,
172        limit: i64,
173    ) -> Result<Vec<ContextGroupRow>> {
174        sqlx::query_as!(
175            ContextGroupRow,
176            r#"
177            SELECT
178                COALESCE(at.agent_name, 'unattributed') as "name!",
179                COUNT(DISTINCT r.context_id)::bigint as "conversations!",
180                COUNT(*)::bigint as "ai_requests!"
181            FROM ai_requests r
182            LEFT JOIN agent_tasks at ON at.task_id = r.task_id
183            WHERE r.created_at >= $1 AND r.created_at < $2
184                  AND NOT r.synthetic
185              AND r.user_id = $3
186              AND r.context_id IS NOT NULL
187            GROUP BY COALESCE(at.agent_name, 'unattributed')
188            ORDER BY COUNT(DISTINCT r.context_id) DESC
189            LIMIT $4
190            "#,
191            start,
192            end,
193            user_id.as_str(),
194            limit
195        )
196        .fetch_all(&*self.pool)
197        .await
198        .map_err(Into::into)
199    }
200
201    pub async fn get_recent_contexts_for_user(
202        &self,
203        user_id: &UserId,
204        end: DateTime<Utc>,
205        limit: i64,
206    ) -> Result<Vec<RecentContextRow>> {
207        sqlx::query_as!(
208            RecentContextRow,
209            r#"
210            SELECT
211                ctx.context_id as "context_id!: ContextId",
212                ctx.last_activity as "last_activity!",
213                ctx.ai_requests as "ai_requests!",
214                last_req.model,
215                last_task.agent_name,
216                uc.name as "context_name?"
217            FROM (
218                SELECT
219                    r.context_id,
220                    MAX(r.created_at) AS last_activity,
221                    COUNT(*) AS ai_requests
222                FROM ai_requests r
223                WHERE r.user_id = $1
224                  AND r.created_at < $2
225                  AND NOT r.synthetic
226                  AND r.context_id IS NOT NULL
227                GROUP BY r.context_id
228                ORDER BY MAX(r.created_at) DESC
229                LIMIT $3
230            ) ctx
231            LEFT JOIN LATERAL (
232                SELECT model, task_id FROM ai_requests
233                WHERE context_id = ctx.context_id
234                ORDER BY created_at DESC
235                LIMIT 1
236            ) last_req ON TRUE
237            LEFT JOIN agent_tasks last_task ON last_task.task_id = last_req.task_id
238            LEFT JOIN user_contexts uc ON uc.context_id = ctx.context_id
239            ORDER BY ctx.last_activity DESC
240            "#,
241            user_id.as_str(),
242            end,
243            limit
244        )
245        .fetch_all(&*self.pool)
246        .await
247        .map_err(Into::into)
248    }
249}