Skip to main content

oximedia_workflow/
workflow_metrics.rs

1//! Workflow runtime metrics collection for `oximedia-workflow`.
2//!
3//! [`WorkflowMetricsCollector`] accumulates [`MetricSample`]s tagged with a
4//! [`WorkflowMetric`] variant and exposes summary statistics (count, sum,
5//! average, min, max) without requiring an external dependency.
6
7#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12// ── Metric kinds ──────────────────────────────────────────────────────────────
13
14/// Discriminates the type of measurement stored in a [`MetricSample`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum WorkflowMetric {
17    /// Wall-clock time a task spent in the queue before execution (seconds).
18    QueueWaitSeconds,
19    /// Wall-clock execution time of a single task (seconds).
20    TaskDurationSeconds,
21    /// Peak resident memory used by a task (bytes).
22    TaskMemoryBytes,
23    /// CPU utilisation percentage sampled during task execution (0–100).
24    CpuPercent,
25    /// Number of retry attempts consumed by a task before success or failure.
26    RetryCount,
27    /// Total workflow wall-clock time from submission to completion (seconds).
28    WorkflowDurationSeconds,
29}
30
31impl WorkflowMetric {
32    /// Returns the SI unit label for this metric.
33    #[must_use]
34    pub fn unit(self) -> &'static str {
35        match self {
36            Self::QueueWaitSeconds | Self::TaskDurationSeconds | Self::WorkflowDurationSeconds => {
37                "s"
38            }
39            Self::TaskMemoryBytes => "bytes",
40            Self::CpuPercent => "%",
41            Self::RetryCount => "count",
42        }
43    }
44
45    /// Returns `true` when lower values indicate better performance.
46    #[must_use]
47    pub fn lower_is_better(self) -> bool {
48        !matches!(self, Self::CpuPercent)
49    }
50}
51
52impl std::fmt::Display for WorkflowMetric {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        let s = match self {
55            Self::QueueWaitSeconds => "queue_wait_seconds",
56            Self::TaskDurationSeconds => "task_duration_seconds",
57            Self::TaskMemoryBytes => "task_memory_bytes",
58            Self::CpuPercent => "cpu_percent",
59            Self::RetryCount => "retry_count",
60            Self::WorkflowDurationSeconds => "workflow_duration_seconds",
61        };
62        write!(f, "{s}")
63    }
64}
65
66// ── Sample ────────────────────────────────────────────────────────────────────
67
68/// A single metric observation tied to a workflow or task identifier.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct MetricSample {
71    /// The kind of metric being recorded.
72    pub metric: WorkflowMetric,
73    /// Identifier of the workflow or task that generated this sample.
74    pub source_id: String,
75    /// The measured value.
76    pub value: f64,
77}
78
79impl MetricSample {
80    /// Creates a new metric sample.
81    #[must_use]
82    pub fn new(metric: WorkflowMetric, source_id: impl Into<String>, value: f64) -> Self {
83        Self {
84            metric,
85            source_id: source_id.into(),
86            value,
87        }
88    }
89}
90
91// ── Summary ───────────────────────────────────────────────────────────────────
92
93/// Aggregated statistics for a single [`WorkflowMetric`] type.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct MetricSummary {
96    /// The metric these statistics describe.
97    pub metric: WorkflowMetric,
98    /// Number of samples included in the summary.
99    pub count: usize,
100    /// Sum of all sample values.
101    pub sum: f64,
102    /// Minimum observed value.
103    pub min: f64,
104    /// Maximum observed value.
105    pub max: f64,
106}
107
108impl MetricSummary {
109    /// Computes the arithmetic mean, or `0.0` when `count == 0`.
110    #[allow(clippy::cast_precision_loss)]
111    #[must_use]
112    pub fn mean(&self) -> f64 {
113        if self.count == 0 {
114            0.0
115        } else {
116            self.sum / self.count as f64
117        }
118    }
119}
120
121// ── Collector ─────────────────────────────────────────────────────────────────
122
123/// Accumulates [`MetricSample`]s and produces per-metric [`MetricSummary`]s.
124#[derive(Debug, Default, Clone)]
125pub struct WorkflowMetricsCollector {
126    samples: Vec<MetricSample>,
127}
128
129impl WorkflowMetricsCollector {
130    /// Creates a new, empty collector.
131    #[must_use]
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Records a single metric observation.
137    pub fn record(&mut self, sample: MetricSample) {
138        self.samples.push(sample);
139    }
140
141    /// Convenience method to record a value directly.
142    pub fn record_value(
143        &mut self,
144        metric: WorkflowMetric,
145        source_id: impl Into<String>,
146        value: f64,
147    ) {
148        self.record(MetricSample::new(metric, source_id, value));
149    }
150
151    /// Total number of samples collected so far.
152    #[must_use]
153    pub fn sample_count(&self) -> usize {
154        self.samples.len()
155    }
156
157    /// Returns all samples for the given metric kind.
158    #[must_use]
159    pub fn samples_for(&self, metric: WorkflowMetric) -> Vec<&MetricSample> {
160        self.samples.iter().filter(|s| s.metric == metric).collect()
161    }
162
163    /// Builds a [`MetricSummary`] for the given metric, or `None` if no
164    /// samples of that kind have been collected.
165    #[must_use]
166    pub fn summarize(&self, metric: WorkflowMetric) -> Option<MetricSummary> {
167        let relevant: Vec<f64> = self
168            .samples
169            .iter()
170            .filter(|s| s.metric == metric)
171            .map(|s| s.value)
172            .collect();
173
174        if relevant.is_empty() {
175            return None;
176        }
177
178        let sum: f64 = relevant.iter().sum();
179        let min = relevant.iter().copied().fold(f64::INFINITY, f64::min);
180        let max = relevant.iter().copied().fold(f64::NEG_INFINITY, f64::max);
181
182        Some(MetricSummary {
183            metric,
184            count: relevant.len(),
185            sum,
186            min,
187            max,
188        })
189    }
190
191    /// Returns summaries for every metric kind that has at least one sample.
192    #[must_use]
193    pub fn all_summaries(&self) -> HashMap<WorkflowMetric, MetricSummary> {
194        let mut map = HashMap::new();
195        for metric in [
196            WorkflowMetric::QueueWaitSeconds,
197            WorkflowMetric::TaskDurationSeconds,
198            WorkflowMetric::TaskMemoryBytes,
199            WorkflowMetric::CpuPercent,
200            WorkflowMetric::RetryCount,
201            WorkflowMetric::WorkflowDurationSeconds,
202        ] {
203            if let Some(summary) = self.summarize(metric) {
204                map.insert(metric, summary);
205            }
206        }
207        map
208    }
209
210    /// Clears all collected samples.
211    pub fn reset(&mut self) {
212        self.samples.clear();
213    }
214}
215
216// ── Tests ─────────────────────────────────────────────────────────────────────
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn collector_with_samples() -> WorkflowMetricsCollector {
223        let mut c = WorkflowMetricsCollector::new();
224        c.record_value(WorkflowMetric::TaskDurationSeconds, "task-1", 10.0);
225        c.record_value(WorkflowMetric::TaskDurationSeconds, "task-2", 20.0);
226        c.record_value(WorkflowMetric::TaskDurationSeconds, "task-3", 30.0);
227        c.record_value(WorkflowMetric::CpuPercent, "task-1", 55.0);
228        c
229    }
230
231    #[test]
232    fn test_new_collector_empty() {
233        let c = WorkflowMetricsCollector::new();
234        assert_eq!(c.sample_count(), 0);
235    }
236
237    #[test]
238    fn test_record_value_increments_count() {
239        let mut c = WorkflowMetricsCollector::new();
240        c.record_value(WorkflowMetric::RetryCount, "wf-1", 2.0);
241        assert_eq!(c.sample_count(), 1);
242    }
243
244    #[test]
245    fn test_samples_for_filters_correctly() {
246        let c = collector_with_samples();
247        let dur_samples = c.samples_for(WorkflowMetric::TaskDurationSeconds);
248        assert_eq!(dur_samples.len(), 3);
249    }
250
251    #[test]
252    fn test_summarize_mean() {
253        let c = collector_with_samples();
254        let summary = c
255            .summarize(WorkflowMetric::TaskDurationSeconds)
256            .expect("should succeed in test");
257        // (10 + 20 + 30) / 3 = 20.0
258        assert!((summary.mean() - 20.0).abs() < 1e-9);
259    }
260
261    #[test]
262    fn test_summarize_min_max() {
263        let c = collector_with_samples();
264        let summary = c
265            .summarize(WorkflowMetric::TaskDurationSeconds)
266            .expect("should succeed in test");
267        assert!((summary.min - 10.0).abs() < 1e-9);
268        assert!((summary.max - 30.0).abs() < 1e-9);
269    }
270
271    #[test]
272    fn test_summarize_count() {
273        let c = collector_with_samples();
274        let summary = c
275            .summarize(WorkflowMetric::TaskDurationSeconds)
276            .expect("should succeed in test");
277        assert_eq!(summary.count, 3);
278    }
279
280    #[test]
281    fn test_summarize_none_for_missing_metric() {
282        let c = collector_with_samples();
283        assert!(c.summarize(WorkflowMetric::QueueWaitSeconds).is_none());
284    }
285
286    #[test]
287    fn test_all_summaries_keys() {
288        let c = collector_with_samples();
289        let summaries = c.all_summaries();
290        assert!(summaries.contains_key(&WorkflowMetric::TaskDurationSeconds));
291        assert!(summaries.contains_key(&WorkflowMetric::CpuPercent));
292        assert!(!summaries.contains_key(&WorkflowMetric::QueueWaitSeconds));
293    }
294
295    #[test]
296    fn test_reset_clears_samples() {
297        let mut c = collector_with_samples();
298        c.reset();
299        assert_eq!(c.sample_count(), 0);
300        assert!(c.summarize(WorkflowMetric::TaskDurationSeconds).is_none());
301    }
302
303    #[test]
304    fn test_metric_unit() {
305        assert_eq!(WorkflowMetric::TaskDurationSeconds.unit(), "s");
306        assert_eq!(WorkflowMetric::TaskMemoryBytes.unit(), "bytes");
307        assert_eq!(WorkflowMetric::CpuPercent.unit(), "%");
308        assert_eq!(WorkflowMetric::RetryCount.unit(), "count");
309    }
310
311    #[test]
312    fn test_metric_lower_is_better() {
313        assert!(WorkflowMetric::TaskDurationSeconds.lower_is_better());
314        assert!(WorkflowMetric::QueueWaitSeconds.lower_is_better());
315        assert!(!WorkflowMetric::CpuPercent.lower_is_better());
316    }
317
318    #[test]
319    fn test_metric_display() {
320        assert_eq!(
321            format!("{}", WorkflowMetric::TaskDurationSeconds),
322            "task_duration_seconds"
323        );
324    }
325
326    #[test]
327    fn test_metric_summary_mean_empty() {
328        let s = MetricSummary {
329            metric: WorkflowMetric::RetryCount,
330            count: 0,
331            sum: 0.0,
332            min: 0.0,
333            max: 0.0,
334        };
335        assert!((s.mean() - 0.0).abs() < 1e-9);
336    }
337
338    #[test]
339    fn test_single_sample_summary() {
340        let mut c = WorkflowMetricsCollector::new();
341        c.record_value(WorkflowMetric::WorkflowDurationSeconds, "wf-a", 42.0);
342        let s = c
343            .summarize(WorkflowMetric::WorkflowDurationSeconds)
344            .expect("should succeed in test");
345        assert_eq!(s.count, 1);
346        assert!((s.min - 42.0).abs() < 1e-9);
347        assert!((s.max - 42.0).abs() < 1e-9);
348        assert!((s.mean() - 42.0).abs() < 1e-9);
349    }
350}
351
352// ═══════════════════════════════════════════════════════════════════════════════
353// Workflow-level execution metrics and aggregator
354// ═══════════════════════════════════════════════════════════════════════════════
355
356/// Per-step timing and resource metrics captured during a workflow run.
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct StepMetric {
359    /// Step identifier.
360    pub step_id: String,
361    /// Wall-clock time at which the step started.
362    pub started_at: std::time::SystemTime,
363    /// How long the step took to complete (seconds).
364    pub duration_secs: f64,
365    /// Whether the step completed successfully.
366    pub success: bool,
367    /// How many retry attempts were consumed.
368    pub retries: u32,
369    /// Optional output size in bytes (e.g. transcoded file size).
370    pub output_size_bytes: Option<u64>,
371    /// Optional CPU time consumed by the step (seconds).
372    pub cpu_seconds: Option<f64>,
373}
374
375impl StepMetric {
376    /// Construct a minimal step metric with only the required fields.
377    #[must_use]
378    pub fn new(step_id: impl Into<String>, duration_secs: f64, success: bool) -> Self {
379        Self {
380            step_id: step_id.into(),
381            started_at: std::time::SystemTime::now(),
382            duration_secs,
383            success,
384            retries: 0,
385            output_size_bytes: None,
386            cpu_seconds: None,
387        }
388    }
389}
390
391/// Execution metrics for a single workflow run.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct WorkflowRunMetrics {
394    /// Workflow identifier.
395    pub workflow_id: String,
396    /// Wall-clock time at which the workflow started.
397    pub started_at: std::time::SystemTime,
398    /// Wall-clock time at which the workflow completed (if finished).
399    pub completed_at: Option<std::time::SystemTime>,
400    /// Per-step metrics collected during this run.
401    pub step_metrics: Vec<StepMetric>,
402    /// Total wall-clock duration of the workflow (seconds), if complete.
403    pub total_duration_secs: Option<f64>,
404    /// Whether the workflow completed successfully.
405    pub success: Option<bool>,
406}
407
408impl WorkflowRunMetrics {
409    /// Create a new metrics record for a workflow that has just started.
410    #[must_use]
411    pub fn new(workflow_id: impl Into<String>) -> Self {
412        Self {
413            workflow_id: workflow_id.into(),
414            started_at: std::time::SystemTime::now(),
415            completed_at: None,
416            step_metrics: Vec::new(),
417            total_duration_secs: None,
418            success: None,
419        }
420    }
421
422    /// Mark the workflow as finished and compute the total duration.
423    pub fn finish(&mut self, success: bool) {
424        let now = std::time::SystemTime::now();
425        self.completed_at = Some(now);
426        self.success = Some(success);
427        self.total_duration_secs = now
428            .duration_since(self.started_at)
429            .map(|d| d.as_secs_f64())
430            .ok();
431    }
432}
433
434/// Accumulates [`WorkflowRunMetrics`] across many runs and computes aggregate
435/// statistics.
436pub struct WorkflowMetricsAggregator {
437    history: Vec<WorkflowRunMetrics>,
438    max_history: usize,
439}
440
441impl WorkflowMetricsAggregator {
442    /// Create a new aggregator that retains at most `max_history` workflow runs.
443    ///
444    /// When `max_history` is exceeded the oldest entry is evicted.
445    #[must_use]
446    pub fn new(max_history: usize) -> Self {
447        Self {
448            history: Vec::new(),
449            max_history,
450        }
451    }
452
453    /// Add a completed workflow run to the history.
454    ///
455    /// If the history is at capacity the oldest entry is removed first.
456    pub fn record(&mut self, metrics: WorkflowRunMetrics) {
457        if self.max_history > 0 && self.history.len() >= self.max_history {
458            self.history.remove(0);
459        }
460        self.history.push(metrics);
461    }
462
463    /// Number of workflow runs currently in the history.
464    #[must_use]
465    pub fn len(&self) -> usize {
466        self.history.len()
467    }
468
469    /// Return `true` when no runs have been recorded.
470    #[must_use]
471    pub fn is_empty(&self) -> bool {
472        self.history.is_empty()
473    }
474
475    /// Mean total duration across all completed runs, or `None` when no
476    /// completed run is present.
477    #[must_use]
478    pub fn avg_duration_secs(&self) -> Option<f64> {
479        let durations: Vec<f64> = self
480            .history
481            .iter()
482            .filter_map(|m| m.total_duration_secs)
483            .collect();
484        if durations.is_empty() {
485            None
486        } else {
487            #[allow(clippy::cast_precision_loss)]
488            Some(durations.iter().sum::<f64>() / durations.len() as f64)
489        }
490    }
491
492    /// Fraction of runs (0.0–1.0) that completed successfully.
493    ///
494    /// Runs without a recorded `success` value are excluded from the
495    /// denominator.
496    #[must_use]
497    #[allow(clippy::cast_precision_loss)]
498    pub fn success_rate(&self) -> f64 {
499        let finished: Vec<bool> = self.history.iter().filter_map(|m| m.success).collect();
500        if finished.is_empty() {
501            return 0.0;
502        }
503        let successes = finished.iter().filter(|&&s| s).count();
504        successes as f64 / finished.len() as f64
505    }
506
507    /// 95th-percentile total duration across all completed runs, or `None`
508    /// when fewer than two completed runs are present.
509    #[must_use]
510    pub fn p95_duration_secs(&self) -> Option<f64> {
511        let mut durations: Vec<f64> = self
512            .history
513            .iter()
514            .filter_map(|m| m.total_duration_secs)
515            .collect();
516        if durations.is_empty() {
517            return None;
518        }
519        durations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
520        #[allow(clippy::cast_precision_loss)]
521        let idx = ((durations.len() as f64 * 0.95).ceil() as usize).saturating_sub(1);
522        let idx = idx.min(durations.len() - 1);
523        Some(durations[idx])
524    }
525
526    /// Return the `n` slowest steps by average duration across all runs.
527    ///
528    /// Returns `(step_id, avg_duration_secs)` pairs sorted by descending
529    /// average duration.
530    #[must_use]
531    #[allow(clippy::cast_precision_loss)]
532    pub fn slowest_steps(&self, n: usize) -> Vec<(String, f64)> {
533        let mut totals: HashMap<String, (f64, usize)> = HashMap::new();
534        for run in &self.history {
535            for step in &run.step_metrics {
536                let entry = totals.entry(step.step_id.clone()).or_insert((0.0, 0));
537                entry.0 += step.duration_secs;
538                entry.1 += 1;
539            }
540        }
541        let mut avgs: Vec<(String, f64)> = totals
542            .into_iter()
543            .map(|(id, (total, count))| (id, total / count as f64))
544            .collect();
545        avgs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
546        avgs.truncate(n);
547        avgs
548    }
549
550    /// Return failure rates per step, sorted by descending failure rate.
551    ///
552    /// Returns `(step_id, failure_rate)` where `failure_rate` is in `0.0..=1.0`.
553    #[must_use]
554    #[allow(clippy::cast_precision_loss)]
555    pub fn failure_rate_by_step(&self) -> Vec<(String, f64)> {
556        let mut counts: HashMap<String, (usize, usize)> = HashMap::new(); // (total, failures)
557        for run in &self.history {
558            for step in &run.step_metrics {
559                let entry = counts.entry(step.step_id.clone()).or_insert((0, 0));
560                entry.0 += 1;
561                if !step.success {
562                    entry.1 += 1;
563                }
564            }
565        }
566        let mut rates: Vec<(String, f64)> = counts
567            .into_iter()
568            .map(|(id, (total, failures))| (id, failures as f64 / total as f64))
569            .collect();
570        rates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
571        rates
572    }
573
574    /// Read-only view of all recorded runs.
575    #[must_use]
576    pub fn history(&self) -> &[WorkflowRunMetrics] {
577        &self.history
578    }
579}
580
581// ── Tests ─────────────────────────────────────────────────────────────────────
582
583#[cfg(test)]
584mod aggregator_tests {
585    use super::*;
586
587    fn make_run(
588        id: &str,
589        duration: f64,
590        success: bool,
591        steps: Vec<StepMetric>,
592    ) -> WorkflowRunMetrics {
593        WorkflowRunMetrics {
594            workflow_id: id.to_string(),
595            started_at: std::time::SystemTime::now(),
596            completed_at: Some(std::time::SystemTime::now()),
597            step_metrics: steps,
598            total_duration_secs: Some(duration),
599            success: Some(success),
600        }
601    }
602
603    fn make_step(id: &str, duration: f64, success: bool) -> StepMetric {
604        StepMetric::new(id, duration, success)
605    }
606
607    #[test]
608    fn test_aggregator_new_is_empty() {
609        let agg = WorkflowMetricsAggregator::new(100);
610        assert!(agg.is_empty());
611        assert_eq!(agg.len(), 0);
612    }
613
614    #[test]
615    fn test_record_increments_len() {
616        let mut agg = WorkflowMetricsAggregator::new(100);
617        agg.record(make_run("wf-1", 10.0, true, vec![]));
618        assert_eq!(agg.len(), 1);
619    }
620
621    #[test]
622    fn test_max_history_evicts_oldest() {
623        let mut agg = WorkflowMetricsAggregator::new(2);
624        agg.record(make_run("wf-1", 10.0, true, vec![]));
625        agg.record(make_run("wf-2", 20.0, true, vec![]));
626        agg.record(make_run("wf-3", 30.0, true, vec![]));
627        assert_eq!(agg.len(), 2);
628        // Oldest (wf-1) should have been evicted.
629        assert_eq!(agg.history()[0].workflow_id, "wf-2");
630    }
631
632    #[test]
633    fn test_avg_duration_secs_correct() {
634        let mut agg = WorkflowMetricsAggregator::new(100);
635        agg.record(make_run("wf-1", 10.0, true, vec![]));
636        agg.record(make_run("wf-2", 30.0, true, vec![]));
637        let avg = agg.avg_duration_secs().expect("should have avg");
638        assert!((avg - 20.0).abs() < 1e-9);
639    }
640
641    #[test]
642    fn test_avg_duration_none_when_empty() {
643        let agg = WorkflowMetricsAggregator::new(100);
644        assert!(agg.avg_duration_secs().is_none());
645    }
646
647    #[test]
648    fn test_success_rate_all_success() {
649        let mut agg = WorkflowMetricsAggregator::new(100);
650        agg.record(make_run("wf-1", 10.0, true, vec![]));
651        agg.record(make_run("wf-2", 10.0, true, vec![]));
652        assert!((agg.success_rate() - 1.0).abs() < 1e-9);
653    }
654
655    #[test]
656    fn test_success_rate_half_success() {
657        let mut agg = WorkflowMetricsAggregator::new(100);
658        agg.record(make_run("wf-1", 10.0, true, vec![]));
659        agg.record(make_run("wf-2", 10.0, false, vec![]));
660        assert!((agg.success_rate() - 0.5).abs() < 1e-9);
661    }
662
663    #[test]
664    fn test_success_rate_zero_when_empty() {
665        let agg = WorkflowMetricsAggregator::new(100);
666        assert!((agg.success_rate() - 0.0).abs() < 1e-9);
667    }
668
669    #[test]
670    fn test_p95_duration_secs() {
671        let mut agg = WorkflowMetricsAggregator::new(100);
672        // 20 values: 1..=20; p95 index = ceil(20 * 0.95) - 1 = 18 → value 19
673        for i in 1u32..=20 {
674            agg.record(make_run(&format!("wf-{i}"), f64::from(i), true, vec![]));
675        }
676        let p95 = agg.p95_duration_secs().expect("should have p95");
677        assert!((p95 - 19.0).abs() < 1e-9);
678    }
679
680    #[test]
681    fn test_p95_none_when_empty() {
682        let agg = WorkflowMetricsAggregator::new(100);
683        assert!(agg.p95_duration_secs().is_none());
684    }
685
686    #[test]
687    fn test_slowest_steps_returns_top_n() {
688        let mut agg = WorkflowMetricsAggregator::new(100);
689        agg.record(make_run(
690            "wf-1",
691            100.0,
692            true,
693            vec![
694                make_step("transcode", 80.0, true),
695                make_step("ingest", 10.0, true),
696                make_step("deliver", 5.0, true),
697            ],
698        ));
699        let slowest = agg.slowest_steps(2);
700        assert_eq!(slowest.len(), 2);
701        assert_eq!(slowest[0].0, "transcode");
702        assert_eq!(slowest[1].0, "ingest");
703    }
704
705    #[test]
706    fn test_failure_rate_by_step_sorted_desc() {
707        let mut agg = WorkflowMetricsAggregator::new(100);
708        // transcode fails 2/2, ingest fails 0/2
709        for _ in 0..2 {
710            agg.record(make_run(
711                "wf",
712                10.0,
713                false,
714                vec![
715                    make_step("transcode", 5.0, false),
716                    make_step("ingest", 2.0, true),
717                ],
718            ));
719        }
720        let rates = agg.failure_rate_by_step();
721        assert!(!rates.is_empty());
722        assert_eq!(rates[0].0, "transcode");
723        assert!((rates[0].1 - 1.0).abs() < 1e-9);
724        let ingest_rate = rates
725            .iter()
726            .find(|(id, _)| id == "ingest")
727            .map(|(_, r)| *r)
728            .unwrap_or(0.0);
729        assert!((ingest_rate - 0.0).abs() < 1e-9);
730    }
731}