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
+54
View File
@@ -95,6 +95,60 @@ def _expand_sample_to_metrics(
if sample.get("storage"):
row("disk_used_pct_worst", host_name, worst_pct)
# Per-core CPU. Sub-resources carry a ':' so the fleet widget filters them out.
for i, core_pct in enumerate(sample.get("cpu_cores") or []):
if core_pct is not None:
row("cpu_pct", f"{host_name}:core{i}", core_pct)
# Richer memory breakdown.
if mem.get("cached_bytes") is not None:
row("mem_cached_bytes", host_name, mem["cached_bytes"])
if mem.get("buffers_bytes") is not None:
row("mem_buffers_bytes", host_name, mem["buffers_bytes"])
# Network throughput — per interface plus a host-level total for the fleet view.
net_rx_total = net_tx_total = 0.0
for iface in sample.get("net") or []:
rx, tx = iface.get("rx_bps", 0.0), iface.get("tx_bps", 0.0)
res = f"{host_name}:net:{iface['iface']}"
row("net_rx_bps", res, rx)
row("net_tx_bps", res, tx)
net_rx_total += rx
net_tx_total += tx
if sample.get("net"):
row("net_rx_bps", host_name, net_rx_total)
row("net_tx_bps", host_name, net_tx_total)
# Disk I/O — per device plus host-level total.
dr_total = dw_total = 0.0
for dev in sample.get("diskio") or []:
rd, wr = dev.get("read_bps", 0.0), dev.get("write_bps", 0.0)
res = f"{host_name}:diskio:{dev['device']}"
row("disk_read_bps", res, rd)
row("disk_write_bps", res, wr)
dr_total += rd
dw_total += wr
if sample.get("diskio"):
row("disk_read_bps", host_name, dr_total)
row("disk_write_bps", host_name, dw_total)
# Temperatures — per sensor plus the host max for at-a-glance.
max_temp: float | None = None
for t in sample.get("temps") or []:
c = t.get("celsius")
if c is None:
continue
row("temp_c", f"{host_name}:temp:{t['label']}", c)
if max_temp is None or c > max_temp:
max_temp = c
if max_temp is not None:
row("temp_c_max", host_name, max_temp)
# Pressure stall information (PSI) — host-level gauges (mem/cpu/io some|full).
for k, v in (sample.get("psi") or {}).items():
if isinstance(v, (int, float)):
row(f"psi_{k}", host_name, v)
return rows