Skip to main content

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}