317 lines
16 KiB
Python
317 lines
16 KiB
Python
# -*- 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()
|