#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum WorkflowMetric {
QueueWaitSeconds,
TaskDurationSeconds,
TaskMemoryBytes,
CpuPercent,
RetryCount,
WorkflowDurationSeconds,
}
impl WorkflowMetric {
#[must_use]
pub fn unit(self) -> &'static str {
match self {
Self::QueueWaitSeconds | Self::TaskDurationSeconds | Self::WorkflowDurationSeconds => {
"s"
}
Self::TaskMemoryBytes => "bytes",
Self::CpuPercent => "%",
Self::RetryCount => "count",
}
}
#[must_use]
pub fn lower_is_better(self) -> bool {
!matches!(self, Self::CpuPercent)
}
}
impl std::fmt::Display for WorkflowMetric {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::QueueWaitSeconds => "queue_wait_seconds",
Self::TaskDurationSeconds => "task_duration_seconds",
Self::TaskMemoryBytes => "task_memory_bytes",
Self::CpuPercent => "cpu_percent",
Self::RetryCount => "retry_count",
Self::WorkflowDurationSeconds => "workflow_duration_seconds",
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricSample {
pub metric: WorkflowMetric,
pub source_id: String,
pub value: f64,
}
impl MetricSample {
#[must_use]
pub fn new(metric: WorkflowMetric, source_id: impl Into<String>, value: f64) -> Self {
Self {
metric,
source_id: source_id.into(),
value,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricSummary {
pub metric: WorkflowMetric,
pub count: usize,
pub sum: f64,
pub min: f64,
pub max: f64,
}
impl MetricSummary {
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn mean(&self) -> f64 {
if self.count == 0 {
0.0
} else {
self.sum / self.count as f64
}
}
}
#[derive(Debug, Default, Clone)]
pub struct WorkflowMetricsCollector {
samples: Vec<MetricSample>,
}
impl WorkflowMetricsCollector {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, sample: MetricSample) {
self.samples.push(sample);
}
pub fn record_value(
&mut self,
metric: WorkflowMetric,
source_id: impl Into<String>,
value: f64,
) {
self.record(MetricSample::new(metric, source_id, value));
}
#[must_use]
pub fn sample_count(&self) -> usize {
self.samples.len()
}
#[must_use]
pub fn samples_for(&self, metric: WorkflowMetric) -> Vec<&MetricSample> {
self.samples.iter().filter(|s| s.metric == metric).collect()
}
#[must_use]
pub fn summarize(&self, metric: WorkflowMetric) -> Option<MetricSummary> {
let relevant: Vec<f64> = self
.samples
.iter()
.filter(|s| s.metric == metric)
.map(|s| s.value)
.collect();
if relevant.is_empty() {
return None;
}
let sum: f64 = relevant.iter().sum();
let min = relevant.iter().copied().fold(f64::INFINITY, f64::min);
let max = relevant.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Some(MetricSummary {
metric,
count: relevant.len(),
sum,
min,
max,
})
}
#[must_use]
pub fn all_summaries(&self) -> HashMap<WorkflowMetric, MetricSummary> {
let mut map = HashMap::new();
for metric in [
WorkflowMetric::QueueWaitSeconds,
WorkflowMetric::TaskDurationSeconds,
WorkflowMetric::TaskMemoryBytes,
WorkflowMetric::CpuPercent,
WorkflowMetric::RetryCount,
WorkflowMetric::WorkflowDurationSeconds,
] {
if let Some(summary) = self.summarize(metric) {
map.insert(metric, summary);
}
}
map
}
pub fn reset(&mut self) {
self.samples.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn collector_with_samples() -> WorkflowMetricsCollector {
let mut c = WorkflowMetricsCollector::new();
c.record_value(WorkflowMetric::TaskDurationSeconds, "task-1", 10.0);
c.record_value(WorkflowMetric::TaskDurationSeconds, "task-2", 20.0);
c.record_value(WorkflowMetric::TaskDurationSeconds, "task-3", 30.0);
c.record_value(WorkflowMetric::CpuPercent, "task-1", 55.0);
c
}
#[test]
fn test_new_collector_empty() {
let c = WorkflowMetricsCollector::new();
assert_eq!(c.sample_count(), 0);
}
#[test]
fn test_record_value_increments_count() {
let mut c = WorkflowMetricsCollector::new();
c.record_value(WorkflowMetric::RetryCount, "wf-1", 2.0);
assert_eq!(c.sample_count(), 1);
}
#[test]
fn test_samples_for_filters_correctly() {
let c = collector_with_samples();
let dur_samples = c.samples_for(WorkflowMetric::TaskDurationSeconds);
assert_eq!(dur_samples.len(), 3);
}
#[test]
fn test_summarize_mean() {
let c = collector_with_samples();
let summary = c
.summarize(WorkflowMetric::TaskDurationSeconds)
.expect("should succeed in test");
assert!((summary.mean() - 20.0).abs() < 1e-9);
}
#[test]
fn test_summarize_min_max() {
let c = collector_with_samples();
let summary = c
.summarize(WorkflowMetric::TaskDurationSeconds)
.expect("should succeed in test");
assert!((summary.min - 10.0).abs() < 1e-9);
assert!((summary.max - 30.0).abs() < 1e-9);
}
#[test]
fn test_summarize_count() {
let c = collector_with_samples();
let summary = c
.summarize(WorkflowMetric::TaskDurationSeconds)
.expect("should succeed in test");
assert_eq!(summary.count, 3);
}
#[test]
fn test_summarize_none_for_missing_metric() {
let c = collector_with_samples();
assert!(c.summarize(WorkflowMetric::QueueWaitSeconds).is_none());
}
#[test]
fn test_all_summaries_keys() {
let c = collector_with_samples();
let summaries = c.all_summaries();
assert!(summaries.contains_key(&WorkflowMetric::TaskDurationSeconds));
assert!(summaries.contains_key(&WorkflowMetric::CpuPercent));
assert!(!summaries.contains_key(&WorkflowMetric::QueueWaitSeconds));
}
#[test]
fn test_reset_clears_samples() {
let mut c = collector_with_samples();
c.reset();
assert_eq!(c.sample_count(), 0);
assert!(c.summarize(WorkflowMetric::TaskDurationSeconds).is_none());
}
#[test]
fn test_metric_unit() {
assert_eq!(WorkflowMetric::TaskDurationSeconds.unit(), "s");
assert_eq!(WorkflowMetric::TaskMemoryBytes.unit(), "bytes");
assert_eq!(WorkflowMetric::CpuPercent.unit(), "%");
assert_eq!(WorkflowMetric::RetryCount.unit(), "count");
}
#[test]
fn test_metric_lower_is_better() {
assert!(WorkflowMetric::TaskDurationSeconds.lower_is_better());
assert!(WorkflowMetric::QueueWaitSeconds.lower_is_better());
assert!(!WorkflowMetric::CpuPercent.lower_is_better());
}
#[test]
fn test_metric_display() {
assert_eq!(
format!("{}", WorkflowMetric::TaskDurationSeconds),
"task_duration_seconds"
);
}
#[test]
fn test_metric_summary_mean_empty() {
let s = MetricSummary {
metric: WorkflowMetric::RetryCount,
count: 0,
sum: 0.0,
min: 0.0,
max: 0.0,
};
assert!((s.mean() - 0.0).abs() < 1e-9);
}
#[test]
fn test_single_sample_summary() {
let mut c = WorkflowMetricsCollector::new();
c.record_value(WorkflowMetric::WorkflowDurationSeconds, "wf-a", 42.0);
let s = c
.summarize(WorkflowMetric::WorkflowDurationSeconds)
.expect("should succeed in test");
assert_eq!(s.count, 1);
assert!((s.min - 42.0).abs() < 1e-9);
assert!((s.max - 42.0).abs() < 1e-9);
assert!((s.mean() - 42.0).abs() < 1e-9);
}
}