feat(host_agent): collect network, disk I/O, per-core CPU, temps, memory PSI
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 8s
CI / integration (push) Successful in 2m17s
CI / publish (push) Has been skipped

Extends the push agent (v1.2.0) with the Netdata-style signals the operator
wants: per-core CPU, per-interface network throughput and per-disk I/O
(rates derived in-agent from monotonic /proc counter deltas, with
counter-reset clamping), hardware temperatures (/sys/class/hwmon),
memory-pressure PSI (/proc/pressure), and cached/buffers memory breakdown.
All stdlib-only.

Server side needs no migration — these land as additional rows in the shared
PluginMetric table via _expand_sample_to_metrics. Per-resource series use a
':' in resource_name (host:net:eth0, host:core0, host:temp:Package) so the
existing fleet widget's ':' filter ignores them; host-level totals/max are
emitted at the bare host resource. New sample keys are optional, so older
agents keep ingesting unchanged.

Unit tests for the new parsers, rate/reset logic, and the expander contract.
Data foundation for the Netdata-style host view (task #867).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 10:11:30 -04:00
parent 3b6e005ed8
commit a9e7baee6a
5 changed files with 447 additions and 24 deletions
+235 -22
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json
import os
import re
import shutil
import signal
import socket
@@ -18,7 +19,7 @@ import urllib.request
from collections import deque
from datetime import datetime, timezone
AGENT_VERSION = "1.1.0"
AGENT_VERSION = "1.2.0"
class ConfigError(Exception):
@@ -71,6 +72,15 @@ MEMINFO_PATH = "/proc/meminfo"
LOADAVG_PATH = "/proc/loadavg"
UPTIME_PATH = "/proc/uptime"
OS_RELEASE_PATH = "/etc/os-release"
NETDEV_PATH = "/proc/net/dev"
DISKSTATS_PATH = "/proc/diskstats"
HWMON_DIR = "/sys/class/hwmon"
PSI_DIR = "/proc/pressure"
# Partition names to drop from disk I/O (we report whole-disk throughput only).
_PARTITION_RE = re.compile(
r"^(?:sd[a-z]+\d+|vd[a-z]+\d+|hd[a-z]+\d+|nvme\d+n\d+p\d+|mmcblk\d+p\d+)$"
)
def _read_file(path: str) -> str:
@@ -78,28 +88,49 @@ def _read_file(path: str) -> str:
return f.read()
def _parse_cpu_line(stat_text: str) -> tuple[int, int]:
"""Return (total_jiffies, idle_jiffies) for the aggregate cpu line."""
def _parse_cpu_lines(stat_text: str) -> dict:
"""Return {name: (total_jiffies, idle_jiffies)} for every cpu line.
'cpu' is the aggregate; 'cpu0', 'cpu1', … are per-core.
"""
out: dict = {}
for line in stat_text.splitlines():
if line.startswith("cpu "):
parts = line.split()
if not line.startswith("cpu"):
continue
parts = line.split()
try:
fields = [int(x) for x in parts[1:]]
idle = fields[3] + (fields[4] if len(fields) > 4 else 0) # idle + iowait
total = sum(fields)
return total, idle
raise RuntimeError("no aggregate cpu line in /proc/stat")
except ValueError:
continue
if len(fields) < 4:
continue
idle = fields[3] + (fields[4] if len(fields) > 4 else 0) # idle + iowait
out[parts[0]] = (sum(fields), idle)
return out
def collect_cpu(sample_window: float = 0.2) -> float:
"""Return CPU utilization % over sample_window seconds."""
t1, i1 = _parse_cpu_line(_read_file(STAT_PATH))
def collect_cpu(sample_window: float = 0.2) -> tuple[float, list]:
"""Return (aggregate_pct, [per_core_pct, …]) over sample_window seconds."""
a = _parse_cpu_lines(_read_file(STAT_PATH))
time.sleep(sample_window)
t2, i2 = _parse_cpu_line(_read_file(STAT_PATH))
dt = t2 - t1
di = i2 - i1
if dt <= 0:
return 0.0
return round(100.0 * (dt - di) / dt, 2)
b = _parse_cpu_lines(_read_file(STAT_PATH))
def _pct(name: str):
if name not in a or name not in b:
return None
dt = b[name][0] - a[name][0]
di = b[name][1] - a[name][1]
if dt <= 0:
return 0.0
return round(100.0 * (dt - di) / dt, 2)
agg = _pct("cpu") or 0.0
cores: list = []
i = 0
while f"cpu{i}" in b:
cores.append(_pct(f"cpu{i}"))
i += 1
return agg, cores
def collect_memory() -> dict:
@@ -125,6 +156,8 @@ def collect_memory() -> dict:
"used_bytes": max(total - available, 0),
"available_bytes": available,
"swap_used_bytes": max(swap_total - swap_free, 0),
"cached_bytes": info.get("Cached", 0),
"buffers_bytes": info.get("Buffers", 0),
}
@@ -210,6 +243,149 @@ def default_mounts() -> list[str]:
return mounts or ["/"]
def collect_net_raw() -> dict:
"""Return {iface: (rx_bytes, tx_bytes)} cumulative counters from /proc/net/dev.
Skips loopback and veth* (container) interfaces to cut noise. Counters are
monotonic; per-second rates are derived from deltas in build_sample().
"""
out: dict = {}
try:
text = _read_file(NETDEV_PATH)
except OSError:
return out
for line in text.splitlines():
if ":" not in line:
continue
name, _, rest = line.partition(":")
name = name.strip()
if name == "lo" or name.startswith("veth"):
continue
f = rest.split()
if len(f) < 16:
continue
try:
out[name] = (int(f[0]), int(f[8])) # rx_bytes, tx_bytes
except ValueError:
continue
return out
def collect_diskio_raw() -> dict:
"""Return {device: (read_bytes, write_bytes)} cumulative from /proc/diskstats.
Whole disks only — loop/ram/sr/fd and partitions are skipped. Sectors are
512 bytes. Counters are monotonic; rates derived from deltas.
"""
out: dict = {}
try:
text = _read_file(DISKSTATS_PATH)
except OSError:
return out
for line in text.splitlines():
f = line.split()
if len(f) < 10:
continue
name = f[2]
if name.startswith(("loop", "ram", "sr", "fd")) or _PARTITION_RE.match(name):
continue
try:
out[name] = (int(f[5]) * 512, int(f[9]) * 512) # sectors_read, sectors_written
except ValueError:
continue
return out
def collect_temps() -> list:
"""Return [{label, celsius}] from /sys/class/hwmon (best-effort, may be empty)."""
out: list = []
try:
chips = os.listdir(HWMON_DIR)
except OSError:
return out
for hw in chips:
d = os.path.join(HWMON_DIR, hw)
chip = ""
try:
chip = _read_file(os.path.join(d, "name")).strip()
except OSError:
pass
try:
files = os.listdir(d)
except OSError:
continue
for fn in files:
if not (fn.startswith("temp") and fn.endswith("_input")):
continue
idx = fn[: -len("_input")] # e.g. "temp1"
try:
milli = int(_read_file(os.path.join(d, fn)).strip())
except (OSError, ValueError):
continue
label = ""
try:
label = _read_file(os.path.join(d, idx + "_label")).strip()
except OSError:
pass
name = label or (f"{chip}_{idx}" if chip else idx)
out.append({"label": name, "celsius": round(milli / 1000.0, 1)})
return out
def _parse_psi(text: str) -> dict:
"""Parse a /proc/pressure/* file into {some_avg10, some_avg60, full_avg10, …}."""
res: dict = {}
for line in text.splitlines():
parts = line.split()
if not parts:
continue
kind = parts[0] # "some" | "full"
for p in parts[1:]:
for prefix, suffix in (("avg10=", "avg10"), ("avg60=", "avg60")):
if p.startswith(prefix):
try:
res[f"{kind}_{suffix}"] = float(p[len(prefix):])
except ValueError:
pass
return res
def collect_psi() -> dict:
"""Return pressure-stall (PSI) gauges, prefixed by resource (mem/cpu/io).
Absent on kernels without CONFIG_PSI — returns only what exists.
"""
out: dict = {}
for res_name, fname in (("mem", "memory"), ("cpu", "cpu"), ("io", "io")):
try:
text = _read_file(os.path.join(PSI_DIR, fname))
except OSError:
continue
for k, v in _parse_psi(text).items():
out[f"{res_name}_{k}"] = v
return out
def _rates(cur: dict, prev: dict, dt: float) -> dict:
"""Per-second rates for counters present in both snapshots over dt seconds.
Skips names missing from prev and negative deltas (counter reset / iface or
disk hot-plug), so a reboot never emits a bogus spike.
"""
out: dict = {}
if dt <= 0:
return out
for name, cur_vals in cur.items():
if name not in prev:
continue
prev_vals = prev[name]
deltas = [c - p for c, p in zip(cur_vals, prev_vals)]
if any(d < 0 for d in deltas):
continue
out[name] = tuple(d / dt for d in deltas)
return out
# ─── ring buffer ─────────────────────────────────────────────────────────────
@@ -232,11 +408,17 @@ class RingBuffer:
# ─── payload ─────────────────────────────────────────────────────────────────
def build_sample(mounts: list[str]) -> dict:
"""Collect one full sample. Partial samples allowed if a collector fails."""
def build_sample(mounts: list[str], state: dict) -> dict:
"""Collect one full sample. Partial samples allowed if a collector fails.
`state` carries the previous network/disk counters + monotonic timestamp so
throughput rates can be derived from deltas; it is mutated in place.
"""
sample: dict = {"ts": datetime.now(timezone.utc).isoformat()}
try:
sample["cpu_pct"] = collect_cpu()
agg, cores = collect_cpu()
sample["cpu_pct"] = agg
sample["cpu_cores"] = cores
except Exception:
sample["cpu_pct"] = None
try:
@@ -255,6 +437,35 @@ def build_sample(mounts: list[str]) -> dict:
sample["storage"] = collect_storage(mounts)
except Exception:
sample["storage"] = []
try:
sample["temps"] = collect_temps()
except Exception:
sample["temps"] = []
try:
sample["psi"] = collect_psi()
except Exception:
sample["psi"] = {}
# Throughput rates: diff cumulative counters against the previous sample.
now_mono = time.monotonic()
try:
net_raw = collect_net_raw()
except Exception:
net_raw = {}
try:
disk_raw = collect_diskio_raw()
except Exception:
disk_raw = {}
dt = now_mono - state["mono"] if state.get("mono") else 0.0
sample["net"] = [
{"iface": n, "rx_bps": round(rx, 1), "tx_bps": round(tx, 1)}
for n, (rx, tx) in _rates(net_raw, state.get("net", {}), dt).items()
]
sample["diskio"] = [
{"device": n, "read_bps": round(rd, 1), "write_bps": round(wr, 1)}
for n, (rd, wr) in _rates(disk_raw, state.get("disk", {}), dt).items()
]
state["net"], state["disk"], state["mono"] = net_raw, disk_raw, now_mono
return sample
@@ -338,6 +549,8 @@ def main_loop(conf_path: str) -> int:
mounts = cfg.get("mounts") or default_mounts()
buffer = RingBuffer(maxlen=20)
backoff = 0
# Carries previous net/disk counters + monotonic ts for rate computation.
rate_state: dict = {}
_log("INFO", f"steward-host-agent {AGENT_VERSION} starting "
f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)")
@@ -353,7 +566,7 @@ def main_loop(conf_path: str) -> int:
_log("ERROR", f"reload failed: {e}")
_reload_requested = False
sample = build_sample(mounts)
sample = build_sample(mounts, rate_state)
buffered = buffer.drain()
payload = build_payload(
samples=buffered + [sample],