From 7c9dc88515e704aad894af5f2a39161ef1d24f7c Mon Sep 17 00:00:00 2001 From: CongkaiTan Date: Fri, 27 Mar 2026 12:07:30 -0700 Subject: [PATCH] Support multi-metric pattern-matching for TimeSeriesDataPointThresholdRule --- Cargo.lock | 43 ++- Cargo.toml | 1 + .../time_series_data_point_threshold_rule.rs | 119 +++++--- src/analytics/rules/efa_stat.rs | 12 +- src/data/common/processed_data_accessor.rs | 25 ++ tests/analytics/test_helpers.rs | 18 ++ ...t_time_series_data_point_threshold_rule.rs | 257 ++++++++++++++++++ 7 files changed, 432 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c594d2d..409e89f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -121,6 +130,7 @@ dependencies = [ "numeric-sort", "perf-event2", "procfs", + "regex", "rustix 0.38.28", "ryu", "serde", @@ -1488,9 +1498,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.5.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -1893,12 +1903,41 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-lite" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rgb" version = "0.8.36" diff --git a/Cargo.toml b/Cargo.toml index f6adcde5..2a94d089 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ tdigest = "0.2" csv = "1.2" csv-to-html = "0.5.10" numeric-sort = "0.1.5" +regex = "1.12.3" [target.'cfg(target_os = "linux")'.dependencies] sysinfo = "0.26.2" diff --git a/src/analytics/rule_templates/time_series_data_point_threshold_rule.rs b/src/analytics/rule_templates/time_series_data_point_threshold_rule.rs index 95702762..f4035f32 100644 --- a/src/analytics/rule_templates/time_series_data_point_threshold_rule.rs +++ b/src/analytics/rule_templates/time_series_data_point_threshold_rule.rs @@ -11,6 +11,7 @@ use std::fmt::Formatter; pub struct TimeSeriesDataPointThresholdRule { pub rule_name: &'static str, pub metric_name: &'static str, + pub regex_matching: bool, pub comparator: Comparator, pub threshold: f64, pub score: f64, @@ -30,6 +31,7 @@ macro_rules! time_series_data_point_threshold { TimeSeriesDataPointThresholdRule{ rule_name: $rule_name, metric_name: $metric_name, + regex_matching: false, comparator: $comparator, threshold: $threshold, score: $score.as_f64(), @@ -40,6 +42,30 @@ macro_rules! time_series_data_point_threshold { } pub(crate) use time_series_data_point_threshold; +macro_rules! time_series_data_point_threshold_multi_metric { + { + name: $rule_name:literal, + pattern: $pattern:literal, + comparator: $comparator:path, + threshold: $threshold:literal, + score: $score:expr, + message: $message:literal, + } => { + AnalyticalRule::TimeSeriesDataPointThresholdRule( + TimeSeriesDataPointThresholdRule{ + rule_name: $rule_name, + metric_name: $pattern, + regex_matching: true, + comparator: $comparator, + threshold: $threshold, + score: $score.as_f64(), + message: $message, + } + ) + }; +} +pub(crate) use time_series_data_point_threshold_multi_metric; + impl fmt::Display for TimeSeriesDataPointThresholdRule { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!( @@ -58,50 +84,63 @@ impl Analyze for TimeSeriesDataPointThresholdRule { processed_data_accessor: &mut ProcessedDataAccessor, ) { for run_name in processed_data.runs.keys() { - // Produce one finding per metric for the data point with the largest absolute score - let mut max_abs_finding_score: f64 = 0.0; - let mut max_score_value: f64 = 0.0; - let mut rule_matched = false; - for &value in processed_data_accessor.time_series_metric_values_iterator( - processed_data, - run_name, - self.metric_name, - ) { - if self.comparator.compare(value, self.threshold) { - rule_matched = true; - let abs_finding_score = - analytics::compute_finding_score(value, self.threshold, self.score).abs(); - if abs_finding_score >= max_abs_finding_score { - max_score_value = value; - max_abs_finding_score = abs_finding_score; + let matched_metric_names = if self.regex_matching { + processed_data_accessor.time_series_matched_metric_names( + processed_data, + run_name, + self.metric_name, + ) + } else { + vec![self.metric_name] + }; + + for metric_name in matched_metric_names { + // Produce one finding per metric for the data point with the largest absolute score + let mut max_abs_finding_score: f64 = 0.0; + let mut max_score_value: f64 = 0.0; + let mut rule_matched = false; + for &value in processed_data_accessor.time_series_metric_values_iterator( + processed_data, + run_name, + metric_name, + ) { + if self.comparator.compare(value, self.threshold) { + rule_matched = true; + let abs_finding_score = + analytics::compute_finding_score(value, self.threshold, self.score) + .abs(); + if abs_finding_score >= max_abs_finding_score { + max_score_value = value; + max_abs_finding_score = abs_finding_score; + } } } - } - if rule_matched { - let finding_score = if self.score > 0.0 { - max_abs_finding_score - } else { - max_abs_finding_score * -1.0 - }; - let finding_description = format!( - "At least one data point in {} is {} ({} the threshold of {}).", - run_name, - formatted_number_string(max_score_value), - self.comparator, - self.threshold - ); + if rule_matched { + let finding_score = if self.score > 0.0 { + max_abs_finding_score + } else { + max_abs_finding_score * -1.0 + }; + let finding_description = format!( + "At least one data point in {} is {} ({} the threshold of {}).", + run_name, + formatted_number_string(max_score_value), + self.comparator, + self.threshold + ); - data_findings.insert_finding( - run_name, - self.metric_name, - AnalyticalFinding::new( - self.rule_name.to_string(), - finding_score, - finding_description, - self.message.to_string(), - ), - ); + data_findings.insert_finding( + run_name, + metric_name, + AnalyticalFinding::new( + self.rule_name.to_string(), + finding_score, + finding_description, + self.message.to_string(), + ), + ); + } } } } diff --git a/src/analytics/rules/efa_stat.rs b/src/analytics/rules/efa_stat.rs index b55b97a5..23738ac0 100644 --- a/src/analytics/rules/efa_stat.rs +++ b/src/analytics/rules/efa_stat.rs @@ -1,7 +1,9 @@ use crate::analytics::rule_templates::time_series_stat_run_comparison_rule::time_series_stat_run_comparison; use crate::analytics::rule_templates::time_series_stat_threshold_rule::time_series_stat_threshold; +use crate::analytics::time_series_data_point_threshold_rule::time_series_data_point_threshold_multi_metric; use crate::analytics::{ - AnalyticalRule, Score, TimeSeriesStatRunComparisonRule, TimeSeriesStatThresholdRule, + AnalyticalRule, Score, TimeSeriesDataPointThresholdRule, TimeSeriesStatRunComparisonRule, + TimeSeriesStatThresholdRule, }; use crate::computations::{Comparator, Stat}; use crate::data::efa_stat::EfaStat; @@ -48,6 +50,14 @@ impl AnalyzeData for EfaStat { score: Score::Critical, message: "The average numbers of bytes transmitted by EFA are different. Verify if this is the expected behavior.", ), + time_series_data_point_threshold_multi_metric!( + name: "EFA Errors", + pattern: "^*_err$", + comparator: Comparator::GreaterEqual, + threshold: 1.0, + score: Score::Poor, + message: "One or more errors occurred in EFA driver. Click the info button to learn more about the error", + ), ] } } diff --git a/src/data/common/processed_data_accessor.rs b/src/data/common/processed_data_accessor.rs index e0cbde41..78a64c4d 100644 --- a/src/data/common/processed_data_accessor.rs +++ b/src/data/common/processed_data_accessor.rs @@ -2,6 +2,7 @@ use crate::computations::{f64_to_fixed_2, Statistics}; use crate::data::common::data_formats::{ AperfData, DataFormat, KeyValueData, ProcessedData, Series, TimeSeriesData, TimeSeriesMetric, }; +use regex::Regex; use std::collections::HashMap; use std::fmt::Write; @@ -83,6 +84,30 @@ impl ProcessedDataAccessor { )) } + /// Returns the list of metric names in the run that matches the pattern. + pub fn time_series_matched_metric_names<'a>( + &self, + processed_data: &'a ProcessedData, + run_name: &str, + pattern: &str, + ) -> Vec<&'a str> { + let re = match Regex::new(pattern) { + Ok(re) => re, + Err(_) => return Vec::new(), + }; + + if let Some(time_series_data) = get_time_series_data(processed_data, run_name) { + time_series_data + .metrics + .keys() + .filter(|&key| re.is_match(key)) + .map(|key| key.as_str()) + .collect() + } else { + Vec::new() + } + } + /// Returns the value of the corresponding key in the key-value data. pub fn key_value_value_by_key<'a>( &self, diff --git a/tests/analytics/test_helpers.rs b/tests/analytics/test_helpers.rs index 93c4c152..712032ec 100644 --- a/tests/analytics/test_helpers.rs +++ b/tests/analytics/test_helpers.rs @@ -86,6 +86,8 @@ pub fn create_processed_data(data_name: &str, runs: Vec<(&str, AperfData)>) -> P pub trait DataFindingsExt { fn has_findings_for_run(&self, run_name: &str) -> bool; fn num_runs_with_findings(&self) -> usize; + fn has_findings_for_metric(&self, run_name: &str, metric_name: &str) -> bool; + fn num_metrics_with_findings(&self, run_name: &str) -> usize; } impl DataFindingsExt for aperf::analytics::DataFindings { @@ -105,4 +107,20 @@ impl DataFindingsExt for aperf::analytics::DataFindings { .map(|obj| obj.len()) .unwrap_or(0) } + + fn has_findings_for_metric(&self, run_name: &str, metric_name: &str) -> bool { + let json = serde_json::to_value(self).unwrap(); + json["per_run_findings"][run_name]["findings"] + .as_object() + .map(|obj| obj.contains_key(metric_name)) + .unwrap_or(false) + } + + fn num_metrics_with_findings(&self, run_name: &str) -> usize { + let json = serde_json::to_value(self).unwrap(); + json["per_run_findings"][run_name]["findings"] + .as_object() + .map(|obj| obj.len()) + .unwrap_or(0) + } } diff --git a/tests/analytics/test_time_series_data_point_threshold_rule.rs b/tests/analytics/test_time_series_data_point_threshold_rule.rs index da2cb6a4..524585c9 100644 --- a/tests/analytics/test_time_series_data_point_threshold_rule.rs +++ b/tests/analytics/test_time_series_data_point_threshold_rule.rs @@ -17,6 +17,7 @@ fn test_no_data_points_exceed_threshold() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -42,6 +43,7 @@ fn test_one_data_point_exceeds_threshold() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -68,6 +70,7 @@ fn test_multiple_data_points_exceed_threshold() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -93,6 +96,7 @@ fn test_less_than_comparator() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Less, threshold: 15.0, score: Score::Bad.as_f64(), @@ -118,6 +122,7 @@ fn test_equal_comparator() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Equal, threshold: 50.0, score: Score::Bad.as_f64(), @@ -143,6 +148,7 @@ fn test_metric_not_found() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric2", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -168,6 +174,7 @@ fn test_empty_values() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -199,6 +206,7 @@ fn test_multiple_runs() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -226,6 +234,7 @@ fn test_negative_score() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: -10.0, @@ -255,6 +264,7 @@ fn test_time_range_filters_data_points() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -312,6 +322,7 @@ fn test_time_range_multi_run() { let rule = TimeSeriesDataPointThresholdRule { rule_name: "test_rule", metric_name: "metric1", + regex_matching: false, comparator: Comparator::Greater, threshold: 50.0, score: Score::Bad.as_f64(), @@ -383,3 +394,249 @@ fn test_time_range_multi_run() { assert!(findings4.has_findings_for_run("run2")); assert!(!findings4.has_findings_for_run("run3")); } + +// ==================== Pattern Matching (multi-metric) Tests ==================== + +#[test] +fn test_regex_matching_matches_multiple_metrics() { + // Three metrics, two match the pattern "cpu_.*", one exceeds threshold in each + let ts_data = create_time_series_data(vec![ + ("cpu_user", vec![60.0, 20.0, 30.0]), + ("cpu_system", vec![10.0, 70.0, 30.0]), + ("mem_used", vec![90.0, 90.0, 90.0]), + ]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + assert_eq!(findings.num_runs_with_findings(), 1); + assert!(findings.has_findings_for_metric("run1", "cpu_user")); + assert!(findings.has_findings_for_metric("run1", "cpu_system")); + // mem_used should not be matched by the pattern + assert!(!findings.has_findings_for_metric("run1", "mem_used")); + assert_eq!(findings.num_metrics_with_findings("run1"), 2); +} + +#[test] +fn test_regex_matching_no_metrics_match_pattern() { + let ts_data = create_time_series_data(vec![ + ("mem_used", vec![60.0, 70.0, 80.0]), + ("mem_free", vec![60.0, 70.0, 80.0]), + ]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + assert_eq!(findings.num_runs_with_findings(), 0); +} + +#[test] +fn test_regex_matching_only_some_matched_metrics_exceed_threshold() { + // Both metrics match the pattern, but only one exceeds the threshold + let ts_data = create_time_series_data(vec![ + ("cpu_user", vec![60.0, 70.0, 80.0]), + ("cpu_system", vec![10.0, 20.0, 30.0]), + ]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + assert_eq!(findings.num_runs_with_findings(), 1); + assert!(findings.has_findings_for_metric("run1", "cpu_user")); + assert!(!findings.has_findings_for_metric("run1", "cpu_system")); + assert_eq!(findings.num_metrics_with_findings("run1"), 1); +} + +#[test] +fn test_regex_matching_multiple_runs() { + // run1: cpu_user exceeds, cpu_system doesn't + // run2: both exceed + let ts_data1 = create_time_series_data(vec![ + ("cpu_user", vec![60.0, 70.0]), + ("cpu_system", vec![10.0, 20.0]), + ]); + let ts_data2 = create_time_series_data(vec![ + ("cpu_user", vec![80.0, 90.0]), + ("cpu_system", vec![60.0, 70.0]), + ]); + let processed_data = create_processed_data( + "test_data", + vec![ + ("run1", AperfData::TimeSeries(ts_data1)), + ("run2", AperfData::TimeSeries(ts_data2)), + ], + ); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + assert_eq!(findings.num_runs_with_findings(), 2); + assert_eq!(findings.num_metrics_with_findings("run1"), 1); + assert!(findings.has_findings_for_metric("run1", "cpu_user")); + assert_eq!(findings.num_metrics_with_findings("run2"), 2); + assert!(findings.has_findings_for_metric("run2", "cpu_user")); + assert!(findings.has_findings_for_metric("run2", "cpu_system")); +} + +#[test] +fn test_regex_matching_invalid_regex() { + let ts_data = create_time_series_data(vec![("cpu_user", vec![60.0, 70.0, 80.0])]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "[invalid", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + // Invalid regex should match nothing + assert_eq!(findings.num_runs_with_findings(), 0); +} + +#[test] +fn test_regex_matching_with_time_range() { + // cpu_user: spikes at t=3,4; cpu_system: spikes at t=0,1 + let ts_data = create_time_series_data(vec![ + ("cpu_user", vec![10.0, 20.0, 30.0, 60.0, 70.0, 10.0]), + ("cpu_system", vec![60.0, 70.0, 10.0, 20.0, 30.0, 10.0]), + ]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: true, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + // Time range 0:1 → only cpu_system has spikes + let mut accessor = ProcessedDataAccessor::from_time_ranges( + HashMap::from([("run1".to_string(), 0)]), + HashMap::from([("run1".to_string(), 1)]), + ); + let mut findings = DataFindings::default(); + rule.analyze(&mut findings, &processed_data, &mut accessor); + assert_eq!(findings.num_runs_with_findings(), 1); + assert!(findings.has_findings_for_metric("run1", "cpu_system")); + assert!(!findings.has_findings_for_metric("run1", "cpu_user")); + + // Time range 3:4 → only cpu_user has spikes + let mut accessor2 = ProcessedDataAccessor::from_time_ranges( + HashMap::from([("run1".to_string(), 3)]), + HashMap::from([("run1".to_string(), 4)]), + ); + let mut findings2 = DataFindings::default(); + rule.analyze(&mut findings2, &processed_data, &mut accessor2); + assert_eq!(findings2.num_runs_with_findings(), 1); + assert!(findings2.has_findings_for_metric("run1", "cpu_user")); + assert!(!findings2.has_findings_for_metric("run1", "cpu_system")); +} + +#[test] +fn test_regex_matching_false_uses_exact_metric_name() { + // With regex_matching: false, "cpu_.*" should be treated as a literal metric name + let ts_data = create_time_series_data(vec![ + ("cpu_user", vec![60.0, 70.0, 80.0]), + ("cpu_system", vec![60.0, 70.0, 80.0]), + ]); + let processed_data = + create_processed_data("test_data", vec![("run1", AperfData::TimeSeries(ts_data))]); + + let rule = TimeSeriesDataPointThresholdRule { + rule_name: "test_rule", + metric_name: "cpu_.*", + regex_matching: false, + comparator: Comparator::Greater, + threshold: 50.0, + score: Score::Bad.as_f64(), + message: "Test message", + }; + + let mut findings = DataFindings::default(); + rule.analyze( + &mut findings, + &processed_data, + &mut ProcessedDataAccessor::new(), + ); + + // No metric literally named "cpu_.*" exists, so no findings + assert_eq!(findings.num_runs_with_findings(), 0); +}