dalongxia/openclaw-log-observer
2026-03-04 13:25:48 +08:00

894 lines
33 KiB
Python
Executable File
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.

#!/usr/bin/env python3
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Dict, List, Optional, Tuple
DEFAULT_APP_TOKEN = "GC2Mb4TXwad8ZusStBecLNg8nCe"
DEFAULT_TABLE_LINK = "https://acnuoqgha8fk.feishu.cn/base/GC2Mb4TXwad8ZusStBecLNg8nCe"
OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json"
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
REPORT_DIR = Path("/var/log/openclaw/log-observer")
REQUIRED_FIELDS = [
"记录类型",
"批次ID",
"巡检时间",
"日志范围(分钟)",
"日志总行数",
"OpenClaw调用次数",
"成功回复次数",
"未完成会话数",
"错误总数",
"网络错误数",
"解析错误数",
"重复回调数",
"问题类型",
"会话ID",
"问题摘要",
"建议动作",
"自动回放状态",
"自动回放会话",
"自动回放摘要",
"状态",
]
ISSUE_PATTERNS = {
"openclaw_call_failed": re.compile(r"OpenClaw 调用失败|openclaw call timeout|OpenClaw 调用超时", re.I),
"parse_failed": re.compile(r"结果解析失败|parse openclaw output failed", re.I),
"network_error": re.compile(r"network exception|keepalive ping timeout|timed out during opening handshake", re.I),
"reply_failed": re.compile(r"reply failed: session=|failed to send final reply", re.I),
"traceback": re.compile(r"Traceback \(most recent call last\)", re.I),
}
DIALOG_PATTERNS = {
"dialog_tool_noise": re.compile(
r"NO TOOLBeenCalled|without a preceding text-to-speech|tts\s*Completed|结果解析失败|原始输出:",
re.I,
),
"dialog_blocked_response": re.compile(
r"权限不足|未登录|无法直接从页面抓取|需要登录后的操作|工具版本不支持点击交互|device identity required|网关令牌缺失|需要配对",
re.I,
),
"dialog_meta_leak": re.compile(r"\[plugins\]|Registered\s+\w+\s+tool|runId\"\s*:\s*\"", re.I),
}
CALL_RE = re.compile(r"openclaw call: session=([^\s]+)")
REPLY_RE = re.compile(r"reply sent: session=([^\s]+)")
DUP_RE = re.compile(r"duplicate callback ignored", re.I)
TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+")
SESSION_RE = re.compile(r"session=([^\s,]+)")
def _run(cmd: List[str], timeout: int = 120) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict:
data = None
req_headers = dict(headers)
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json; charset=utf-8"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e
except Exception as e:
raise RuntimeError(f"request failed {method} {url}: {e}") from e
def _load_feishu_app() -> Tuple[str, str]:
app_id = os.getenv("FEISHU_APP_ID", "").strip()
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
if app_id and app_secret:
return app_id, app_secret
cfg_path = Path(OPENCLAW_CONFIG)
if not cfg_path.exists():
raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)")
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
ch = (cfg.get("channels") or {}).get("feishu") or {}
app_id = (ch.get("appId") or "").strip()
app_secret = (ch.get("appSecret") or "").strip()
if not app_id or not app_secret:
raise RuntimeError("feishu appId/appSecret is empty in openclaw.json")
return app_id, app_secret
def _tenant_token(app_id: str, app_secret: str) -> str:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
res = _http_json("POST", url, headers={}, payload={"app_id": app_id, "app_secret": app_secret})
if res.get("code") != 0:
raise RuntimeError(f"feishu auth failed: {res}")
token = res.get("tenant_access_token")
if not token:
raise RuntimeError("feishu auth succeeded but tenant_access_token missing")
return token
def _create_table(token: str, app_token: str, table_name: str) -> str:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"table": {"name": table_name[:100]}},
)
if res.get("code") != 0:
raise RuntimeError(f"create table failed ({table_name}): {res}")
data = res.get("data") or {}
table_id = data.get("table_id") or (data.get("table") or {}).get("table_id") or ""
if not table_id:
raise RuntimeError(f"create table succeeded but table_id missing: {res}")
return table_id
def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields?page_size=500"
res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"})
if res.get("code") != 0:
raise RuntimeError(f"list fields failed: {res}")
out: Dict[str, str] = {}
for it in ((res.get("data") or {}).get("items") or []):
name = it.get("field_name")
fid = it.get("field_id")
if name and fid:
out[name] = fid
return out
def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"field_name": field_name, "type": 1},
)
if res.get("code") != 0:
raise RuntimeError(f"create field failed ({field_name}): {res}")
def _ensure_fields(token: str, app_token: str, table_id: str) -> None:
fields = _list_fields(token, app_token, table_id)
for name in REQUIRED_FIELDS:
if name not in fields:
_create_field(token, app_token, table_id, name)
time.sleep(0.3)
def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> None:
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
res = _http_json(
"POST",
url,
headers={"Authorization": f"Bearer {token}"},
payload={"fields": fields},
)
if res.get("code") != 0:
raise RuntimeError(f"create record failed: {res}")
def _build_table_link(base_link: str, app_token: str, table_id: str) -> str:
if not base_link:
return f"https://acnuoqgha8fk.feishu.cn/base/{app_token}?table={table_id}"
parsed = urllib.parse.urlsplit(base_link)
query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query_pairs = [(k, v) for (k, v) in query_pairs if k.lower() not in {"table", "view"}]
query_pairs.append(("table", table_id))
query = urllib.parse.urlencode(query_pairs)
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment))
def _extract_text_from_openclaw(stdout: str) -> str:
raw = (stdout or "").strip()
if not raw:
return ""
decoder = json.JSONDecoder()
data = None
idx = raw.find("{")
while idx != -1:
try:
data, _ = decoder.raw_decode(raw[idx:])
break
except Exception:
idx = raw.find("{", idx + 1)
if not isinstance(data, dict):
return ""
payloads = ((data.get("result") or {}).get("payloads") or [])
text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)]
return "\n".join(t for t in text_items if t).strip()
def _collect_logs(service: str, since_minutes: int) -> List[str]:
cmd = [
"journalctl",
"-u",
service,
"--since",
f"{since_minutes} min ago",
"--no-pager",
"-o",
"short-iso",
]
proc = _run(cmd)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "journalctl failed").strip()
raise RuntimeError(f"collect logs failed: {err}")
return [line.rstrip("\n") for line in (proc.stdout or "").splitlines() if line.strip()]
def _extract_session(line: str) -> str:
for regex in (CALL_RE, REPLY_RE, SESSION_RE):
m = regex.search(line)
if m:
return m.group(1)
return ""
def _analyze_logs(lines: List[str]) -> Dict:
call_count = 0
reply_count = 0
duplicate_count = 0
issue_counts = {k: 0 for k in ISSUE_PATTERNS}
calls_by_session: Dict[str, int] = {}
replies_by_session: Dict[str, int] = {}
issues: List[Dict] = []
for line in lines:
m = CALL_RE.search(line)
if m:
call_count += 1
session = m.group(1)
calls_by_session[session] = calls_by_session.get(session, 0) + 1
m = REPLY_RE.search(line)
if m:
reply_count += 1
session = m.group(1)
replies_by_session[session] = replies_by_session.get(session, 0) + 1
if DUP_RE.search(line):
duplicate_count += 1
for issue_type, patt in ISSUE_PATTERNS.items():
if patt.search(line):
issue_counts[issue_type] += 1
ts_match = TS_RE.match(line)
issues.append(
{
"type": issue_type,
"timestamp": ts_match.group(1) if ts_match else "",
"line": line[-600:],
"session": _extract_session(line),
}
)
break
unresolved_sessions = []
for session, cnt in calls_by_session.items():
rc = replies_by_session.get(session, 0)
if cnt > rc:
unresolved_sessions.append({"session": session, "pending_calls": cnt - rc})
unresolved_sessions = sorted(unresolved_sessions, key=lambda x: (-x["pending_calls"], x["session"]))
return {
"line_count": len(lines),
"call_count": call_count,
"reply_count": reply_count,
"duplicate_count": duplicate_count,
"issue_counts": issue_counts,
"issues": issues,
"unresolved_sessions": unresolved_sessions,
}
def _collect_session_ids(lines: List[str]) -> List[str]:
seen = set()
out = []
for line in lines:
sid = _extract_session(line)
if sid and sid not in seen:
seen.add(sid)
out.append(sid)
return out
def _parse_iso_utc(value: str) -> Optional[dt.datetime]:
if not value:
return None
try:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
parsed = dt.datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return parsed.astimezone(dt.timezone.utc)
except Exception:
return None
def _extract_content_text(content) -> str:
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") != "text":
continue
text = item.get("text")
if text:
parts.append(str(text))
return "\n".join(parts).strip()
return ""
def _normalize_text(text: str) -> str:
return re.sub(r"[\s,,。.!!??::~~`'\"\-_/\\]+", "", text or "").lower()
def _dialog_issue(
issue_type: str,
session_id: str,
timestamp: str,
user_text: str,
assistant_text: str,
) -> Dict:
user_short = (user_text or "").replace("\n", " ")[:120]
assistant_short = (assistant_text or "").replace("\n", " ")[:320]
line = f"user={user_short} | assistant={assistant_short}"
return {
"type": issue_type,
"timestamp": timestamp,
"line": line,
"session": session_id,
}
def _analyze_dialog_anomalies(lines: List[str], since_minutes: int) -> Dict:
session_ids = _collect_session_ids(lines)
cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=since_minutes)
issue_counts = {k: 0 for k in DIALOG_PATTERNS}
issue_counts["dialog_repeat_reply"] = 0
issue_counts["dialog_large_code_dump"] = 0
issues: List[Dict] = []
inspected = 0
for session_id in session_ids:
session_file = SESSIONS_DIR / f"{session_id}.jsonl"
if not session_file.exists() or not session_file.is_file():
continue
inspected += 1
last_user_text = ""
last_assistant_norm = ""
try:
with session_file.open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if obj.get("type") != "message":
continue
msg = obj.get("message") or {}
role = (msg.get("role") or "").lower()
content_text = _extract_content_text(msg.get("content"))
if not content_text:
continue
ts_raw = obj.get("timestamp") or ""
ts_dt = _parse_iso_utc(ts_raw)
if ts_dt and ts_dt < cutoff:
continue
ts_text = ts_raw or ""
if role == "user":
last_user_text = content_text
continue
if role != "assistant":
continue
normalized = _normalize_text(content_text)
for issue_type, patt in DIALOG_PATTERNS.items():
if patt.search(content_text):
issue_counts[issue_type] += 1
issues.append(_dialog_issue(issue_type, session_id, ts_text, last_user_text, content_text))
break
if normalized and last_assistant_norm and normalized == last_assistant_norm and len(normalized) >= 24:
issue_counts["dialog_repeat_reply"] += 1
issues.append(_dialog_issue("dialog_repeat_reply", session_id, ts_text, last_user_text, content_text))
last_assistant_norm = normalized
if "```" in content_text:
code_line_count = sum(1 for ln in content_text.splitlines() if ln.strip())
if code_line_count >= 24:
issue_counts["dialog_large_code_dump"] += 1
issues.append(_dialog_issue("dialog_large_code_dump", session_id, ts_text, last_user_text, content_text))
except Exception:
issues.append(
{
"type": "dialog_session_parse_failed",
"timestamp": dt.datetime.now().isoformat(),
"line": f"session file parse failed: {session_file}",
"session": session_id,
}
)
return {
"dialog_issue_counts": issue_counts,
"dialog_issues": issues,
"dialog_inspected_sessions": inspected,
}
def _last_user_prompt(session_id: str) -> str:
p = SESSIONS_DIR / f"{session_id}.jsonl"
if not p.exists() or p.stat().st_size == 0:
return ""
prompt = ""
try:
with p.open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except Exception:
continue
if obj.get("type") != "message":
continue
message = obj.get("message") or {}
role = (message.get("role") or "").lower()
if role != "user":
continue
content = _extract_content_text(message.get("content"))
if content:
prompt = content
except Exception:
return ""
return prompt[:1200]
def _is_safe_readonly_prompt(prompt: str) -> bool:
if not prompt:
return False
lowered = prompt.lower()
dangerous = ["删除", "drop ", "truncate", "update ", "insert ", "修改", "推送", "发送", "写入", "支付", "转账"]
if any(k in lowered for k in dangerous):
return False
read_only = ["查询", "分析", "检查", "统计", "汇总", "搜索", "报表", "售后", "库存", "sku", "店铺"]
return any(k in lowered for k in read_only)
def _openclaw_replay(prompt: str, timeout: int = 240) -> Tuple[bool, str, str]:
replay_session = f"observer-replay-{int(time.time())}-{hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:6]}"
cmd = [
"/usr/bin/openclaw",
"agent",
"--session-id",
replay_session,
"--message",
prompt,
"--json",
"--timeout",
str(timeout),
]
proc = _run(cmd, timeout=timeout + 20)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw replay failed").strip()
return False, replay_session, err[:300]
text = _extract_text_from_openclaw(proc.stdout or "")
if not text:
text = (proc.stdout or "")[:300]
ok = "调用失败" not in text and "超时" not in text and "解析失败" not in text
summary = text.replace("\n", " ")[:260]
return ok, replay_session, summary
def _total_issue_count(analysis: Dict) -> int:
base = sum((analysis.get("issue_counts") or {}).values())
dialog = sum((analysis.get("dialog_issue_counts") or {}).values())
return base + dialog
def _openclaw_observer_comment(analysis: Dict, top_lines: List[str], timeout: int = 180) -> str:
payload = {
"summary": {
"line_count": analysis["line_count"],
"call_count": analysis["call_count"],
"reply_count": analysis["reply_count"],
"duplicate_count": analysis["duplicate_count"],
"issue_counts": analysis["issue_counts"],
"dialog_issue_counts": analysis.get("dialog_issue_counts", {}),
"dialog_inspected_sessions": analysis.get("dialog_inspected_sessions", 0),
"unresolved_sessions": analysis["unresolved_sessions"][:10],
},
"samples": top_lines[:20],
}
prompt = (
"你是日志巡检分身。根据以下 JSON 生成简短巡检结论,输出三段:"
"\n1) 主要问题(最多3条)"
"\n2) 对主机器人的修复建议(最多5条,必须可执行)"
"\n3) 优先级最高的一条修复动作"
"\n请重点覆盖对话返回异常(如无关输出、工具噪音、重复回复)。"
"\n请用中文、短句、不要废话。\n\n"
+ json.dumps(payload, ensure_ascii=False)
)
cmd = [
"/usr/bin/openclaw",
"agent",
"--session-id",
"log-observer-main",
"--message",
prompt,
"--json",
"--timeout",
str(timeout),
]
proc = _run(cmd, timeout=timeout + 20)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "openclaw observer failed").strip()
return f"分身分析失败: {err[:300]}"
text = _extract_text_from_openclaw(proc.stdout or "")
return text or "分身分析完成,但未返回文本。"
def _recommendation(issue_type: str) -> str:
mapping = {
"network_error": "检查网络抖动与 websocket keepalive,建议提高重连退避并增加心跳监控。",
"parse_failed": "统一 OpenClaw 输出解析入口,先剥离插件日志再解码 JSON。",
"openclaw_call_failed": "检查模型/API可用性与超时阈值,必要时降级到本地简答模式。",
"reply_failed": "检查 ActionCard 权限,失败时强制文本兜底避免无回复。",
"traceback": "排查异常栈并加防护,避免同类异常重复触发。",
"dialog_tool_noise": "在回复前增加输出清洗,拦截 tool 元信息/tts 噪音文本。",
"dialog_blocked_response": "对登录/权限失败加自动重试和明确下一步,不直接抛给用户。",
"dialog_meta_leak": "过滤插件注册/运行元数据,用户侧只保留结论。",
"dialog_repeat_reply": "对同会话相同答复加内容去重,避免重复发送。",
"dialog_large_code_dump": "非代码任务启用摘要模式,限制长代码块直接外发。",
"dialog_session_parse_failed": "检查 session 文件格式和异常记录,保证可解析。",
}
return mapping.get(issue_type, "检查对应日志并补充防护与重试策略。")
def _write_report(batch_id: str, since_minutes: int, analysis: Dict, observer_text: str, replay_rows: List[Dict], lines: List[str]) -> Path:
REPORT_DIR.mkdir(parents=True, exist_ok=True)
report_path = REPORT_DIR / f"{batch_id}.md"
issues_top = analysis["issues"][:16]
unresolved_top = analysis["unresolved_sessions"][:12]
dialog_top = (analysis.get("dialog_issues") or [])[:16]
out = []
out.append(f"# 日志巡检报告 {batch_id}")
out.append("")
out.append(f"- 巡检时间: {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
out.append(f"- 日志范围: 最近 {since_minutes} 分钟")
out.append(f"- 日志总行数: {analysis['line_count']}")
out.append(f"- OpenClaw调用/成功回复: {analysis['call_count']} / {analysis['reply_count']}")
out.append(f"- 未完成会话: {len(analysis['unresolved_sessions'])}")
out.append(f"- 错误总数: {_total_issue_count(analysis)}")
out.append(f"- 对话巡检会话数: {analysis.get('dialog_inspected_sessions', 0)}")
out.append(f"- 对话内容异常数: {sum((analysis.get('dialog_issue_counts') or {}).values())}")
out.append("")
out.append("## 分身结论")
out.append("")
out.append(observer_text.strip() or "无")
out.append("")
out.append("## 问题明细")
out.append("")
if not issues_top:
out.append("- 无错误问题")
else:
for idx, row in enumerate(issues_top, start=1):
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
out.append(f" - 时间: {row.get('timestamp') or '-'}")
out.append(f" - 日志: {row.get('line')}")
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
out.append("")
out.append("## 对话内容异常")
out.append("")
if not dialog_top:
out.append("- 无")
else:
for idx, row in enumerate(dialog_top, start=1):
out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}")
out.append(f" - 时间: {row.get('timestamp') or '-'}")
out.append(f" - 内容: {row.get('line')}")
out.append(f" - 建议: {_recommendation(row.get('type') or '')}")
out.append("")
out.append("## 未完成会话")
out.append("")
if not unresolved_top:
out.append("- 无")
else:
for row in unresolved_top:
out.append(f"- {row['session']}: pending_calls={row['pending_calls']}")
out.append("")
out.append("## 自动回放")
out.append("")
if not replay_rows:
out.append("- 未执行")
else:
for row in replay_rows:
out.append(f"- {row['session']} -> {row['status']} ({row['replay_session']}) {row['summary']}")
out.append("")
out.append("## 日志样本")
out.append("")
for line in lines[-40:]:
out.append(f"- {line[-800:]}")
report_path.write_text("\n".join(out) + "\n", encoding="utf-8")
latest = REPORT_DIR / "latest.md"
latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8")
return report_path
def _push_to_feishu(
batch_id: str,
since_minutes: int,
analysis: Dict,
observer_text: str,
replay_rows: List[Dict],
app_token: str,
base_link: str,
shared_table: bool,
) -> Tuple[str, str]:
app_id, app_secret = _load_feishu_app()
token = _tenant_token(app_id, app_secret)
table_name = f"日志巡检-{dt.datetime.now().strftime('%m%d-%H%M')}"
table_id = ""
if shared_table:
parsed = urllib.parse.urlsplit(base_link or "")
query = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True))
table_id = query.get("table", "").strip()
if not table_id:
raise RuntimeError("shared-table 模式下未提供 table_id")
else:
table_id = _create_table(token, app_token, table_name)
_ensure_fields(token, app_token, table_id)
now_text = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_create_record(
token,
app_token,
table_id,
{
"记录类型": "summary",
"批次ID": batch_id,
"巡检时间": now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": str(analysis["line_count"]),
"OpenClaw调用次数": str(analysis["call_count"]),
"成功回复次数": str(analysis["reply_count"]),
"未完成会话数": str(len(analysis["unresolved_sessions"])),
"错误总数": str(_total_issue_count(analysis)),
"网络错误数": str(analysis["issue_counts"].get("network_error", 0)),
"解析错误数": str(analysis["issue_counts"].get("parse_failed", 0)),
"重复回调数": str(analysis["duplicate_count"]),
"问题类型": "巡检汇总",
"会话ID": "-",
"问题摘要": (
observer_text[:5000]
+ "\n\n"
+ f"对话巡检会话数={analysis.get('dialog_inspected_sessions', 0)}, 对话内容异常数={sum((analysis.get('dialog_issue_counts') or {}).values())}"
)[:8000],
"建议动作": "按优先级逐条修复,修复后观察下一周期是否下降。",
"自动回放状态": "已执行" if replay_rows else "未执行",
"自动回放会话": str(len(replay_rows)),
"自动回放摘要": "; ".join([f"{r['session']}:{r['status']}" for r in replay_rows])[:8000],
"状态": "ok",
},
)
for row in analysis["issues"][:40]:
_create_record(
token,
app_token,
table_id,
{
"记录类型": "issue",
"批次ID": batch_id,
"巡检时间": row.get("timestamp") or now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": "",
"OpenClaw调用次数": "",
"成功回复次数": "",
"未完成会话数": "",
"错误总数": "",
"网络错误数": "",
"解析错误数": "",
"重复回调数": "",
"问题类型": row.get("type") or "",
"会话ID": row.get("session") or "",
"问题摘要": row.get("line", "")[:8000],
"建议动作": _recommendation(row.get("type") or ""),
"自动回放状态": "",
"自动回放会话": "",
"自动回放摘要": "",
"状态": "pending",
},
)
for row in replay_rows[:20]:
_create_record(
token,
app_token,
table_id,
{
"记录类型": "replay",
"批次ID": batch_id,
"巡检时间": now_text,
"日志范围(分钟)": str(since_minutes),
"日志总行数": "",
"OpenClaw调用次数": "",
"成功回复次数": "",
"未完成会话数": "",
"错误总数": "",
"网络错误数": "",
"解析错误数": "",
"重复回调数": "",
"问题类型": "auto-replay",
"会话ID": row.get("session") or "",
"问题摘要": row.get("prompt", "")[:8000],
"建议动作": "若回放成功,检查主机器人同类问题是否已恢复。",
"自动回放状态": row.get("status", ""),
"自动回放会话": row.get("replay_session", ""),
"自动回放摘要": row.get("summary", "")[:8000],
"状态": "ok" if row.get("status") == "success" else "failed",
},
)
table_link = _build_table_link(base_link, app_token, table_id)
return table_id, table_link
def main() -> int:
parser = argparse.ArgumentParser(description="OpenClaw log observer")
parser.add_argument("--service", default="openclaw-dingtalk-bridge.service")
parser.add_argument("--since-minutes", type=int, default=60)
parser.add_argument("--push-feishu", action="store_true")
parser.add_argument("--shared-table", action="store_true")
parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN)
parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK)
parser.add_argument("--replay-failures", action="store_true")
parser.add_argument("--replay-limit", type=int, default=2)
args = parser.parse_args()
since_minutes = max(5, min(args.since_minutes, 24 * 60))
batch_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
try:
lines = _collect_logs(args.service, since_minutes)
analysis = _analyze_logs(lines)
dialog_result = _analyze_dialog_anomalies(lines, since_minutes)
analysis.update(dialog_result)
analysis["issues"].extend(dialog_result.get("dialog_issues", []))
except Exception as e:
print("STATUS=error")
print(f"ERROR={e}")
return 2
issue_lines = [row["line"] for row in analysis["issues"][:20]]
observer_text = _openclaw_observer_comment(analysis, issue_lines)
replay_rows: List[Dict] = []
if args.replay_failures:
for item in analysis["unresolved_sessions"][: max(0, args.replay_limit)]:
session_id = item["session"]
prompt = _last_user_prompt(session_id)
if not _is_safe_readonly_prompt(prompt):
replay_rows.append(
{
"session": session_id,
"prompt": prompt[:200],
"status": "skipped",
"replay_session": "",
"summary": "prompt为空或非只读任务,跳过",
}
)
continue
ok, replay_session, summary = _openclaw_replay(prompt)
replay_rows.append(
{
"session": session_id,
"prompt": prompt[:500],
"status": "success" if ok else "failed",
"replay_session": replay_session,
"summary": summary,
}
)
report_path = _write_report(batch_id, since_minutes, analysis, observer_text, replay_rows, lines)
table_id = ""
table_link = ""
if args.push_feishu:
try:
table_id, table_link = _push_to_feishu(
batch_id=batch_id,
since_minutes=since_minutes,
analysis=analysis,
observer_text=observer_text,
replay_rows=replay_rows,
app_token=args.app_token,
base_link=args.table_link,
shared_table=args.shared_table,
)
except Exception as e:
print("STATUS=partial")
print(f"ERROR=push feishu failed: {e}")
print(f"BATCH_ID={batch_id}")
print(f"REPORT_PATH={report_path}")
print(f"LINE_COUNT={analysis['line_count']}")
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
return 1
print("STATUS=ok")
print(f"BATCH_ID={batch_id}")
print(f"REPORT_PATH={report_path}")
print(f"LINE_COUNT={analysis['line_count']}")
print(f"CALL_COUNT={analysis['call_count']}")
print(f"REPLY_COUNT={analysis['reply_count']}")
print(f"ISSUE_COUNT={_total_issue_count(analysis)}")
print(f"NETWORK_ERRORS={analysis['issue_counts'].get('network_error', 0)}")
print(f"PARSE_ERRORS={analysis['issue_counts'].get('parse_failed', 0)}")
print(f"DUPLICATE_CALLBACKS={analysis['duplicate_count']}")
print(f"DIALOG_INSPECTED_SESSIONS={analysis.get('dialog_inspected_sessions', 0)}")
print(f"DIALOG_ANOMALIES={sum((analysis.get('dialog_issue_counts') or {}).values())}")
print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}")
print(f"REPLAY_ROWS={len(replay_rows)}")
if table_id:
print(f"TABLE_ID={table_id}")
if table_link:
print(f"TABLE_LINK={table_link}")
return 0
if __name__ == "__main__":
raise SystemExit(main())