1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::SessionId;
5
6#[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 #[serde(default)]
22 pub daily: Vec<UsageBucket>,
23 #[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 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 pub fn sessions(&self) -> SessionCounts {
64 self.sessions
65 }
66
67 pub fn compute(&self) -> ComputeTotals {
70 self.compute
71 }
72}
73
74pub const BUCKET_SPAN: Duration = Duration::days(1);
76
77#[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 pub fn is_partial(&self) -> bool {
91 self.end.signed_duration_since(self.start) < BUCKET_SPAN
92 }
93
94 pub fn is_empty(&self) -> bool {
96 self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
97 }
98}
99
100#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
105pub struct SessionUsagePage {
106 pub since: DateTime<Utc>,
110 pub until: DateTime<Utc>,
111 pub sessions: Vec<SessionUsage>,
114 #[serde(default)]
117 pub next_cursor: Option<String>,
118}
119
120#[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#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
138pub struct UsagePricing {
139 pub dollars_per_slot: f64,
140 pub dollars_per_minute: f64,
141 pub max_ms_per_slot: u64,
143}
144
145impl UsagePricing {
146 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#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
158pub struct BundleBuildUsage {
159 pub requested: u64,
160}
161
162#[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#[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 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 #[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 #[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}