Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions watchers/system_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,93 @@ def get_system_metrics(self) -> tuple[float, float, Any]:
ram_percent = memory.percent
return cpu_percent, ram_percent, memory

def get_top_processes_by_cpu(self, top_n: int = 5) -> list[dict[str, Any]]:
"""
Get top N processes consuming the most CPU.

Args:
top_n: Number of top processes to return

Returns:
List of dictionaries with process information (pid, name, cpu_percent)
"""
processes = []
try:
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
try:
pinfo = proc.info
if pinfo['cpu_percent'] is not None:
processes.append(pinfo)
Comment on lines +83 to +87

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-process CPU percentage retrieved via psutil.process_iter(['cpu_percent']) returns an instantaneous value that may be 0.0 for many processes, especially on the first call or for processes that haven't been actively running. This could result in misleading or empty leaderboards. Consider using cpu_percent(interval=0.1) on each process object to get more accurate CPU usage, or document that the values represent instantaneous CPU usage and may not reflect overall resource consumption.

Suggested change
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
try:
pinfo = proc.info
if pinfo['cpu_percent'] is not None:
processes.append(pinfo)
# Use a short interval to get a more accurate CPU usage sample per process.
for proc in psutil.process_iter(['pid', 'name']):
try:
cpu_percent = proc.cpu_percent(interval=0.1)
if cpu_percent is None:
continue
pinfo = proc.info
pinfo['cpu_percent'] = cpu_percent
processes.append(pinfo)

Copilot uses AI. Check for mistakes.
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
pass
# The process may have terminated or become inaccessible between
# iteration and inspection; skip it and continue with the next one.
continue

Copilot uses AI. Check for mistakes.
except Exception as e:
logger.error(f"Error getting top CPU processes: {e}")
Comment thread
Gennadiyev marked this conversation as resolved.
return []

# Sort by CPU usage and return top N
processes.sort(key=lambda x: x['cpu_percent'], reverse=True)
return processes[:top_n]

def get_top_processes_by_memory(self, top_n: int = 5) -> list[dict[str, Any]]:
"""
Get top N processes consuming the most memory.

Args:
top_n: Number of top processes to return

Returns:
List of dictionaries with process information (pid, name, memory_percent, memory_mb)
"""
processes = []
try:
for proc in psutil.process_iter(['pid', 'name', 'memory_percent', 'memory_info']):
try:
pinfo = proc.info
if pinfo['memory_percent'] is not None:

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If pinfo['memory_info'] is None, accessing .rss will raise an AttributeError, which is not caught by the inner exception handler that only catches psutil.NoSuchProcess and psutil.AccessDenied. This will cause the outer exception handler to catch it and return an empty list. Consider adding a null check for pinfo['memory_info'] before accessing its rss attribute, similar to how memory_percent is checked on line 113.

Suggested change
if pinfo['memory_percent'] is not None:
if pinfo['memory_percent'] is not None and pinfo['memory_info'] is not None:

Copilot uses AI. Check for mistakes.
# Add memory in MB for better readability
pinfo['memory_mb'] = pinfo['memory_info'].rss / (1024 * 1024)
processes.append(pinfo)
except (psutil.NoSuchProcess, psutil.AccessDenied):

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
pass
except Exception as e:
logger.error(f"Error getting top memory processes: {e}")

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error logging should include exc_info=True parameter to capture stack traces for debugging. This is the consistent pattern used throughout the codebase for exception logging.

Copilot uses AI. Check for mistakes.
return []

# Sort by memory usage and return top N
processes.sort(key=lambda x: x['memory_percent'], reverse=True)
return processes[:top_n]

def format_process_leaderboard(
self, processes: list[dict[str, Any]], metric_type: str
) -> str:
"""
Format process information as a markdown leaderboard.

Args:
processes: List of process dictionaries
metric_type: Type of metric ('cpu' or 'memory')

Returns:
Formatted markdown string
"""
if not processes:
return "_No process information available_"

leaderboard = "\n**Top Consuming Processes:**\n"
for i, proc in enumerate(processes, 1):
name = proc.get('name', 'Unknown')
pid = proc.get('pid', '?')

if metric_type == 'cpu':
usage = proc.get('cpu_percent', 0)
leaderboard += f"{i}. `{name}` (PID: {pid}) - {usage:.1f}%\n"
Comment on lines +145 to +150

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Process names obtained from psutil could potentially contain backticks or other markdown special characters that could break the markdown formatting. Consider escaping or sanitizing the process name before including it in backticks to prevent markdown rendering issues. For example, a process named "my`process" would result in malformed markdown.

