rmi-backend/scripts/health_monitor.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

373 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""
RMI Health Monitoring Cron
============================
Checks: backend health, worker health (base + solana), Redis connectivity.
POSTs alerts to a webhook if anything is down.
Run via cron every 1-5 minutes:
*/2 * * * * cd /root/backend && python3 scripts/health_monitor.py >> /var/log/rmi_health.log 2>&1
Environment variables:
HEALTH_WEBHOOK_URL - webhook URL for alerts (Slack, Discord, PagerDuty, etc.)
BACKEND_URL - backend health endpoint (default: http://localhost:8000/health)
REDIS_HOST - Redis host (default: localhost)
REDIS_PORT - Redis port (default: 6379)
REDIS_PASSWORD - Redis password (default: empty)
BASE_WORKER_URL - Base worker health endpoint
SOLANA_WORKER_URL - Solana worker health endpoint
Author: RMI Development
Date: 2026-05-20
"""
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.request
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("health_monitor")
# ── Configuration ─────────────────────────────────────────────────
HEALTH_WEBHOOK_URL = os.getenv("HEALTH_WEBHOOK_URL", "")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000/health")
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
BASE_WORKER_URL = os.getenv("BASE_WORKER_URL", "http://localhost:8000/api/v1/x402/fallback/status")
SOLANA_WORKER_URL = os.getenv("SOLANA_WORKER_URL", "")
CHECK_TIMEOUT = 10 # seconds
ALERT_COOLDOWN = 300 # 5 minutes - don't re-alert for same service within this window
# Track last alert time per service to avoid spam
_last_alerts: dict = {}
def _load_cooldown_state():
"""Load alert cooldown state from file (survives restarts)."""
try:
state_file = "/tmp/rmi_health_cooldown.json"
if os.path.exists(state_file):
with open(state_file) as f:
return json.load(f)
except Exception:
pass
return {}
def _save_cooldown_state(state: dict):
"""Save alert cooldown state to file."""
try:
state_file = "/tmp/rmi_health_cooldown.json"
with open(state_file, "w") as f:
json.dump(state, f)
except Exception:
pass
def _should_alert(service: str) -> bool:
"""Check if we should send an alert (respects cooldown)."""
now = time.time()
state = _load_cooldown_state()
last = state.get(service, 0)
if now - last < ALERT_COOLDOWN:
return False
state[service] = now
_save_cooldown_state(state)
return True
# ── Health checks ──────────────────────────────────────────────────
def check_backend() -> dict:
"""Check backend /health endpoint."""
try:
req = urllib.request.Request(BACKEND_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=CHECK_TIMEOUT) as resp:
data = json.loads(resp.read().decode())
return {"service": "backend", "status": "healthy", "details": data}
except Exception as e:
return {"service": "backend", "status": "down", "error": str(e)[:200]}
def check_redis() -> dict:
"""Check Redis connectivity."""
try:
import redis
r = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
)
r.ping()
info = r.info("server")
return {
"service": "redis",
"status": "healthy",
"details": {
"version": info.get("redis_version", "unknown"),
"uptime_seconds": info.get("uptime_in_seconds", 0),
"connected_clients": info.get("connected_clients", 0),
"used_memory_human": info.get("used_memory_human", "unknown"),
},
}
except Exception as e:
return {"service": "redis", "status": "down", "error": str(e)[:200]}
def check_base_worker() -> dict:
"""Check Base x402 worker/gateway health."""
try:
req = urllib.request.Request(BASE_WORKER_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=CHECK_TIMEOUT) as resp:
data = json.loads(resp.read().decode())
return {"service": "base_worker", "status": "healthy", "details": data}
except Exception as e:
return {"service": "base_worker", "status": "down", "error": str(e)[:200]}
def check_solana_worker() -> dict:
"""Check Solana x402 worker/gateway health."""
if not SOLANA_WORKER_URL:
return {
"service": "solana_worker",
"status": "not_configured",
"details": "SOLANA_WORKER_URL not set",
}
try:
req = urllib.request.Request(SOLANA_WORKER_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=CHECK_TIMEOUT) as resp:
data = json.loads(resp.read().decode())
return {"service": "solana_worker", "status": "healthy", "details": data}
except Exception as e:
return {"service": "solana_worker", "status": "down", "error": str(e)[:200]}
# ── Alert dispatch ──────────────────────────────────────────────────
def send_alert(results: list):
"""POST alert to webhook for any down services."""
if not HEALTH_WEBHOOK_URL:
logger.warning("HEALTH_WEBHOOK_URL not set - skipping alert dispatch")
return
down_services = [r for r in results if r.get("status") == "down"]
if not down_services:
return # All healthy
# Filter by cooldown
alertable = [s for s in down_services if _should_alert(s["service"])]
if not alertable:
logger.info(f"{len(down_services)} service(s) down but in alert cooldown")
return
# Build alert payload
alert_lines = []
for s in alertable:
alert_lines.append(f"**{s['service']}** is DOWN: {s.get('error', 'unknown error')}")
# Support multiple webhook formats
# Slack/Discord format
payload = {
"text": f"RMI Health Alert - {len(alertable)} service(s) down",
"attachments": [
{
"title": "Service Health Alert",
"color": "danger",
"text": "\n".join(alert_lines),
"footer": "RMI Health Monitor",
"ts": int(time.time()),
}
],
# Also include raw data for custom webhooks
"services": alertable,
"all_results": results,
}
# Try sending
try:
data = json.dumps(payload).encode()
req = urllib.request.Request(
HEALTH_WEBHOOK_URL,
data=data,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
logger.info(f"Alert sent to webhook: HTTP {resp.status} - {len(alertable)} services down")
except Exception as e:
logger.error(f"Failed to send alert to webhook: {e}")
def send_recovery_alert(recovered: list):
"""Send recovery notification."""
if not HEALTH_WEBHOOK_URL or not recovered:
return
# Clear cooldown for recovered services
state = _load_cooldown_state()
for s in recovered:
service_name = s["service"]
if service_name in state:
del state[service_name]
_save_cooldown_state(state)
alertable = [s for s in recovered if _should_alert(f"{s['service']}_recovery")]
if not alertable:
return
lines = [f"**{s['service']}** is back UP" for s in alertable]
payload = {
"text": f"RMI Recovery - {len(alertable)} service(s) recovered",
"attachments": [
{
"title": "Service Recovery",
"color": "good",
"text": "\n".join(lines),
"footer": "RMI Health Monitor",
"ts": int(time.time()),
}
],
}
try:
data = json.dumps(payload).encode()
req = urllib.request.Request(
HEALTH_WEBHOOK_URL,
data=data,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10):
logger.info(f"Recovery alert sent: {len(alertable)} services recovered")
except Exception as e:
logger.error(f"Failed to send recovery alert: {e}")
# ── Previous state tracking ─────────────────────────────────────────
def _load_prev_state():
"""Load previous health state for recovery detection."""
try:
state_file = "/tmp/rmi_health_state.json"
if os.path.exists(state_file):
with open(state_file) as f:
return json.load(f)
except Exception:
pass
return {}
def _save_prev_state(state: dict):
"""Save current health state for recovery detection."""
try:
state_file = "/tmp/rmi_health_state.json"
with open(state_file, "w") as f:
json.dump(state, f)
except Exception:
pass
# ── Main ────────────────────────────────────────────────────────────
def main():
"""Run all health checks and dispatch alerts."""
# Support loading from any .env file
if "--dotenv" in sys.argv:
import dotenv
idx = sys.argv.index("--dotenv")
if idx + 1 < len(sys.argv):
dotenv.load_dotenv(sys.argv[idx + 1])
else:
dotenv.load_dotenv()
elif os.path.exists("/root/.env"):
try:
from dotenv import load_dotenv
load_dotenv("/root/.env")
except ImportError:
pass
# Re-read config after dotenv
global HEALTH_WEBHOOK_URL, BACKEND_URL, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
global BASE_WORKER_URL, SOLANA_WORKER_URL
HEALTH_WEBHOOK_URL = os.getenv("HEALTH_WEBHOOK_URL", "")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000/health")
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
BASE_WORKER_URL = os.getenv("BASE_WORKER_URL", "http://localhost:8000/api/v1/x402/fallback/status")
SOLANA_WORKER_URL = os.getenv("SOLANA_WORKER_URL", "")
logger.info("Running health checks...")
results = [
check_backend(),
check_redis(),
check_base_worker(),
check_solana_worker(),
]
# Detect recoveries
prev_state = _load_prev_state()
current_state = {r["service"]: r["status"] for r in results}
recovered = []
for r in results:
if r["status"] == "healthy" and prev_state.get(r["service"]) == "down":
recovered.append(r)
# Save current state
_save_prev_state(current_state)
# Log results
for r in results:
status = r["status"]
if status == "healthy":
logger.info(f" {r['service']}: healthy")
elif status == "not_configured":
logger.info(f" {r['service']}: not configured (skipped)")
else:
logger.error(f" {r['service']}: DOWN - {r.get('error', 'unknown')}")
# Send alerts for down services
send_alert(results)
# Send recovery alerts
if recovered:
send_recovery_alert(recovered)
for r in recovered:
logger.info(f" {r['service']}: RECOVERED")
# Print summary
healthy = sum(1 for r in results if r["status"] == "healthy")
total = len(results)
down = sum(1 for r in results if r["status"] == "down")
logger.info(f"Health summary: {healthy}/{total} healthy, {down} down")
# Exit with error code if anything is down (useful for cron monitoring)
if down > 0:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()