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