Files
ocr_data/tools/fleet_monitor.py
2026-09-28 16:09:31 +08:00

317 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""全队健康度监控: 汇总所有机器最新一小时的处理摘要, 跑异常检测, 产出报告。
配合 run_worker.py 里的 job_cos.run_hourly_digest_loop() 后台线程(每台机器每小时自动上传
一份小摘要到 COS)使用 —— 本脚本负责把这些小摘要拉回来汇总判断。
用法(项目根目录运行, 建议 crontab 每小时跑一次,比如 5分/10分 触发一次都行,脚本自己会
算准要看哪个整点):
python tools/fleet_monitor.py # 单小时报告 + 异常检测(原有行为)
python tools/fleet_monitor.py --history 24 # 全队历史趋势(最近24个小时, 不跑异常检测)
python tools/fleet_monitor.py --history # 不带数字 = 默认 24 小时
设计文档: docs/superpowers/specs/2026-07-08-fleet-monitoring-design.md
不接外部通知渠道(企业微信/钉钉/飞书webhook 是后续可插拔扩展点, 本次只落地本地+COS报告文件)。
"""
import os
import sys
import unicodedata
from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tool import job_cos # noqa: E402
# ---- 异常检测阈值(先写死成常量, 要调直接改这里) ----
OFFLINE_HOURS = 2 # 机器掉线: 最新摘要距目标小时 >= 这么多小时
SUCCESS_RATE_MIN = 0.5 # 单小时成功率骤降阈值
SUCCESS_RATE_MIN_SAMPLE = 10 # 样本太少(成功+错误 < 这个数)不判断成功率, 避免噪音
MACHINE_DROP_RATIO = 0.3 # 单机吞吐骤降: 本小时产出 < 自己24h均值的这个比例
FLEET_DROP_RATIO = 0.5 # 全队吞吐骤降: 全队本小时产出 < 全队24h均值的这个比例
BASELINE_HOURS = 24 # 基线取多少小时的历史均值(不含目标小时本身)
KNOWN_MACHINES_LOOKBACK = 48 # "这台机器算不算存在"往回看多少小时
REPORT_DIR = "./monitor_reports"
DEFAULT_HISTORY_HOURS = 24 # --history 不带数字时默认看多少小时
def _dw(s):
"""字符串显示宽度(中文/全角算2), 用于历史趋势表格对齐。"""
return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in str(s))
def _padl(s, w):
"""按显示宽度左对齐(文字在前, 空格补在后面) —— 跟 hourly_stats.py 的同名函数保持一致约定。"""
s = str(s)
return s + " " * max(0, w - _dw(s))
def _padr(s, w):
"""按显示宽度右对齐(空格补在前面, 文字贴右边)。"""
return " " * max(0, w - _dw(s)) + str(s)
def target_hour(now=None) -> datetime:
"""确定要汇总的目标整点小时: 最新一个已经过完的整点; 如果当前分钟数<5(刚跨过整点没多久,
部分机器可能还没上传完), 再往回退一个小时留缓冲。"""
now = now or datetime.now()
completed = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
if now.minute < 5:
completed -= timedelta(hours=1)
return completed
def fetch_range_digests(end_hour_dt: datetime, hours_back: int) -> dict:
"""拉取 [end_hour_dt - hours_back, end_hour_dt] 闭区间(按小时)所有机器的摘要,
返回 {hour_key(YYYYMMDDHH): {machine_id: digest_dict} | None}。
区分两种"没数据": 该小时【列COS目录本身失败】(比如COS暂时不可达)记为 None——这是需要
main() 判断是否整体报错退出的信号; 该小时【列成功但目录是空的】(单纯没人上报)记为 {}——
这是正常情况, 不代表故障。单台机器的摘要【下载】失败只跳过该机器、打印警告, 不影响这个
小时其它机器的数据, 也不算列目录失败。"""
result = {}
for i in range(hours_back + 1):
hdt = end_hour_dt - timedelta(hours=i)
hkey = hdt.strftime("%Y%m%d%H")
try:
files = job_cos.list_hourly_digest_files_for_hour(hdt)
except Exception as e:
print(f"[WARN] 列出 {hkey} 的摘要文件失败: {e}")
result[hkey] = None
continue
machines = {}
for machine_id, key in files:
try:
machines[machine_id] = job_cos.download_hourly_digest(key)
except Exception as e:
print(f"[WARN] 下载 {machine_id} 在 {hkey} 的摘要失败, 跳过: {e}")
result[hkey] = machines
return result
def compute_baseline(range_digests: dict, target_hdt: datetime) -> dict:
"""算每台机器过去 BASELINE_HOURS 小时(不含目标小时本身)的 success_hours 均值。
只用真实拉到的小时数据平均(缺的小时/列目录失败的小时都不计入, 不当0算, 避免拖低均值),
没有任何数据的机器不出现在结果里。"""
sums = {}
counts = {}
for i in range(1, BASELINE_HOURS + 1):
hkey = (target_hdt - timedelta(hours=i)).strftime("%Y%m%d%H")
for machine_id, digest in (range_digests.get(hkey) or {}).items():
sums[machine_id] = sums.get(machine_id, 0.0) + digest.get("success_hours", 0.0)
counts[machine_id] = counts.get(machine_id, 0) + 1
return {m: sums[m] / counts[m] for m in sums}
def known_machines_and_latest_hour(range_digests: dict) -> dict:
"""扫全部拉到的小时, 返回 {machine_id: 该机器出现过的最新 hour_key}。列目录失败(None)的
小时直接跳过, 不当"没有任何机器"处理(避免把偶发的单小时COS故障误判成全队一起掉线)。"""
latest = {}
for hkey, machines in range_digests.items():
if machines is None:
continue
for machine_id in machines:
if machine_id not in latest or hkey > latest[machine_id]:
latest[machine_id] = hkey
return latest
def aggregate_history(range_digests: dict, hours_back: int, end_hdt: datetime) -> list:
"""把 fetch_range_digests() 拉到的多小时摘要,按小时汇总成全队合计一行,按时间正序(从早到晚)
返回 [(hour_key, machine_count, count, success, no_subtitle, error, success_hours, ok), ...]。
ok=False 表示这个小时列COS目录本身失败(range_digests[hkey] is None), 其余字段填0/0.0;
ok=True 且 machine_count=0 是"列目录成功但没人上报"的正常空窗口(比如还没到那个整点),
跟 ok=False(拉取本身失败)是两码事,不能混在一起看成同一种"没数据"。"""
rows = []
for i in range(hours_back, -1, -1):
hdt = end_hdt - timedelta(hours=i)
hkey = hdt.strftime("%Y%m%d%H")
machines = range_digests.get(hkey)
if machines is None:
rows.append((hkey, 0, 0, 0, 0, 0, 0.0, False))
continue
count = sum(d.get("count", 0) for d in machines.values())
success = sum(d.get("success", 0) for d in machines.values())
no_sub = sum(d.get("no_subtitle", 0) for d in machines.values())
error = sum(d.get("error", 0) for d in machines.values())
succ_hours = sum(d.get("success_hours", 0.0) for d in machines.values())
rows.append((hkey, len(machines), count, success, no_sub, error, succ_hours, True))
return rows
def print_history(hours_back: int):
"""打印全队历史多个小时的汇总趋势(拉COS各机器已上传的小时摘要, 不依赖本机 数据.jsonl,
只要各机器在跑带小时摘要上传功能的新版 run_worker.py 就能看到)。跟 tools/hourly_stats.py
的单机版本是同一个"按process_start所在小时分桶计数"的逻辑, 只是数据源从本机 数据.jsonl
换成了COS上各机器上传的小时摘要, 且是全队加总, 不是单机。"""
thdt = target_hour()
print(f"目标小时: {thdt.strftime('%Y-%m-%d %H:00')} (往前拉 {hours_back} 小时, 共 {hours_back+1} 个小时)")
range_digests = fetch_range_digests(thdt, hours_back)
rows = aggregate_history(range_digests, hours_back, thdt)
# 每列宽度都比内容本身多留至少2个显示宽度的间隔, 避免"小时"这种定长字符串(如
# "2026-07-09 13:00"共16字符)紧贴着下一列数字、看起来像连在一起。
W = {"小时": 18, "机器数": 8, "视频数": 8, "成功": 6, "无字幕": 8, "错误": 6, "产出小时数": 12}
header = (_padl("小时", W["小时"]) + _padr("机器数", W["机器数"]) + _padr("视频数", W["视频数"]) +
_padr("成功", W["成功"]) + _padr("无字幕", W["无字幕"]) + _padr("错误", W["错误"]) +
_padr("产出小时数", W["产出小时数"]))
width = sum(W.values())
print("=" * width)
print(header)
print("-" * width)
tot = {"count": 0, "success": 0, "no_subtitle": 0, "error": 0, "succ_hours": 0.0}
fail_hours = []
for hkey, mcount, count, success, no_sub, error, succ_hours, ok in rows:
disp_hour = f"{hkey[:4]}-{hkey[4:6]}-{hkey[6:8]} {hkey[8:10]}:00"
if not ok:
print(_padl(disp_hour, W["小时"]) + _padr("(拉取失败)", width - W["小时"]))
fail_hours.append(hkey)
continue
print(_padl(disp_hour, W["小时"]) + _padr(mcount, W["机器数"]) + _padr(count, W["视频数"]) +
_padr(success, W["成功"]) + _padr(no_sub, W["无字幕"]) + _padr(error, W["错误"]) +
_padr(f"{succ_hours:.1f}", W["产出小时数"]))
tot["count"] += count
tot["success"] += success
tot["no_subtitle"] += no_sub
tot["error"] += error
tot["succ_hours"] += succ_hours
print("-" * width)
nh = len(rows) - len(fail_hours)
if nh > 0:
print(_padl("合计/时均", W["小时"]) + _padr(tot["count"], W["视频数"] + W["机器数"]) +
_padr(tot["success"], W["成功"]) + _padr(tot["no_subtitle"], W["无字幕"]) +
_padr(tot["error"], W["错误"]) + _padr(f"{tot['succ_hours']:.1f}", W["产出小时数"]))
print(f"\n共 {nh} 个有效小时 | 时均处理 {tot['count']/nh:.0f} 条 | "
f"时均产出 {tot['succ_hours']/nh:.1f} 小时视频内容")
if fail_hours:
print(f"\n({len(fail_hours)} 个小时COS拉取失败, 已跳过: {', '.join(fail_hours)})")
def detect_anomalies(target_hdt: datetime, target_digests: dict,
baseline: dict, latest_hour: dict) -> list:
"""跑4条异常检测规则, 返回 [(level, message), ...],level ∈ {"严重","警告"}。"""
anomalies = []
# ① 机器掉线: 最新一次上报距目标小时 >= OFFLINE_HOURS
for machine_id, last_hkey in latest_hour.items():
last_hdt = datetime.strptime(last_hkey, "%Y%m%d%H")
gap_hours = (target_hdt - last_hdt).total_seconds() / 3600
if gap_hours >= OFFLINE_HOURS:
anomalies.append(("严重", f"机器 {machine_id} 已连续{gap_hours:.0f}小时无上报数据,可能停机"))
# ② 单小时成功率骤降
for machine_id, digest in target_digests.items():
succ, err = digest.get("success", 0), digest.get("error", 0)
sample = succ + err
if sample >= SUCCESS_RATE_MIN_SAMPLE:
rate = succ / sample
if rate < SUCCESS_RATE_MIN:
anomalies.append(("警告", f"机器 {machine_id} 本小时成功率仅{rate*100:.0f}%({succ}/{sample})"))
# ③ 单机吞吐骤降
for machine_id, digest in target_digests.items():
base = baseline.get(machine_id)
if base and base > 0:
cur = digest.get("success_hours", 0.0)
if cur < base * MACHINE_DROP_RATIO:
anomalies.append(("警告", f"机器 {machine_id} 本小时产出{cur:.1f}小时,远低于24h均值{base:.1f}小时"))
# ④ 全队吞吐骤降
fleet_cur = sum(d.get("success_hours", 0.0) for d in target_digests.values())
fleet_base = sum(baseline.values())
if fleet_base > 0 and fleet_cur < fleet_base * FLEET_DROP_RATIO:
anomalies.append(("严重", f"全队本小时总产出{fleet_cur:.1f}小时,远低于24h均值{fleet_base:.1f}小时,可能是全局性问题(比如OCR端点故障)"))
return anomalies
def render_report(target_hdt: datetime, target_digests: dict,
baseline: dict, anomalies: list) -> str:
"""生成 Markdown 报告文本。"""
lines = [f"# 全队监控报告 {target_hdt.strftime('%Y-%m-%d %H:00')}", ""]
lines.append("## 异常告警")
if anomalies:
for level, msg in anomalies:
icon = "🔴" if level == "严重" else "🟡"
lines.append(f"- {icon} [{level}] {msg}")
else:
lines.append("✅ 本小时一切正常")
lines.append("")
lines.append("## 本小时明细")
lines.append("| 机器 | 视频数 | 成功 | 无字幕 | 错误 | 产出小时数 |")
lines.append("|---|---|---|---|---|---|")
for machine_id in sorted(target_digests):
d = target_digests[machine_id]
lines.append(f"| {machine_id} | {d.get('count',0)} | {d.get('success',0)} | "
f"{d.get('no_subtitle',0)} | {d.get('error',0)} | {d.get('success_hours',0):.1f} |")
lines.append("")
lines.append("## 过去24小时全队趋势")
fleet_cur = sum(d.get("success_hours", 0.0) for d in target_digests.values())
fleet_base = sum(baseline.values())
lines.append(f"全队本小时产出小时数总和:{fleet_cur:.1f},24小时均值:{fleet_base:.1f}")
return "\n".join(lines) + "\n"
def _parse_history_arg(argv):
"""解析 --history [N] —— 带上就返回要看多少小时(不带数字用默认值), 没带这个参数返回 None
(跑原来的单小时报告模式)。"""
if "--history" not in argv:
return None
i = argv.index("--history")
if i + 1 < len(argv):
try:
return int(argv[i + 1])
except ValueError:
pass
return DEFAULT_HISTORY_HOURS
def main():
hours_back = _parse_history_arg(sys.argv[1:])
if hours_back is not None:
print_history(hours_back)
return
thdt = target_hour()
print(f"目标小时: {thdt.strftime('%Y-%m-%d %H:00')}")
range_digests = fetch_range_digests(thdt, KNOWN_MACHINES_LOOKBACK)
target_hkey = thdt.strftime("%Y%m%d%H")
target_slot = range_digests.get(target_hkey)
if target_slot is None:
# 目标小时本身列目录都失败了(COS大概率整体不可达) -> 按设计文档"错误处理"要求直接报错退出,
# 不生成一份看起来正常、实际数据全空的假报告掩盖问题。
print(f"[FAIL] 目标小时 {target_hkey} 的摘要目录列取失败(COS可能不可达), 中止, 不生成报告")
sys.exit(1)
target_digests = target_slot
if not target_digests:
print(f"[WARN] 目标小时 {target_hkey} 没有拉到任何机器的摘要, 可能还没到点(正常空目录, 不是故障)")
baseline = compute_baseline(range_digests, thdt)
latest_hour = known_machines_and_latest_hour(range_digests)
anomalies = detect_anomalies(thdt, target_digests, baseline, latest_hour)
report = render_report(thdt, target_digests, baseline, anomalies)
os.makedirs(REPORT_DIR, exist_ok=True)
local_path = os.path.join(REPORT_DIR, f"{thdt.strftime('%Y%m%d_%H')}.md")
with open(local_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"[OK] 报告已写入 {local_path}")
try:
dst = job_cos.upload_monitor_report(local_path, thdt)
if dst:
print(f"[OK] 报告已上传 -> {dst}")
except Exception as e:
print(f"[WARN] 报告上传COS失败(本地文件已保留): {e}")
if anomalies:
print(f"\n共检测到 {len(anomalies)} 条异常,详见报告。")
else:
print("\n本小时一切正常。")
if __name__ == "__main__":
main()