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