1#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum WorkflowMetric {
17 QueueWaitSeconds,
19 TaskDurationSeconds,
21 TaskMemoryBytes,
23 CpuPercent,
25 RetryCount,
27 WorkflowDurationSeconds,
29}
30
31impl WorkflowMetric {
32 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct MetricSample {
71 pub metric: WorkflowMetric,
73 pub source_id: String,
75 pub value: f64,
77}
78
79impl MetricSample {
80 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct MetricSummary {
96 pub metric: WorkflowMetric,
98 pub count: usize,
100 pub sum: f64,
102 pub min: f64,
104 pub max: f64,
106}
107
108impl MetricSummary {
109 #[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#[derive(Debug, Default, Clone)]
125pub struct WorkflowMetricsCollector {
126 samples: Vec<MetricSample>,
127}
128
129impl WorkflowMetricsCollector {
130 #[must_use]
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn record(&mut self, sample: MetricSample) {
138 self.samples.push(sample);
139 }
140
141 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 #[must_use]
153 pub fn sample_count(&self) -> usize {
154 self.samples.len()
155 }
156
157 #[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 #[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 #[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 pub fn reset(&mut self) {
212 self.samples.clear();
213 }
214}
215
216#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct StepMetric {
359 pub step_id: String,
361 pub started_at: std::time::SystemTime,
363 pub duration_secs: f64,
365 pub success: bool,
367 pub retries: u32,
369 pub output_size_bytes: Option<u64>,
371 pub cpu_seconds: Option<f64>,
373}
374
375impl StepMetric {
376 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct WorkflowRunMetrics {
394 pub workflow_id: String,
396 pub started_at: std::time::SystemTime,
398 pub completed_at: Option<std::time::SystemTime>,
400 pub step_metrics: Vec<StepMetric>,
402 pub total_duration_secs: Option<f64>,
404 pub success: Option<bool>,
406}
407
408impl WorkflowRunMetrics {
409 #[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 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
434pub struct WorkflowMetricsAggregator {
437 history: Vec<WorkflowRunMetrics>,
438 max_history: usize,
439}
440
441impl WorkflowMetricsAggregator {
442 #[must_use]
446 pub fn new(max_history: usize) -> Self {
447 Self {
448 history: Vec::new(),
449 max_history,
450 }
451 }
452
453 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 #[must_use]
465 pub fn len(&self) -> usize {
466 self.history.len()
467 }
468
469 #[must_use]
471 pub fn is_empty(&self) -> bool {
472 self.history.is_empty()
473 }
474
475 #[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 #[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 #[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 #[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 #[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(); 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 #[must_use]
576 pub fn history(&self) -> &[WorkflowRunMetrics] {
577 &self.history
578 }
579}
580
581#[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 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 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 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}