0d1deb5fe1
Co-Authored-By: Claude Code <noreply@anthropic.com>
452 lines
19 KiB
Python
452 lines
19 KiB
Python
"""sanguo-data-monitor — VPS 数据面监控班次(spec §20.15.3, 23:50 evening / 08:05 morning).
|
|
|
|
只读体检不修数据; 告警写 data/alerts.db(单写者), 前端/晨检消费 health JSON。
|
|
日志源=akshare_static_download 自身 FileHandler(data/static/logs/, utf-8 确定性)
|
|
——migration_logs 是 PS 重定向副本身受编码坑, 不作解析源。
|
|
退出码: 0=绿/黄, 3=有红(wrapper 恒转 0, 红态由 alerts/health 承载)。
|
|
"""
|
|
import argparse
|
|
import datetime as dt
|
|
import glob
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_REPO = os.path.dirname(os.path.dirname(_HERE))
|
|
for _p in (_HERE, _REPO):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
from sanguo_data import alerts_store as store # noqa: E402
|
|
import monitor_checks as chk # noqa: E402
|
|
import monitor_registry as reg # noqa: E402
|
|
|
|
_TODAY_OVERRIDE = None # 测试注入口
|
|
_HEALTH_DIR = "monitor"
|
|
_LOG_GLOB = os.path.join("static", "logs", "akshare_static_*.log")
|
|
_LOG_BACK_DAYS = {"evening": 1, "morning": 8} # morning 覆盖周六 03:00 周班
|
|
_OK_LAST_RESULTS = {"0", "267009", "267011"} # 成功/运行中/未跑过
|
|
|
|
|
|
def _now():
|
|
if _TODAY_OVERRIDE is not None:
|
|
return dt.datetime.combine(_TODAY_OVERRIDE, dt.time(23, 50))
|
|
return dt.datetime.now()
|
|
|
|
|
|
def _event(key, severity, title, detail, evidence, host="vps", source="data",
|
|
today=None):
|
|
return {"alert_id": f"{key}-{today}", "check_key": key, "source": source,
|
|
"host": host, "severity": severity, "title": title,
|
|
"detail": detail, "evidence": evidence}
|
|
|
|
|
|
def _ro_conn(path):
|
|
import sqlite3
|
|
return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=10)
|
|
|
|
|
|
# ---------- 结果层: 逐注册表项(log_only 归过程层, 不入此处) ----------
|
|
|
|
def check_entry(e, ctx):
|
|
root = os.path.join(ctx["data_root"], e.get("root", "static"))
|
|
name, kind = e["name"], e["kind"]
|
|
ev = {"kind": kind}
|
|
|
|
def done(check_kind, status, expected, actual, evidence=None):
|
|
return {"key": f"data-{name}-{check_kind}", "status": status,
|
|
"expected": expected, "actual": actual,
|
|
"evidence": evidence or ev}
|
|
|
|
if kind == "per_date":
|
|
exp = chk.expected_daily(ctx["days"], ctx["now"], e.get("ready", "20:30"),
|
|
e.get("lag", 0))
|
|
got = chk.latest_stem(os.path.join(root, e["subdir"]), e["pattern"])
|
|
latest = got[0].split("_")[0] if got else None
|
|
if exp is None: # 无日历(db 不可达): 只做存在性弱检查
|
|
return done("stale", "green" if got else "yellow", "n/a", latest)
|
|
if latest != exp:
|
|
return done("stale", e.get("severity", "red"), exp, latest,
|
|
{"producer": e.get("producer")})
|
|
rows = chk.parquet_rows(got[1]) if got else None
|
|
lo, hi = e["row_band"]
|
|
if rows is None or not (lo <= rows <= hi):
|
|
return done("rows", "yellow", f"{lo}~{hi}", rows)
|
|
return done("stale", "green", exp, latest)
|
|
|
|
if kind == "weekly_friday":
|
|
exp = chk.expected_weekly(ctx["days"], ctx["now"], e.get("ready", "06:00"))
|
|
got = chk.latest_stem(os.path.join(root, e["subdir"]), e["pattern"])
|
|
latest = got[0].split("_")[0] if got else None
|
|
if exp is None:
|
|
return done("stale", "green" if got else "yellow", "n/a", latest)
|
|
if latest != exp:
|
|
return done("stale", e.get("severity", "red"), exp, latest)
|
|
lo, hi = e["row_band"]
|
|
rows = chk.parquet_rows(got[1]) if got else None
|
|
if rows is None or not (lo <= rows <= hi):
|
|
return done("rows", "yellow", f"{lo}~{hi}", rows)
|
|
return done("stale", "green", exp, latest)
|
|
|
|
if kind == "month_bucket":
|
|
got = chk.latest_stem(os.path.join(root, e["subdir"]), e["pattern"])
|
|
if not got:
|
|
return done("stale", e.get("severity", "yellow"), "any", None)
|
|
ym = got[0].split("_")[0]
|
|
try:
|
|
d0 = dt.date(int(ym[:4]), int(ym[4:6]), 1)
|
|
except ValueError:
|
|
return done("stale", "yellow", "YYYYMM", ym)
|
|
age = (ctx["now"].date() - d0).days
|
|
if age > e["max_age_days"]:
|
|
return done("stale", "yellow", f"<={e['max_age_days']}d", f"{age}d")
|
|
lo, hi = e["row_band"]
|
|
rows = chk.parquet_rows(got[1])
|
|
if rows is not None and not (lo <= rows <= hi):
|
|
return done("rows", "yellow", f"{lo}~{hi}", rows)
|
|
return done("stale", "green", ym, ym)
|
|
|
|
if kind == "year_file":
|
|
year = str(ctx["now"].year)
|
|
p = os.path.join(root, e["subdir"], f"{year}.parquet")
|
|
if not os.path.exists(p):
|
|
return done("stale", e.get("severity", "yellow"), year, None)
|
|
mtime = dt.date.fromtimestamp(os.path.getmtime(p)).isoformat()
|
|
exp = chk.expected_daily(ctx["days"], ctx["now"], e.get("ready", "22:30"))
|
|
want = exp.replace("-", "") if exp else None
|
|
got_d = mtime.replace("-", "")
|
|
if want and got_d < want:
|
|
return done("stale", e.get("severity", "yellow"), want, got_d)
|
|
return done("stale", "green", want, got_d)
|
|
|
|
if kind == "dir_mtime":
|
|
d = os.path.join(root, e["subdir"])
|
|
if not os.path.isdir(d):
|
|
return done("stale", e.get("severity", "yellow"), "exists", None)
|
|
age_h = (ctx["now"] - dt.datetime.fromtimestamp(
|
|
chk.dir_latest_mtime(d))).total_seconds() / 3600
|
|
if age_h > e["max_age_days"] * 24:
|
|
return done("stale", "yellow", f"<={e['max_age_days']}d",
|
|
f"{age_h:.0f}h")
|
|
return done("stale", "green", f"<={e['max_age_days']}d", f"{age_h:.0f}h")
|
|
|
|
if kind == "db_sample":
|
|
exp = chk.expected_daily(ctx["days"], ctx["now"], e.get("ready", "22:30"),
|
|
e.get("lag", 0))
|
|
worst = None
|
|
for sym, exc in e["samples"]:
|
|
try:
|
|
conn = _ro_conn(os.path.join(ctx["data_root"], "quant_trading.db"))
|
|
row = conn.execute(
|
|
"SELECT MAX(datetime) FROM dbbardata WHERE symbol=? AND "
|
|
"exchange=? AND interval='d'", (sym, exc)).fetchone()
|
|
conn.close()
|
|
got_d = (row[0] or "").replace("-", "")
|
|
except Exception: # noqa: BLE001 只读探测, 库坏=黄红由调用方判
|
|
got_d = None
|
|
if exp and got_d != exp and worst is None:
|
|
worst = {"symbol": f"{sym}.{exc}", "expected": exp, "actual": got_d}
|
|
if exp and worst:
|
|
return done("stale", e.get("severity", "red"), exp,
|
|
worst["actual"], worst)
|
|
return done("stale", "green", exp, "ok")
|
|
|
|
if kind == "vintage_json":
|
|
p = os.path.join(root, e["subdir"])
|
|
try:
|
|
with open(p, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, ValueError):
|
|
return done("verdict", "yellow", "readable", None)
|
|
checked = data.get("checked_at", "")
|
|
verdict = data.get("status", data.get("overall", "unknown"))
|
|
try:
|
|
stale = not checked or (ctx["now"] - dt.datetime.fromisoformat(
|
|
checked)).total_seconds() > 26 * 3600
|
|
except ValueError:
|
|
stale = True
|
|
if stale:
|
|
return done("verdict", "yellow", "<=26h", checked)
|
|
if verdict in ("ok", "green", 0):
|
|
return done("verdict", "green", "ok", verdict)
|
|
return done("verdict", "yellow", "ok", verdict)
|
|
|
|
if kind == "static_exists":
|
|
d = os.path.join(root, e["subdir"])
|
|
exists = os.path.isdir(d) and any(
|
|
f.endswith(".parquet") for f in os.listdir(d))
|
|
return done("exists", "green" if exists else e.get("severity", "yellow"),
|
|
"exists", exists)
|
|
|
|
if kind == "disk":
|
|
r = chk.check_disk(ctx["data_root"])
|
|
return {"key": "infra-disk-data", "status": r["status"],
|
|
"expected": f">={e['warn_gb']}G", "actual": f"{r['free_gb']}G",
|
|
"evidence": r}
|
|
|
|
return done("unknown", "yellow", "n/a", None)
|
|
|
|
|
|
# ---------- 过程层: 日志统计行+签名分诊 ----------
|
|
|
|
def collect_log_layer(data_root, now, back_days):
|
|
logs = sorted(glob.glob(os.path.join(data_root, _LOG_GLOB)))
|
|
keep = [p for p in logs
|
|
if (now - dt.datetime.fromtimestamp(os.path.getmtime(p))).days
|
|
<= back_days]
|
|
stats, sigs = {}, []
|
|
for p in keep:
|
|
try:
|
|
with open(p, encoding="utf-8", errors="replace") as f:
|
|
text = f.read()
|
|
except OSError:
|
|
continue
|
|
for t, s in chk.parse_stat_lines(text).items():
|
|
prev = stats.get(t)
|
|
stats[t] = s if prev is None or s.get("rows", 0) >= prev.get("rows", 0) \
|
|
else prev
|
|
sigs.extend(chk.scan_signatures(text))
|
|
return stats, sigs
|
|
|
|
|
|
def log_layer_results(stats, sigs, today):
|
|
"""统计行 failed>0 → 按 sig 分级告警; 断路器独立键; 其余 ok → passed."""
|
|
results, passed = [], []
|
|
worst = max((s["severity"] for s in sigs), default=None)
|
|
breaker = any(s["sig"] == "breaker" for s in sigs)
|
|
for t, s in sorted(stats.items()):
|
|
if s.get("failed", 0) > 0:
|
|
ev = {"stats": s, "signatures": sigs}
|
|
sev = "red" if (worst == "red" or breaker) else "yellow"
|
|
results.append(_event(f"data-{t}-failed", sev,
|
|
f"{t} 类型级 failed={s['failed']}",
|
|
f"rows={s.get('rows')}", ev, today=today))
|
|
else:
|
|
passed.append(f"data-{t}-failed")
|
|
if breaker:
|
|
results.append(_event("data-akshare-breaker", "red",
|
|
"断路器触发(熔断连累: 后续类型整批跳过)",
|
|
"exit code 2 会话", {"signatures": sigs}, today=today))
|
|
else:
|
|
passed.append("data-akshare-breaker")
|
|
return results, passed
|
|
|
|
|
|
def log_only_results(entries, stats, sigs, today):
|
|
"""valuation 等 log_only 项: 统计行存在且 failed=0(周班项平日无行属正常)."""
|
|
results, passed = [], []
|
|
red_sig = any(s["severity"] == "red" for s in sigs)
|
|
for e in entries:
|
|
key = f"data-{e['name']}-stat"
|
|
s = stats.get(e["name"])
|
|
if s is None:
|
|
if e["log_cadence"] == "weekly":
|
|
continue
|
|
results.append(_event(key, "yellow", f"{e['name']} 无当日统计行",
|
|
"任务未跑或日志缺失", {}, today=today))
|
|
elif s.get("failed", 0) > 0:
|
|
results.append(_event(key, "red" if red_sig else "yellow",
|
|
f"{e['name']} 统计 failed={s['failed']}",
|
|
f"rows={s.get('rows')}",
|
|
{"stats": s}, today=today))
|
|
else:
|
|
passed.append(key)
|
|
return results, passed
|
|
|
|
|
|
# ---------- 调度层: schtask 在岗(spec §20.15.1 表后注; retry 一次性死案即此类) ----------
|
|
|
|
def schtask_results(snapshot, now):
|
|
results = []
|
|
today = now.date().isoformat().replace("-", "")
|
|
limits = {"daily": 26 if now.weekday() < 5 else 80, "weekday": 26,
|
|
"weekly": 8 * 24, "monthly": 40 * 24, "always": None}
|
|
for name, cadence in reg.SCHTASKS:
|
|
key = f"infra-task-{name}"
|
|
info = snapshot.get(name)
|
|
if info is None:
|
|
results.append(_event(key, "red", f"schtask {name} 缺失",
|
|
"schtasks /query 无此任务", {}, source="infra",
|
|
today=today))
|
|
continue
|
|
next_run = info.get("next_run", "")
|
|
last_run = info.get("last_run", "")
|
|
last_result = info.get("last_result", "")
|
|
bad = []
|
|
if "N/A" in next_run and cadence != "always":
|
|
bad.append("next_run=N/A(一次性死)")
|
|
if last_result and last_result not in _OK_LAST_RESULTS:
|
|
bad.append(f"last_result={last_result}")
|
|
limit_h = limits[cadence]
|
|
if cadence == "weekday" and now.weekday() >= 5:
|
|
limit_h = None
|
|
age_h = _parse_schtask_age(last_run, now)
|
|
if limit_h is not None and age_h is not None and age_h > limit_h:
|
|
bad.append(f"last_run {age_h:.0f}h 前(限 {limit_h}h)")
|
|
if bad:
|
|
results.append(_event(key, "red" if "N/A" in next_run else "yellow",
|
|
f"schtask {name} 异常", "; ".join(bad),
|
|
{"next_run": next_run, "last_run": last_run,
|
|
"last_result": last_result}, source="infra",
|
|
today=today))
|
|
return results
|
|
|
|
|
|
def _parse_schtask_age(last_run, now):
|
|
if not last_run or "N/A" in last_run:
|
|
return None
|
|
for fmt in ("%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
|
try:
|
|
t = dt.datetime.strptime(last_run.strip(), fmt)
|
|
return (now - t).total_seconds() / 3600
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
# ---------- 衍生层: NAS inbox 摄取(单写者纪律: NAS 不写远端 sqlite) ----------
|
|
|
|
def ingest_inbox(conn, data_root, now):
|
|
inbox = os.path.join(data_root, _HEALTH_DIR, "inbox")
|
|
latest_ts = None
|
|
if not os.path.isdir(inbox):
|
|
return latest_ts
|
|
processed = os.path.join(inbox, "processed")
|
|
os.makedirs(processed, exist_ok=True)
|
|
for p in sorted(glob.glob(os.path.join(inbox, "*.json"))):
|
|
try:
|
|
with open(p, encoding="utf-8") as f:
|
|
payload = json.load(f)
|
|
for ev in payload.get("events", []):
|
|
store.upsert_alert(conn, ev)
|
|
ts = payload.get("generated_at")
|
|
if ts:
|
|
latest_ts = max(latest_ts or "", ts)
|
|
os.replace(p, os.path.join(processed, os.path.basename(p)))
|
|
except (OSError, ValueError) as exc:
|
|
print(f"[inbox] skip bad file {p}: {exc}")
|
|
return latest_ts
|
|
|
|
|
|
# ---------- 主流程 ----------
|
|
|
|
def write_health(data_root, health):
|
|
d = os.path.join(data_root, _HEALTH_DIR)
|
|
os.makedirs(d, exist_ok=True)
|
|
for fn in (f"health_{health['date']}_{health['shift']}.json",
|
|
"health_latest.json"):
|
|
tmp = os.path.join(d, fn + ".tmp")
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump(health, f, ensure_ascii=False, indent=1)
|
|
os.replace(tmp, os.path.join(d, fn))
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--shift", choices=["evening", "morning"], default="evening")
|
|
ap.add_argument("--data-root", default="data")
|
|
ap.add_argument("--db", default=store.default_alerts_db_path())
|
|
ap.add_argument("--skip-tasks", action="store_true")
|
|
ap.add_argument("--skip-inbox", action="store_true")
|
|
args = ap.parse_args(argv)
|
|
|
|
now = _now()
|
|
today = now.date().isoformat().replace("-", "")
|
|
db_path = args.db
|
|
if args.db == "data/alerts.db" and args.data_root != "data":
|
|
db_path = os.path.join(args.data_root, "alerts.db")
|
|
|
|
days = chk.trading_days(
|
|
os.path.join(args.data_root, "quant_trading.db"), now.date())
|
|
if not days: # 日历降级: 近 50 日周一~五(节假日误报由带宽/黄级兜底)
|
|
d = now.date() - dt.timedelta(days=50)
|
|
while d <= now.date():
|
|
if d.weekday() < 5:
|
|
days.append(d.isoformat())
|
|
d += dt.timedelta(days=1)
|
|
ctx = {"data_root": args.data_root, "days": days, "now": now}
|
|
|
|
conn = store.connect(db_path)
|
|
checks, events, passed = [], [], []
|
|
|
|
for e in reg.REGISTRY:
|
|
if not e.get("enabled", True) or e["kind"] == "log_only":
|
|
continue # log_only 归过程层 log_only_results
|
|
r = check_entry(e, ctx)
|
|
checks.append(r)
|
|
if r["status"] == "green":
|
|
passed.append(r["key"])
|
|
else:
|
|
kindword = r["key"].rsplit("-", 1)[-1] # stale/rows/verdict/exists
|
|
events.append(_event(
|
|
r["key"], r["status"],
|
|
f"{e['name']} {kindword} 异常",
|
|
f"actual={r['actual']} expected={r['expected']}",
|
|
{"evidence": r.get("evidence", {}), "kind": e["kind"],
|
|
"producer": e.get("producer", "vps")},
|
|
source="infra" if r["key"].startswith("infra-") else "data",
|
|
today=today))
|
|
|
|
stats, sigs = collect_log_layer(args.data_root, now,
|
|
_LOG_BACK_DAYS[args.shift])
|
|
log_events, log_passed = log_layer_results(stats, sigs, today)
|
|
lo_events, lo_passed = log_only_results(
|
|
[e for e in reg.REGISTRY if e["kind"] == "log_only"
|
|
and e.get("enabled", True)], stats, sigs, today)
|
|
events += log_events + lo_events
|
|
passed += log_passed + lo_passed
|
|
|
|
if not args.skip_tasks and os.name == "nt":
|
|
out = subprocess.run(["schtasks", "/query", "/fo", "LIST", "/v"],
|
|
capture_output=True, text=True, errors="replace",
|
|
timeout=180).stdout
|
|
task_events = schtask_results(chk.parse_schtasks_list(out), now)
|
|
events += task_events
|
|
flagged = {ev["check_key"] for ev in task_events}
|
|
passed += [f"infra-task-{n}" for n, _ in reg.SCHTASKS
|
|
if f"infra-task-{n}" not in flagged]
|
|
|
|
nas_silent_key = "infra-nas-monitor-silent"
|
|
if not args.skip_inbox:
|
|
nas_ts = ingest_inbox(conn, args.data_root, now)
|
|
if nas_ts:
|
|
try:
|
|
age_h = (now - dt.datetime.fromisoformat(nas_ts)
|
|
).total_seconds() / 3600
|
|
except ValueError:
|
|
age_h = None
|
|
if age_h is not None and age_h > 30:
|
|
events.append(_event(nas_silent_key, "yellow",
|
|
"NAS 监控静默>30h", f"last={nas_ts}",
|
|
{"last_inbox_ts": nas_ts}, source="infra",
|
|
today=today))
|
|
else:
|
|
passed.append(nas_silent_key)
|
|
|
|
for ev in events:
|
|
store.upsert_alert(conn, ev)
|
|
store.resolve_passed(conn, passed)
|
|
store.purge_expired(conn)
|
|
conn.close()
|
|
|
|
counts = {s: sum(1 for c in checks if c["status"] == s)
|
|
for s in ("green", "yellow", "red")}
|
|
red_events = sum(1 for ev in events if ev["severity"] == "red")
|
|
yellow_events = sum(1 for ev in events if ev["severity"] == "yellow")
|
|
overall = "red" if (counts["red"] or red_events) else (
|
|
"yellow" if (counts["yellow"] or yellow_events) else "green")
|
|
health = {"date": now.date().isoformat(), "shift": args.shift, "host": "vps",
|
|
"generated_at": now.isoformat(timespec="seconds"),
|
|
"overall": overall, "counts": counts,
|
|
"alerts_emitted": len(events), "checks": checks}
|
|
write_health(args.data_root, health)
|
|
print(f"[data-monitor] {args.shift} overall={overall} checks={len(checks)}"
|
|
f" alerts={len(events)}")
|
|
return 3 if overall == "red" else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|