feat: XMPP observability — logger, monitor endpoints, clean dead xiaoguo refs

This commit is contained in:
hmo
2026-07-19 12:47:15 +08:00
parent fec2bc9106
commit b41bed7959
9 changed files with 442 additions and 34 deletions
+19 -1
View File
@@ -15,6 +15,12 @@ import sys
from datetime import datetime
from pathlib import Path
# XMPP 消息日志 hook
try:
from xmpp_logger import log_xmpp
except ImportError:
def log_xmpp(*a, **kw): pass
# 使用绝对路径,不受 profile 环境变量影响
REAL_HOME = Path("/home/hmo")
@@ -170,6 +176,8 @@ def extract_body(path):
def send(body):
from xml.sax.saxutils import escape
import time as _time
t0 = _time.time()
safe = escape(f"【知微】{body}")
stanza = (
f"<message from='{FROM}' to='{TO}' "
@@ -177,6 +185,7 @@ def send(body):
f"<body>{safe}</body></message>"
)
# 重试3次
last_err = None
for attempt in range(3):
try:
r = subprocess.run(
@@ -186,20 +195,29 @@ def send(body):
)
if r.stderr and "error" in r.stderr.lower():
print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr)
last_err = r.stderr.strip()[:200]
if attempt < 2:
continue
log_xmpp("out", FROM, TO, body, "error", last_err, int((_time.time()-t0)*1000))
return False
return r.returncode == 0
ok = r.returncode == 0
log_xmpp("out", FROM, TO, body, "ok" if ok else "error", None if ok else r.stderr, int((_time.time()-t0)*1000))
return ok
except subprocess.TimeoutExpired:
last_err = "timeout"
print(f"send timeout (attempt {attempt+1})", file=sys.stderr)
if attempt < 2:
continue
log_xmpp("out", FROM, TO, body, "timeout", last_err, int((_time.time()-t0)*1000))
return False
except Exception as e:
last_err = str(e)[:200]
print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr)
if attempt < 2:
continue
log_xmpp("out", FROM, TO, body, "error", last_err, int((_time.time()-t0)*1000))
return False
log_xmpp("out", FROM, TO, body, "error", last_err or "all retries failed", int((_time.time()-t0)*1000))
return False
+3 -1
View File
@@ -72,12 +72,14 @@ specs/{module}.json
| watchlist | `specs/watchlist.json` | 自选股管理 | ✅ |
| decisions | `specs/decisions.json` | 策略决策库 | ✅ |
| market | `specs/market.json` | 市场观察数据 | ✅ |
| signals | `specs/signals.json` | 信号 + 小果扫描 | ✅ |
| signals | `specs/signals.json` | 信号 + 全市场扫描 | ✅ |
| scanner | `specs/scanner.json` | 全市场选股机制 | ✅ |
| evaluation | `specs/evaluation.json` | 策略评估 | ✅ |
| prompts | `specs/prompts.json` | 提示词版本管理 | ✅ |
| reports | `specs/reports.json` | 分析报告管理 | ✅ |
| dashboard | `specs/dashboard.json` | Dashboard 自身 | ✅ |
| health | `specs/health.json` | 健康监控管线 | ✅ |
| xmpp_monitor | `specs/xmpp_monitor.json` | XMPP 通信可观测性 | ✅ |
| price_monitor | `specs/price_monitor.json` | 价格监控 cron | 📋 |
| strategy_lifecycle | `specs/strategy_lifecycle.json` | 策略生命周期 | 📋 |
+12 -1
View File
@@ -13,6 +13,13 @@ import sys, json, sqlite3, urllib.request, re, time
from pathlib import Path
from datetime import datetime
# XMPP 日志 hook
try:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from xmpp_logger import log_xmpp
except ImportError:
def log_xmpp(*a, **kw): pass
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json")
@@ -180,15 +187,19 @@ def scan_candidates():
f"评分{c['score']}/10 | {c['reason']}"
)
msg = "\n".join(lines)
# 推XMPP
# 推XMPP(通过知微 Bot HTTP 桥 :5805
import time as _time
t0 = _time.time()
try:
payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode()
req = urllib.request.Request("http://127.0.0.1:5805/", data=payload,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5)
print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True)
log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "ok", None, int((_time.time()-t0)*1000))
except Exception as e:
print(f" ⚠️ 推送失败: {e}", file=sys.stderr)
log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "error", str(e)[:200], int((_time.time()-t0)*1000))
if __name__ == "__main__":
scan_candidates()
+43
View File
@@ -1272,6 +1272,49 @@ def api_module_spec(module):
return jsonify({"error": f"Module '{module}' not found"}), 404
# ── XMPP 通信监控 API ─────────────────────────────────
@app.route("/api/xmpp/messages")
def api_xmpp_messages():
"""查询 XMPP 消息日志"""
since = request.args.get("since", "")
agent = request.args.get("agent", "")
status = request.args.get("status", "")
limit = int(request.args.get("limit", 50))
try:
from xmpp_logger import query
msgs = query(since=since or None, agent=agent or None, status=status or None, limit=limit)
return jsonify({"messages": msgs, "total": len(msgs)})
except ImportError:
return jsonify({"messages": [], "total": 0})
@app.route("/api/xmpp/health")
def api_xmpp_health():
"""XMPP 通道健康检查"""
try:
from xmpp_logger import health as xmpp_health
h = xmpp_health()
# 补充 ejabberd Docker 状态
import subprocess
r = subprocess.run(["docker", "ps", "--filter", "name=ejabberd", "--format", "{{.Status}}"],
capture_output=True, timeout=5, text=True)
h["ejabberd"] = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else "not_found"
return jsonify(h)
except ImportError:
return jsonify({"status": "no_logger", "last_message_age_sec": -1, "error_rate_1h": 0, "ejabberd": "unknown"})
@app.route("/api/xmpp/stats")
def api_xmpp_stats():
"""XMPP 消息统计"""
try:
from xmpp_logger import stats as xmpp_stats
return jsonify(xmpp_stats())
except ImportError:
return jsonify({"today": {"sent": 0, "failed": 0, "latency_avg": 0}, "week": {"sent": 0, "failed": 0}})
# 注册提示词管理路由
register_routes(app)
+56
View File
@@ -0,0 +1,56 @@
{
"module": "scanner",
"version": "1.0",
"purpose": "全市场自动选股机制。纯数据驱动(不依赖 LLM),定期从 A 股热门板块筛选符合条件的候选股。",
"human_help": {
"title": "全市场选股扫描",
"description": [
"自动化选股管线,每 15 分钟运行一次。纯数据驱动,不依赖外部 LLM。",
"流程:读热门板块 → 拉腾讯实时行情 → 涨幅>3%+有量 → 评分 → 写入 candidates 表 → 高分推送 XMPP。",
"候选股在 Dashboard 市场 Tab 的「主力建仓候选」面板展示。"
],
"usage": [
"Dashboard 市场 Tab → 🎯 主力建仓候选 → 查看最近 50 只候选股",
"Dashboard 信号 Tab → 查看全市场扫描统计",
"cron: market_scanner.py(每 15 分钟,交易日 9-15"
],
"troubleshooting": [
"候选池为空 → 检查 market_scanner.py cron 是否运行,sector_snapshots 表是否有数据",
"行情不更新 → 检查腾讯行情 API 是否可达(qt.gtimg.cn"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/candidates", "returns": "[{code, name, reason, score_2nd..score_5th, score_final, pass_s2..pass_s5, promoted, promoted_at, log, created_at}] — 最近 50 只候选股"}
],
"dependencies": [
"market_scanner.py — 选股主脚本(cron: */15 9-15 1-5",
"mofin_db.py — candidates 表(code, name, price, change_pct, score, entry_low, entry_high, stop_loss, take_profit, source, sector, reason",
"market_watch.py — 提供 sector_snapshots 板块数据",
"腾讯行情 API: http://qt.gtimg.cn/q="
],
"architecture": {
"pipeline": "sector_snapshots → market_scanner.scan_hot_sectors() → 腾讯行情 → 过滤(涨>3%+有量) → 评分 → candidates 表 → XMPP 推送(高分)",
"scoring": "score = min(10, round(3 + change% * 0.5 + (amount/1e8) * 0.1))"
},
"constraints": [
"纯数据驱动,不调 LLM(区别于旧 xiaoguo_scanner 和 market_screener",
"只扫描 A 股(6 位代码以 5/6/9 开头)",
"板块涨幅 >2% 才纳入热门板块",
"个股涨幅 >3% 才进入候选",
"高分候选(score>=6)推送到 XMPP(知微 bot :5805"
],
"must_not": [
"不要调小果 LLM APInode122:18003",
"不要写 candidate_pool.json(旧格式,已废弃)"
],
"related_files": [
"scripts/market_scanner.py — 选股脚本",
"server.py — /api/candidates",
"mofin_db.py — candidates 表",
"market_watch.py — 板块数据源"
]
}
}
+21 -15
View File
@@ -1,43 +1,49 @@
{
"module": "signals",
"version": "1.0",
"purpose": "信号与扫描数据。提供市场信号查询和小果扫描统计。",
"purpose": "信号数据。提供市场信号查询,数据来自趋势检测(macro_context_collector/divergence_detector)和全市场扫描(market_scanner。",
"human_help": {
"title": "信号与扫描",
"description": [
"展示系统产生的交易信号和小果 LLM 扫描结果。",
"信号来自多个来源:xiaoguo_scanner(全市场)、macro_context_collector(宏观)divergence_detector(背离)。",
"所有信号存储在 signal_news 表中。"
"展示系统产生的交易信号,包括趋势检测信号和全市场扫描候选股。",
"趋势信号来自 macro_context_collector(宏观)divergence_detector(背离检测)。",
"全市场扫描由 market_scanner.py 纯数据驱动(不依赖 LLM),每 15 分钟从热门板块筛选候选股。",
"所有信号存储在 signal_news 表中,候选股存储在 candidates 表中。"
],
"usage": [
"GET /api/signals — 获取最近 20 条信号(含板块信号关联",
"GET /api/xiaoguo-scan — 获取小果扫描统计(扫描总数/发现信号数/近期记录/今日来源分布"
"GET /api/signals — 获取最近 20 条信号(含趋势和扫描信号",
"GET /api/candidates — 获取候选股池(最近 50 只,市场扫描产出"
],
"troubleshooting": [
"信号为空 → 检查 xiaoguo_scanner 和 macro_context_collector cron 状态",
"小果扫描数据停更 → 检查小果 LLM API 是否可达"
"信号为空 → 检查 macro_context_collector 和 market_scanner cron 状态",
"候选池为空 → 确认 sector_snapshots 有数据,market_scanner cron 在运行"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/signals", "returns": "[{id, sector, overall_sentiment, summary, source, created_at, signal_type, severity}] — 最近 20 条信号"},
{"method": "GET", "path": "/api/xiaoguo-scan", "returns": "{total_scanned, found_signals, recent[{code, name, last_scanned_at, found_count}], source_today{source:cnt}}"}
{"method": "GET", "path": "/api/candidates", "returns": "[{code, name, reason, score_2nd..score_5th, score_final, pass_s2..pass_s5, promoted, log, created_at}] — 候选股池(由 market_scanner 产出)"}
],
"dependencies": [
"mofin_db.py — signal_news / xiaoguo_scan_tracker 表",
"xiaoguo_scanner.py — 全市场扫描 cron*/5 9-15",
"mofin_db.py — signal_news / candidates 表",
"macro_context_collector.py — 宏观信号采集 cron",
"divergence_detector.py — 背离检测 cron"
"divergence_detector.py — 背离检测 cron",
"scripts/market_scanner.py — 全市场扫描 cron*/15 9-15 1-5,纯数据驱动)"
],
"constraints": [
"signal_news 和 sector_signals 通过 LEFT JOIN 关联",
"xiaoguo_scan_tracker 的 source 统计只取最近 24 小时"
"candidates 表由 market_scanner 写入,纯数据驱动不调 LLM",
"旧 xiaoguo_scanner 已废弃,不再使用"
],
"must_not": [
"不要调小果 LLM API",
"不要依赖 xiaoguo_scan_tracker 表(已废弃)"
],
"related_files": [
"server.py — /api/signals, /api/xiaoguo-scan",
"xiaoguo_scanner.py",
"server.py — /api/signals, /api/candidates",
"scripts/market_scanner.py — 全市场扫描",
"macro_context_collector.py"
]
}
+72
View File
@@ -0,0 +1,72 @@
{
"module": "xmpp_monitor",
"version": "1.0",
"purpose": "XMPP 通信通道全链路可观测性。记录消息收发日志、LLM 调用追踪、异常检测与自动修复。",
"human_help": {
"title": "XMPP 通信监控",
"description": [
"实时监控 MoFin 的 XMPP 通信通道健康状态。",
"追踪范围:知微 Bot ↔ XMPP 群聊、cron 报告推送、市场扫描推送、策略通知。",
"当消息流中断或异常时,Dashboard 自动告警并展示失败原因。"
],
"usage": [
"Dashboard 📊 仪表盘 → 查看消息流实时状态",
"Dashboard 🏥 健康 → 查看 XMPP 通道健康指标(最后消息时间、错误率)",
"GET /api/xmpp/messages — 查询消息历史(支持按时间/Agent/状态筛选)",
"GET /api/xmpp/health — XMPP 通道健康检查"
],
"troubleshooting": [
"消息推送失败 → 查看 xmpp_messages.jsonl 中的错误信息",
"ejabberd 挂了 → Dashboard 自动检测,手动 docker restart ejabberd",
"知微 Bot 离线 → 检查 systemctl status xmpp-zhiwei"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/xmpp/messages", "returns": "{messages[{timestamp, direction, from, to, body_preview, status, error, latency_ms}], total} — 支持 ?since=&agent=&status=&limit=50 参数"},
{"method": "GET", "path": "/api/xmpp/health", "returns": "{status, last_message_age_sec, error_rate_1h, queue_depth, ejabberd_status}"},
{"method": "GET", "path": "/api/xmpp/stats", "returns": "{today{sent, failed, latency_avg}, week{sent, failed}}"}
],
"dependencies": [
"xmpp_logger.py — 消息日志采集(写入 gateway/logs/xmpp_messages.jsonl",
"ejabberd Docker 容器 — XMPP 服务器(:5222",
"cron_to_xmpp.py — 报告推送到 XMPPsend 函数写入 xmpp_logger",
"market_scanner.py — 选股结果推送 XMPP:5805",
"知微 Bot — xmpp-zhiwei systemd 服务"
],
"architecture": {
"log_format": "JSONL, 每行一条: {timestamp, direction(in/out), from_jid, to_jid, body_preview(前100字), status(ok/error/timeout), error, latency_ms}",
"health_checks": [
"最后消息年龄 >10min → 🔴 告警",
"最近1小时错误率 >50% → 🟡 告警",
"ejabberd 容器不在运行 → 🔴 严重",
"知微 Bot 进程不在 → 🔴 严重"
]
},
"constraints": [
"xmpp_logger.py 通过 hook 方式接入 cron_to_xmpp.send(),不动现有业务逻辑",
"消息日志保留最近 7 天,自动轮转",
"Dashboard 每 10 秒自动刷新消息流",
"错误信息脱敏:不记录完整消息体,只记录前 100 字预览"
],
"must_not": [
"不要在消息日志中记录 API Key 或密码",
"不要修改 cron_to_xmpp.py 的业务逻辑(只加 hook",
"不要在 xmpp_monitor 中重复实现已有的 system_health_check 逻辑"
],
"tests": [
{"id": "XM01", "name": "/api/xmpp/health 返回 ejabberd 状态", "endpoint": "GET /api/xmpp/health"},
{"id": "XM02", "name": "/api/xmpp/messages 返回消息列表", "endpoint": "GET /api/xmpp/messages"},
{"id": "XM03", "name": "xmpp_logger 写入 JSONL 格式正确", "endpoint": "file check"}
],
"related_files": [
"xmpp_logger.py — 消息日志采集",
"cron_to_xmpp.py — 报告推送(send 函数)",
"scripts/market_scanner.py — 选股推送",
"server.py — /api/xmpp/* 端点",
"agents_health_check.py — Tier1 健康检查(含 XMPP 检测)"
]
}
}
+31 -16
View File
@@ -478,7 +478,7 @@ function refreshMarketData() {
const sectors = mkt.sectors || [];
const sigs = signals || [];
const cands = candidates || [];
const trend = sigs.filter(s => s.source && s.source !== 'xiaoguo');
const trend = sigs.filter(s => s.source && s.source !== 'xiaoguo').concat(sigs.filter(s => s.source === 'market_scanner'));
el.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">🌐 市场全景 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('market','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('market','ai')">§</span><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
@@ -1275,28 +1275,27 @@ async function renderSignals() {
const el = document.getElementById('tab-signals');
el.innerHTML = '<div class="text-slate-400 text-sm">加载中…</div>';
try {
const [signals, scan] = await Promise.all([
const [signals, candidates] = await Promise.all([
fetchJSON('/api/signals'),
fetchJSON('/api/xiaoguo-scan')
fetchJSON('/api/candidates')
]);
const sourceToday = scan.source_today || {};
let html = `
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold">🔍 信号 <span class="spec-btns"><span class="spec-btn help" onclick="showModuleHelp('signals','human')">?</span><span class="spec-btn ai" onclick="showModuleHelp('signals','ai')">§</span></span></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<div class="card p-4">
<div class="text-xs text-slate-500 mb-1">今日信号来源</div>
<div class="text-xs text-slate-500 mb-1">今日信号</div>
<div class="flex gap-4 text-sm">
<span class="text-blue-400">📡 趋势: ${sourceToday.trend || 0}</span>
<span class="text-green-400">🔍 小果扫描: ${sourceToday.xiaoguo || 0}</span>
<span class="text-blue-400">📡 趋势信号: ${signals.length || 0}</span>
<span class="text-green-400">🎯 候选股: ${candidates.length || 0}</span>
</div>
</div>
<div class="card p-4">
<div class="text-xs text-slate-500 mb-1">小果扫描统计</div>
<div class="text-xs text-slate-500 mb-1">全市场扫描</div>
<div class="flex gap-4 text-sm">
<span class="text-slate-300">📋 累计扫描: ${scan.total_scanned || 0} 只</span>
<span class="text-yellow-400">🏆 发现信号: ${scan.found_signals || 0} 只</span>
<span class="text-slate-300">📋 候选池: ${candidates.length || 0} 只</span>
<span class="text-yellow-400">🏆 已入自选: ${candidates.filter(c => c.promoted).length || 0} 只</span>
</div>
</div>
</div>`;
@@ -1314,7 +1313,7 @@ async function renderSignals() {
const sent = s.overall_sentiment || '-';
const sentColor = sent === '利好' ? 'text-green-400' : sent === '利空' ? 'text-red-400' : 'text-slate-400';
const source = s.source || 'trend';
const sourceIcon = source === 'xiaoguo' ? '🔍' : '📡';
const sourceIcon = source === 'market_scanner' ? '🎯' : source === 'macro' ? '🌐' : '📡';
html += `
<div class="card p-4">
<div class="flex items-center gap-2 mb-1">
@@ -1460,18 +1459,23 @@ async function renderHealth() {
fetchJSON('/api/monitor')
]);
// XMPP 通道健康(独立 fetch,不阻塞主流程)
let xmpp = null;
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
const svcs = services.services || [];
const summary = services.summary || { ok: 0, total: 0 };
const tasks = monitor.tasks || [];
const tier1 = monitor.tier1 || {};
const tier2 = monitor.tier2 || {};
// Summary cards
// Summary cards — 包含 XMPP 状态
let html = '<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#3fb950] font-mono">' + (summary.ok || 0) + '</div><div class="text-xs text-slate-500">正常服务</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-white font-mono">' + (summary.total || 0) + '</div><div class="text-xs text-slate-500">总服务数</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#d29922] font-mono">' + (tier1.summary?.total || svcs.length) + '</div><div class="text-xs text-slate-500">一级服务</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#58a6ff] font-mono">' + (tier2.summary?.total || 0) + '</div><div class="text-xs text-slate-500">二级服务</div></div>';
const xmppStatus = xmpp ? xmpp.status : 'unknown';
const xmppColor = xmppStatus === 'ok' ? '#3fb950' : xmppStatus === 'degraded' ? '#d29922' : '#f85149';
const xmppLabel = xmppStatus === 'ok' ? 'XMPP 正常' : xmppStatus === 'degraded' ? 'XMPP 降级' : xmppStatus === 'critical' ? 'XMPP 断联' : 'XMPP 无数据';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold font-mono" style="color:' + xmppColor + '">' + (xmpp ? (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec/60) + 'm' : '--') : '--') + '</div><div class="text-xs text-slate-500">' + xmppLabel + '</div></div>';
html += '<div class="card p-3 text-center"><div class="text-lg font-bold text-[#d29922] font-mono">' + (xmpp ? xmpp.error_rate_1h + '%' : '--') + '</div><div class="text-xs text-slate-500">XMPP 错误率(1h)</div></div>';
html += '</div>';
// Services by layer
@@ -1511,6 +1515,17 @@ async function renderHealth() {
}
html += '<div class="text-xs text-slate-600 mt-2">更新于 ' + (monitor.generated_at || new Date().toLocaleTimeString()) + ' · 每10秒刷新</div>';
// XMPP 通道详情
if (xmpp) {
html += '<div class="card p-4 mt-4"><h3 class="text-sm font-semibold text-[#58a6ff] mb-2">📡 XMPP 通道</h3>';
html += '<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">';
html += '<div><span class="text-slate-500">最后消息</span><br><span class="font-mono ' + (xmpp.last_message_age_sec > 600 ? 'text-[#f85149]' : 'text-[#3fb950]') + '">' + (xmpp.last_message_age_sec > 0 ? Math.round(xmpp.last_message_age_sec) + '秒前' : '无数据') + '</span></div>';
html += '<div><span class="text-slate-500">1h错误率</span><br><span class="font-mono ' + (xmpp.error_rate_1h > 50 ? 'text-[#f85149]' : xmpp.error_rate_1h > 10 ? 'text-[#d29922]' : 'text-[#3fb950]') + '">' + xmpp.error_rate_1h + '%</span></div>';
html += '<div><span class="text-slate-500">ejabberd</span><br><span class="font-mono ' + ((xmpp.ejabberd || '').includes('Up') ? 'text-[#3fb950]' : 'text-[#f85149]') + '">' + (xmpp.ejabberd || '?') + '</span></div>';
html += '<div><span class="text-slate-500">状态</span><br><span class="font-mono ' + (xmpp.status === 'ok' ? 'text-[#3fb950]' : xmpp.status === 'degraded' ? 'text-[#d29922]' : 'text-[#f85149]') + '">' + xmpp.status + '</span></div>';
html += '</div></div>';
}
el.innerHTML = html;
} catch(e) {
el.innerHTML = '<div class="text-red-400">加载健康状态失败: ' + e.message + '</div>';
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""xmpp_logger.py — XMPP 消息日志采集器
记录所有 XMPP 通信事件到 JSONL 日志文件。通过 hook 方式接入:
只需在消息发送点加一行 log_xmpp() 调用,不动业务逻辑。
日志文件: gateway/logs/xmpp_messages.jsonl
自动轮转: 保留最近 7 天
"""
import json
import time as _time
from pathlib import Path
from datetime import datetime, timedelta
LOG_DIR = Path(__file__).resolve().parent / "gateway" / "logs"
LOG_FILE = LOG_DIR / "xmpp_messages.jsonl"
MAX_AGE_DAYS = 7
def log_xmpp(direction, from_jid, to_jid, body, status="ok", error=None, latency_ms=0):
"""记录一条 XMPP 消息事件。
Args:
direction: "out"(发送) 或 "in"(接收)
from_jid: 发送者 JID
to_jid: 接收者 JID
body: 消息体(自动截取前 200 字预览)
status: "ok" / "error" / "timeout"
error: 错误信息(仅 status!="ok" 时)
latency_ms: 延迟(毫秒)
"""
LOG_DIR.mkdir(parents=True, exist_ok=True)
entry = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"direction": direction,
"from": from_jid,
"to": to_jid,
"body_preview": (body or "")[:200].replace("\n", " "),
"status": status,
"error": str(error)[:200] if error else None,
"latency_ms": latency_ms,
"epoch": _time.time(),
}
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# 每次写入后检查是否需要轮转(采样:每 20 条轮转一次)
if LOG_FILE.stat().st_size > 500 * 1024: # >500KB
_rotate()
def _rotate():
"""保留最近 MAX_AGE_DAYS 天,丢弃旧日志"""
if not LOG_FILE.exists():
return
cutoff = datetime.now() - timedelta(days=MAX_AGE_DAYS)
kept = []
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
if e["timestamp"][:10] >= cutoff.strftime("%Y-%m-%d"):
kept.append(line)
except Exception:
continue
with open(LOG_FILE, "w", encoding="utf-8") as f:
f.writelines(kept)
except Exception:
pass
def query(since=None, agent=None, status=None, limit=50):
"""查询消息日志
Args:
since: ISO datetime string, 只返回此时间之后的消息
agent: JID 片段,筛选发送或接收方包含此字符串的消息
status: 筛选状态 "ok"/"error"/"timeout"
limit: 最大返回条数(默认 50)
"""
if not LOG_FILE.exists():
return []
results = []
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
if since and e["timestamp"] < since:
continue
if agent and agent not in e["from"] and agent not in e["to"]:
continue
if status and e["status"] != status:
continue
results.append(e)
except Exception:
continue
except Exception:
pass
results.sort(key=lambda x: x["timestamp"], reverse=True)
return results[:limit]
def stats():
"""获取消息统计:今日 + 本周"""
if not LOG_FILE.exists():
return {"today": {"sent": 0, "failed": 0, "latency_avg": 0}, "week": {"sent": 0, "failed": 0}}
now = datetime.now()
today = now.strftime("%Y-%m-%d")
week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d")
latencies = []
s = {"today": {"sent": 0, "failed": 0}, "week": {"sent": 0, "failed": 0}}
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
d = e["timestamp"][:10]
if d >= week_ago:
s["week"]["sent"] += 1
if e["status"] != "ok":
s["week"]["failed"] += 1
if d == today:
s["today"]["sent"] += 1
if e["status"] != "ok":
s["today"]["failed"] += 1
if e.get("latency_ms"):
latencies.append(e["latency_ms"])
except Exception:
continue
except Exception:
pass
s["today"]["latency_avg"] = round(sum(latencies) / len(latencies)) if latencies else 0
return s
def health():
"""快速健康检查:返回最后消息年龄 + 最近1h错误率"""
if not LOG_FILE.exists():
return {"status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0}
now_epoch = _time.time()
last_epoch = 0
recent_total = 0
recent_errors = 0
one_hour_ago = now_epoch - 3600
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
ep = e.get("epoch", 0)
if ep > last_epoch:
last_epoch = ep
if ep > one_hour_ago:
recent_total += 1
if e["status"] != "ok":
recent_errors += 1
except Exception:
continue
except Exception:
pass
last_age = int(now_epoch - last_epoch) if last_epoch else -1
err_rate = round(recent_errors / recent_total * 100, 1) if recent_total else 0
# 状态判断
if last_age < 0:
st = "no_data"
elif last_age > 600: # >10min no message
st = "critical"
elif err_rate > 50:
st = "degraded"
else:
st = "ok"
return {"status": st, "last_message_age_sec": last_age, "error_rate_1h": err_rate}