Skip to main content

systemprompt_analytics/repository/costs/
platform.rs

1//! Platform-wide cost queries for `CostAnalyticsRepository`.
2//!
3//! Aggregates spend, tokens, and request counts across all users, with
4//! breakdowns by model, provider, agent, and user and a trend series for the
5//! platform cost dashboard. Sums and counts read the hourly rollup
6//! (`report_ai_usage_hourly`), widening the window to whole UTC hours at its
7//! start; the agent of a request is the one its task had when the request
8//! was written; the billing-source split reads the rollup's personal-key
9//! share. Two reads stay on `report_ai_requests` for their window: the
10//! per-user breakdown, whose conversation count is a distinct count of
11//! contexts no rollup can add up, and the trend series, which is per request.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use super::CostAnalyticsRepository;
17use crate::Result;
18use chrono::{DateTime, Utc};
19use systemprompt_identifiers::UserId;
20
21use crate::models::reporting::{
22    CostBreakdownRow, CostSummaryRow, CostTrendRow, CostUserBreakdownRow, PreviousCostRow,
23};
24
25impl CostAnalyticsRepository {
26    pub async fn get_summary(
27        &self,
28        start: DateTime<Utc>,
29        end: DateTime<Utc>,
30    ) -> Result<CostSummaryRow> {
31        sqlx::query_as!(
32            CostSummaryRow,
33            r#"
34            SELECT
35                COALESCE(SUM(request_count), 0)::bigint as "requests!",
36                SUM(cost_microdollars)::bigint as "cost",
37                SUM(tokens)::bigint as "tokens",
38                SUM(reasoning_tokens)::bigint as "reasoning_tokens",
39                SUM(cache_read_tokens)::bigint as "cache_read_tokens",
40                SUM(cache_creation_tokens)::bigint as "cache_creation_tokens"
41            FROM report_ai_usage_hourly
42            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
43              AND NOT synthetic
44            "#,
45            start,
46            end
47        )
48        .fetch_one(&*self.pool)
49        .await
50        .map_err(Into::into)
51    }
52
53    pub async fn get_previous_cost(
54        &self,
55        start: DateTime<Utc>,
56        end: DateTime<Utc>,
57    ) -> Result<PreviousCostRow> {
58        sqlx::query_as!(
59            PreviousCostRow,
60            r#"
61            SELECT SUM(cost_microdollars)::bigint as "cost"
62            FROM report_ai_usage_hourly
63            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
64              AND NOT synthetic
65            "#,
66            start,
67            end
68        )
69        .fetch_one(&*self.pool)
70        .await
71        .map_err(Into::into)
72    }
73
74    pub async fn get_breakdown_by_model(
75        &self,
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                SUM(request_count)::bigint as "requests!",
87                COALESCE(SUM(tokens), 0)::bigint as "tokens!"
88            FROM report_ai_usage_hourly
89            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
90              AND NOT synthetic
91              AND model <> ''
92            GROUP BY model
93            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
94            LIMIT $3
95            "#,
96            start,
97            end,
98            limit
99        )
100        .fetch_all(&*self.pool)
101        .await
102        .map_err(Into::into)
103    }
104
105    pub async fn get_breakdown_by_provider(
106        &self,
107        start: DateTime<Utc>,
108        end: DateTime<Utc>,
109        limit: i64,
110    ) -> Result<Vec<CostBreakdownRow>> {
111        sqlx::query_as!(
112            CostBreakdownRow,
113            r#"
114            SELECT
115                provider as "name!",
116                COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
117                SUM(request_count)::bigint as "requests!",
118                COALESCE(SUM(tokens), 0)::bigint as "tokens!"
119            FROM report_ai_usage_hourly
120            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
121              AND NOT synthetic
122              AND provider <> ''
123            GROUP BY provider
124            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
125            LIMIT $3
126            "#,
127            start,
128            end,
129            limit
130        )
131        .fetch_all(&*self.pool)
132        .await
133        .map_err(Into::into)
134    }
135
136    pub async fn get_breakdown_by_billing_source(
137        &self,
138        start: DateTime<Utc>,
139        end: DateTime<Utc>,
140        limit: i64,
141    ) -> Result<Vec<CostBreakdownRow>> {
142        sqlx::query_as!(
143            CostBreakdownRow,
144            r#"
145            WITH totals AS (
146                SELECT
147                    COALESCE(SUM(cost_microdollars), 0)::bigint AS cost,
148                    COALESCE(SUM(request_count), 0)::bigint AS requests,
149                    COALESCE(SUM(tokens), 0)::bigint AS tokens,
150                    COALESCE(SUM(user_key_cost_microdollars), 0)::bigint AS key_cost,
151                    COALESCE(SUM(user_key_request_count), 0)::bigint AS key_requests,
152                    COALESCE(SUM(user_key_tokens), 0)::bigint AS key_tokens
153                FROM report_ai_usage_hourly
154                WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
155                  AND NOT synthetic
156            )
157            SELECT
158                split.name as "name!",
159                split.cost as "cost!",
160                split.requests as "requests!",
161                split.tokens as "tokens!"
162            FROM totals t
163            CROSS JOIN LATERAL (VALUES
164                ('platform', t.cost - t.key_cost, t.requests - t.key_requests,
165                 t.tokens - t.key_tokens),
166                ('user_key', t.key_cost, t.key_requests, t.key_tokens)
167            ) AS split(name, cost, requests, tokens)
168            WHERE split.requests > 0
169            ORDER BY split.cost DESC
170            LIMIT $3
171            "#,
172            start,
173            end,
174            limit
175        )
176        .fetch_all(&*self.pool)
177        .await
178        .map_err(Into::into)
179    }
180
181    pub async fn get_breakdown_by_user(
182        &self,
183        start: DateTime<Utc>,
184        end: DateTime<Utc>,
185        limit: i64,
186    ) -> Result<Vec<CostUserBreakdownRow>> {
187        sqlx::query_as!(
188            CostUserBreakdownRow,
189            r#"
190            SELECT
191                r.user_id as "user_id!: UserId",
192                u.name as "name?",
193                COALESCE(SUM(r.cost_microdollars), 0)::bigint as "cost!",
194                COUNT(*)::bigint as "requests!",
195                COALESCE(SUM(r.tokens_used), 0)::bigint as "tokens!",
196                COUNT(DISTINCT r.context_id)::bigint as "conversations!"
197            FROM report_ai_requests r
198            LEFT JOIN report_users u ON u.id = r.user_id
199            WHERE r.created_at >= $1 AND r.created_at < $2
200              AND NOT r.synthetic
201            GROUP BY r.user_id, u.name
202            ORDER BY SUM(r.cost_microdollars) DESC NULLS LAST
203            LIMIT $3
204            "#,
205            start,
206            end,
207            limit
208        )
209        .fetch_all(&*self.pool)
210        .await
211        .map_err(Into::into)
212    }
213
214    pub async fn get_breakdown_by_agent(
215        &self,
216        start: DateTime<Utc>,
217        end: DateTime<Utc>,
218        limit: i64,
219    ) -> Result<Vec<CostBreakdownRow>> {
220        sqlx::query_as!(
221            CostBreakdownRow,
222            r#"
223            (
224                SELECT
225                    agent_name as "name!",
226                    COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
227                    SUM(request_count)::bigint as "requests!",
228                    COALESCE(SUM(tokens), 0)::bigint as "tokens!"
229                FROM report_ai_usage_hourly
230                WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
231                  AND NOT synthetic
232                  AND agent_name <> ''
233                GROUP BY agent_name
234                ORDER BY SUM(cost_microdollars) DESC NULLS LAST
235                LIMIT $3
236            )
237            UNION ALL
238            (
239                SELECT
240                    'unattributed' as "name!",
241                    COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
242                    COALESCE(SUM(request_count), 0)::bigint as "requests!",
243                    COALESCE(SUM(tokens), 0)::bigint as "tokens!"
244                FROM report_ai_usage_hourly
245                WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
246                  AND NOT synthetic
247                  AND agent_name = ''
248                HAVING SUM(request_count) > 0
249            )
250            "#,
251            start,
252            end,
253            limit
254        )
255        .fetch_all(&*self.pool)
256        .await
257        .map_err(Into::into)
258    }
259
260    pub async fn get_costs_for_trends(
261        &self,
262        start: DateTime<Utc>,
263        end: DateTime<Utc>,
264    ) -> Result<Vec<CostTrendRow>> {
265        sqlx::query_as!(
266            CostTrendRow,
267            r#"
268            SELECT
269                created_at as "created_at!",
270                cost_microdollars,
271                tokens_used
272            FROM report_ai_requests
273            WHERE created_at >= $1 AND created_at < $2
274              AND NOT synthetic
275            ORDER BY created_at
276            "#,
277            start,
278            end
279        )
280        .fetch_all(&*self.pool)
281        .await
282        .map_err(Into::into)
283    }
284}