From 50ae83ee4348e4066d543c6f6aceab09a4176c73 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 6 Aug 2026 15:42:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(compare-alert):=20failed=20=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E5=8D=95=E6=9F=90=E5=B9=B3=E5=8F=B0=E7=9C=9F=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E5=A4=B1=E8=B4=A5=E8=A2=AB=E4=B8=9A=E5=8A=A1=20headli?= =?UTF-8?q?ne=20=E7=9B=96=E4=BD=8F=E6=97=B6=E8=A1=A5=E5=88=A4=20T1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一条比价里若某平台是干净业务结局(京东 items_not_found),fail_reason 会被派生成它当 headline,盖住另一平台的真技术崩溃(淘宝 status=failed / 比价过程出错),报警只看这单一 fail_reason → 静默漏报。新增 _target_technical_failure_reason 扫 raw_payload.platform_results: 任一目标平台 status=failed 且 reason 非业务话术(biz_exclude 过滤打烊/未找到等误标)→ 补判 T1。 纯业务失败仍不误报。线上 trace 20260806_144544。 Co-Authored-By: Claude Opus 4.8 --- app/services/compare_alert.py | 31 ++++++++++++++++++++++++++++++- tests/test_compare_alert_rules.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/services/compare_alert.py b/app/services/compare_alert.py index 7cc114d..ca4966c 100644 --- a/app/services/compare_alert.py +++ b/app/services/compare_alert.py @@ -64,6 +64,31 @@ def classify_cancelled_fallback( return None +def _target_technical_failure_reason( + rec: Any, biz_exclude_keywords: tuple[str, ...] +) -> str | None: + """某目标平台「真技术失败」(pricebot 原始 status=='failed' 且 reason 非业务话术)的原因;无 → None。 + + 记录级 fail_reason 是「展示口径」——一条比价里若某平台是干净业务结局(京东未找到菜),它会被派生 + 成 headline,盖住另一平台的真技术崩溃(淘宝'比价过程出错')。这里扫 raw_payload.platform_results + 补判:任一目标平台 status='failed' 且 reason 不含业务词(pricebot 偶把打烊/不配送漏标成 failed, + 用 biz_exclude 过滤掉这些业务误标)→ 真技术崩溃。缺 raw_payload/platform_results / 结构异常 → None。 + """ + raw = getattr(rec, "raw_payload", None) + pr = raw.get("platform_results") if isinstance(raw, dict) else None + if not isinstance(pr, dict): + return None + for v in pr.values(): + if not isinstance(v, dict) or v.get("is_source"): + continue + if v.get("status") != "failed": + continue + reason = (v.get("reason") or "").strip() + if not any(w in reason for w in biz_exclude_keywords): + return reason or "比价过程出错" + return None + + def classify_record( rec: Any, *, @@ -87,7 +112,11 @@ def classify_record( return make_hit(rec, "T6", f"识别失败·{fail_reason[:80]}") if any(w in fail_reason for w in timeout_keywords): return make_hit(rec, "T2", fail_reason[:80]) - return None + # fail_reason 是干净业务 headline,但可能盖住某目标平台的真技术失败(比价过程出错)→ 补判 T1。 + tech = _target_technical_failure_reason(rec, biz_exclude_keywords) + if tech: + return make_hit(rec, "T1", f"技术失败·{tech[:80]}") + return None # 纯业务失败,不报 if status == "cancelled": return classify_cancelled_fallback( rec, diff --git a/tests/test_compare_alert_rules.py b/tests/test_compare_alert_rules.py index 712645c..80523f7 100644 --- a/tests/test_compare_alert_rules.py +++ b/tests/test_compare_alert_rules.py @@ -59,3 +59,34 @@ def test_reason_texts(): assert t1_empty.reason == "技术失败·比价过程出错" t5 = classify_record(_rec(status="cancelled", total_ms=98000, step_count=26), **KW) assert t5.reason == "深度放弃" + + +# ---- failed 混合单:业务 headline 盖住某平台真技术失败(线上 trace 20260806_144544)---- + +def test_failed_business_headline_masks_target_technical_failure_reports_t1(): + # fail_reason 被派生成京东业务原因(未找到),却盖住淘宝 status=failed 的真技术崩溃 → 补判 T1。 + rec = _rec( + status="failed", fail_reason="京东此店内未找到这些菜品", + information="比价过程出错,请稍后重试", + raw_payload={"platform_results": { + "meituan": {"status": "source", "is_source": True}, + "jd_waimai": {"status": "items_not_found", "reason": "京东此店内未找到这些菜品", "is_source": False}, + "taobao_flash": {"status": "failed", "reason": "比价过程出错,请稍后重试", "is_source": False}, + }}, + ) + hit = classify_record(rec, **KW) + assert hit is not None + assert hit.alert_type == "T1" + + +def test_failed_target_failed_but_business_reason_no_false_positive(): + # pricebot 把打烊漏标成 status=failed,但 reason 是业务话术 → 不算技术崩溃,不报(不误报)。 + rec = _rec( + status="failed", fail_reason="京东此店内未找到这些菜品", + information="比价过程出错,请稍后重试", + raw_payload={"platform_results": { + "jd_waimai": {"status": "items_not_found", "reason": "京东此店内未找到这些菜品", "is_source": False}, + "taobao_flash": {"status": "failed", "reason": "门店已打烊,无法比价", "is_source": False}, + }}, + ) + assert classify_record(rec, **KW) is None -- 2.52.0 From 4cc667f5be46e2aba37f406e094b95217c0f9125 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 6 Aug 2026 15:42:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(compare-alert):=20=E6=8A=A5=E8=AD=A6?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=91=98=E8=A6=81=E5=8A=A0=E3=80=8C=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=E3=80=8D=E8=AF=B4=E6=98=8E(=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E6=94=BE=E5=BC=83=E5=B8=A6=E9=85=8D=E7=BD=AE=E9=98=88=E5=80=BC?= =?UTF-8?q?=20/=20=E6=8A=80=E6=9C=AF=E5=A4=B1=E8=B4=A5=E5=8F=A3=E5=BE=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片摘要追加一行判据,让收报警的人一眼知道为什么报:深度放弃=耗时>{配置}s 或 步数>{配置} (阈值取自 COMPARE_ALERT_CANCELLED_MS/STEP_THRESHOLD、随配置变);系统技术失败=无业务原因的 系统错 或 任一平台 status=failed;T2/T6 同理,只列当期出现的类型。追加进摘要 markdown、不新增 卡片元素(不打断 table 位置)。format_alert_card 加两个阈值参数(默认=config 默认,worker 传实值)。 Co-Authored-By: Claude Opus 4.8 --- app/core/compare_alert_worker.py | 2 ++ app/services/compare_alert_format.py | 25 +++++++++++++++++++++++++ tests/test_compare_alert_format.py | 26 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/app/core/compare_alert_worker.py b/app/core/compare_alert_worker.py index 60a1dcb..4a9778b 100644 --- a/app/core/compare_alert_worker.py +++ b/app/core/compare_alert_worker.py @@ -226,6 +226,8 @@ def _scan_and_alert() -> None: interval_min=interval_min, max_detail_per_type=settings.COMPARE_ALERT_MAX_DETAIL_PER_TYPE, max_total=settings.COMPARE_ALERT_MAX_TOTAL, + cancelled_ms_threshold=settings.COMPARE_ALERT_CANCELLED_MS_THRESHOLD, + cancelled_step_threshold=settings.COMPARE_ALERT_CANCELLED_STEP_THRESHOLD, ) try: diff --git a/app/services/compare_alert_format.py b/app/services/compare_alert_format.py index 5542a14..5168e70 100644 --- a/app/services/compare_alert_format.py +++ b/app/services/compare_alert_format.py @@ -186,6 +186,20 @@ _TABLE_COLUMNS = [ ] +def _type_criteria(t: str, cancelled_ms_threshold: int, cancelled_step_threshold: int) -> str: + """各触发类型的「判据」文案,展示在卡片摘要里让收报警的人一眼知道为什么报。 + T5 带当前配置阈值(耗时 ms→s、步数);T1 覆盖两条来源(整场系统错 + 混合单里任一平台 status=failed)。""" + if t == "T5": + return f"耗时>{round(cancelled_ms_threshold / 1000)}s 或 步数>{cancelled_step_threshold}" + if t == "T1": + return "无业务原因的系统错 或 任一平台 status=failed" + if t == "T2": + return "原因含 超时/启动/加载" + if t == "T6": + return "原因含 未识别" + return "" + + def format_alert_card( hits: list[AlertHit], *, @@ -194,6 +208,8 @@ def format_alert_card( interval_min: int, max_detail_per_type: int, max_total: int, + cancelled_ms_threshold: int = 90000, + cancelled_step_threshold: int = 30, ) -> dict: """返回飞书 schema 2.0 卡片 dict(配合 send_feishu_card 发送)。 @@ -225,6 +241,15 @@ def format_alert_card( f"**合计 {total} 条**:" + " | ".join(count_parts) ) + # ---------- 判据说明(本期出现的类型各给一行判据,T5 带当前配置阈值)---------- + criteria_parts = [ + f"{ALERT_TYPE_LABELS[t]}={_type_criteria(t, cancelled_ms_threshold, cancelled_step_threshold)}" + for t in _TYPE_ORDER + if grouped_count.get(t) + ] + if criteria_parts: + md_content += "\n判据:" + " | ".join(criteria_parts) + # ---------- 截断:超 max_total 只出摘要 ---------- if total > max_total: md_content += f"\n超 {max_total} 条仅列计数,明细见分析库 comparison_record" diff --git a/tests/test_compare_alert_format.py b/tests/test_compare_alert_format.py index 23f0dba..fb07bd0 100644 --- a/tests/test_compare_alert_format.py +++ b/tests/test_compare_alert_format.py @@ -570,3 +570,29 @@ def test_card_stuck_point_shown_in_row(): ) row_without = card_without["body"]["elements"][1]["rows"][0] assert row_without["stuck"] == "-" + + +def test_card_shows_criteria_legend_with_config_thresholds(): + """卡片摘要含「判据」说明:深度放弃带当前配置阈值、技术失败说明任一平台 status=failed。""" + hits = _card_hits(1, "T5", total_ms=95000) + _card_hits(1, "T1") + card = format_alert_card( + hits, window_label="w", phone_map={}, interval_min=15, + max_detail_per_type=20, max_total=50, + cancelled_ms_threshold=90000, cancelled_step_threshold=30, + ) + md = card["body"]["elements"][0]["content"] + assert "判据" in md + assert "耗时>90s 或 步数>30" in md # 深度放弃判据带配置值 + assert "任一平台 status=failed" in md # 技术失败判据 + + +def test_card_criteria_reflects_custom_thresholds(): + """判据里的阈值随配置变化(不是写死 90/30)。""" + card = format_alert_card( + _card_hits(1, "T5", total_ms=200000), + window_label="w", phone_map={}, interval_min=15, + max_detail_per_type=20, max_total=50, + cancelled_ms_threshold=180000, cancelled_step_threshold=45, + ) + md = card["body"]["elements"][0]["content"] + assert "耗时>180s 或 步数>45" in md -- 2.52.0 From b3138ef6c3d1c7c3cbe94ea3b460070c3eef77bd Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 6 Aug 2026 16:39:15 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(compare-alert):=20=E6=89=80=E6=9C=89?= =?UTF-8?q?=E6=8A=A5=E8=AD=A6=E5=B8=A6=E6=9C=AB=E5=B8=A7=E3=80=8C=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=C2=B7=E7=8E=AF=E8=8A=82=C2=B7=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E3=80=8D(=E9=80=80=E5=87=BA/=E5=A4=B1=E8=B4=A5=E5=89=8D?= =?UTF-8?q?=E5=9C=A8=E5=93=AA=E5=B1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给每条报警补末帧定位:trace_stuck.StuckPoint 加 detected_page、label() 带出页面; read_stuck_points 复用同一次读顺带返回 last(末帧,帧数最多平台的末帧);last_step 带页面。 worker 里 cancelled 走耗时兜底(超阈值但没卡点,如秒退)时用 res.last 补末帧;卡死/失败经 label() 自动带页面。卡片列名「卡点」→「末帧」。页面暂用 pricebot detected_page 原始英文 (待看清线上枚举后再补中文映射)。 Co-Authored-By: Claude Opus 4.8 --- app/core/compare_alert_worker.py | 7 +++++-- app/services/compare_alert_format.py | 2 +- app/services/trace_stuck.py | 23 ++++++++++++++++----- tests/test_compare_alert_format.py | 4 ++-- tests/test_compare_alert_stuck_worker.py | 5 +++-- tests/test_trace_stuck.py | 26 +++++++++++++++++++++--- 6 files changed, 52 insertions(+), 15 deletions(-) diff --git a/app/core/compare_alert_worker.py b/app/core/compare_alert_worker.py index 4a9778b..e0f0477 100644 --- a/app/core/compare_alert_worker.py +++ b/app/core/compare_alert_worker.py @@ -127,13 +127,16 @@ def build_hits( hit = _dc_replace(make_hit(rec, "T5", "深度放弃"), stuck_point=stuck) else: # trace 判出卡点 → 上面带卡点报。其余一律回退耗时/步数兜底:可读但没判出卡点、 - # 读不到、无 trace,都过 total_ms/step 阈值——超长放弃照报(卡点列留空),不再因 - # 「trace 可读但不原地卡」把超长放弃整条吞掉(线上 trace 几乎总可读,否则耗时阈值形同虚设)。 + # 读不到、无 trace,都过 total_ms/step 阈值——超长放弃照报,不再因「trace 可读但不 + # 原地卡」把超长放弃整条吞掉(线上 trace 几乎总可读,否则耗时阈值形同虚设)。 hit = classify_cancelled_fallback( rec, cancelled_ms_threshold=cancelled_ms_threshold, cancelled_step_threshold=cancelled_step_threshold, ) + # 没卡点但命中(超阈值放弃)→ 用末帧标「退出前在哪屏」(平台·环节·页面) + if hit is not None and res is not None and res.last is not None: + hit = _dc_replace(hit, stuck_point=res.last.label()) if hit is not None: hits.append(hit) else: diff --git a/app/services/compare_alert_format.py b/app/services/compare_alert_format.py index 5168e70..4babbb0 100644 --- a/app/services/compare_alert_format.py +++ b/app/services/compare_alert_format.py @@ -180,7 +180,7 @@ _TABLE_COLUMNS = [ {"name": "phone", "display_name": "手机号", "data_type": "text"}, {"name": "cost", "display_name": "用时", "data_type": "text"}, {"name": "reason", "display_name": "失败原因", "data_type": "text"}, - {"name": "stuck", "display_name": "卡点", "data_type": "text"}, + {"name": "stuck", "display_name": "末帧", "data_type": "text"}, {"name": "ver", "display_name": "版本", "data_type": "text"}, {"name": "trace", "display_name": "trace", "data_type": "lark_md"}, ] diff --git a/app/services/trace_stuck.py b/app/services/trace_stuck.py index 5f982cc..7142cf4 100644 --- a/app/services/trace_stuck.py +++ b/app/services/trace_stuck.py @@ -46,17 +46,22 @@ class StuckPoint: pipeline_step: str frames: int # 末段连续困住的帧数(上限 max_tail) stuck_ms: int | None = None # 末段连续卡住的时长(ms);无 timestamp 时 None + detected_page: str | None = None # 末帧所在页面(pricebot detected_page 原值);无则 None def label(self) -> str: p = PLATFORM_LABELS.get(self.platform, self.platform) s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step) - return f"{p}·{s}" + base = f"{p}·{s}" + if self.detected_page: + base += f"·{self.detected_page}" # 页面暂用 pricebot 原值(英文),无中文映射 + return base @dataclass(frozen=True) class StuckResult: readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」) points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死 + last: StuckPoint | None = None # 末帧(帧数最多平台的末帧,带 detected_page);读不到 → None def dir_name_from_trace_url(trace_url: str | None) -> str | None: @@ -108,7 +113,7 @@ def _platform_stuck( stuck_ms = round((t_last - t_first).total_seconds() * 1000) if t_last and t_first else None if stuck_ms is not None and stuck_ms < 0: stuck_ms = None # 时钟不单调(帧 timestamp 回退)→ 降级为不显示时长 - return StuckPoint(platform, last_ps, count, stuck_ms) + return StuckPoint(platform, last_ps, count, stuck_ms, detected_page=last_pg) def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult: @@ -118,6 +123,8 @@ def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> Stuc return StuckResult(readable=False, points=[]) points: list[StuckPoint] = [] any_frames = False + last: StuckPoint | None = None + best_n = -1 for pdir in sorted(trace_dir.iterdir()): if not pdir.is_dir(): continue @@ -128,9 +135,15 @@ def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> Stuc sp = _platform_stuck(pdir.name, step_files, threshold, max_tail) if sp is not None: points.append(sp) + # 末帧:取帧数最多平台的末帧(与 last_step 同口径),带 detected_page + if len(step_files) > best_n: + ps, pg, _ = _read_head(step_files[-1]) + if ps is not None: + best_n = len(step_files) + last = StuckPoint(pdir.name, ps, len(step_files), detected_page=pg) if not any_frames: return StuckResult(readable=False, points=[]) - return StuckResult(readable=True, points=points) + return StuckResult(readable=True, points=points, last=last) except OSError: return StuckResult(readable=False, points=[]) @@ -151,9 +164,9 @@ def last_step(trace_dir: Path) -> StuckPoint | None: if best is None: return None _, platform, step_files = best - ps, _pg, _ts = _read_head(step_files[-1]) + ps, pg, _ts = _read_head(step_files[-1]) if ps is None: return None - return StuckPoint(platform, ps, len(step_files)) # stuck_ms=None(failed 不算时长) + return StuckPoint(platform, ps, len(step_files), detected_page=pg) # stuck_ms=None(failed 不算时长) except OSError: return None diff --git a/tests/test_compare_alert_format.py b/tests/test_compare_alert_format.py index fb07bd0..45e312b 100644 --- a/tests/test_compare_alert_format.py +++ b/tests/test_compare_alert_format.py @@ -282,8 +282,8 @@ def test_card_has_table_seven_columns(): cols = table["columns"] assert len(cols) == 7 display_names = [c["display_name"] for c in cols] - assert display_names == ["时间", "手机号", "用时", "失败原因", "卡点", "版本", "trace"] - # 「卡点」列在「失败原因」后、「版本」前 + assert display_names == ["时间", "手机号", "用时", "失败原因", "末帧", "版本", "trace"] + # 「末帧」列在「失败原因」后、「版本」前 names = [c["name"] for c in cols] reason_idx = names.index("reason") stuck_idx = names.index("stuck") diff --git a/tests/test_compare_alert_stuck_worker.py b/tests/test_compare_alert_stuck_worker.py index de6a3d8..d3a835d 100644 --- a/tests/test_compare_alert_stuck_worker.py +++ b/tests/test_compare_alert_stuck_worker.py @@ -46,10 +46,11 @@ def test_cancelled_stuck_reports_via_trace(tmp_path): assert hits[0].reason == "深度放弃" assert "美团·加菜" in hits[0].stuck_point assert "帧" in hits[0].stuck_point + assert "meal_detail_popup" in hits[0].stuck_point # 卡点带末帧页面 def test_cancelled_readable_not_stuck_long_duration_reports(tmp_path): - # trace 可读但没判出原地卡点:仍过耗时/步数阈值兜底,超长(>90s)照报 T5,卡点列留空。 + # trace 可读但没判出原地卡点:仍过耗时/步数阈值兜底,超长(>90s)照报 T5;没卡点也用末帧标"退出前在哪屏"。 # (线上 trace 几乎总可读,若不回退则 total_ms 阈值形同虚设、超长放弃永不报——见 compare-fail-alert 排查。) p = tmp_path / "20260804_y" / "eleme" _frame(p, 0, "set_address", "home") @@ -61,7 +62,7 @@ def test_cancelled_readable_not_stuck_long_duration_reports(tmp_path): assert len(hits) == 1 assert hits[0].alert_type == "T5" assert hits[0].reason == "深度放弃" - assert hits[0].stuck_point is None # 没卡点 → 卡片卡点列显 "-" + assert hits[0].stuck_point == "饿了么·进店·store" # 无卡点 → 用末帧(平台·环节·页面)标退出前在哪屏 def test_cancelled_readable_not_stuck_short_no_report(tmp_path): diff --git a/tests/test_trace_stuck.py b/tests/test_trace_stuck.py index 72aa367..a1e0faf 100644 --- a/tests/test_trace_stuck.py +++ b/tests/test_trace_stuck.py @@ -29,7 +29,7 @@ def test_stuck_when_tail_repeats_same_step(tmp_path): _frame(pdir, i, "add_one_dish", "meal_detail_popup") res = read_stuck_points(tmp_path, threshold=15, max_tail=40) assert res.readable is True - assert res.points == [StuckPoint("meituan", "add_one_dish", 20)] + assert res.points == [StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup")] def test_not_stuck_when_progressing(tmp_path): @@ -81,7 +81,7 @@ def test_per_platform_one_stuck_one_normal(tmp_path): _frame(e, i, "enter_store", "store") res = read_stuck_points(tmp_path, threshold=15, max_tail=40) assert res.readable is True - assert res.points == [StuckPoint("meituan", "add_one_dish", 18)] + assert res.points == [StuckPoint("meituan", "add_one_dish", 18, detected_page="meal_detail_popup")] def test_last_step_returns_busiest_platform_last_env(tmp_path): @@ -92,7 +92,27 @@ def test_last_step_returns_busiest_platform_last_env(tmp_path): for i in range(3): _frame(e, i, "enter_store", "store") sp = last_step(tmp_path) - assert sp == StuckPoint("meituan", "add_one_dish", 20) + assert sp == StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup") + + +def test_stuck_point_label_includes_page(): + # label 带页面(平台/环节中文 + 页面原始英文);无页面时只到环节 + assert StuckPoint("meituan", "add_one_dish", 20, detected_page="meal_detail_popup").label() \ + == "美团·加菜·meal_detail_popup" + assert StuckPoint("meituan", "add_one_dish", 20).label() == "美团·加菜" + + +def test_read_stuck_points_returns_last_frame(tmp_path): + # res.last = 帧数最多平台的末帧(带 detected_page),供"退出前在哪屏"用 + m = tmp_path / "meituan" + for i in range(6): + _frame(m, i, "enter_store", "store") + _frame(m, 6, "add_one_dish", "menu") # 末帧换到 menu + e = tmp_path / "eleme" + _frame(e, 0, "set_address", "home") + res = read_stuck_points(tmp_path, threshold=15, max_tail=40) + assert res.last == StuckPoint("meituan", "add_one_dish", 7, detected_page="menu") + assert res.points == [] # 没卡死,但末帧照样有 def test_dir_name_from_trace_url(): -- 2.52.0