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    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub session_name: Option<String>,
129    pub started_at: DateTime<Utc>,
130    pub ended_at: DateTime<Utc>,
131    pub executed_slot_count: u64,
132    pub session_duration_ms: u64,
133}
134
135/// The billing rates a report's totals are priced at. Served by the manager so
136/// the rates live in one place rather than in every client.
137#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
138pub struct UsagePricing {
139    pub dollars_per_slot: f64,
140    pub dollars_per_minute: f64,
141    /// A run slower than this per slot is billed by wall time instead.
142    pub max_ms_per_slot: u64,
143}
144
145impl UsagePricing {
146    /// Dollars for `slots` executed over `duration_ms` of wall time.
147    pub fn cost(&self, slots: u64, duration_ms: u64) -> f64 {
148        if slots.saturating_mul(self.max_ms_per_slot) >= duration_ms {
149            slots as f64 * self.dollars_per_slot
150        } else {
151            duration_ms as f64 / 1000.0 / 60.0 * self.dollars_per_minute
152        }
153    }
154}
155
156/// Bundle-build usage for the window.
157#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
158pub struct BundleBuildUsage {
159    pub requested: u64,
160}
161
162/// Point counts by outcome, not session-deduplicated, so the three need not
163/// balance: a pre-start failure has no `started`, a cross-manager handoff
164/// re-emits it. Billing uses the compute totals, which derive only from the
165/// terminal points.
166///
167/// Within a [`UsageBucket`], `started` counts sessions that began that day and
168/// `completed`/`failed` those that ended that day.
169#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
170pub struct SessionCounts {
171    pub started: u64,
172    pub completed: u64,
173    pub failed: u64,
174}
175
176/// A session contributes wholly to the window — or [`UsageBucket`] — its run
177/// ended in, even when it ran across the boundary.
178#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
179pub struct ComputeTotals {
180    pub executed_slot_count: u64,
181    pub session_duration_ms: u64,
182}
183
184impl SessionCounts {
185    pub fn saturating_add(self, other: Self) -> Self {
186        Self {
187            started: self.started.saturating_add(other.started),
188            completed: self.completed.saturating_add(other.completed),
189            failed: self.failed.saturating_add(other.failed),
190        }
191    }
192}
193
194impl ComputeTotals {
195    pub fn saturating_add(self, other: Self) -> Self {
196        Self {
197            executed_slot_count: self
198                .executed_slot_count
199                .saturating_add(other.executed_slot_count),
200            session_duration_ms: self
201                .session_duration_ms
202                .saturating_add(other.session_duration_ms),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn report(bundle_builds: Option<BundleBuildUsage>) -> UsageReport {
212        UsageReport::new(
213            "k".to_string(),
214            "2026-01-01T00:00:00Z".parse().unwrap(),
215            "2026-01-02T00:00:00Z".parse().unwrap(),
216            vec![UsageBucket {
217                start: "2026-01-01T00:00:00Z".parse().unwrap(),
218                end: "2026-01-02T00:00:00Z".parse().unwrap(),
219                sessions: SessionCounts::default(),
220                compute: ComputeTotals::default(),
221            }],
222            bundle_builds,
223            None,
224        )
225    }
226
227    fn session() -> SessionUsage {
228        SessionUsage {
229            session_id: "backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b".parse().unwrap(),
230            session_name: None,
231            started_at: "2026-01-01T09:14:02Z".parse().unwrap(),
232            ended_at: "2026-01-01T09:18:41Z".parse().unwrap(),
233            executed_slot_count: 3_100,
234            session_duration_ms: 279_000,
235        }
236    }
237
238    fn bucket(day: &str, sessions: SessionCounts, compute: ComputeTotals) -> UsageBucket {
239        let start: DateTime<Utc> = format!("2026-01-{day}T00:00:00Z").parse().unwrap();
240        UsageBucket {
241            start,
242            end: start + BUCKET_SPAN,
243            sessions,
244            compute,
245        }
246    }
247
248    #[test]
249    fn new_sums_the_totals_from_the_buckets() {
250        let report = UsageReport::new(
251            "k".to_string(),
252            "2026-01-01T00:00:00Z".parse().unwrap(),
253            "2026-01-03T00:00:00Z".parse().unwrap(),
254            vec![
255                bucket(
256                    "01",
257                    SessionCounts {
258                        started: 1,
259                        completed: 1,
260                        failed: 0,
261                    },
262                    ComputeTotals {
263                        executed_slot_count: 5,
264                        session_duration_ms: 9,
265                    },
266                ),
267                bucket(
268                    "02",
269                    SessionCounts {
270                        started: 2,
271                        completed: 1,
272                        failed: 1,
273                    },
274                    ComputeTotals {
275                        executed_slot_count: 7,
276                        session_duration_ms: 3,
277                    },
278                ),
279            ],
280            None,
281            None,
282        );
283        assert_eq!(
284            report.sessions(),
285            SessionCounts {
286                started: 3,
287                completed: 2,
288                failed: 1
289            }
290        );
291        assert_eq!(
292            report.compute(),
293            ComputeTotals {
294                executed_slot_count: 12,
295                session_duration_ms: 12
296            }
297        );
298    }
299
300    #[test]
301    fn new_totals_of_no_buckets_are_zero() {
302        let report = UsageReport::new(
303            "k".to_string(),
304            "2026-01-01T00:00:00Z".parse().unwrap(),
305            "2026-01-01T00:00:00Z".parse().unwrap(),
306            vec![],
307            None,
308            None,
309        );
310        assert_eq!(report.sessions(), SessionCounts::default());
311        assert_eq!(report.compute(), ComputeTotals::default());
312    }
313
314    #[test]
315    fn usage_report_absent_bundle_builds_round_trips_to_none() {
316        let json = serde_json::to_string(&report(None)).unwrap();
317        assert!(!json.contains("bundle_builds"));
318        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
319        assert_eq!(parsed.bundle_builds, None);
320    }
321
322    #[test]
323    fn usage_report_absent_daily_round_trips_to_empty() {
324        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}}"#;
325        let parsed: UsageReport = serde_json::from_str(json).unwrap();
326        assert!(parsed.daily.is_empty());
327        // The totals stay on the wire so an empty `daily` does not read as no usage.
328        assert_eq!(
329            parsed.sessions(),
330            SessionCounts {
331                started: 1,
332                completed: 1,
333                failed: 0
334            }
335        );
336        assert_eq!(
337            parsed.compute(),
338            ComputeTotals {
339                executed_slot_count: 5,
340                session_duration_ms: 9
341            }
342        );
343    }
344
345    #[test]
346    fn usage_report_daily_round_trips() {
347        let json = serde_json::to_string(&report(None)).unwrap();
348        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
349        assert_eq!(parsed.daily, report(None).daily);
350    }
351
352    /// The prefixed form is what anything taking a session id accepts.
353    #[test]
354    fn usage_sessions_round_trip_with_prefixed_ids() {
355        let json = serde_json::to_string(&session_page()).unwrap();
356        assert!(json.contains("\"backtest_0198f3a1c2d34e5f8a9b0c1d2e3f4a5b\""));
357        assert!(!json.contains("session_name"));
358        let parsed: SessionUsagePage = serde_json::from_str(&json).unwrap();
359        assert_eq!(parsed, session_page());
360    }
361
362    fn session_page() -> SessionUsagePage {
363        SessionUsagePage {
364            since: "2026-01-01T00:00:00Z".parse().unwrap(),
365            until: "2026-01-02T00:00:00Z".parse().unwrap(),
366            sessions: vec![session()],
367            next_cursor: None,
368        }
369    }
370
371    /// An omitted cursor means "nothing further", not a parse failure.
372    #[test]
373    fn usage_sessions_absent_cursor_round_trips_to_none() {
374        let json =
375            r#"{"since":"2026-01-01T00:00:00Z","until":"2026-01-02T00:00:00Z","sessions":[]}"#;
376        let parsed: SessionUsagePage = serde_json::from_str(json).unwrap();
377        assert_eq!(parsed.next_cursor, None);
378        assert!(parsed.sessions.is_empty());
379    }
380
381    #[test]
382    fn usage_report_present_bundle_builds_round_trips() {
383        let json = serde_json::to_string(&report(Some(BundleBuildUsage { requested: 3 }))).unwrap();
384        let parsed: UsageReport = serde_json::from_str(&json).unwrap();
385        assert_eq!(
386            parsed.bundle_builds,
387            Some(BundleBuildUsage { requested: 3 })
388        );
389    }
390}