#!/usr/bin/env python3 """Post a latency window-comparison card to Feishu. Optional companion to the Product Metrics Comparison Report workflow. Consumes the comparison.json emitted by compare_backend_latency_reports.py (NOT the daily slow-log report.json) and renders a before/after delta card: global timeout / slow-rate / P95 movement plus the routes that improved or regressed most. Network + auth logic mirrors notify_feishu_daily_prod_report.py. """ from __future__ import annotations import argparse import json from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen FEISHU_API = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" MAX_ROUTE_ROWS = 8 MAX_ROUTE_CHARS = 48 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--app-id", required=True) parser.add_argument("--app-secret", required=True) parser.add_argument("--chat-id", required=True) parser.add_argument("--comparison-json", required=True) parser.add_argument("--run-url", required=True) return parser.parse_args() def post_json(url: str, payload: dict[str, object], headers: dict[str, str] | None = None) -> dict[str, object]: merged_headers = {"Content-Type": "application/json; charset=utf-8"} if headers: merged_headers.update(headers) request = Request(url, data=json.dumps(payload).encode("utf-8"), headers=merged_headers, method="POST") try: with urlopen(request, timeout=30) as response: return json.loads(response.read().decode("utf-8")) except HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {exc.code} calling {url}: {body}") from exc except URLError as exc: raise RuntimeError(f"network error calling {url}: {exc}") from exc def fetch_tenant_access_token(app_id: str, app_secret: str) -> str: payload = post_json( "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {"app_id": app_id, "app_secret": app_secret}, ) token = str(payload.get("tenant_access_token", "")).strip() if not token: raise RuntimeError(f"missing tenant_access_token in Feishu response: {json.dumps(payload, ensure_ascii=True)}") return token def fmt_pct(value: object) -> str: try: return f"{float(value):.2f}%" except (TypeError, ValueError): return "-" def fmt_change(value: object) -> str: """Signed pct change with an arrow; None (no baseline) -> 'n/a'.""" if value is None: return "n/a" try: v = float(value) except (TypeError, ValueError): return "-" arrow = "🔺" if v > 0 else ("🟢" if v < 0 else "▪") sign = "+" if v > 0 else "" return f"{arrow} {sign}{v:.2f}%" def truncate(value: object, limit: int) -> str: text = str(value if value is not None else "").replace("\n", " ").replace("|", "/").strip() return text[: limit - 1].rstrip() + "
" if len(text) > limit else text def metric_lookup(comparison: dict, label: str) -> dict: for m in comparison.get("global_metrics", []): if m.get("metric") == label: return m return {} def build_card(comparison: dict, run_url: str) -> dict: before_lbl = comparison.get("before_date", "before") after_lbl = comparison.get("after_date", "after") slow = metric_lookup(comparison, "Slow Rate %") inter = metric_lookup(comparison, "Interactive Slow Rate %") p95 = metric_lookup(comparison, "Global P95") p99 = metric_lookup(comparison, "Global P99") def short(key: str, value: str) -> dict: return {"is_short": True, "text": {"tag": "lark_md", "content": f"**{key}**\n{value}"}} elements: list[dict] = [ {"tag": "div", "text": {"tag": "lark_md", "content": f"**对比窗口**\n前: {before_lbl} ({comparison.get('before_total_requests',0)} 请求)\n" f"后: {after_lbl} ({comparison.get('after_total_requests',0)} 请求)"}}, {"tag": "hr"}, {"tag": "div", "fields": [ short("超时率", f"{fmt_pct(slow.get('before'))} → {fmt_pct(slow.get('after'))}"), short("超时率变化", fmt_change(slow.get("change_pct"))), short("亀互超时率", f"{fmt_pct(inter.get('before'))} → {fmt_pct(inter.get('after'))}"), short("亀互超时率变化", fmt_change(inter.get("change_pct"))), short("P95", f"{p95.get('before','-')} → {p95.get('after','-')} ms"), short("P95 变化", fmt_change(p95.get("change_pct"))), short("P99", f"{p99.get('before','-')} → {p99.get('after','-')} ms"), short("P99 变化", fmt_change(p99.get("change_pct"))), ]}, {"tag": "hr"}, ] routes = comparison.get("route_comparisons", []) improved = sorted((r for r in routes if (r.get("change_p95_latency_ms") or 0) < -5), key=lambda r: r["change_p95_latency_ms"])[:MAX_ROUTE_ROWS] degraded = sorted((r for r in routes if (r.get("change_p95_latency_ms") or 0) > 5), key=lambda r: -r["change_p95_latency_ms"])[:MAX_ROUTE_ROWS] def route_lines(rows: list[dict]) -> str: if not rows: return "_无_" return "\n".join( f"`{truncate(r['route'], MAX_ROUTE_CHARS)}` " f"{r.get('before_p95_latency_ms','-')}→{r.get('after_p95_latency_ms','-')}ms " f"({fmt_change(r.get('change_p95_latency_ms'))})" for r in rows ) elements.append({"tag": "div", "text": {"tag": "lark_md", "content": f"**🟢 变奜的接口 (P95 降 >5%)**\n{route_lines(improved)}"}}) elements.append({"tag": "div", "text": {"tag": "lark_md", "content": f"**🔺 变差的接口 (P95 升 >5%)**\n{route_lines(degraded)}"}}) elements.append({"tag": "div", "text": {"tag": "lark_md", "content": f"[查看完敎报告]({run_url})"}}) header_tpl = "red" if degraded else "green" return { "header": {"template": header_tpl, "title": {"tag": "plain_text", "content": "产品指标对比报告 / Metrics Comparison"}}, "elements": elements, } def send_chat_message(token: str, chat_id: str, card: dict) -> None: payload = { "receive_id": chat_id, "msg_type": "interactive", "content": json.dumps(card, ensure_ascii=False), } resp = post_json(FEISHU_API, payload, headers={"Authorization": f"Bearer {token}"}) if resp.get("code") not in (0, None): raise RuntimeError(f"Feishu send message failed: {json.dumps(resp, ensure_ascii=True)}") def main() -> int: args = parse_args() comparison = json.loads(Path(args.comparison_json).read_text(encoding="utf-8")) token = fetch_tenant_access_token(args.app_id, args.app_secret) card = build_card(comparison, args.run_url) send_chat_message(token, args.chat_id, card) print("Feishu comparison card sent") return 0 if __name__ == "__main__": raise SystemExit(main())