167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
|
|
"""Small, dependency-free runtime diagnostics for the local stand.
|
||
|
|
|
||
|
|
The product has to run on Windows as well as Linux. Keep the collector in
|
||
|
|
the standard library: unavailable host counters are returned as ``None``
|
||
|
|
instead of making the administrator screen fail.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import sys
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
from collections import deque
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
STARTED_MONOTONIC = time.monotonic()
|
||
|
|
_events: deque[dict] = deque(maxlen=500)
|
||
|
|
_installed = False
|
||
|
|
_sample_lock = threading.Lock()
|
||
|
|
_last_wall = time.monotonic()
|
||
|
|
_last_cpu = time.process_time()
|
||
|
|
|
||
|
|
_CREDENTIALS = re.compile(r"(://[^:/\s]+:)([^@/\s]+)(@)")
|
||
|
|
_TOKENS = re.compile(
|
||
|
|
r"(?i)\b(session_secret|llm_api_key|authorization|password)\b\s*[:=]\s*([^\s,;]+)"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _safe(message: str) -> str:
|
||
|
|
message = _CREDENTIALS.sub(r"\1[скрыто]\3", message)
|
||
|
|
message = _TOKENS.sub(r"\1=[скрыто]", message)
|
||
|
|
return message.replace("\x00", "")[:2000]
|
||
|
|
|
||
|
|
|
||
|
|
class DiagnosticHandler(logging.Handler):
|
||
|
|
"""Bounded warning/error journal safe enough to expose to an admin."""
|
||
|
|
|
||
|
|
def emit(self, record: logging.LogRecord) -> None:
|
||
|
|
try:
|
||
|
|
_events.append({
|
||
|
|
"at": datetime.fromtimestamp(record.created, timezone.utc),
|
||
|
|
"level": record.levelname,
|
||
|
|
"source": record.name[:120],
|
||
|
|
"message": _safe(record.getMessage()),
|
||
|
|
})
|
||
|
|
except Exception: # logging must never take the service down
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def install_diagnostics() -> None:
|
||
|
|
global _installed
|
||
|
|
if _installed:
|
||
|
|
return
|
||
|
|
handler = DiagnosticHandler(level=logging.WARNING)
|
||
|
|
logging.getLogger().addHandler(handler)
|
||
|
|
_installed = True
|
||
|
|
|
||
|
|
|
||
|
|
def recent_events(*, limit: int = 100) -> list[dict]:
|
||
|
|
return list(_events)[-max(1, min(limit, 500)):][::-1]
|
||
|
|
|
||
|
|
|
||
|
|
def _rss_bytes() -> int | None:
|
||
|
|
try:
|
||
|
|
if sys.platform.startswith("linux"):
|
||
|
|
resident_pages = int(Path("/proc/self/statm").read_text().split()[1])
|
||
|
|
return resident_pages * int(os.sysconf("SC_PAGE_SIZE"))
|
||
|
|
if os.name == "nt":
|
||
|
|
import ctypes
|
||
|
|
from ctypes import wintypes
|
||
|
|
|
||
|
|
class Counters(ctypes.Structure):
|
||
|
|
_fields_ = [
|
||
|
|
("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
|
||
|
|
("PeakWorkingSetSize", ctypes.c_size_t),
|
||
|
|
("WorkingSetSize", ctypes.c_size_t),
|
||
|
|
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
||
|
|
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
||
|
|
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
||
|
|
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
||
|
|
("PagefileUsage", ctypes.c_size_t),
|
||
|
|
("PeakPagefileUsage", ctypes.c_size_t),
|
||
|
|
]
|
||
|
|
|
||
|
|
counters = Counters()
|
||
|
|
counters.cb = ctypes.sizeof(counters)
|
||
|
|
ctypes.windll.psapi.GetProcessMemoryInfo( # type: ignore[attr-defined]
|
||
|
|
ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb
|
||
|
|
)
|
||
|
|
return int(counters.WorkingSetSize)
|
||
|
|
import resource
|
||
|
|
|
||
|
|
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||
|
|
return int(value if sys.platform == "darwin" else value * 1024)
|
||
|
|
except (OSError, ValueError, AttributeError, ImportError, IndexError):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _memory() -> tuple[int | None, int | None]:
|
||
|
|
try:
|
||
|
|
if sys.platform.startswith("linux"):
|
||
|
|
fields: dict[str, int] = {}
|
||
|
|
for line in Path("/proc/meminfo").read_text().splitlines():
|
||
|
|
name, value = line.split(":", 1)
|
||
|
|
fields[name] = int(value.strip().split()[0]) * 1024
|
||
|
|
return fields.get("MemTotal"), fields.get("MemAvailable")
|
||
|
|
if os.name == "nt":
|
||
|
|
import ctypes
|
||
|
|
|
||
|
|
class MemoryStatus(ctypes.Structure):
|
||
|
|
_fields_ = [
|
||
|
|
("length", ctypes.c_ulong), ("memory_load", ctypes.c_ulong),
|
||
|
|
("total_physical", ctypes.c_ulonglong),
|
||
|
|
("available_physical", ctypes.c_ulonglong),
|
||
|
|
("total_page_file", ctypes.c_ulonglong),
|
||
|
|
("available_page_file", ctypes.c_ulonglong),
|
||
|
|
("total_virtual", ctypes.c_ulonglong),
|
||
|
|
("available_virtual", ctypes.c_ulonglong),
|
||
|
|
("available_extended_virtual", ctypes.c_ulonglong),
|
||
|
|
]
|
||
|
|
|
||
|
|
status = MemoryStatus()
|
||
|
|
status.length = ctypes.sizeof(status)
|
||
|
|
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined]
|
||
|
|
return int(status.total_physical), int(status.available_physical)
|
||
|
|
page = int(os.sysconf("SC_PAGE_SIZE"))
|
||
|
|
return int(os.sysconf("SC_PHYS_PAGES")) * page, None
|
||
|
|
except (OSError, ValueError, AttributeError, ImportError):
|
||
|
|
return None, None
|
||
|
|
|
||
|
|
|
||
|
|
def sample_metrics() -> dict:
|
||
|
|
"""Return a cheap point-in-time sample; the first CPU sample is zero."""
|
||
|
|
global _last_wall, _last_cpu
|
||
|
|
now = time.monotonic()
|
||
|
|
cpu_now = time.process_time()
|
||
|
|
with _sample_lock:
|
||
|
|
wall_delta = max(now - _last_wall, 0.001)
|
||
|
|
cpu_percent = max(0.0, (cpu_now - _last_cpu) / wall_delta * 100.0)
|
||
|
|
_last_wall, _last_cpu = now, cpu_now
|
||
|
|
|
||
|
|
cores = os.cpu_count() or 1
|
||
|
|
try:
|
||
|
|
load_1m_percent = os.getloadavg()[0] / cores * 100.0
|
||
|
|
except (AttributeError, OSError):
|
||
|
|
load_1m_percent = None
|
||
|
|
memory_total, memory_available = _memory()
|
||
|
|
disk = shutil.disk_usage(Path.cwd())
|
||
|
|
return {
|
||
|
|
"at": datetime.now(timezone.utc),
|
||
|
|
"uptime_seconds": max(0.0, now - STARTED_MONOTONIC),
|
||
|
|
"cpu_percent": round(cpu_percent, 2),
|
||
|
|
"load_1m_percent": round(load_1m_percent, 2) if load_1m_percent is not None else None,
|
||
|
|
"cpu_cores": cores,
|
||
|
|
"rss_bytes": _rss_bytes(),
|
||
|
|
"memory_total_bytes": memory_total,
|
||
|
|
"memory_available_bytes": memory_available,
|
||
|
|
"disk_total_bytes": disk.total,
|
||
|
|
"disk_free_bytes": disk.free,
|
||
|
|
"threads": threading.active_count(),
|
||
|
|
}
|