"""Resource monitoring — CPU, RAM, and disk usage under load. ``ResourceMonitor`` collects lightweight resource snapshots. When ``psutil`` is installed it uses richer per-process metrics; otherwise it falls back to stdlib primitives (``shutil.disk_usage``, ``os.getloadavg``). Usage:: monitor = ResourceMonitor() monitor.sample() # single reading monitor.sample_n(10, interval_s=0.5) # 10 readings, 0.5 s apart print(monitor.summary()) """ from __future__ import annotations import logging import os import shutil import time from dataclasses import dataclass from datetime import UTC, datetime logger = logging.getLogger(__name__) @dataclass class ResourceSnapshot: """Point-in-time resource usage reading. Attributes: timestamp: ISO-8601 timestamp. cpu_percent: CPU usage 0–100; ``-1`` if unavailable. memory_used_mb: Resident memory in MiB; ``-1`` if unavailable. memory_total_mb: Total system memory in MiB; ``-1`` if unavailable. disk_used_gb: Disk used for the watched path in GiB. disk_total_gb: Total disk for the watched path in GiB. load_avg_1m: 1-minute load average; ``-1`` on Windows. """ timestamp: str cpu_percent: float = -1.0 memory_used_mb: float = -1.0 memory_total_mb: float = -1.0 disk_used_gb: float = -1.0 disk_total_gb: float = -1.0 load_avg_1m: float = -1.0 class ResourceMonitor: """Lightweight resource monitor for multi-agent load testing. Captures ``ResourceSnapshot`` readings and retains the last *max_history* entries. Uses ``psutil`` when available, with a graceful fallback to stdlib primitives. Parameters ---------- max_history: Maximum number of snapshots retained in memory. watch_path: Filesystem path used for disk-usage measurement. """ def __init__( self, *, max_history: int = 100, watch_path: str = ".", ) -> None: self._max = max_history self._watch = watch_path self._history: list[ResourceSnapshot] = [] self._psutil = self._try_import_psutil() # -- public API -------------------------------------------------------- def sample(self) -> ResourceSnapshot: """Take a single resource snapshot and add it to history.""" snap = self._collect() self._history.append(snap) if len(self._history) > self._max: self._history = self._history[-self._max :] return snap def sample_n( self, n: int, *, interval_s: float = 0.1, ) -> list[ResourceSnapshot]: """Take *n* samples spaced *interval_s* seconds apart. Useful for profiling resource usage during a stress test run. """ results: list[ResourceSnapshot] = [] for i in range(n): results.append(self.sample()) if i < n - 1: time.sleep(interval_s) return results @property def history(self) -> list[ResourceSnapshot]: return list(self._history) def peak_cpu(self) -> float: """Return the highest cpu_percent seen, or ``-1`` if no samples.""" valid = [s.cpu_percent for s in self._history if s.cpu_percent >= 0] return max(valid) if valid else -1.0 def peak_memory_mb(self) -> float: """Return the highest memory_used_mb seen, or ``-1`` if no samples.""" valid = [s.memory_used_mb for s in self._history if s.memory_used_mb >= 0] return max(valid) if valid else -1.0 def summary(self) -> str: """Human-readable summary of recorded resource snapshots.""" if not self._history: return "ResourceMonitor: no samples collected" return ( f"ResourceMonitor: {len(self._history)} samples — " f"peak CPU {self.peak_cpu():.1f}%, " f"peak RAM {self.peak_memory_mb():.1f} MiB" ) # -- internal ---------------------------------------------------------- def _collect(self) -> ResourceSnapshot: ts = datetime.now(UTC).isoformat() # Disk (always available via stdlib) try: usage = shutil.disk_usage(self._watch) disk_used_gb = round((usage.total - usage.free) / (1024**3), 3) disk_total_gb = round(usage.total / (1024**3), 3) except OSError: disk_used_gb = -1.0 disk_total_gb = -1.0 # Load average (POSIX only) try: load_avg_1m = round(os.getloadavg()[0], 3) except AttributeError: load_avg_1m = -1.0 # Windows if self._psutil: return self._collect_psutil(ts, disk_used_gb, disk_total_gb, load_avg_1m) return ResourceSnapshot( timestamp=ts, disk_used_gb=disk_used_gb, disk_total_gb=disk_total_gb, load_avg_1m=load_avg_1m, ) def _collect_psutil( self, ts: str, disk_used_gb: float, disk_total_gb: float, load_avg_1m: float, ) -> ResourceSnapshot: psutil = self._psutil try: cpu = round(psutil.cpu_percent(interval=None), 2) except Exception: cpu = -1.0 try: vm = psutil.virtual_memory() mem_used = round(vm.used / (1024**2), 2) mem_total = round(vm.total / (1024**2), 2) except Exception: mem_used = -1.0 mem_total = -1.0 return ResourceSnapshot( timestamp=ts, cpu_percent=cpu, memory_used_mb=mem_used, memory_total_mb=mem_total, disk_used_gb=disk_used_gb, disk_total_gb=disk_total_gb, load_avg_1m=load_avg_1m, ) @staticmethod def _try_import_psutil(): try: import psutil return psutil except ImportError: logger.debug( "ResourceMonitor: psutil not available — using stdlib fallback" ) return None