feat(compare-alert): failed 混合单某平台真技术失败被业务 headline 盖住时补判 T1 #222

Merged
guke merged 4 commits from feat-compare-alert-mixed-failure-criteria into main 2026-08-06 16:45:46 +08:00
6 changed files with 52 additions and 15 deletions
+5 -2
View File
@@ -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:
+1 -1
View File
@@ -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"},
]
+18 -5
View File
@@ -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
+2 -2
View File
@@ -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")
+3 -2
View File
@@ -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):
+23 -3
View File
@@ -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():