ironflow_store/entities/stats.rs
1//! [`RunStats`] — aggregated statistics across all runs.
2//! [`StatsHistoryBucket`] — time-bucketed statistics for trend charts.
3
4use std::fmt;
5
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10/// Aggregated statistics for all runs in the store.
11///
12/// Computed efficiently by the store implementation (single SQL query in PostgreSQL,
13/// in-memory aggregation in InMemoryStore).
14///
15/// # Examples
16///
17/// ```
18/// use ironflow_store::entities::RunStats;
19/// use rust_decimal::Decimal;
20///
21/// let stats = RunStats {
22/// total_runs: 100,
23/// completed_runs: 80,
24/// failed_runs: 15,
25/// cancelled_runs: 5,
26/// active_runs: 0,
27/// total_cost_usd: Decimal::new(4250, 2),
28/// total_duration_ms: 3600000,
29/// };
30/// assert_eq!(stats.total_runs, 100);
31/// ```
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct RunStats {
34 /// Total number of runs ever created.
35 pub total_runs: u64,
36 /// Runs that reached the `Completed` state.
37 pub completed_runs: u64,
38 /// Runs that reached the `Failed` state.
39 pub failed_runs: u64,
40 /// Runs that reached the `Cancelled` state.
41 pub cancelled_runs: u64,
42 /// Runs in an active state: `Pending`, `Running`, or `Retrying`.
43 pub active_runs: u64,
44 /// Total cost in USD across all runs.
45 pub total_cost_usd: Decimal,
46 /// Total execution time in milliseconds across all runs.
47 pub total_duration_ms: u64,
48}
49
50/// Time period for historical statistics queries.
51///
52/// Controls how far back the query reaches from the current time.
53///
54/// # Examples
55///
56/// ```
57/// use ironflow_store::entities::HistoryPeriod;
58///
59/// let period = HistoryPeriod::SevenDays;
60/// assert_eq!(period.to_string(), "7d");
61/// ```
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub enum HistoryPeriod {
65 /// Last 24 hours.
66 #[serde(rename = "24h")]
67 TwentyFourHours,
68 /// Last 7 days.
69 #[default]
70 #[serde(rename = "7d")]
71 SevenDays,
72 /// Last 30 days.
73 #[serde(rename = "30d")]
74 ThirtyDays,
75 /// Last 90 days.
76 #[serde(rename = "90d")]
77 NinetyDays,
78}
79
80impl fmt::Display for HistoryPeriod {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::TwentyFourHours => write!(f, "24h"),
84 Self::SevenDays => write!(f, "7d"),
85 Self::ThirtyDays => write!(f, "30d"),
86 Self::NinetyDays => write!(f, "90d"),
87 }
88 }
89}
90
91impl HistoryPeriod {
92 /// Returns the default granularity for this period.
93 ///
94 /// - `24h` -> `1h`
95 /// - `7d` -> `1d`
96 /// - `30d` -> `1d`
97 /// - `90d` -> `1w`
98 ///
99 /// # Examples
100 ///
101 /// ```
102 /// use ironflow_store::entities::{HistoryPeriod, HistoryGranularity};
103 ///
104 /// assert_eq!(HistoryPeriod::TwentyFourHours.default_granularity(), HistoryGranularity::OneHour);
105 /// assert_eq!(HistoryPeriod::NinetyDays.default_granularity(), HistoryGranularity::OneWeek);
106 /// ```
107 pub fn default_granularity(&self) -> HistoryGranularity {
108 match self {
109 Self::TwentyFourHours => HistoryGranularity::OneHour,
110 Self::SevenDays | Self::ThirtyDays => HistoryGranularity::OneDay,
111 Self::NinetyDays => HistoryGranularity::OneWeek,
112 }
113 }
114
115 /// Returns the number of hours this period spans.
116 ///
117 /// # Examples
118 ///
119 /// ```
120 /// use ironflow_store::entities::HistoryPeriod;
121 ///
122 /// assert_eq!(HistoryPeriod::TwentyFourHours.hours(), 24);
123 /// assert_eq!(HistoryPeriod::SevenDays.hours(), 168);
124 /// ```
125 pub fn hours(&self) -> i64 {
126 match self {
127 Self::TwentyFourHours => 24,
128 Self::SevenDays => 7 * 24,
129 Self::ThirtyDays => 30 * 24,
130 Self::NinetyDays => 90 * 24,
131 }
132 }
133}
134
135/// Time bucket granularity for historical statistics.
136///
137/// Controls the size of each time bucket in the response.
138///
139/// # Examples
140///
141/// ```
142/// use ironflow_store::entities::HistoryGranularity;
143///
144/// let gran = HistoryGranularity::OneDay;
145/// assert_eq!(gran.to_string(), "1d");
146/// assert_eq!(gran.pg_interval(), "day");
147/// ```
148#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150pub enum HistoryGranularity {
151 /// One-hour buckets.
152 #[serde(rename = "1h")]
153 OneHour,
154 /// One-day buckets.
155 #[serde(rename = "1d")]
156 OneDay,
157 /// One-week buckets.
158 #[serde(rename = "1w")]
159 OneWeek,
160}
161
162impl fmt::Display for HistoryGranularity {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Self::OneHour => write!(f, "1h"),
166 Self::OneDay => write!(f, "1d"),
167 Self::OneWeek => write!(f, "1w"),
168 }
169 }
170}
171
172impl HistoryGranularity {
173 /// Returns the PostgreSQL `date_trunc` interval name.
174 ///
175 /// # Examples
176 ///
177 /// ```
178 /// use ironflow_store::entities::HistoryGranularity;
179 ///
180 /// assert_eq!(HistoryGranularity::OneHour.pg_interval(), "hour");
181 /// ```
182 pub fn pg_interval(&self) -> &'static str {
183 match self {
184 Self::OneHour => "hour",
185 Self::OneDay => "day",
186 Self::OneWeek => "week",
187 }
188 }
189
190 /// Returns the number of seconds in one bucket of this granularity.
191 ///
192 /// # Examples
193 ///
194 /// ```
195 /// use ironflow_store::entities::HistoryGranularity;
196 ///
197 /// assert_eq!(HistoryGranularity::OneHour.seconds(), 3600);
198 /// ```
199 pub fn seconds(&self) -> i64 {
200 match self {
201 Self::OneHour => 3600,
202 Self::OneDay => 86400,
203 Self::OneWeek => 604800,
204 }
205 }
206}
207
208/// Filter for historical statistics queries.
209///
210/// # Examples
211///
212/// ```
213/// use ironflow_store::entities::{StatsHistoryFilter, HistoryPeriod, HistoryGranularity};
214///
215/// let filter = StatsHistoryFilter {
216/// workflow_name: Some("deploy".to_string()),
217/// period: HistoryPeriod::SevenDays,
218/// granularity: HistoryGranularity::OneDay,
219/// };
220/// assert_eq!(filter.period.to_string(), "7d");
221/// ```
222#[derive(Debug, Clone)]
223pub struct StatsHistoryFilter {
224 /// Filter by workflow name (exact match). `None` means all workflows.
225 pub workflow_name: Option<String>,
226 /// How far back to query.
227 pub period: HistoryPeriod,
228 /// Size of each time bucket.
229 pub granularity: HistoryGranularity,
230}
231
232/// One time bucket of aggregated run statistics.
233///
234/// Each bucket covers a time range determined by the granularity
235/// (1 hour, 1 day, or 1 week).
236///
237/// # Examples
238///
239/// ```
240/// use chrono::Utc;
241/// use rust_decimal::Decimal;
242/// use ironflow_store::entities::StatsHistoryBucket;
243///
244/// let bucket = StatsHistoryBucket {
245/// time: Utc::now(),
246/// completed: 42,
247/// failed: 3,
248/// cancelled: 1,
249/// avg_duration_ms: 45000,
250/// p95_duration_ms: 120000,
251/// total_cost_usd: Decimal::new(123, 2),
252/// };
253/// assert_eq!(bucket.completed, 42);
254/// ```
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct StatsHistoryBucket {
257 /// Start of the time bucket.
258 pub time: DateTime<Utc>,
259 /// Number of runs that completed in this bucket.
260 pub completed: u64,
261 /// Number of runs that failed in this bucket.
262 pub failed: u64,
263 /// Number of runs that were cancelled in this bucket.
264 pub cancelled: u64,
265 /// Average duration in milliseconds of terminal runs in this bucket.
266 pub avg_duration_ms: u64,
267 /// 95th percentile duration in milliseconds of terminal runs in this bucket.
268 pub p95_duration_ms: u64,
269 /// Total cost in USD of all runs in this bucket.
270 pub total_cost_usd: Decimal,
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn default_is_zeros() {
279 let stats = RunStats::default();
280 assert_eq!(stats.total_runs, 0);
281 assert_eq!(stats.completed_runs, 0);
282 assert_eq!(stats.failed_runs, 0);
283 assert_eq!(stats.cancelled_runs, 0);
284 assert_eq!(stats.active_runs, 0);
285 assert_eq!(stats.total_cost_usd, Decimal::ZERO);
286 assert_eq!(stats.total_duration_ms, 0);
287 }
288
289 #[test]
290 fn serde_roundtrip() {
291 let stats = RunStats {
292 total_runs: 100,
293 completed_runs: 80,
294 failed_runs: 15,
295 cancelled_runs: 5,
296 active_runs: 0,
297 total_cost_usd: Decimal::new(4250, 2),
298 total_duration_ms: 3600000,
299 };
300 let json = serde_json::to_string(&stats).expect("serialize");
301 let back: RunStats = serde_json::from_str(&json).expect("deserialize");
302 assert_eq!(stats.total_runs, back.total_runs);
303 assert_eq!(stats.completed_runs, back.completed_runs);
304 assert_eq!(stats.failed_runs, back.failed_runs);
305 assert_eq!(stats.cancelled_runs, back.cancelled_runs);
306 assert_eq!(stats.active_runs, back.active_runs);
307 assert_eq!(stats.total_cost_usd, back.total_cost_usd);
308 assert_eq!(stats.total_duration_ms, back.total_duration_ms);
309 }
310}