272 lines
10 KiB
Python
Executable File
272 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""持续记录工作手机 SDK、WS 与 Hook 稳定性,供 24h/长期验收使用。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import signal
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
from urllib.request import Request, urlopen
|
||
|
||
|
||
def request_json(url: str, timeout: float, api_key: str = "") -> dict[str, Any]:
|
||
headers = {"Accept": "application/json", "User-Agent": "workphone-stability/1.1"}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
headers["X-API-Key"] = api_key
|
||
request = Request(url, headers=headers)
|
||
with urlopen(request, timeout=timeout) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
|
||
|
||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||
temp = path.with_suffix(path.suffix + ".tmp")
|
||
temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
temp.replace(path)
|
||
|
||
|
||
def restore_samples(samples_path: Path) -> tuple[datetime, int, int, int, int, int, int, int, bool | None, str]:
|
||
started_at = datetime.now().astimezone()
|
||
total = ok = failures = offline_events = reconnects = 0
|
||
latency_total = max_latency = 0
|
||
last_ok: bool | None = None
|
||
last_error = ""
|
||
if not samples_path.exists():
|
||
return (
|
||
started_at,
|
||
total,
|
||
ok,
|
||
failures,
|
||
offline_events,
|
||
reconnects,
|
||
latency_total,
|
||
max_latency,
|
||
last_ok,
|
||
last_error,
|
||
)
|
||
|
||
for line in samples_path.read_text(encoding="utf-8").splitlines():
|
||
try:
|
||
row = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if not isinstance(row, dict):
|
||
continue
|
||
try:
|
||
timestamp = datetime.fromisoformat(str(row.get("timestamp", "")))
|
||
if total == 0:
|
||
started_at = timestamp
|
||
except ValueError:
|
||
pass
|
||
row_ok = bool(row.get("ok"))
|
||
total += 1
|
||
ok += int(row_ok)
|
||
failures += int(not row_ok)
|
||
elapsed_ms = int(row.get("request_elapsed_ms") or 0)
|
||
latency_total += elapsed_ms
|
||
max_latency = max(max_latency, elapsed_ms)
|
||
if row_ok:
|
||
if last_ok is False:
|
||
reconnects += 1
|
||
last_error = ""
|
||
else:
|
||
if last_ok is not False:
|
||
offline_events += 1
|
||
last_error = str(row.get("error") or "sdk/ws/hook not ready")
|
||
last_ok = row_ok
|
||
|
||
return (
|
||
started_at,
|
||
total,
|
||
ok,
|
||
failures,
|
||
offline_events,
|
||
reconnects,
|
||
latency_total,
|
||
max_latency,
|
||
last_ok,
|
||
last_error,
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument("--base", default="http://127.0.0.1:8899")
|
||
parser.add_argument("--device-id", default="xgfe65eimrrofyws")
|
||
parser.add_argument("--interval-seconds", type=float, default=60)
|
||
parser.add_argument("--timeout-seconds", type=float, default=45)
|
||
parser.add_argument("--require-hook", action="store_true", help="额外把Hook纳入成功门禁")
|
||
parser.add_argument("--output-dir", required=True)
|
||
args = parser.parse_args()
|
||
api_key = os.environ.get("WORKPHONE_API_KEY", "").strip()
|
||
|
||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
samples_path = output_dir / "stability_samples.jsonl"
|
||
summary_path = output_dir / "stability_latest_summary.json"
|
||
events_path = output_dir / "stability_recovery_events.jsonl"
|
||
stop = False
|
||
|
||
def handle_stop(_signum: int, _frame: Any) -> None:
|
||
nonlocal stop
|
||
stop = True
|
||
|
||
signal.signal(signal.SIGTERM, handle_stop)
|
||
signal.signal(signal.SIGINT, handle_stop)
|
||
|
||
(
|
||
started_at,
|
||
total,
|
||
ok,
|
||
failures,
|
||
offline_events,
|
||
reconnects,
|
||
latency_total,
|
||
max_latency,
|
||
last_ok,
|
||
last_error,
|
||
) = restore_samples(samples_path)
|
||
|
||
while not stop:
|
||
sample_started = time.time()
|
||
now = datetime.now().astimezone()
|
||
row: dict[str, Any] = {
|
||
"timestamp": now.isoformat(),
|
||
"device_id": args.device_id,
|
||
"ok": False,
|
||
}
|
||
try:
|
||
health = request_json(f"{args.base}/health", args.timeout_seconds, api_key)
|
||
probe: dict[str, Any] = {}
|
||
watch_error = ""
|
||
try:
|
||
query = urlencode({"device_id": args.device_id, "samples": 1, "interval_seconds": 0})
|
||
watch = request_json(
|
||
f"{args.base}/api/v3/stability/watch?{query}&include_hook={'true' if args.require_hook else 'false'}",
|
||
args.timeout_seconds,
|
||
api_key,
|
||
)
|
||
watch_data = watch.get("data") if isinstance(watch.get("data"), dict) else {}
|
||
samples = watch_data.get("samples") if isinstance(watch_data.get("samples"), list) else []
|
||
probe = samples[0] if samples and isinstance(samples[0], dict) else {}
|
||
except Exception as exc:
|
||
watch_error = str(exc)[:500]
|
||
device_ids = health.get("device_ids") if isinstance(health.get("device_ids"), list) else []
|
||
ws_online = bool(probe.get("ws_online")) if probe else args.device_id in device_ids
|
||
row.update(
|
||
{
|
||
"sdk_healthy": health.get("status") == "healthy",
|
||
"devices_online": health.get("devices_online"),
|
||
"ws_online": ws_online,
|
||
"adb_online": bool(probe.get("adb_online")),
|
||
"hook_ok": bool(probe.get("hook_ok")),
|
||
"hook_probe_included": bool(probe.get("hook_probe_included")),
|
||
"wechat_running": probe.get("wechat_running"),
|
||
"wechat_foreground": probe.get("wechat_foreground"),
|
||
"foreground_service": probe.get("foreground_service"),
|
||
"agent_running": probe.get("agent_running"),
|
||
"network_type": probe.get("network_type", ""),
|
||
"last_heartbeat": probe.get("last_heartbeat"),
|
||
"heartbeat_age_seconds": probe.get("heartbeat_age_seconds"),
|
||
"heartbeat_stale": probe.get("heartbeat_stale"),
|
||
"connect_stage": probe.get("connect_stage", ""),
|
||
"recovery_reason": probe.get("recovery_reason", ""),
|
||
"wechat_version": probe.get("wechat_version", ""),
|
||
"probe_latency_ms": probe.get("latency_ms", 0),
|
||
"probe_mode": "deep_watch" if probe else "health_fallback",
|
||
"watch_error": watch_error,
|
||
"error": probe.get("error", ""),
|
||
}
|
||
)
|
||
connection_ok = bool(
|
||
row["sdk_healthy"]
|
||
and row["ws_online"]
|
||
and not row.get("heartbeat_stale", False)
|
||
)
|
||
row["ok"] = bool(connection_ok and (not args.require_hook or row["hook_ok"]))
|
||
except Exception as exc:
|
||
row["error"] = str(exc)[:500]
|
||
|
||
row["request_elapsed_ms"] = int((time.time() - sample_started) * 1000)
|
||
total += 1
|
||
latency_total += row["request_elapsed_ms"]
|
||
max_latency = max(max_latency, row["request_elapsed_ms"])
|
||
if row["ok"]:
|
||
ok += 1
|
||
if last_ok is False:
|
||
reconnects += 1
|
||
last_error = ""
|
||
else:
|
||
failures += 1
|
||
last_error = str(row.get("error") or "sdk/ws/hook not ready")
|
||
if last_ok is not False:
|
||
offline_events += 1
|
||
last_ok = bool(row["ok"])
|
||
|
||
# 记录状态边沿,保留掉线开始、恢复和服务端/设备端原因,便于24小时回读。
|
||
previous_ok = None
|
||
if samples_path.exists():
|
||
try:
|
||
previous_lines = samples_path.read_text(encoding="utf-8").splitlines()
|
||
if previous_lines:
|
||
previous_ok = bool(json.loads(previous_lines[-1]).get("ok"))
|
||
except (OSError, ValueError, json.JSONDecodeError):
|
||
previous_ok = None
|
||
if previous_ok != bool(row["ok"]):
|
||
event = {
|
||
"timestamp": row["timestamp"],
|
||
"device_id": args.device_id,
|
||
"event": "recovered" if row["ok"] else "offline_started",
|
||
"reason": row.get("error") or row.get("recovery_reason") or (
|
||
"connection_ok" if row["ok"] else "sdk/ws/heartbeat not ready"
|
||
),
|
||
"network_type": row.get("network_type", ""),
|
||
"connect_stage": row.get("connect_stage", ""),
|
||
"heartbeat_age_seconds": row.get("heartbeat_age_seconds"),
|
||
}
|
||
with events_path.open("a", encoding="utf-8") as stream:
|
||
stream.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||
|
||
with samples_path.open("a", encoding="utf-8") as stream:
|
||
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
|
||
elapsed_seconds = max((now - started_at).total_seconds(), 0)
|
||
summary = {
|
||
"started_at": started_at.isoformat(),
|
||
"updated_at": now.isoformat(),
|
||
"elapsed_seconds": int(elapsed_seconds),
|
||
"elapsed_hours": round(elapsed_seconds / 3600, 3),
|
||
"device_id": args.device_id,
|
||
"base": args.base,
|
||
"interval_seconds": args.interval_seconds,
|
||
"total_samples": total,
|
||
"ok_samples": ok,
|
||
"failed_samples": failures,
|
||
"success_rate": round(ok / total, 6) if total else 0,
|
||
"offline_events": offline_events,
|
||
"reconnects": reconnects,
|
||
"average_request_elapsed_ms": round(latency_total / total, 2) if total else 0,
|
||
"max_request_elapsed_ms": max_latency,
|
||
"last_ok": bool(row["ok"]),
|
||
"last_error": last_error,
|
||
"acceptance_24h_complete": elapsed_seconds >= 86400 and failures == 0,
|
||
"hook_required": args.require_hook,
|
||
"recovery_events_file": str(events_path),
|
||
"samples_file": str(samples_path),
|
||
}
|
||
write_json(summary_path, summary)
|
||
if not stop:
|
||
time.sleep(max(args.interval_seconds, 5))
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|