diff --git a/scripts/live/monitor_health.py b/scripts/live/monitor_health.py index 1e6922e..375502c 100644 --- a/scripts/live/monitor_health.py +++ b/scripts/live/monitor_health.py @@ -10,20 +10,91 @@ monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSP uv run python scripts/live/monitor_health.py # report uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta + uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a + # max_age<=6h (cuscino_watch, venue_watch, collect_chain), notifica solo il NUOVO + # rispetto a data/live/monitor_health_detto.json — pensato per girare ogni ora + # (cron_cuscino.sh), non per sostituire il giro giornaliero completo. """ from __future__ import annotations +import json import sys +from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402 +from src.live.monitor_health import ( # noqa: E402 + MIN_COVERAGE, + alert_nuovi, + alerts_from, + check_all, + marcatore_aggiornato, + monitors_orari, +) from src.live.notifier import notify # noqa: E402 +# (#30) marcatore "gia' detto" del giro orario: {"detti": [], "ts": ...}. Vive fuori +# da src/ perche' e' I/O, non parte della misura pura. +DETTO = ROOT / "data" / "live" / "monitor_health_detto.json" + + +def _carica_detti(path: Path = DETTO) -> set[str]: + if not path.exists(): + return set() + try: + return set(json.loads(path.read_text()).get("detti", [])) + except Exception: + return set() + + +def _salva_detti(detti: set[str], path: Path = DETTO) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps( + {"detti": sorted(detti), "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")}, + indent=2)) + + +def main_orari() -> int: + """(#30) Giro orario: solo i sorveglianti a max_age<=6h, notifica solo il NUOVO rispetto al + marcatore. Exit 2 se c'e' un alert (anche gia' detto), 0 altrimenti — il giro giornaliero + resta l'unico posto dove "nessun alert nuovo" vuol dire davvero "tutto OK".""" + quiet = "--quiet" in sys.argv + rows = check_all(ROOT, monitors=monitors_orari()) + alerts = alerts_from(rows) + detti = _carica_detti(DETTO) # DETTO letto qui, non nel default del parametro (monkeypatch) + nuovi = alert_nuovi(rows, detti) + aggiornato = marcatore_aggiornato(rows, detti) + + if not quiet or alerts: + print("=" * 92) + print(" MONITOR HEALTH — giro orario (sorveglianti con max_age <= 6h)") + print("=" * 92) + for r in rows: + cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-" + age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-" + print(f" {r['name']:<20}{r['status']:>10}{age:>13}{cov:>10}") + + inviato = True + if nuovi: + msgs = alerts_from(nuovi) + inviato = notify("🩺 MONITOR HEALTH — un sorvegliante orario non sta registrando", + {f"alert {i+1}": m for i, m in enumerate(msgs)}) + for m in msgs: + print(f" ALERT (nuovo): {m}") + if not inviato: + print(" ⚠️ invio FALLITO — marcatore NON aggiornato: il prossimo giro ritenta") + if not nuovi or inviato: + _salva_detti(aggiornato, DETTO) + + return 2 if alerts else 0 + def main() -> int: + if "--solo-orari" in sys.argv: + return main_orari() + rows = check_all(ROOT) alerts = alerts_from(rows) @@ -55,15 +126,17 @@ def main() -> int: return 0 -USO = """uso: monitor_health.py [--quiet] +USO = """uso: monitor_health.py [--quiet] [--solo-orari] monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla. uv run python scripts/live/monitor_health.py # report uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta + uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a + max_age<=6h, notifica solo il NUOVO rispetto al marcatore Dettaglio nel docstring in testa al file.""" if __name__ == "__main__": from src.live.cli import valida - valida("monitor_health.py", USO, flag=("--quiet",), con_valore=()) + valida("monitor_health.py", USO, flag=("--quiet", "--solo-orari"), con_valore=()) raise SystemExit(main()) diff --git a/src/live/monitor_health.py b/src/live/monitor_health.py index c58dc78..6789c01 100644 --- a/src/live/monitor_health.py +++ b/src/live/monitor_health.py @@ -49,6 +49,9 @@ import numpy as np MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO) +SOGLIA_ORARI_H = 6.0 # (#30) max_age_h <= questa soglia = "sorvegliante orario": i tre + # con max_age 3h (cuscino_watch, venue_watch, collect_chain) oggi, + # derivato dalla tabella e non da una lista di nomi cablata @dataclass(frozen=True) @@ -244,3 +247,46 @@ def alerts_from(rows: list[dict]) -> list[str]: msg += f" | METTE A RISCHIO il gate {r['gate']}" out.append(msg) return out + + +# =========================================================================== +# GIRO ORARIO (#30). Il giro giornaliero (cron_daily) resta com'e': report completo, avviso +# sempre. I sorveglianti a max_age <= 6h (oggi cuscino_watch, venue_watch, collect_chain) si +# scoprono fermi fino a 24h dopo se nessuno li guarda fuori da quel giro: qui si guardano ogni +# ora, ma si notifica solo cio' che e' NUOVO rispetto all'ultimo avviso (niente Telegram a +# raffica sullo stesso guasto fermo). Tutto PURO: il marcatore lo legge/scrive lo script. +# =========================================================================== +def monitors_orari(monitors: tuple[MonitorSpec, ...] = MONITORS, + soglia_h: float = SOGLIA_ORARI_H) -> tuple[MonitorSpec, ...]: + """I monitor con cadenza di controllo ORARIA: max_age_h <= soglia_h. Derivato dalla tabella + `MONITORS`, non da una lista di nomi cablata: aggiungere un monitor orario lo include qui + senza toccare questa funzione.""" + return tuple(m for m in monitors if m.max_age_h <= soglia_h) + + +def chiave_alert(row: dict) -> str: + """Chiave stabile di un alert per il marcatore 'gia' detto': nome + stato, NON il testo + (`why` contiene l'eta' dell'ultima barra, che cambia a ogni giro anche se il guasto e' lo + stesso).""" + return f"{row['name']}:{row['status']}" + + +def alert_nuovi(rows: list[dict], detti: set[str]) -> list[dict]: + """Le righe-alert (non OK, non NUOVO — vedi `alerts_from`) la cui chiave NON e' nel + marcatore `detti`: quelle davvero nuove, le sole da notificare.""" + return [r for r in rows if r["status"] not in ("OK", "NUOVO") and chiave_alert(r) not in detti] + + +def marcatore_aggiornato(rows: list[dict], detti: set[str]) -> set[str]: + """Il marcatore dopo questo giro: le chiavi dei monitor ancora in alert restano/si aggiungono, + quelle dei monitor tornati OK si TOLGONO — cosi' un nuovo guasto sullo stesso monitor riparte + con un nuovo avviso invece di restare 'gia' detto' per sempre. Non scrive nulla: lo fa lo + script, e solo se la notifica e' partita (stessa regola di fee_watch #29).""" + out = set(detti) + for r in rows: + chiavi_monitor = {k for k in out if k.startswith(f"{r['name']}:")} + if r["status"] == "OK": + out -= chiavi_monitor + elif r["status"] != "NUOVO": + out.add(chiave_alert(r)) + return out diff --git a/tests/test_monitor_health_orari.py b/tests/test_monitor_health_orari.py new file mode 100644 index 0000000..2acf578 --- /dev/null +++ b/tests/test_monitor_health_orari.py @@ -0,0 +1,140 @@ +"""Test del giro ORARIO di monitor_health (#30). + +I sorveglianti a max_age<=6h (cuscino_watch, venue_watch, collect_chain) girano alle 00:30 col +report giornaliero e basta: un guasto si scopre fino a 24h dopo. Qui si guarda ogni ora, ma si +notifica solo cio' che e' NUOVO rispetto al marcatore "gia' detto" — altrimenti un monitor fermo +manderebbe lo stesso Telegram ogni ora per giorni. + +Le funzioni PURE (filtro per max_age, chiave dell'alert, chi e' nuovo, il marcatore aggiornato) +stanno in src/live/monitor_health.py e si testano su righe finte, senza toccare disco o rete. +L'orchestrazione (leggere/scrivere il marcatore, chiamare notify) sta nello script e si testa qui +sotto con monkeypatch — NIENTE Telegram vero.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "live")) + +from src.live.monitor_health import ( # noqa: E402 + MonitorSpec, + alert_nuovi, + chiave_alert, + marcatore_aggiornato, + monitors_orari, +) + +import monitor_health as MH # noqa: E402 (scripts/live/monitor_health.py) + +# righe finte, nella forma che assess()/check_all() restituiscono davvero +ROW_FERMO = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=3, age_h=4.0, + coverage=0.9, expected=3, status="FERMO", why="ultima barra 4h fa (limite 3h)") +ROW_OK = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=10, age_h=0.2, + coverage=1.0, expected=10, status="OK", why="") +ROW_NUOVO = dict(name="venue_watch", gate=None, n_bars=1, age_h=0.1, coverage=None, + expected=None, status="NUOVO", why="1 barra: non c'e' abbastanza serie") + + +# =========================================================================== +# funzioni PURE — filtro per max_age (src/live/monitor_health.py) +# =========================================================================== +def test_il_filtro_deriva_dalla_tabella_non_da_una_lista_di_nomi(): + specs = ( + MonitorSpec("a", "a", "runs.jsonl", 1.0, max_age_h=3.0), + MonitorSpec("b", "b", "runs.jsonl", 1.0, max_age_h=6.0), # al bordo: dentro + MonitorSpec("c", "c", "runs.jsonl", 24.0, max_age_h=30.0), + ) + assert {m.name for m in monitors_orari(specs)} == {"a", "b"} + + +def test_i_tre_orari_di_oggi_sono_quelli_noti(): + """Se la tabella cambia questo test lo dice: e' l'ancora fra il registro e il flag.""" + assert {m.name for m in monitors_orari()} == {"collect_chain", "cuscino_watch", "venue_watch"} + + +# =========================================================================== +# funzioni PURE — chiave, nuovi, marcatore +# =========================================================================== +def test_chiave_e_nome_piu_stato_non_il_testo(): + """Il testo (`why`) contiene l'eta', che cambia ogni ora anche se il guasto e' lo stesso.""" + assert chiave_alert(ROW_FERMO) == "venue_watch:FERMO" + piu_vecchio = {**ROW_FERMO, "age_h": 9.0, "why": "ultima barra 9h fa (limite 3h)"} + assert chiave_alert(piu_vecchio) == chiave_alert(ROW_FERMO) + + +def test_alert_nuovo_se_la_chiave_non_e_nel_marcatore(): + assert alert_nuovi([ROW_FERMO], set()) == [ROW_FERMO] + + +def test_lo_stesso_alert_al_secondo_giro_non_e_piu_nuovo(): + assert alert_nuovi([ROW_FERMO], {"venue_watch:FERMO"}) == [] + + +def test_nuovo_non_e_un_alert_da_notificare(): + assert alert_nuovi([ROW_NUOVO], set()) == [] + + +def test_il_marcatore_aggiunge_la_chiave_di_un_alert(): + assert marcatore_aggiornato([ROW_FERMO], set()) == {"venue_watch:FERMO"} + + +def test_il_ritorno_a_ok_toglie_la_chiave_dal_marcatore(): + """Cosi' un nuovo guasto sullo stesso monitor riparte con un nuovo avviso.""" + assert marcatore_aggiornato([ROW_OK], {"venue_watch:FERMO"}) == set() + + +# =========================================================================== +# orchestrazione — scripts/live/monitor_health.py main_orari(), NIENTE Telegram +# =========================================================================== +def test_notify_fallito_non_scrive_il_marcatore(tmp_path, monkeypatch): + """Stessa regola di fee_watch #29: niente 'gia' detto' su un invio fallito, altrimenti il + prossimo giro non ritenterebbe.""" + detto = tmp_path / "detto.json" + monkeypatch.setattr(MH, "DETTO", detto) + monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO]) + chiamate = [] + monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append((a, k)), False)[1]) + monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"]) + + rc = MH.main_orari() + + assert rc == 2 # c'e' un alert + assert len(chiamate) == 1 # e si e' provato a notificarlo + assert not detto.exists() # ma l'invio e' fallito: nessun marcatore + + +def test_notify_riuscito_scrive_il_marcatore_e_non_rinotifica(tmp_path, monkeypatch): + detto = tmp_path / "detto.json" + monkeypatch.setattr(MH, "DETTO", detto) + monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO]) + monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"]) + + monkeypatch.setattr(MH, "notify", lambda *a, **k: True) + rc1 = MH.main_orari() + assert rc1 == 2 + assert json.loads(detto.read_text())["detti"] == ["venue_watch:FERMO"] + + chiamate = [] + monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1]) + rc2 = MH.main_orari() + assert rc2 == 2 # l'alert c'e' ancora (anche se gia' detto: exit 2 comunque) + assert chiamate == [] # ma non si rimanda lo stesso Telegram + + +def test_tornato_ok_il_giro_dopo_pulisce_il_marcatore(tmp_path, monkeypatch): + detto = tmp_path / "detto.json" + detto.write_text(json.dumps({"detti": ["venue_watch:FERMO"], "ts": "2026-09-27T00:00:00Z"})) + monkeypatch.setattr(MH, "DETTO", detto) + monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_OK]) + monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"]) + chiamate = [] + monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1]) + + rc = MH.main_orari() + + assert rc == 0 + assert chiamate == [] # OK non notifica mai + assert json.loads(detto.read_text())["detti"] == []