Skip to main content

agentsight_capture/runners/
system.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4use super::{EventStream, Runner, RunnerError};
5use crate::analyzers::Analyzer;
6use crate::event::Event;
7use crate::sources::proc::ProcSnapshot;
8use crate::view::process_select;
9use async_trait::async_trait;
10use futures::stream::Stream;
11use serde_json::json;
12use std::collections::HashMap;
13use std::fs;
14use std::pin::Pin;
15use std::time::Duration;
16use tokio::time;
17
18/// Configuration for system resource monitoring
19#[derive(Debug, Clone)]
20pub struct SystemConfig {
21    /// Monitoring interval in seconds (default: 10)
22    pub interval_secs: u64,
23    /// Monitor specific PID (None = monitor all)
24    pub pid: Option<u32>,
25    /// Process name to monitor (None = monitor all)
26    pub comm: Option<String>,
27    /// Session ID to monitor (None = monitor by pid/comm/system-wide)
28    pub session_id: Option<u32>,
29    /// Include child processes in aggregation
30    pub include_children: bool,
31    /// CPU usage threshold for alerts (%)
32    pub cpu_threshold: Option<f64>,
33    /// Memory usage threshold for alerts (MB)
34    pub memory_threshold: Option<u64>,
35}
36
37impl Default for SystemConfig {
38    fn default() -> Self {
39        Self {
40            interval_secs: 10,
41            pid: None,
42            comm: None,
43            session_id: None,
44            include_children: true,
45            cpu_threshold: None,
46            memory_threshold: None,
47        }
48    }
49}
50
51/// Runner for collecting system resource metrics (CPU and memory)
52pub struct SystemRunner {
53    config: SystemConfig,
54    analyzers: Vec<Box<dyn Analyzer>>,
55}
56
57impl SystemRunner {
58    /// Create a new system runner with default configuration
59    pub fn new() -> Self {
60        Self {
61            config: SystemConfig::default(),
62            analyzers: Vec::new(),
63        }
64    }
65
66    /// Set the monitoring interval in seconds
67    pub fn interval(mut self, secs: u64) -> Self {
68        self.config.interval_secs = secs;
69        self
70    }
71
72    /// Monitor a specific PID
73    pub fn pid(mut self, pid: u32) -> Self {
74        self.config.pid = Some(pid);
75        self
76    }
77
78    /// Monitor processes by name
79    pub fn comm(mut self, comm: impl Into<String>) -> Self {
80        self.config.comm = Some(comm.into());
81        self
82    }
83
84    /// Monitor a process session.
85    pub fn session(mut self, session_id: u32) -> Self {
86        self.config.session_id = Some(session_id);
87        self
88    }
89
90    /// Include child processes in metrics aggregation
91    pub fn include_children(mut self, include: bool) -> Self {
92        self.config.include_children = include;
93        self
94    }
95
96    /// Set CPU usage threshold for alerts (%)
97    pub fn cpu_threshold(mut self, threshold: f64) -> Self {
98        self.config.cpu_threshold = Some(threshold);
99        self
100    }
101
102    /// Set memory usage threshold for alerts (MB)
103    pub fn memory_threshold(mut self, threshold: u64) -> Self {
104        self.config.memory_threshold = Some(threshold);
105        self
106    }
107}
108
109impl Default for SystemRunner {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115#[async_trait]
116impl Runner for SystemRunner {
117    async fn run(&mut self) -> Result<EventStream, RunnerError> {
118        let config = self.config.clone();
119
120        // Create the event stream
121        let stream = create_system_event_stream(config);
122
123        // Process through analyzers
124        let event_stream = super::common::AnalyzerProcessor::process_through_analyzers(
125            Box::pin(stream),
126            &mut self.analyzers,
127        )
128        .await?;
129
130        Ok(event_stream)
131    }
132
133    fn add_analyzer(mut self, analyzer: Box<dyn Analyzer>) -> Self {
134        self.analyzers.push(analyzer);
135        self
136    }
137}
138
139/// Create a stream of system monitoring events
140fn create_system_event_stream(config: SystemConfig) -> Pin<Box<dyn Stream<Item = Event> + Send>> {
141    Box::pin(async_stream::stream! {
142        let mut interval = time::interval(Duration::from_secs(config.interval_secs));
143        let mut previous_stats: HashMap<u32, ProcessStats> = HashMap::new();
144
145        loop {
146            interval.tick().await;
147
148            let snapshot = match ProcSnapshot::collect() {
149                Ok(snapshot) => snapshot,
150                Err(err) => {
151                    log::warn!("failed to collect /proc snapshot: {}", err);
152                    continue;
153                }
154            };
155            let timestamp = (snapshot.uptime_s * 1_000_000_000.0) as u64;
156            previous_stats.retain(|pid, _| snapshot.procs.contains_key(pid));
157
158            if let Some(session_id) = config.session_id {
159                let pids = snapshot.pids_in_session(session_id);
160                if !pids.is_empty()
161                    && let Ok(event) = collect_process_metrics(
162                        session_id,
163                        &pids,
164                        timestamp,
165                        &mut previous_stats,
166                        &config,
167                        &snapshot,
168                    )
169                {
170                    yield event;
171                }
172                continue;
173            }
174
175            // Find target PIDs to monitor
176            let target_pids = find_target_pids(&config, &snapshot);
177
178            if target_pids.is_empty() {
179                // If monitoring by name/pid and nothing found, continue waiting
180                if config.pid.is_some() || config.comm.is_some() {
181                    continue;
182                }
183                // Otherwise, emit system-wide metrics
184                if let Ok(system_metrics) = get_system_wide_metrics(timestamp) {
185                    yield system_metrics;
186                }
187                continue;
188            }
189
190            // Collect metrics for each target PID
191            for pid in target_pids {
192                // Get all PIDs to monitor (including children if configured)
193                let pids_to_monitor = if config.include_children {
194                    snapshot.process_family(pid)
195                } else {
196                    vec![pid]
197                };
198
199                // Aggregate metrics across all monitored PIDs
200                if let Ok(event) = collect_process_metrics(
201                    pid,
202                    &pids_to_monitor,
203                    timestamp,
204                    &mut previous_stats,
205                    &config,
206                    &snapshot,
207                ) {
208                    yield event;
209                }
210            }
211        }
212    })
213}
214
215/// Process statistics for CPU calculation
216#[derive(Debug, Clone)]
217struct ProcessStats {
218    ticks: u64,
219    timestamp: u64,
220}
221
222/// Find PIDs that match the monitoring criteria
223fn find_target_pids(config: &SystemConfig, snapshot: &ProcSnapshot) -> Vec<u32> {
224    if let Some(pid) = config.pid {
225        // Monitor specific PID
226        if snapshot.procs.contains_key(&pid) {
227            vec![pid]
228        } else {
229            vec![]
230        }
231    } else if let Some(ref comm_pattern) = config.comm {
232        // Find PIDs by process name
233        process_select::pids_matching_comm(snapshot, comm_pattern)
234    } else {
235        // No specific target - caller should handle system-wide monitoring
236        vec![]
237    }
238}
239
240/// Collect metrics for a process and its children
241fn collect_process_metrics(
242    main_pid: u32,
243    all_pids: &[u32],
244    timestamp: u64,
245    previous_stats: &mut HashMap<u32, ProcessStats>,
246    config: &SystemConfig,
247    snapshot: &ProcSnapshot,
248) -> Result<Event, Box<dyn std::error::Error + Send + Sync>> {
249    let mut total_rss_kb = 0u64;
250    let mut total_vsz_kb = 0u64;
251    let mut total_cpu_percent = 0.0f64;
252    let mut thread_count = 0u32;
253    let mut process_name = String::from("unknown");
254
255    if let Some(proc_info) = snapshot.procs.get(&main_pid) {
256        process_name = proc_info.comm.clone();
257    }
258
259    // Aggregate metrics across all PIDs
260    for &pid in all_pids {
261        let Some(proc_info) = snapshot.procs.get(&pid) else {
262            continue;
263        };
264        total_rss_kb += proc_info.rss_kb;
265        total_vsz_kb += proc_info.vsz_kb;
266
267        let stats = ProcessStats {
268            ticks: proc_info.ticks,
269            timestamp,
270        };
271        let cpu_percent = calculate_cpu_percentage(pid, &stats, previous_stats);
272        total_cpu_percent += cpu_percent;
273
274        // Count threads (only for main process)
275        if pid == main_pid {
276            thread_count = proc_info.threads;
277        }
278    }
279
280    let children_count = all_pids.len() - 1; // Exclude main process
281
282    // Check thresholds for alerts
283    let mut alert = false;
284    if let Some(cpu_threshold) = config.cpu_threshold
285        && total_cpu_percent >= cpu_threshold
286    {
287        alert = true;
288    }
289    if let Some(memory_threshold) = config.memory_threshold
290        && total_rss_kb / 1024 >= memory_threshold
291    {
292        alert = true;
293    }
294
295    // Build JSON payload
296    let payload = json!({
297        "type": "system_metrics",
298        "pid": main_pid,
299        "comm": process_name,
300        "timestamp": timestamp,
301        "cpu": {
302            "percent": format!("{:.2}", total_cpu_percent),
303            "cores": num_cpus::get(),
304        },
305        "memory": {
306            "rss_kb": total_rss_kb,
307            "rss_mb": total_rss_kb / 1024,
308            "vsz_kb": total_vsz_kb,
309            "vsz_mb": total_vsz_kb / 1024,
310        },
311        "process": {
312            "threads": thread_count,
313            "children": children_count,
314        },
315        "alert": alert,
316    });
317
318    Ok(Event::new_with_timestamp(
319        timestamp,
320        "system".to_string(),
321        main_pid,
322        process_name,
323        payload,
324    ))
325}
326
327/// Get system-wide metrics when no specific process is targeted
328fn get_system_wide_metrics(
329    timestamp: u64,
330) -> Result<Event, Box<dyn std::error::Error + Send + Sync>> {
331    // Read system-wide CPU and memory info
332    let cpu_cores = num_cpus::get();
333
334    // Get load average
335    let load_avg = get_load_average()?;
336
337    // Get total memory info
338    let (total_mem_kb, free_mem_kb, available_mem_kb) = get_system_memory()?;
339    let used_mem_kb = total_mem_kb - available_mem_kb;
340    let used_percent = (used_mem_kb as f64 / total_mem_kb as f64) * 100.0;
341
342    let payload = json!({
343        "type": "system_wide",
344        "timestamp": timestamp,
345        "cpu": {
346            "cores": cpu_cores,
347            "load_avg_1min": load_avg.0,
348            "load_avg_5min": load_avg.1,
349            "load_avg_15min": load_avg.2,
350        },
351        "memory": {
352            "total_kb": total_mem_kb,
353            "total_mb": total_mem_kb / 1024,
354            "used_kb": used_mem_kb,
355            "used_mb": used_mem_kb / 1024,
356            "free_kb": free_mem_kb,
357            "available_kb": available_mem_kb,
358            "used_percent": format!("{:.2}", used_percent),
359        },
360    });
361
362    Ok(Event::new_with_timestamp(
363        timestamp,
364        "system".to_string(),
365        0, // No specific PID for system-wide metrics
366        "system".to_string(),
367        payload,
368    ))
369}
370
371/// Calculate CPU percentage based on previous stats
372fn calculate_cpu_percentage(
373    pid: u32,
374    current: &ProcessStats,
375    previous_stats: &mut HashMap<u32, ProcessStats>,
376) -> f64 {
377    let cpu_percent = if let Some(prev) = previous_stats.get(&pid) {
378        let time_delta = current.timestamp.saturating_sub(prev.timestamp) as f64 / 1_000_000_000.0;
379        let cpu_delta = current.ticks.saturating_sub(prev.ticks);
380
381        // CPU ticks to percentage (assumes USER_HZ = 100)
382        let user_hz = 100.0;
383        if time_delta > 0.0 {
384            (cpu_delta as f64 / user_hz / time_delta) * 100.0
385        } else {
386            0.0
387        }
388    } else {
389        0.0 // First measurement, no previous data
390    };
391
392    // Update previous stats
393    previous_stats.insert(pid, current.clone());
394
395    cpu_percent
396}
397
398/// Get system load average
399fn get_load_average() -> Result<(f64, f64, f64), Box<dyn std::error::Error + Send + Sync>> {
400    let loadavg = fs::read_to_string("/proc/loadavg")?;
401    let fields: Vec<&str> = loadavg.split_whitespace().collect();
402
403    if fields.len() < 3 {
404        return Err("Invalid loadavg format".into());
405    }
406
407    Ok((fields[0].parse()?, fields[1].parse()?, fields[2].parse()?))
408}
409
410/// Get system memory information from /proc/meminfo
411fn get_system_memory() -> Result<(u64, u64, u64), Box<dyn std::error::Error + Send + Sync>> {
412    let meminfo = fs::read_to_string("/proc/meminfo")?;
413    let mut total_kb = 0u64;
414    let mut free_kb = 0u64;
415    let mut available_kb = 0u64;
416
417    for line in meminfo.lines() {
418        if line.starts_with("MemTotal:") {
419            total_kb = parse_meminfo_line(line)?;
420        } else if line.starts_with("MemFree:") {
421            free_kb = parse_meminfo_line(line)?;
422        } else if line.starts_with("MemAvailable:") {
423            available_kb = parse_meminfo_line(line)?;
424        }
425    }
426
427    Ok((total_kb, free_kb, available_kb))
428}
429
430/// Parse a single line from /proc/meminfo
431fn parse_meminfo_line(line: &str) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
432    let parts: Vec<&str> = line.split_whitespace().collect();
433    if parts.len() < 2 {
434        return Err("Invalid meminfo line".into());
435    }
436    Ok(parts[1].parse()?)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_system_runner_creation() {
445        let runner = SystemRunner::new();
446        assert_eq!(runner.config.interval_secs, 10);
447    }
448
449    #[test]
450    fn test_system_runner_with_config() {
451        let runner = SystemRunner::new()
452            .interval(5)
453            .pid(1234)
454            .include_children(false)
455            .cpu_threshold(80.0)
456            .memory_threshold(500);
457
458        assert_eq!(runner.config.interval_secs, 5);
459        assert_eq!(runner.config.pid, Some(1234));
460        assert!(!runner.config.include_children);
461        assert_eq!(runner.config.cpu_threshold, Some(80.0));
462        assert_eq!(runner.config.memory_threshold, Some(500));
463    }
464
465    #[tokio::test]
466    async fn test_system_runner_stream() {
467        use futures::StreamExt;
468        use tokio::time::{Duration, timeout};
469
470        // Create a runner that monitors the test process itself
471        let current_pid = std::process::id();
472        let mut runner = SystemRunner::new().interval(1).pid(current_pid);
473
474        match runner.run().await {
475            Ok(mut stream) => {
476                // Collect events for 3 seconds
477                let result = timeout(Duration::from_secs(3), async {
478                    let mut count = 0;
479                    while let Some(event) = stream.next().await {
480                        count += 1;
481                        assert_eq!(event.source, "system");
482                        assert_eq!(event.pid, current_pid);
483
484                        // Verify payload structure
485                        let payload = &event.data;
486                        assert!(payload.get("cpu").is_some());
487                        assert!(payload.get("memory").is_some());
488                        assert!(payload.get("process").is_some());
489
490                        if count >= 2 {
491                            break;
492                        }
493                    }
494                    count
495                })
496                .await;
497
498                match result {
499                    Ok(count) => assert!(count >= 2, "Should collect at least 2 events"),
500                    Err(_) => panic!("Timeout waiting for events"),
501                }
502            }
503            Err(e) => panic!("Failed to run SystemRunner: {}", e),
504        }
505    }
506}