245 lines
9.3 KiB
Python
245 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
||
"""cron_health_monitor.py — 全局cron job健康监控 v2
|
||
|
||
每10分钟跑一次,检查:
|
||
1. 所有启用cron job的last_status是否为failed
|
||
2. 关键job的last_run_at是否超过预期空闲时间
|
||
3. price_monitor等核心管道是否活着
|
||
4. 自己是否正常跑(自检)
|
||
|
||
发现新问题 → 推XMPP告警(同类问题2小时内不重复推)
|
||
"""
|
||
import json, os, sys, re
|
||
from pathlib import Path
|
||
from datetime import datetime, timezone, timedelta
|
||
from urllib.request import Request, urlopen
|
||
|
||
XMPP_BRIDGE = "http://127.0.0.1:5805/"
|
||
XMPP_USER = "hmo@yoin.fun"
|
||
STATE_FILE = Path.home() / ".hermes" / ".cron_health_state.json"
|
||
COOLDOWN_HOURS = 2 # 同类告警2小时内不重复
|
||
|
||
# 关键job及其最大允许空闲分钟
|
||
# 只监控实时管道(每分~每小时跑的关键任务)
|
||
# 日/周任务不在此列(它们不耽误实时推荐也不需要秒级响应)
|
||
CRITICAL_JOBS = {
|
||
"price_monitor": {"max_idle_min": 15}, # 每2分
|
||
"MoFin盘前中监控": {"max_idle_min": 90}, # 每25分(盘中)
|
||
"自选买入区提醒": {"max_idle_min": 90}, # 每30分
|
||
"自选买入区提醒-盘前午间尾盘": {"max_idle_min": 185}, # 每3小时(9/12/15点),允许185分
|
||
"宏观风险信号消费": {"max_idle_min": 40}, # 每15分
|
||
"重评管道审计": {"max_idle_min": 90}, # 每30分
|
||
"全局cron健康监控": {"max_idle_min": 30}, # 每10分 ← 自己
|
||
"实时消息中继": {"max_idle_min": 20},
|
||
"宏观上下文刷新": {"max_idle_min": 90}, # 每5分
|
||
}
|
||
|
||
def xmpp_push(text):
|
||
try:
|
||
payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode()
|
||
req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"})
|
||
urlopen(req, timeout=5)
|
||
return True
|
||
except Exception as e:
|
||
print(f"[XMPP推送失败] {e}", file=sys.stderr)
|
||
return False
|
||
|
||
def parse_time(ts_str):
|
||
"""解析ISO时间戳为datetime"""
|
||
if not ts_str:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(ts_str)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt
|
||
except Exception:
|
||
return None
|
||
|
||
def get_idle_minutes(last_run_str):
|
||
"""计算上次运行到现在过了多少分钟"""
|
||
last = parse_time(last_run_str)
|
||
if not last:
|
||
return None
|
||
now = datetime.now(timezone.utc)
|
||
return (now - last).total_seconds() / 60
|
||
|
||
def scan_jobs_files():
|
||
"""扫描所有cron jobs.json,合并返回全量job列表"""
|
||
jobs = []
|
||
seen = set()
|
||
for jf in [
|
||
"/home/hmo/.hermes/cron/jobs.json",
|
||
"/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json",
|
||
]:
|
||
try:
|
||
data = json.load(open(jf))
|
||
for job in data.get("jobs", []):
|
||
jid = job.get("id", "")
|
||
if jid in seen:
|
||
continue
|
||
seen.add(jid)
|
||
jobs.append({
|
||
"id": jid,
|
||
"name": job.get("name", jid[:12]) or jid[:12],
|
||
"status": job.get("last_status", ""),
|
||
"last_run": job.get("last_run_at", ""),
|
||
"enabled": job.get("enabled", True),
|
||
"no_agent": job.get("no_agent", False),
|
||
"schedule": job.get("schedule", {}),
|
||
})
|
||
except Exception:
|
||
pass
|
||
return jobs
|
||
|
||
def check_idle(jobs):
|
||
"""检查关键job是否超过最大空闲时间"""
|
||
stale = []
|
||
for j in jobs:
|
||
if not j["enabled"]:
|
||
continue
|
||
name = j["name"]
|
||
rule = None
|
||
for key, r in CRITICAL_JOBS.items():
|
||
if key.lower() in name.lower():
|
||
rule = r
|
||
break
|
||
if not rule:
|
||
continue
|
||
idle = get_idle_minutes(j["last_run"])
|
||
if idle is None:
|
||
stale.append((j, "无运行记录"))
|
||
elif idle > rule["max_idle_min"]:
|
||
idle_str = f"{idle:.0f}分"
|
||
stale.append((j, f"超过{rule['max_idle_min']}分未运行(已{idle_str})"))
|
||
return stale
|
||
|
||
def load_state():
|
||
try:
|
||
with open(STATE_FILE) as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return {}
|
||
|
||
def save_state(state):
|
||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(STATE_FILE, "w") as f:
|
||
json.dump(state, f, indent=2)
|
||
|
||
def is_new_alert(alert_key, state):
|
||
"""检查同类告警是否在cooldown期内"""
|
||
now = datetime.now().timestamp()
|
||
last = state.get(alert_key, {}).get("last_alerted", 0)
|
||
return (now - last) > COOLDOWN_HOURS * 3600
|
||
|
||
def main():
|
||
now_str = datetime.now().strftime("%H:%M")
|
||
jobs = scan_jobs_files()
|
||
state = load_state()
|
||
alerts = []
|
||
|
||
# ── 检查1:last_status=failed ──
|
||
failed_jobs = [j for j in jobs if j["status"] == "failed" and j["enabled"]]
|
||
if failed_jobs:
|
||
for j in failed_jobs:
|
||
tag = "no_agent" if j["no_agent"] else "LLM"
|
||
key = f"failed_{j['id']}"
|
||
if is_new_alert(key, state):
|
||
alerts.append(f"❌ {j['name']}({j['id'][:8]}) [{tag}] {j['status']} @ {j['last_run'][:19]}")
|
||
state[key] = {"last_alerted": datetime.now().timestamp()}
|
||
|
||
# ── 检查2:关键job空闲超时 ──
|
||
stale_jobs = check_idle(jobs)
|
||
if stale_jobs:
|
||
for j, reason in stale_jobs:
|
||
key = f"stale_{j['id']}"
|
||
if is_new_alert(key, state):
|
||
alerts.append(f"⏰ {j['name']}({j['id'][:8]}) {reason}")
|
||
state[key] = {"last_alerted": datetime.now().timestamp()}
|
||
|
||
# ── 检查3:XMPP bridge是否在线(TCP端口检测,不发消息到Dad) ─
|
||
bridge_key = "bridge_down"
|
||
try:
|
||
import socket
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.settimeout(3)
|
||
result = sock.connect_ex(("127.0.0.1", 5805))
|
||
sock.close()
|
||
bridge_ok = (result == 0)
|
||
if not bridge_ok and is_new_alert(bridge_key, state):
|
||
alerts.append("🔴 XMPP bridge(5805)端口无响应")
|
||
state[bridge_key] = {"last_alerted": datetime.now().timestamp()}
|
||
except Exception as e:
|
||
if is_new_alert(bridge_key, state):
|
||
alerts.append(f"🔴 XMPP bridge检测失败: {e}")
|
||
state[bridge_key] = {"last_alerted": datetime.now().timestamp()}
|
||
|
||
# ── 检查4:gateway LLM推理是否正常(发真实请求测响应时间) ─
|
||
gateway_key = "gateway_down"
|
||
try:
|
||
import urllib.request, json, time
|
||
payload = json.dumps({
|
||
"model": "position-analyst",
|
||
"messages": [{"role": "user", "content": "ok"}],
|
||
"max_tokens": 5
|
||
}).encode()
|
||
req = urllib.request.Request("http://127.0.0.1:8643/v1/chat/completions",
|
||
data=payload,
|
||
headers={"Authorization": "Bearer hermes123", "Content-Type": "application/json"})
|
||
t0 = time.time()
|
||
resp = urllib.request.urlopen(req, timeout=30)
|
||
elapsed = time.time() - t0
|
||
data = json.loads(resp.read())
|
||
usage = data.get("usage", {})
|
||
tok = usage.get("total_tokens", 0)
|
||
if elapsed > 25 and is_new_alert(gateway_key, state):
|
||
alerts.append(f"🔴 gateway响应慢: {elapsed:.0f}s ({tok}tok)")
|
||
state[gateway_key] = {"last_alerted": datetime.now().timestamp()}
|
||
except Exception as e:
|
||
if is_new_alert(gateway_key, state):
|
||
alerts.append(f"🔴 gateway推理失败: {e}")
|
||
state[gateway_key] = {"last_alerted": datetime.now().timestamp()}
|
||
|
||
# ── 检查5:price_monitor数据新鲜度(直接查DB,不依赖job记录)─
|
||
price_key = "price_stale"
|
||
try:
|
||
import sqlite3
|
||
conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
|
||
lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0]
|
||
conn.close()
|
||
if lp:
|
||
lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp
|
||
if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None:
|
||
mins = (datetime.now() - lp_dt).total_seconds() / 60
|
||
if mins > 15 and is_new_alert(price_key, state):
|
||
alerts.append(f"🔴 price_monitor {mins:.0f}分未更新数据")
|
||
state[price_key] = {"last_alerted": datetime.now().timestamp()}
|
||
except Exception as e:
|
||
if is_new_alert(price_key, state):
|
||
alerts.append(f"🔴 price_monitor查询失败: {e}")
|
||
state[price_key] = {"last_alerted": datetime.now().timestamp()}
|
||
|
||
# ── 本脚本自检:检查自己是否在job列表里且有正常last_run ──
|
||
my_name = "全局cron健康监控"
|
||
myself = [j for j in jobs if my_name in j["name"]]
|
||
if myself:
|
||
m = myself[0]
|
||
idle = get_idle_minutes(m["last_run"])
|
||
if idle is None or idle > 30:
|
||
# 自己异常但可能还在跑?记录但不推(否则死循环)
|
||
print(f"[SELF_CHECK] 自身last_run={m.get('last_run','?')} {idle:.0f}分前", file=sys.stderr)
|
||
|
||
save_state(state)
|
||
|
||
if not alerts:
|
||
print("[SILENT]")
|
||
return
|
||
|
||
# 推告警
|
||
text = f"🔴 {len(alerts)}条健康告警 ({now_str}):\n" + "\n".join(alerts)
|
||
print(text, file=sys.stderr)
|
||
xmpp_push(text)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|