agentsight_capture/runners/
system.rs1use 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#[derive(Debug, Clone)]
20pub struct SystemConfig {
21 pub interval_secs: u64,
23 pub pid: Option<u32>,
25 pub comm: Option<String>,
27 pub session_id: Option<u32>,
29 pub include_children: bool,
31 pub cpu_threshold: Option<f64>,
33 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
51pub struct SystemRunner {
53 config: SystemConfig,
54 analyzers: Vec<Box<dyn Analyzer>>,
55}
56
57impl SystemRunner {
58 pub fn new() -> Self {
60 Self {
61 config: SystemConfig::default(),
62 analyzers: Vec::new(),
63 }
64 }
65
66 pub fn interval(mut self, secs: u64) -> Self {
68 self.config.interval_secs = secs;
69 self
70 }
71
72 pub fn pid(mut self, pid: u32) -> Self {
74 self.config.pid = Some(pid);
75 self
76 }
77
78 pub fn comm(mut self, comm: impl Into<String>) -> Self {
80 self.config.comm = Some(comm.into());
81 self
82 }
83
84 pub fn session(mut self, session_id: u32) -> Self {
86 self.config.session_id = Some(session_id);
87 self
88 }
89
90 pub fn include_children(mut self, include: bool) -> Self {
92 self.config.include_children = include;
93 self
94 }
95
96 pub fn cpu_threshold(mut self, threshold: f64) -> Self {
98 self.config.cpu_threshold = Some(threshold);
99 self
100 }
101
102 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 let stream = create_system_event_stream(config);
122
123 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
139fn 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 let target_pids = find_target_pids(&config, &snapshot);
177
178 if target_pids.is_empty() {
179 if config.pid.is_some() || config.comm.is_some() {
181 continue;
182 }
183 if let Ok(system_metrics) = get_system_wide_metrics(timestamp) {
185 yield system_metrics;
186 }
187 continue;
188 }
189
190 for pid in target_pids {
192 let pids_to_monitor = if config.include_children {
194 snapshot.process_family(pid)
195 } else {
196 vec![pid]
197 };
198
199 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#[derive(Debug, Clone)]
217struct ProcessStats {
218 ticks: u64,
219 timestamp: u64,
220}
221
222fn find_target_pids(config: &SystemConfig, snapshot: &ProcSnapshot) -> Vec<u32> {
224 if let Some(pid) = config.pid {
225 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 process_select::pids_matching_comm(snapshot, comm_pattern)
234 } else {
235 vec![]
237 }
238}
239
240fn 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 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 if pid == main_pid {
276 thread_count = proc_info.threads;
277 }
278 }
279
280 let children_count = all_pids.len() - 1; 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 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
327fn get_system_wide_metrics(
329 timestamp: u64,
330) -> Result<Event, Box<dyn std::error::Error + Send + Sync>> {
331 let cpu_cores = num_cpus::get();
333
334 let load_avg = get_load_average()?;
336
337 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, "system".to_string(),
367 payload,
368 ))
369}
370
371fn 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 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 };
391
392 previous_stats.insert(pid, current.clone());
394
395 cpu_percent
396}
397
398fn 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
410fn 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
430fn 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 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 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 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}