diff --git a/perf-monitor/README.md b/perf-monitor/README.md index ad9c18164..45157f311 100644 --- a/perf-monitor/README.md +++ b/perf-monitor/README.md @@ -77,11 +77,39 @@ Only the fields you specify are overridden; others inherit from `[thresholds]`. ### Metrics -| Metric | Description | -| ---------------- | ---------------------------------------------------------- | -| CPU Avg / P95 | Process CPU usage normalized by logical CPU count (0-100%) | -| Memory Avg / P95 | Resident Set Size (RSS) in MB | -| Memory Growth | Last sample minus first sample (detects obvious leaks) | +CPU and memory are aggregated across the target process **and all of its +descendants** (children, grandchildren, ...). This matters for apps that +spawn helper / renderer processes (e.g. Tauri's WebView2 host on Windows, +Electron-style multi-process layouts) — measuring only the parent PID +would miss the bulk of the actual resource usage. + +| Metric | Description | +| ---------------- | ------------------------------------------------------------------------ | +| CPU Avg / P95 | Aggregated CPU usage normalized by logical CPU count (0-100%) | +| Memory Avg / P95 | Aggregated Resident Set Size (RSS) in MB across the process tree | +| Memory Growth | Last sample minus first sample (detects obvious leaks) | + +### Report sections + +The text report (and the matching JSON output) is structured to make +CI logs easy to scan: + +1. **Aggregated (process tree)** — avg / P95 / max across the parent + + descendants, with per-check `PASS` / `FAIL` against the configured + thresholds. +2. **Per-Process Breakdown** — one block per observed PID showing the + process name, how many sample ticks it appeared in, and its own + avg / max / P95 for CPU and RSS. The root process is listed first and + marked `(root)`; descendants follow in descending order of average CPU. +3. **Failure Reasons** — printed only when at least one threshold was + exceeded; enumerates exactly which checks failed and the offending + value vs. the threshold. The JSON output exposes the same list as a + `failure_reasons` array. + +If the measurement aborts before any samples are collected (binary +crashed during warmup, etc.), the failure reason and the captured +child stderr are printed under a `=== Performance Test Failed ===` +banner so the cause is visible at the bottom of the CI log. ### Exit Code diff --git a/perf-monitor/src/main.rs b/perf-monitor/src/main.rs index d8adf5224..16f177902 100644 --- a/perf-monitor/src/main.rs +++ b/perf-monitor/src/main.rs @@ -72,7 +72,10 @@ fn main() -> ExitCode { } } Err(e) => { - eprintln!("Error: monitoring failed: {e}"); + eprintln!(); + eprintln!("=== Performance Test Failed ==="); + eprintln!("Failure reason: monitoring aborted before metrics could be collected"); + eprintln!("Details: {e}"); ExitCode::FAILURE } } diff --git a/perf-monitor/src/monitor.rs b/perf-monitor/src/monitor.rs index c398c0a2c..ba1a08139 100644 --- a/perf-monitor/src/monitor.rs +++ b/perf-monitor/src/monitor.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; use std::io::Read as _; use std::path::Path; use std::process::{Child, Command, Stdio}; @@ -14,6 +16,21 @@ pub struct ProcessMetrics { pub memory_rss_mb: f64, } +/// Aggregated statistics for a single PID observed during the measurement window. +#[derive(Debug, Clone)] +pub struct ProcessStats { + pub pid: u32, + pub name: String, + pub is_root: bool, + pub sample_count: usize, + pub avg_cpu: f32, + pub max_cpu: f32, + pub p95_cpu: f32, + pub avg_memory_mb: f64, + pub max_memory_mb: f64, + pub p95_memory_mb: f64, +} + #[derive(Debug)] pub struct MonitorResult { pub samples: Vec, @@ -26,6 +43,25 @@ pub struct MonitorResult { pub memory_growth_mb: f64, pub duration_seconds: u64, pub warmup_seconds: u64, + pub per_process: Vec, +} + +/// One process's contribution to a single sample tick. +#[derive(Debug, Clone)] +struct ProcessSnapshot { + pid: Pid, + name: String, + cpu_usage_raw: f32, + memory_bytes: u64, +} + +/// Accumulator that gathers per-PID samples across the whole measurement window. +#[derive(Debug, Default)] +struct ProcessTrack { + name: String, + is_root: bool, + cpu_samples: Vec, + memory_samples_mb: Vec, } /// RAII guard that ensures the child process is terminated on drop. @@ -51,8 +87,10 @@ pub fn run_monitor( let mut system = System::new(); - // Initial refresh to establish CPU baseline - system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + // Initial refresh to establish CPU baseline. We refresh ALL processes + // because the target may spawn descendants at any time, and sysinfo + // computes CPU usage as a delta from the previous refresh of that PID. + system.refresh_processes(ProcessesToUpdate::All, true); eprintln!("Warming up for {} seconds...", timing.warmup_seconds); @@ -62,7 +100,7 @@ pub fn run_monitor( if guard.0.try_wait()?.is_some() { return Err(format_exit_error(&mut guard, "warmup")); } - system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + system.refresh_processes(ProcessesToUpdate::All, true); } eprintln!( @@ -73,7 +111,8 @@ pub fn run_monitor( // Measurement phase let interval = Duration::from_millis(timing.sample_interval_ms); let total_samples = (timing.measurement_seconds * 1000) / timing.sample_interval_ms; - let mut samples = Vec::with_capacity(total_samples as usize); + let mut samples: Vec = Vec::with_capacity(total_samples as usize); + let mut per_pid_tracks: HashMap = HashMap::new(); for _ in 0..total_samples { thread::sleep(interval); @@ -82,19 +121,35 @@ pub fn run_monitor( return Err(format_exit_error(&mut guard, "measurement")); } - system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + system.refresh_processes(ProcessesToUpdate::All, true); - if let Some(process) = system.process(pid) { - let cpu_normalized = process.cpu_usage() / num_cpus; - let memory_mb = process.memory() as f64 / (1024.0 * 1024.0); + let snapshots = match capture_subtree(&system, pid) { + Some(s) => s, + None => return Err("Process disappeared during measurement".into()), + }; - samples.push(ProcessMetrics { - cpu_usage: cpu_normalized, - memory_rss_mb: memory_mb, + let mut tick_cpu_raw = 0.0_f32; + let mut tick_memory_bytes: u64 = 0; + for snap in &snapshots { + tick_cpu_raw += snap.cpu_usage_raw; + tick_memory_bytes += snap.memory_bytes; + + let track = per_pid_tracks.entry(snap.pid).or_insert_with(|| ProcessTrack { + name: snap.name.clone(), + is_root: snap.pid == pid, + cpu_samples: Vec::new(), + memory_samples_mb: Vec::new(), }); - } else { - return Err("Process disappeared during measurement".into()); + track.cpu_samples.push(snap.cpu_usage_raw / num_cpus); + track + .memory_samples_mb + .push(snap.memory_bytes as f64 / (1024.0 * 1024.0)); } + + samples.push(ProcessMetrics { + cpu_usage: tick_cpu_raw / num_cpus, + memory_rss_mb: tick_memory_bytes as f64 / (1024.0 * 1024.0), + }); } // ProcessGuard::drop will terminate the process @@ -106,15 +161,65 @@ pub fn run_monitor( Ok(compute_result( samples, + per_pid_tracks, timing.measurement_seconds, timing.warmup_seconds, )) } +/// Captures per-process metrics for the root PID and all of its descendants. +/// Returns `None` if the root PID is no longer present in `system`. +fn capture_subtree(system: &System, root_pid: Pid) -> Option> { + system.process(root_pid)?; + + let mut children_map: HashMap> = HashMap::new(); + let mut snapshot_map: HashMap = HashMap::new(); + for (pid, process) in system.processes() { + snapshot_map.insert( + *pid, + ProcessSnapshot { + pid: *pid, + name: process.name().to_string_lossy().into_owned(), + cpu_usage_raw: process.cpu_usage(), + memory_bytes: process.memory(), + }, + ); + if let Some(parent_pid) = process.parent() { + children_map.entry(parent_pid).or_default().push(*pid); + } + } + + Some(walk_subtree(root_pid, &children_map, &snapshot_map)) +} + +/// Walks the subtree rooted at `root`, returning per-process snapshots. +/// Guards against cycles caused by an inconsistent parent chain. +fn walk_subtree( + root: Pid, + children: &HashMap>, + snapshots: &HashMap, +) -> Vec { + let mut visited: HashSet = HashSet::new(); + let mut stack: Vec = vec![root]; + let mut out: Vec = Vec::new(); + + while let Some(pid) = stack.pop() { + if !visited.insert(pid) { + continue; + } + if let Some(snap) = snapshots.get(&pid) { + out.push(snap.clone()); + } + if let Some(child_pids) = children.get(&pid) { + stack.extend(child_pids); + } + } + + out +} + fn launch_process(binary_path: &Path) -> Result> { - let child = Command::new(binary_path) - .stderr(Stdio::piped()) - .spawn()?; + let child = Command::new(binary_path).stderr(Stdio::piped()).spawn()?; eprintln!("Launched process with PID: {}", child.id()); Ok(child) } @@ -124,15 +229,11 @@ fn format_exit_error( phase: &str, ) -> Box { let status = guard.0.try_wait().ok().flatten(); - let stderr = guard - .0 - .stderr - .take() - .and_then(|mut s| { - let mut buf = String::new(); - s.read_to_string(&mut buf).ok()?; - if buf.is_empty() { None } else { Some(buf) } - }); + let stderr = guard.0.stderr.take().and_then(|mut s| { + let mut buf = String::new(); + s.read_to_string(&mut buf).ok()?; + if buf.is_empty() { None } else { Some(buf) } + }); let mut msg = format!("Process exited during {phase}"); if let Some(st) = status { @@ -146,6 +247,7 @@ fn format_exit_error( fn compute_result( samples: Vec, + per_pid_tracks: HashMap, duration_seconds: u64, warmup_seconds: u64, ) -> MonitorResult { @@ -176,6 +278,8 @@ fn compute_result( let last_memory = samples.last().map(|s| s.memory_rss_mb).unwrap_or(0.0); let memory_growth_mb = last_memory - first_memory; + let per_process = compute_per_process_stats(per_pid_tracks); + MonitorResult { samples, avg_cpu, @@ -187,15 +291,71 @@ fn compute_result( memory_growth_mb, duration_seconds, warmup_seconds, + per_process, } } +fn compute_per_process_stats( + tracks: HashMap, +) -> Vec { + let mut stats: Vec = tracks + .into_iter() + .filter_map(|(pid, track)| { + if track.cpu_samples.is_empty() { + return None; + } + let n = track.cpu_samples.len() as f32; + let avg_cpu = track.cpu_samples.iter().sum::() / n; + let max_cpu = track + .cpu_samples + .iter() + .copied() + .fold(f32::NEG_INFINITY, f32::max); + let p95_cpu = percentile_f32(&track.cpu_samples, 95.0); + + let n_f64 = track.memory_samples_mb.len() as f64; + let avg_memory_mb = track.memory_samples_mb.iter().sum::() / n_f64; + let max_memory_mb = track + .memory_samples_mb + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let p95_memory_mb = percentile_f64(&track.memory_samples_mb, 95.0); + + Some(ProcessStats { + pid: pid.as_u32(), + name: track.name, + is_root: track.is_root, + sample_count: track.cpu_samples.len(), + avg_cpu, + max_cpu, + p95_cpu, + avg_memory_mb, + max_memory_mb, + p95_memory_mb, + }) + }) + .collect(); + + // Root first, then descendants by avg CPU descending (most interesting first). + stats.sort_by(|a, b| match (a.is_root, b.is_root) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => b + .avg_cpu + .partial_cmp(&a.avg_cpu) + .unwrap_or(Ordering::Equal), + }); + + stats +} + fn percentile_f32(values: &[f32], pct: f32) -> f32 { if values.is_empty() { return 0.0; } let mut sorted: Vec = values.to_vec(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); let idx = ((pct / 100.0) * (sorted.len() - 1) as f32).round() as usize; sorted[idx.min(sorted.len() - 1)] } @@ -205,7 +365,7 @@ fn percentile_f64(values: &[f64], pct: f64) -> f64 { return 0.0; } let mut sorted: Vec = values.to_vec(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); let idx = ((pct / 100.0) * (sorted.len() - 1) as f64).round() as usize; sorted[idx.min(sorted.len() - 1)] } @@ -214,6 +374,19 @@ fn percentile_f64(values: &[f64], pct: f64) -> f64 { mod tests { use super::*; + fn pid(n: usize) -> Pid { + Pid::from(n) + } + + fn snap(pid_n: usize, name: &str, cpu: f32, mem: u64) -> ProcessSnapshot { + ProcessSnapshot { + pid: pid(pid_n), + name: name.to_string(), + cpu_usage_raw: cpu, + memory_bytes: mem, + } + } + #[test] fn percentile_f32_empty_returns_zero() { assert_eq!(percentile_f32(&[], 95.0), 0.0); @@ -231,24 +404,18 @@ mod tests { #[test] fn percentile_f32_known_values_odd_count() { - // Sorted: [1, 2, 3, 4, 5] - // P95 index: 0.95 * 4 = 3.8 → round to 4 → value = 5 let values = vec![3.0, 1.0, 5.0, 2.0, 4.0]; assert_eq!(percentile_f32(&values, 95.0), 5.0); } #[test] fn percentile_f32_known_values_even_count() { - // Sorted: [10, 20, 30, 40] - // P95 index: 0.95 * 3 = 2.85 → round to 3 → value = 40 let values = vec![30.0, 10.0, 40.0, 20.0]; assert_eq!(percentile_f32(&values, 95.0), 40.0); } #[test] fn percentile_f32_p50_median() { - // Sorted: [1, 2, 3, 4, 5] - // P50 index: 0.50 * 4 = 2.0 → value = 3 let values = vec![5.0, 3.0, 1.0, 4.0, 2.0]; assert_eq!(percentile_f32(&values, 50.0), 3.0); } @@ -259,26 +426,155 @@ mod tests { assert_eq!(percentile_f64(&values, 95.0), 5.0); } + #[test] + fn walk_subtree_root_only() { + let mut snapshots = HashMap::new(); + snapshots.insert(pid(1), snap(1, "root", 10.0, 1024)); + let children = HashMap::new(); + + let result = walk_subtree(pid(1), &children, &snapshots); + assert_eq!(result.len(), 1); + assert_eq!(result[0].pid, pid(1)); + } + + #[test] + fn walk_subtree_visits_all_descendants() { + // Tree: 1 + // / \ + // 2 3 + // | + // 4 + let mut snapshots = HashMap::new(); + snapshots.insert(pid(1), snap(1, "root", 10.0, 100)); + snapshots.insert(pid(2), snap(2, "child-a", 20.0, 200)); + snapshots.insert(pid(3), snap(3, "child-b", 30.0, 300)); + snapshots.insert(pid(4), snap(4, "grandchild", 40.0, 400)); + + let mut children = HashMap::new(); + children.insert(pid(1), vec![pid(2), pid(3)]); + children.insert(pid(2), vec![pid(4)]); + + let result = walk_subtree(pid(1), &children, &snapshots); + let total_cpu: f32 = result.iter().map(|s| s.cpu_usage_raw).sum(); + let total_mem: u64 = result.iter().map(|s| s.memory_bytes).sum(); + assert_eq!(result.len(), 4); + assert!((total_cpu - 100.0).abs() < 0.01); + assert_eq!(total_mem, 1000); + } + + #[test] + fn walk_subtree_ignores_siblings_outside_subtree() { + let mut snapshots = HashMap::new(); + snapshots.insert(pid(1), snap(1, "root", 10.0, 100)); + snapshots.insert(pid(2), snap(2, "child", 20.0, 200)); + snapshots.insert(pid(99), snap(99, "unrelated", 999.0, 9999)); + + let mut children = HashMap::new(); + children.insert(pid(1), vec![pid(2)]); + + let result = walk_subtree(pid(1), &children, &snapshots); + assert_eq!(result.len(), 2); + assert!(result.iter().all(|s| s.pid != pid(99))); + } + + #[test] + fn walk_subtree_handles_cycle_without_double_counting() { + // Pathological: 1 -> 2 -> 1 (cycle). + let mut snapshots = HashMap::new(); + snapshots.insert(pid(1), snap(1, "root", 10.0, 100)); + snapshots.insert(pid(2), snap(2, "child", 20.0, 200)); + + let mut children = HashMap::new(); + children.insert(pid(1), vec![pid(2)]); + children.insert(pid(2), vec![pid(1)]); + + let result = walk_subtree(pid(1), &children, &snapshots); + assert_eq!(result.len(), 2); + } + + #[test] + fn walk_subtree_missing_root_returns_empty() { + let snapshots = HashMap::new(); + let children = HashMap::new(); + let result = walk_subtree(pid(1), &children, &snapshots); + assert!(result.is_empty()); + } + + #[test] + fn compute_per_process_stats_sorts_root_first_then_by_avg_cpu() { + let mut tracks: HashMap = HashMap::new(); + tracks.insert(pid(1), ProcessTrack { + name: "root".into(), + is_root: true, + cpu_samples: vec![1.0, 1.0], + memory_samples_mb: vec![10.0, 10.0], + }); + tracks.insert(pid(2), ProcessTrack { + name: "low-cpu".into(), + is_root: false, + cpu_samples: vec![5.0], + memory_samples_mb: vec![20.0], + }); + tracks.insert(pid(3), ProcessTrack { + name: "hi-cpu".into(), + is_root: false, + cpu_samples: vec![90.0], + memory_samples_mb: vec![30.0], + }); + + let stats = compute_per_process_stats(tracks); + assert_eq!(stats.len(), 3); + assert_eq!(stats[0].name, "root"); + assert_eq!(stats[1].name, "hi-cpu"); + assert_eq!(stats[2].name, "low-cpu"); + } + + #[test] + fn compute_per_process_stats_basic_metrics() { + let mut tracks: HashMap = HashMap::new(); + tracks.insert(pid(7), ProcessTrack { + name: "worker".into(), + is_root: false, + cpu_samples: vec![10.0, 20.0, 30.0], + memory_samples_mb: vec![100.0, 150.0, 200.0], + }); + + let stats = compute_per_process_stats(tracks); + assert_eq!(stats.len(), 1); + let s = &stats[0]; + assert_eq!(s.pid, 7); + assert_eq!(s.sample_count, 3); + assert!((s.avg_cpu - 20.0).abs() < 0.01); + assert!((s.max_cpu - 30.0).abs() < 0.01); + assert!((s.avg_memory_mb - 150.0).abs() < 0.01); + assert!((s.max_memory_mb - 200.0).abs() < 0.01); + } + #[test] fn compute_result_basic_statistics() { let samples = vec![ - ProcessMetrics { cpu_usage: 10.0, memory_rss_mb: 100.0 }, - ProcessMetrics { cpu_usage: 20.0, memory_rss_mb: 200.0 }, - ProcessMetrics { cpu_usage: 30.0, memory_rss_mb: 150.0 }, + ProcessMetrics { + cpu_usage: 10.0, + memory_rss_mb: 100.0, + }, + ProcessMetrics { + cpu_usage: 20.0, + memory_rss_mb: 200.0, + }, + ProcessMetrics { + cpu_usage: 30.0, + memory_rss_mb: 150.0, + }, ]; - let result = compute_result(samples, 3, 5); + let result = compute_result(samples, HashMap::new(), 3, 5); assert_eq!(result.duration_seconds, 3); assert_eq!(result.warmup_seconds, 5); - - // avg = (10 + 20 + 30) / 3 = 20 assert!((result.avg_cpu - 20.0).abs() < 0.01); - // max = 30 assert!((result.max_cpu - 30.0).abs() < 0.01); - // avg memory = (100 + 200 + 150) / 3 = 150 assert!((result.avg_memory_mb - 150.0).abs() < 0.01); - // growth = last - first = 150 - 100 = 50 assert!((result.memory_growth_mb - 50.0).abs() < 0.01); + assert!(result.per_process.is_empty()); } } diff --git a/perf-monitor/src/report.rs b/perf-monitor/src/report.rs index 82cd00ad3..84259b8aa 100644 --- a/perf-monitor/src/report.rs +++ b/perf-monitor/src/report.rs @@ -1,7 +1,7 @@ use serde::Serialize; use crate::config::Thresholds; -use crate::monitor::MonitorResult; +use crate::monitor::{MonitorResult, ProcessStats}; #[derive(Debug, Clone, clap::ValueEnum)] pub enum OutputFormat { @@ -16,6 +16,7 @@ struct CheckResult { mem_p95_ok: bool, mem_growth_ok: bool, all_passed: bool, + failure_reasons: Vec, } #[derive(Debug, Serialize)] @@ -25,6 +26,8 @@ struct JsonReport { sample_count: usize, cpu: CpuReport, memory: MemoryReport, + per_process: Vec, + failure_reasons: Vec, passed: bool, } @@ -53,6 +56,20 @@ struct MemoryReport { growth_passed: bool, } +#[derive(Debug, Serialize)] +struct PerProcessReport { + pid: u32, + name: String, + is_root: bool, + sample_count: usize, + cpu_avg_percent: f32, + cpu_max_percent: f32, + cpu_p95_percent: f32, + memory_avg_mb: f64, + memory_max_mb: f64, + memory_p95_mb: f64, +} + /// Formats and prints the report. Returns `true` if all checks passed. pub fn format_report( result: &MonitorResult, @@ -77,6 +94,38 @@ impl CheckResult { let mem_p95_ok = result.p95_memory_mb <= thresholds.max_p95_memory_mb; let mem_growth_ok = result.memory_growth_mb <= thresholds.max_memory_growth_mb; + let mut failure_reasons = Vec::new(); + if !cpu_avg_ok { + failure_reasons.push(format!( + "CPU average {:.1}% exceeded threshold {:.1}%", + result.avg_cpu, thresholds.max_avg_cpu_percent + )); + } + if !cpu_p95_ok { + failure_reasons.push(format!( + "CPU P95 {:.1}% exceeded threshold {:.1}%", + result.p95_cpu, thresholds.max_p95_cpu_percent + )); + } + if !mem_avg_ok { + failure_reasons.push(format!( + "Memory average {:.1} MB exceeded threshold {:.1} MB", + result.avg_memory_mb, thresholds.max_avg_memory_mb + )); + } + if !mem_p95_ok { + failure_reasons.push(format!( + "Memory P95 {:.1} MB exceeded threshold {:.1} MB", + result.p95_memory_mb, thresholds.max_p95_memory_mb + )); + } + if !mem_growth_ok { + failure_reasons.push(format!( + "Memory growth {:.1} MB exceeded threshold {:.1} MB", + result.memory_growth_mb, thresholds.max_memory_growth_mb + )); + } + Self { cpu_avg_ok, cpu_p95_ok, @@ -84,6 +133,7 @@ impl CheckResult { mem_p95_ok, mem_growth_ok, all_passed: cpu_avg_ok && cpu_p95_ok && mem_avg_ok && mem_p95_ok && mem_growth_ok, + failure_reasons, } } } @@ -104,44 +154,79 @@ fn print_text_report( result.duration_seconds, result.warmup_seconds ); println!("Samples: {}", result.samples.len()); + println!("Processes observed: {}", result.per_process.len()); println!(); - println!("CPU Usage:"); + println!("Aggregated (process tree):"); + println!(" CPU Usage:"); println!( - " Average: {:>6.1}% (threshold: {:>5.1}%) {}", + " Average: {:>6.1}% (threshold: {:>5.1}%) {}", result.avg_cpu, thresholds.max_avg_cpu_percent, status_label(check.cpu_avg_ok) ); println!( - " P95: {:>6.1}% (threshold: {:>5.1}%) {}", + " P95: {:>6.1}% (threshold: {:>5.1}%) {}", result.p95_cpu, thresholds.max_p95_cpu_percent, status_label(check.cpu_p95_ok) ); - println!(); - println!("Memory (RSS):"); + println!(" Memory (RSS):"); println!( - " Average: {:>7.1} MB (threshold: {:>6.1} MB) {}", + " Average: {:>7.1} MB (threshold: {:>6.1} MB) {}", result.avg_memory_mb, thresholds.max_avg_memory_mb, status_label(check.mem_avg_ok) ); println!( - " P95: {:>7.1} MB (threshold: {:>6.1} MB) {}", + " P95: {:>7.1} MB (threshold: {:>6.1} MB) {}", result.p95_memory_mb, thresholds.max_p95_memory_mb, status_label(check.mem_p95_ok) ); println!( - " Growth: {:>7.1} MB (threshold: {:>6.1} MB) {}", + " Growth: {:>7.1} MB (threshold: {:>6.1} MB) {}", result.memory_growth_mb, thresholds.max_memory_growth_mb, status_label(check.mem_growth_ok) ); + + print_per_process_text(&result.per_process, result.samples.len()); + + if !check.failure_reasons.is_empty() { + println!(); + println!("Failure Reasons:"); + for reason in &check.failure_reasons { + println!(" - {reason}"); + } + } + println!(); println!("Result: {}", if check.all_passed { "PASS" } else { "FAIL" }); } +fn print_per_process_text(per_process: &[ProcessStats], total_samples: usize) { + if per_process.is_empty() { + return; + } + println!(); + println!("Per-Process Breakdown:"); + for stats in per_process { + let role = if stats.is_root { " (root)" } else { "" }; + println!( + " PID {:>6} {}{} [observed {}/{} samples]", + stats.pid, stats.name, role, stats.sample_count, total_samples + ); + println!( + " CPU avg/max/P95: {:>6.1}% / {:>6.1}% / {:>6.1}%", + stats.avg_cpu, stats.max_cpu, stats.p95_cpu + ); + println!( + " Mem avg/max/P95: {:>7.1} MB / {:>7.1} MB / {:>7.1} MB", + stats.avg_memory_mb, stats.max_memory_mb, stats.p95_memory_mb + ); + } +} + fn print_json_report( result: &MonitorResult, thresholds: &Thresholds, @@ -172,6 +257,23 @@ fn print_json_report( p95_passed: check.mem_p95_ok, growth_passed: check.mem_growth_ok, }, + per_process: result + .per_process + .iter() + .map(|s| PerProcessReport { + pid: s.pid, + name: s.name.clone(), + is_root: s.is_root, + sample_count: s.sample_count, + cpu_avg_percent: s.avg_cpu, + cpu_max_percent: s.max_cpu, + cpu_p95_percent: s.p95_cpu, + memory_avg_mb: s.avg_memory_mb, + memory_max_mb: s.max_memory_mb, + memory_p95_mb: s.p95_memory_mb, + }) + .collect(), + failure_reasons: check.failure_reasons.clone(), passed: check.all_passed, };