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 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#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
136pub struct UsagePricing {
137 pub dollars_per_slot: f64,
138 pub dollars_per_minute: f64,
139 pub max_ms_per_slot: u64,
141}
142
143impl UsagePricing {
144 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#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
156pub struct BundleBuildUsage {
157 pub requested: u64,
158}
159
160#[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#[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 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 #[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 #[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}