Skip to main content

simulator_api/
usage.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::SessionId;
5
6/// Response body of `GET /usage`.
7///
8/// `since` is the start of the UTC day it falls in, so it may precede the
9/// requested value.
10#[derive(Debug, Serialize, Deserialize)]
11pub struct UsageReport {
12    pub api_key_name: String,
13    pub since: DateTime<Utc>,
14    pub until: DateTime<Utc>,
15    sessions: SessionCounts,
16    compute: ComputeTotals,
17    /// One bucket per UTC day in `(since, until]`, ordered and gapless: an idle
18    /// day is present with zero counts, and a session ending on a midnight
19    /// belongs to the bucket that midnight closes. The series sums to
20    /// `sessions` and `compute`; it is empty only from a server predating it.
21    #[serde(default)]
22    pub daily: Vec<UsageBucket>,
23    /// Present only for keys with the `build_bundle` feature.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub bundle_builds: Option<BundleBuildUsage>,
26}
27
28impl UsageReport {
29    /// Folds the totals from `daily`, which is the only way to set them.
30    pub fn new(
31        api_key_name: String,
32        since: DateTime<Utc>,
33        until: DateTime<Utc>,
34        daily: Vec<UsageBucket>,
35        bundle_builds: Option<BundleBuildUsage>,
36    ) -> Self {
37        let (sessions, compute) = daily.iter().fold(
38            (SessionCounts::default(), ComputeTotals::default()),
39            |(sessions, compute), bucket| {
40                (
41                    sessions.saturating_add(bucket.sessions),
42                    compute.saturating_add(bucket.compute),
43                )
44            },
45        );
46        Self {
47            api_key_name,
48            since,
49            until,
50            sessions,
51            compute,
52            daily,
53            bundle_builds,
54        }
55    }
56
57    /// The sum over `daily`, except on a response from a server predating it,
58    /// where it is the total that server reported for the whole window.
59    pub fn sessions(&self) -> SessionCounts {
60        self.sessions
61    }
62
63    /// The sum over `daily`, except on a response from a server predating it,
64    /// where it is the total that server reported for the whole window.
65    pub fn compute(&self) -> ComputeTotals {
66        self.compute
67    }
68}
69
70/// What one whole [`UsageBucket`] spans.
71pub const BUCKET_SPAN: Duration = Duration::days(1);
72
73/// One UTC day of a [`UsageReport`], covering `(start, end]`. `end` is the
74/// following midnight, except for a last bucket that ends at the report's
75/// `until`.
76#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
77pub struct UsageBucket {
78    pub start: DateTime<Utc>,
79    pub end: DateTime<Utc>,
80    pub sessions: SessionCounts,
81    pub compute: ComputeTotals,
82}
83
84impl UsageBucket {
85    /// Only the last bucket of a report can be partial.
86    pub fn is_partial(&self) -> bool {
87        self.end.signed_duration_since(self.start) < BUCKET_SPAN
88    }
89
90    /// A day with no activity, kept only to leave the series gapless.
91    pub fn is_empty(&self) -> bool {
92        self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
93    }
94}
95
96/// Response body of `GET /usage/sessions`.
97///
98/// Its own endpoint because it is the one part of a usage report that grows
99/// with usage rather than with the window.
100#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
101pub struct SessionUsagePage {
102    /// The window actually served, `since` snapped back to its UTC midnight.
103    /// Pass these back with `next_cursor` so every page resolves the same
104    /// window rather than one drifting with `now`.
105    pub since: DateTime<Utc>,
106    pub until: DateTime<Utc>,
107    /// Every session that ended in the window, in no promised order: the pages
108    /// together are the guarantee, not their sequence.
109    pub sessions: Vec<SessionUsage>,
110    /// Opaque continuation token, `None` once the window holds nothing further.
111    /// Pass it back as `cursor` to read the next page.
112    #[serde(default)]
113    pub next_cursor: Option<String>,
114}
115
116/// One session that ran under the key, as its terminal event reported it.
117///
118/// Built from the same fire-and-forget metric points as the rest of the report,
119/// so a point dropped under back-pressure takes its session out of the list.
120#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
121pub struct SessionUsage {
122    pub session_id: SessionId,
123    pub started_at: DateTime<Utc>,
124    pub ended_at: DateTime<Utc>,
125    pub executed_slot_count: u64,
126    pub session_duration_ms: u64,
127}
128
129/// Bundle-build usage for the window.
130#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
131pub struct BundleBuildUsage {
132    pub requested: u64,
133}
134
135/// Point counts by outcome, not session-deduplicated, so the three need not
136/// balance: a pre-start failure has no `started`, a cross-manager handoff
137/// re-emits it. Billing uses the compute totals, which derive only from the
138/// terminal points.
139///
140/// Within a [`UsageBucket`], `started` counts sessions that began that day and
141/// `completed`/`failed` those that ended that day.
142#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
143pub struct SessionCounts {
144    pub started: u64,
145    pub completed: u64,
146    pub failed: u64,
147}
148
149/// A session contributes wholly to the window — or [`UsageBucket`] — its run
150/// ended in, even when it ran across the boundary.
151#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
152pub struct ComputeTotals {
153    pub executed_slot_count: u64,
154    pub session_duration_ms: u64,
155}
156
157impl SessionCounts {
158    pub fn saturating_add(self, other: Self) -> Self {
159        Self {
160            started: self.started.saturating_add(other.started),
161            completed: self.completed.saturating_add(other.completed),
162            failed: self.failed.saturating_add(other.failed),
163        }
164    }
165}
166
167impl ComputeTotals {
168    pub fn saturating_add(self, other: Self) -> Self {
169        Self {
170            executed_slot_count: self
171                .executed_slot_count
172                .saturating_add(other.executed_slot_count),
173            session_duration_ms: self
174                .session_duration_ms
175                .saturating_add(other.session_duration_ms),
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
185        UsageReport::new(
186            "k".to_string(),
187            "2026-01-01T00:00:00Z".parse().unwrap(),
188            "2026-01-02T00:00:00Z".parse().unwrap(),
189            vec![UsageBucket {
190                start: "2026-01-01T00:00:00Z".parse().unwrap(),
191                end: "2026-01-02T00:00:00Z".parse().unwrap(),
192                sessions: SessionCounts::default(),
193                compute: ComputeTotals::default(),
194            }],
195            bundle_builds,
196        )
197    }
198
199    fn session() -> SessionUsage {
200        SessionUsage {
201            session_id: "backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b".parse().unwrap(),
202            started_at: "2026-01-01T09:14:02Z".parse().unwrap(),
203            ended_at: "2026-01-01T09:18:41Z".parse().unwrap(),
204            executed_slot_count: 3_100,
205            session_duration_ms: 279_000,
206        }
207    }
208
209    fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
210        let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
211        UsageBucket {
212            start,
213            end: start + BUCKET_SPAN,
214            sessions,
215            compute,
216        }
217    }
218
219    #[test]
220    fn new_sums_the_totals_from_the_buckets() {
221        let report = UsageReport::new(
222            "k".to_string(),
223            "2026-01-01T00:00:00Z".parse().unwrap(),
224            "2026-01-03T00:00:00Z".parse().unwrap(),
225            vec![
226                bucket(
227                    "01",
228                    SessionCounts {
229                        started: 1,
230                        completed: 1,
231                        failed: 0,
232                    },
233                    ComputeTotals {
234                        executed_slot_count: 5,
235                        session_duration_ms: 9,
236                    },
237                ),
238                bucket(
239                    "02",
240                    SessionCounts {
241                        started: 2,
242                        completed: 1,
243                        failed: 1,
244                    },
245                    ComputeTotals {
246                        executed_slot_count: 7,
247                        session_duration_ms: 3,
248                    },
249                ),
250            ],
251            None,
252        );
253        assert_eq!(
254            report.sessions(),
255            SessionCounts {
256                started: 3,
257                completed: 2,
258                failed: 1
259            }
260        );
261        assert_eq!(
262            report.compute(),
263            ComputeTotals {
264                executed_slot_count: 12,
265                session_duration_ms: 12
266            }
267        );
268    }
269
270    #[test]
271    fn new_totals_of_no_buckets_are_zero() {
272        let report = UsageReport::new(
273            "k".to_string(),
274            "2026-01-01T00:00:00Z".parse().unwrap(),
275            "2026-01-01T00:00:00Z".parse().unwrap(),
276            vec![],
277            None,
278        );
279        assert_eq!(report.sessions(), SessionCounts::default());
280        assert_eq!(report.compute(), ComputeTotals::default());
281    }
282
283    #[test]
284    fn usage_report_absent_bundle_builds_round_trips_to_none() {
285        let json = serde_json::to_string(&report(None)).unwrap();
286        assert!(!json.contains("bundle_builds"));
287        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
288        assert_eq!(parsed.bundle_builds, None);
289    }
290
291    #[test]
292    fn usage_report_absent_daily_round_trips_to_empty() {
293        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}}"#;
294        let parsed: UsageReport = serde_json::from_str(json).unwrap();
295        assert!(parsed.daily.is_empty());
296        // The totals stay on the wire so an empty `daily` does not read as no usage.
297        assert_eq!(
298            parsed.sessions(),
299            SessionCounts {
300                started: 1,
301                completed: 1,
302                failed: 0
303            }
304        );
305        assert_eq!(
306            parsed.compute(),
307            ComputeTotals {
308                executed_slot_count: 5,
309                session_duration_ms: 9
310            }
311        );
312    }
313
314    #[test]
315    fn usage_report_daily_round_trips() {
316        let json = serde_json::to_string(&report(None)).unwrap();
317        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
318        assert_eq!(parsed.daily, report(None).daily);
319    }
320
321    /// The prefixed form is what anything taking a session id accepts.
322    #[test]
323    fn usage_sessions_round_trip_with_prefixed_ids() {
324        let json = serde_json::to_string(&session_page()).unwrap();
325        assert!(json.contains("\"backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b\""));
326        let parsed: SessionUsagePage = serde_json::from_str(&json).unwrap();
327        assert_eq!(parsed, session_page());
328    }
329
330    fn session_page() -> SessionUsagePage {
331        SessionUsagePage {
332            since: "2026-01-01T00:00:00Z".parse().unwrap(),
333            until: "2026-01-02T00:00:00Z".parse().unwrap(),
334            sessions: vec![session()],
335            next_cursor: None,
336        }
337    }
338
339    /// An omitted cursor means "nothing further", not a parse failure.
340    #[test]
341    fn usage_sessions_absent_cursor_round_trips_to_none() {
342        let json =
343            r#"{"since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":[]}"#;
344        let parsed: SessionUsagePage = serde_json::from_str(json).unwrap();
345        assert_eq!(parsed.next_cursor, None);
346        assert!(parsed.sessions.is_empty());
347    }
348
349    #[test]
350    fn usage_report_present_bundle_builds_round_trips() {
351        let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
352        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
353        assert_eq!(
354            parsed.bundle_builds,
355            Some(BundleBuildUsage { requested: 3 })
356        );
357    }
358}