Copilot uses AI. Check for mistakes.
elif metric_type == 'memory':
usage_pct = proc.get('memory_percent', 0)
usage_mb = proc.get('memory_mb', 0)
leaderboard += f"{i}. `{name}` (PID: {pid}) - {usage_pct:.1f}% ({usage_mb:.0f} MB)\n"
Comment on lines +148 to +154

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The format_process_leaderboard method does not handle cases where metric_type is neither 'cpu' nor 'memory'. When an invalid metric type is provided, the method will silently add empty entries to the leaderboard (just the numbering without any usage information). Consider adding an else clause that either raises a ValueError for invalid metric types or logs a warning and returns an error message.

Copilot uses AI. Check for mistakes.

return leaderboard

def should_send_alert(self, last_alert_time: float) -> bool:
"""
Check if enough time has passed since the last alert.
Expand All @@ -89,12 +176,19 @@ async def send_cpu_alert(self, cpu_percent: float) -> None:
cpu_percent: Current CPU usage percentage
"""
logger.warning(f"CPU usage high: {cpu_percent:.1f}%")

# Get top CPU-consuming processes
top_processes = self.get_top_processes_by_cpu(top_n=5)
process_leaderboard = self.format_process_leaderboard(top_processes, 'cpu')
Comment on lines +180 to +182

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling get_top_processes_by_cpu() and get_top_processes_by_memory() during an alert adds computational overhead at a time when the system is already under stress (CPU or RAM usage has exceeded thresholds). Iterating through all processes with psutil.process_iter() can be resource-intensive on systems with many processes. Consider whether this additional load during high-usage scenarios could exacerbate the problem. An alternative approach would be to collect process information periodically in the background and cache it, then use cached data when sending alerts.

Copilot uses AI. Check for mistakes.

markdown_content = f"""# ⚠️ High CPU Usage

**Current Usage:** {cpu_percent:.1f}%
**Threshold:** {self.cpu_threshold}%

CPU usage has exceeded the configured threshold.

{process_leaderboard}
"""
await self.notifier.send(markdown_content)
self.last_cpu_alert_time = time.time()
Expand All @@ -114,6 +208,10 @@ async def send_ram_alert(self, ram_percent: float, memory: Any) -> None:
used_gb = memory.used / (1024**3)
available_gb = memory.available / (1024**3)

# Get top memory-consuming processes
top_processes = self.get_top_processes_by_memory(top_n=5)
process_leaderboard = self.format_process_leaderboard(top_processes, 'memory')

Comment on lines +211 to +214

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling get_top_processes_by_memory() during an alert adds computational overhead at a time when the system is already under stress (RAM usage has exceeded the threshold). Iterating through all processes with psutil.process_iter() can be resource-intensive on systems with many processes. Consider whether this additional load during high-usage scenarios could exacerbate the problem. An alternative approach would be to collect process information periodically in the background and cache it, then use cached data when sending alerts.

Suggested change
# Get top memory-consuming processes
top_processes = self.get_top_processes_by_memory(top_n=5)
process_leaderboard = self.format_process_leaderboard(top_processes, 'memory')
# Get top memory-consuming processes, with simple caching to reduce
# overhead when the system is already under RAM pressure.
cache_ttl_seconds = 10
now = time.time()
cached_leaderboard = getattr(self, "_last_ram_process_leaderboard", None)
cached_time = getattr(self, "_last_ram_process_leaderboard_time", 0.0)
if cached_leaderboard is None or (now - cached_time) > cache_ttl_seconds:
top_processes = self.get_top_processes_by_memory(top_n=5)
process_leaderboard = self.format_process_leaderboard(top_processes, 'memory')
# Update cache
self._last_ram_process_leaderboard = process_leaderboard
self._last_ram_process_leaderboard_time = now
else:
process_leaderboard = cached_leaderboard

Copilot uses AI. Check for mistakes.
markdown_content = f"""# ⚠️ High RAM Usage

**Current Usage:** {ram_percent:.1f}%
Expand All @@ -123,6 +221,8 @@ async def send_ram_alert(self, ram_percent: float, memory: Any) -> None:
**Available:** {available_gb:.2f} GB

RAM usage has exceeded the configured threshold.

{process_leaderboard}
"""
await self.notifier.send(markdown_content)
self.last_ram_alert_time = time.time()
Expand Down
Loading