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():