Skip to main content

simulator_api/
usage.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Response body of `GET /usage`.
5///
6/// `since` is the start of the UTC day it falls in, so it may precede the
7/// requested value.
8#[derive(Debug, Serialize, Deserialize)]
9pub struct UsageReport {
10    pub api_key_name: String,
11    pub since: DateTime<Utc>,
12    pub until: DateTime<Utc>,
13    sessions: SessionCounts,
14    compute: ComputeTotals,
15    /// One bucket per UTC day in `(since, until]`, ordered and gapless: an idle
16    /// day is present with zero counts, and a session ending on a midnight
17    /// belongs to the bucket that midnight closes. The series sums to
18    /// `sessions` and `compute`; it is empty only from a server predating it.
19    #[serde(default)]
20    pub daily: Vec<UsageBucket>,
21    /// Present only for keys with the `build_bundle` feature.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub bundle_builds: Option<BundleBuildUsage>,
24}
25
26impl UsageReport {
27    /// Folds the totals from `daily`, which is the only way to set them.
28    pub fn new(
29        api_key_name: String,
30        since: DateTime<Utc>,
31        until: DateTime<Utc>,
32        daily: Vec<UsageBucket>,
33        bundle_builds: Option<BundleBuildUsage>,
34    ) -> Self {
35        let (sessions, compute) = daily.iter().fold(
36            (SessionCounts::default(), ComputeTotals::default()),
37            |(sessions, compute), bucket| {
38                (
39                    sessions.saturating_add(bucket.sessions),
40                    compute.saturating_add(bucket.compute),
41                )
42            },
43        );
44        Self {
45            api_key_name,
46            since,
47            until,
48            sessions,
49            compute,
50            daily,
51            bundle_builds,
52        }
53    }
54
55    /// The sum over `daily`, except on a response from a server predating it,
56    /// where it is the total that server reported for the whole window.
57    pub fn sessions(&self) -> SessionCounts {
58        self.sessions
59    }
60
61    /// The sum over `daily`, except on a response from a server predating it,
62    /// where it is the total that server reported for the whole window.
63    pub fn compute(&self) -> ComputeTotals {
64        self.compute
65    }
66}
67
68/// What one whole [`UsageBucket`] spans.
69pub const BUCKET_SPAN: Duration = Duration::days(1);
70
71/// One UTC day of a [`UsageReport`], covering `(start, end]`. `end` is the
72/// following midnight, except for a last bucket that ends at the report's
73/// `until`.
74#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
75pub struct UsageBucket {
76    pub start: DateTime<Utc>,
77    pub end: DateTime<Utc>,
78    pub sessions: SessionCounts,
79    pub compute: ComputeTotals,
80}
81
82impl UsageBucket {
83    /// Only the last bucket of a report can be partial.
84    pub fn is_partial(&self) -> bool {
85        self.end.signed_duration_since(self.start) < BUCKET_SPAN
86    }
87
88    /// A day with no activity, kept only to leave the series gapless.
89    pub fn is_empty(&self) -> bool {
90        self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
91    }
92}
93
94/// Bundle-build usage for the window.
95#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
96pub struct BundleBuildUsage {
97    pub requested: u64,
98}
99
100/// Point counts by outcome, not session-deduplicated, so the three need not
101/// balance: a pre-start failure has no `started`, a cross-manager handoff
102/// re-emits it. Billing uses the compute totals, which derive only from the
103/// terminal points.
104///
105/// Within a [`UsageBucket`], `started` counts sessions that began that day and
106/// `completed`/`failed` those that ended that day.
107#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
108pub struct SessionCounts {
109    pub started: u64,
110    pub completed: u64,
111    pub failed: u64,
112}
113
114/// A session contributes wholly to the window — or [`UsageBucket`] — its run
115/// ended in, even when it ran across the boundary.
116#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
117pub struct ComputeTotals {
118    pub executed_slot_count: u64,
119    pub session_duration_ms: u64,
120}
121
122impl SessionCounts {
123    pub fn saturating_add(self, other: Self) -> Self {
124        Self {
125            started: self.started.saturating_add(other.started),
126            completed: self.completed.saturating_add(other.completed),
127            failed: self.failed.saturating_add(other.failed),
128        }
129    }
130}
131
132impl ComputeTotals {
133    pub fn saturating_add(self, other: Self) -> Self {
134        Self {
135            executed_slot_count: self
136                .executed_slot_count
137                .saturating_add(other.executed_slot_count),
138            session_duration_ms: self
139                .session_duration_ms
140                .saturating_add(other.session_duration_ms),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
150        UsageReport::new(
151            "k".to_string(),
152            "2026-01-01T00:00:00Z".parse().unwrap(),
153            "2026-01-02T00:00:00Z".parse().unwrap(),
154            vec![UsageBucket {
155                start: "2026-01-01T00:00:00Z".parse().unwrap(),
156                end: "2026-01-02T00:00:00Z".parse().unwrap(),
157                sessions: SessionCounts::default(),
158                compute: ComputeTotals::default(),
159            }],
160            bundle_builds,
161        )
162    }
163
164    fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
165        let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
166        UsageBucket {
167            start,
168            end: start + BUCKET_SPAN,
169            sessions,
170            compute,
171        }
172    }
173
174    #[test]
175    fn new_sums_the_totals_from_the_buckets() {
176        let report = UsageReport::new(
177            "k".to_string(),
178            "2026-01-01T00:00:00Z".parse().unwrap(),
179            "2026-01-03T00:00:00Z".parse().unwrap(),
180            vec![
181                bucket(
182                    "01",
183                    SessionCounts {
184                        started: 1,
185                        completed: 1,
186                        failed: 0,
187                    },
188                    ComputeTotals {
189                        executed_slot_count: 5,
190                        session_duration_ms: 9,
191                    },
192                ),
193                bucket(
194                    "02",
195                    SessionCounts {
196                        started: 2,
197                        completed: 1,
198                        failed: 1,
199                    },
200                    ComputeTotals {
201                        executed_slot_count: 7,
202                        session_duration_ms: 3,
203                    },
204                ),
205            ],
206            None,
207        );
208        assert_eq!(
209            report.sessions(),
210            SessionCounts {
211                started: 3,
212                completed: 2,
213                failed: 1
214            }
215        );
216        assert_eq!(
217            report.compute(),
218            ComputeTotals {
219                executed_slot_count: 12,
220                session_duration_ms: 12
221            }
222        );
223    }
224
225    #[test]
226    fn new_totals_of_no_buckets_are_zero() {
227        let report = UsageReport::new(
228            "k".to_string(),
229            "2026-01-01T00:00:00Z".parse().unwrap(),
230            "2026-01-01T00:00:00Z".parse().unwrap(),
231            vec![],
232            None,
233        );
234        assert_eq!(report.sessions(), SessionCounts::default());
235        assert_eq!(report.compute(), ComputeTotals::default());
236    }
237
238    #[test]
239    fn usage_report_absent_bundle_builds_round_trips_to_none() {
240        let json = serde_json::to_string(&report(None)).unwrap();
241        assert!(!json.contains("bundle_builds"));
242        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
243        assert_eq!(parsed.bundle_builds, None);
244    }
245
246    #[test]
247    fn usage_report_absent_daily_round_trips_to_empty() {
248        let json = r#"{"api_key_name":"k","since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":{"started":1,"completed":1,"failed":0},"compute":{"executed_slot_count":5,"session_duration_ms":9}}"#;
249        let parsed: UsageReport = serde_json::from_str(json).unwrap();
250        assert!(parsed.daily.is_empty());
251        // The totals stay on the wire so an empty `daily` does not read as no usage.
252        assert_eq!(
253            parsed.sessions(),
254            SessionCounts {
255                started: 1,
256                completed: 1,
257                failed: 0
258            }
259        );
260        assert_eq!(
261            parsed.compute(),
262            ComputeTotals {
263                executed_slot_count: 5,
264                session_duration_ms: 9
265            }
266        );
267    }
268
269    #[test]
270    fn usage_report_daily_round_trips() {
271        let json = serde_json::to_string(&report(None)).unwrap();
272        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
273        assert_eq!(parsed.daily, report(None).daily);
274    }
275
276    #[test]
277    fn usage_report_present_bundle_builds_round_trips() {
278        let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
279        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
280        assert_eq!(
281            parsed.bundle_builds,
282            Some(BundleBuildUsage { requested: 3 })
283        );
284    }
285}