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