90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
|
|
"""
|
||
|
|
system_stats_service.py — Lightweight host metrics → ClickHouse.
|
||
|
|
Polls /proc every 30s. Zero dependencies beyond stdlib.
|
||
|
|
Writes to dolphin.system_stats via HTTP INSERT.
|
||
|
|
|
||
|
|
Run: python3 /root/ch-setup/system_stats_service.py
|
||
|
|
(Place on mount at prod/system_stats_service.py before adding to supervisord)
|
||
|
|
"""
|
||
|
|
import time, json, urllib.request, logging
|
||
|
|
|
||
|
|
CH_URL = "http://localhost:8123"
|
||
|
|
CH_USER = "dolphin"
|
||
|
|
CH_PASS = "dolphin_ch_2026"
|
||
|
|
POLL_S = 30
|
||
|
|
|
||
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
||
|
|
log = logging.getLogger("system_stats")
|
||
|
|
|
||
|
|
_prev_net: dict = {}
|
||
|
|
|
||
|
|
def read_mem() -> dict:
|
||
|
|
"""Parse /proc/meminfo → used/available GB."""
|
||
|
|
info = {}
|
||
|
|
with open("/proc/meminfo") as f:
|
||
|
|
for line in f:
|
||
|
|
k, v = line.split(":")
|
||
|
|
info[k.strip()] = int(v.split()[0]) # kB
|
||
|
|
total = info["MemTotal"] / 1024 / 1024
|
||
|
|
available = info["MemAvailable"] / 1024 / 1024
|
||
|
|
used = total - available
|
||
|
|
return {"mem_used_gb": round(used, 2),
|
||
|
|
"mem_available_gb": round(available, 2),
|
||
|
|
"mem_pct": round(used / total * 100, 1)}
|
||
|
|
|
||
|
|
def read_load() -> dict:
|
||
|
|
"""Parse /proc/loadavg."""
|
||
|
|
with open("/proc/loadavg") as f:
|
||
|
|
parts = f.read().split()
|
||
|
|
return {"load_1m": float(parts[0]),
|
||
|
|
"load_5m": float(parts[1]),
|
||
|
|
"load_15m": float(parts[2])}
|
||
|
|
|
||
|
|
def read_net() -> dict:
|
||
|
|
"""Parse /proc/net/dev → MB/s delta across non-lo interfaces."""
|
||
|
|
global _prev_net
|
||
|
|
ifaces = {}
|
||
|
|
with open("/proc/net/dev") as f:
|
||
|
|
for line in f.readlines()[2:]:
|
||
|
|
parts = line.split()
|
||
|
|
iface = parts[0].rstrip(":")
|
||
|
|
if iface == "lo":
|
||
|
|
continue
|
||
|
|
ifaces[iface] = {"rx": int(parts[1]), "tx": int(parts[9])}
|
||
|
|
|
||
|
|
now = time.monotonic()
|
||
|
|
result = {"net_rx_mb_s": 0.0, "net_tx_mb_s": 0.0, "net_iface": "all"}
|
||
|
|
if _prev_net:
|
||
|
|
dt = now - _prev_net["_ts"]
|
||
|
|
rx_total = sum(ifaces[i]["rx"] - _prev_net.get(i, {}).get("rx", ifaces[i]["rx"]) for i in ifaces)
|
||
|
|
tx_total = sum(ifaces[i]["tx"] - _prev_net.get(i, {}).get("tx", ifaces[i]["tx"]) for i in ifaces)
|
||
|
|
result["net_rx_mb_s"] = round(max(0, rx_total) / 1024 / 1024 / dt, 3)
|
||
|
|
result["net_tx_mb_s"] = round(max(0, tx_total) / 1024 / 1024 / dt, 3)
|
||
|
|
|
||
|
|
_prev_net = {**ifaces, "_ts": now}
|
||
|
|
return result
|
||
|
|
|
||
|
|
def ch_insert(row: dict):
|
||
|
|
body = (json.dumps(row) + "\n").encode()
|
||
|
|
url = f"{CH_URL}/?database=dolphin&query=INSERT+INTO+system_stats+FORMAT+JSONEachRow"
|
||
|
|
req = urllib.request.Request(url, data=body, method="POST")
|
||
|
|
req.add_header("X-ClickHouse-User", CH_USER)
|
||
|
|
req.add_header("X-ClickHouse-Key", CH_PASS)
|
||
|
|
req.add_header("Content-Type", "application/octet-stream")
|
||
|
|
urllib.request.urlopen(req, timeout=5)
|
||
|
|
|
||
|
|
def main():
|
||
|
|
log.info("system_stats_service starting — poll every %ds", POLL_S)
|
||
|
|
while True:
|
||
|
|
try:
|
||
|
|
ts_ms = int(time.time() * 1_000) # DateTime64(3) expects milliseconds
|
||
|
|
row = {"ts": ts_ms, **read_mem(), **read_load(), **read_net()}
|
||
|
|
ch_insert(row)
|
||
|
|
log.debug("inserted: %s", row)
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("error: %s", e)
|
||
|
|
time.sleep(POLL_S)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|