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}
27
28impl UsageReport {
29 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 pub fn sessions(&self) -> SessionCounts {
60 self.sessions
61 }
62
63 pub fn compute(&self) -> ComputeTotals {
66 self.compute
67 }
68}
69
70pub const BUCKET_SPAN: Duration = Duration::days(1);
72
73#[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 pub fn is_partial(&self) -> bool {
87 self.end.signed_duration_since(self.start) < BUCKET_SPAN
88 }
89
90 pub fn is_empty(&self) -> bool {
92 self.sessions == SessionCounts::default() && self.compute == ComputeTotals::default()
93 }
94}
95
96#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
101pub struct SessionUsagePage {
102 pub since: DateTime<Utc>,
106 pub until: DateTime<Utc>,
107 pub sessions: Vec<SessionUsage>,
110 #[serde(default)]
113 pub next_cursor: Option<String>,
114}
115
116#[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#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
131pub struct BundleBuildUsage {
132 pub requested: u64,
133}
134
135#[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#[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 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 #[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 #[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}