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. Two reads stay on `report_ai_requests` for their window: the
9//! per-user breakdown, whose conversation count is a distinct count of
10//! contexts no rollup can add up, and the trend series, which is per request.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use super::CostAnalyticsRepository;
16use crate::Result;
17use chrono::{DateTime, Utc};
18use systemprompt_identifiers::UserId;
19
20use crate::models::reporting::{
21    CostBreakdownRow, CostSummaryRow, CostTrendRow, CostUserBreakdownRow, PreviousCostRow,
22};
23
24impl CostAnalyticsRepository {
25    pub async fn get_summary(
26        &self,
27        start: DateTime<Utc>,
28        end: DateTime<Utc>,
29    ) -> Result<CostSummaryRow> {
30        sqlx::query_as!(
31            CostSummaryRow,
32            r#"
33            SELECT
34                COALESCE(SUM(request_count), 0)::bigint as "requests!",
35                SUM(cost_microdollars)::bigint as "cost",
36                SUM(tokens)::bigint as "tokens",
37                SUM(reasoning_tokens)::bigint as "reasoning_tokens",
38                SUM(cache_read_tokens)::bigint as "cache_read_tokens",
39                SUM(cache_creation_tokens)::bigint as "cache_creation_tokens"
40            FROM report_ai_usage_hourly
41            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
42              AND NOT synthetic
43            "#,
44            start,
45            end
46        )
47        .fetch_one(&*self.pool)
48        .await
49        .map_err(Into::into)
50    }
51
52    pub async fn get_previous_cost(
53        &self,
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_usage_hourly
62            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
63              AND NOT synthetic
64            "#,
65            start,
66            end
67        )
68        .fetch_one(&*self.pool)
69        .await
70        .map_err(Into::into)
71    }
72
73    pub async fn get_breakdown_by_model(
74        &self,
75        start: DateTime<Utc>,
76        end: DateTime<Utc>,
77        limit: i64,
78    ) -> Result<Vec<CostBreakdownRow>> {
79        sqlx::query_as!(
80            CostBreakdownRow,
81            r#"
82            SELECT
83                model as "name!",
84                COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
85                SUM(request_count)::bigint as "requests!",
86                COALESCE(SUM(tokens), 0)::bigint as "tokens!"
87            FROM report_ai_usage_hourly
88            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
89              AND NOT synthetic
90              AND model <> ''
91            GROUP BY model
92            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
93            LIMIT $3
94            "#,
95            start,
96            end,
97            limit
98        )
99        .fetch_all(&*self.pool)
100        .await
101        .map_err(Into::into)
102    }
103
104    pub async fn get_breakdown_by_provider(
105        &self,
106        start: DateTime<Utc>,
107        end: DateTime<Utc>,
108        limit: i64,
109    ) -> Result<Vec<CostBreakdownRow>> {
110        sqlx::query_as!(
111            CostBreakdownRow,
112            r#"
113            SELECT
114                provider as "name!",
115                COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
116                SUM(request_count)::bigint as "requests!",
117                COALESCE(SUM(tokens), 0)::bigint as "tokens!"
118            FROM report_ai_usage_hourly
119            WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
120              AND NOT synthetic
121              AND provider <> ''
122            GROUP BY provider
123            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
124            LIMIT $3
125            "#,
126            start,
127            end,
128            limit
129        )
130        .fetch_all(&*self.pool)
131        .await
132        .map_err(Into::into)
133    }
134
135    pub async fn get_breakdown_by_user(
136        &self,
137        start: DateTime<Utc>,
138        end: DateTime<Utc>,
139        limit: i64,
140    ) -> Result<Vec<CostUserBreakdownRow>> {
141        sqlx::query_as!(
142            CostUserBreakdownRow,
143            r#"
144            SELECT
145                r.user_id as "user_id!: UserId",
146                u.name as "name?",
147                COALESCE(SUM(r.cost_microdollars), 0)::bigint as "cost!",
148                COUNT(*)::bigint as "requests!",
149                COALESCE(SUM(r.tokens_used), 0)::bigint as "tokens!",
150                COUNT(DISTINCT r.context_id)::bigint as "conversations!"
151            FROM report_ai_requests r
152            LEFT JOIN report_users u ON u.id = r.user_id
153            WHERE r.created_at >= $1 AND r.created_at < $2
154              AND NOT r.synthetic
155            GROUP BY r.user_id, u.name
156            ORDER BY SUM(r.cost_microdollars) DESC NULLS LAST
157            LIMIT $3
158            "#,
159            start,
160            end,
161            limit
162        )
163        .fetch_all(&*self.pool)
164        .await
165        .map_err(Into::into)
166    }
167
168    pub async fn get_breakdown_by_agent(
169        &self,
170        start: DateTime<Utc>,
171        end: DateTime<Utc>,
172        limit: i64,
173    ) -> Result<Vec<CostBreakdownRow>> {
174        sqlx::query_as!(
175            CostBreakdownRow,
176            r#"
177            (
178                SELECT
179                    agent_name as "name!",
180                    COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
181                    SUM(request_count)::bigint as "requests!",
182                    COALESCE(SUM(tokens), 0)::bigint as "tokens!"
183                FROM report_ai_usage_hourly
184                WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
185                  AND NOT synthetic
186                  AND agent_name <> ''
187                GROUP BY agent_name
188                ORDER BY SUM(cost_microdollars) DESC NULLS LAST
189                LIMIT $3
190            )
191            UNION ALL
192            (
193                SELECT
194                    'unattributed' as "name!",
195                    COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
196                    COALESCE(SUM(request_count), 0)::bigint as "requests!",
197                    COALESCE(SUM(tokens), 0)::bigint as "tokens!"
198                FROM report_ai_usage_hourly
199                WHERE bucket >= date_trunc('hour', $1::timestamptz, 'UTC') AND bucket < $2
200                  AND NOT synthetic
201                  AND agent_name = ''
202                HAVING SUM(request_count) > 0
203            )
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_costs_for_trends(
215        &self,
216        start: DateTime<Utc>,
217        end: DateTime<Utc>,
218    ) -> Result<Vec<CostTrendRow>> {
219        sqlx::query_as!(
220            CostTrendRow,
221            r#"
222            SELECT
223                created_at as "created_at!",
224                cost_microdollars,
225                tokens_used
226            FROM report_ai_requests
227            WHERE created_at >= $1 AND created_at < $2
228              AND NOT synthetic
229            ORDER BY created_at
230            "#,
231            start,
232            end
233        )
234        .fetch_all(&*self.pool)
235        .await
236        .map_err(Into::into)
237    }
238}