Compare commits

..

34 Commits

Author SHA1 Message Date
guke 9f804bf0cb Merge branch 'main' into feat-compare-fail-alert
冲突解决:compare_alert_worker.py / test_compare_alert_stuck_worker.py /
2026-08-05-compare-stuck-detection-design.md 三处均以本地(ours)为准——
保留「cancelled 可读但无卡点时回退耗时兜底(超长放弃不漏报)」的修复,
覆盖 main 带入的旧行为(可读无卡点→不报)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 13:59:17 +08:00
guke 63daeeaf9b fix(compare-alert): cancelled trace 可读但无卡点时回退耗时兜底(超长放弃不再漏报)
线上 trace 几乎总可读,旧逻辑「可读但没判出卡点 → 不报」使 total_ms>90s 阈值形同
虚设,超长放弃(实测 113s / 516s)一条都报不出。改为只有判出卡点才独占带卡点的 T5,
其余(可读没卡点 / 读不到 / 无 trace)一律回退耗时/步数兜底,超长照报(卡点列留空)。
同步更新 stuck-detection 设计文档 6.1 + 修订说明。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 13:52:48 +08:00
guke 523d970c45 chore(compare-alert): 扫描间隔默认 30min→15min + 同步 test_defaults
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 17:09:45 +08:00
guke 44beb3b8de Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat-compare-fail-alert
# Conflicts:
#	app/admin/repositories/queries.py
2026-08-05 17:03:48 +08:00
guke 62b30342ed refactor(compare-alert): 补 _fmt_stuck 类型注解 + 卡片 docstring 列序 + failed 卡点注释
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:51:06 +08:00
guke 347c4c7de4 feat(compare-alert): 卡点独立成列(AlertHit.stuck_point + 卡片第5列),reason 去重简化
- AlertHit 加 stuck_point: str | None = None 字段(格式化好的「平台·环节 帧/s」)
- classify_cancelled_fallback reason 简化为「深度放弃」(耗时/步数已在「用时」列,不重复)
- build_hits 加 _fmt_stuck helper;cancelled 卡死 stuck_point=「美团·加菜 110帧/32s」;
  failed stuck_point=环节标签、reason 不再附「卡在 X」
- format_alert_card 列序改为 时间/手机号/用时/失败原因/卡点/版本/trace (7列)
- 同步更新 test_compare_alert_stuck_worker / _fallback / _format / _rules 断言

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:47:23 +08:00
guke e135ba9a84 fix(compare-alert): stuck_ms 负值(时钟回退)降级为 None
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:33:30 +08:00
guke dd96fc2151 feat(compare-alert): StuckPoint 加 stuck_ms(末段卡住时长,读帧 timestamp)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 16:27:07 +08:00
guke fed3541a51 refactor(compare-alert): 抽 _build_card 消除卡片三路径重复 + grouped 显式化
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:47:49 +08:00
guke 924e40a84e feat(compare-alert): 固化飞书卡片 table 格式(format_alert_card + send_feishu_card),worker 切换
- feishu_notifier: 新增 send_feishu_card(interactive msg_type,复用 _post_feishu)
- compare_alert_format: 新增 format_alert_card(schema 2.0, header red, markdown摘要+table 6列)
  - 列序: 时间/手机号/用时/失败原因/版本/trace(lark_md);无 width 属性
  - cost 列 helper _cost_cell: total_ms→Ns / step_count→M步 / 两者用" / "连 / 都无给"-"
  - 截断: 超 max_total 只出摘要; 空 hits 返回「本期无异常」卡片
  - 保留 format_alert_message / format_alert_post(有测试依赖)
- compare_alert_worker: _send(post) → _send_card(card); _scan_and_alert 调 format_alert_card
  - SEND_EMPTY 分支: 传空 hits 给 format_alert_card 得「本期无异常」卡片
  - webhook 空降级保留; build_hits/水位逻辑不动
- tests: format/feishu/worker 测试全适配新接口,86 passed 零回归
- 删除临时脚本 scripts/_test_alert_card.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:37:13 +08:00
guke 9598c7a1da feat(compare-alert): AlertHit 加 total_ms/step_count(卡片用时列数据源)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 15:27:47 +08:00
guke 0663ee5542 fix(compare-alert): worker import 排序 + build_hits 返回类型 + 共享预算测试
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:52:47 +08:00
guke 45a8e7b972 feat(compare-alert): worker 编排 build_hits(cancelled trace 优先+保底、failed 附卡点)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:41:13 +08:00
guke 02d2e56ef6 feat(compare-alert): 抽出 classify_cancelled_fallback + 公开 make_hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:32:53 +08:00
guke a7e8141497 fix(compare-alert): trace_stuck _read_head 防损坏帧 UnicodeDecodeError 崩溃
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:29:25 +08:00
guke c930957e90 feat(compare-alert): trace_stuck 卡死判据(末段原地打转)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:19:53 +08:00
guke 6c143dc9f2 feat(compare-alert): 卡死定位 4 个配置项
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:14:06 +08:00
guke b6ece681f3 docs(compare-alert): 卡死定位实现计划(4 tasks, TDD)
trace_stuck 模块 + compare_alert fallback 抽取 + worker build_hits 编排;
每 task 含完整测试代码、精确文件路径与命令。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 14:07:38 +08:00
guke 818ae1c9e1 docs(compare-alert): 卡死定位报警增强设计 spec
cancelled 判据从整场耗时/帧数改为 trace 末段原地打转(逐平台判、
读到确认没卡则信 trace、仅读不到才回退保底);failed 类附卡点;
同机直读 pricebot work_logs、不改 pricebot、不落库。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 13:49:39 +08:00
guke 4becde8d75 fix(compare-alert): 开关/webhook 默认值加注释提醒放 .env + conftest 隔离 test_defaults
飞书 webhook(敏感)和 ENABLED 不该硬编码进 config.py 默认值(会泄露进仓库/误带到生产默认开),
统一放 .env(gitignore)。conftest 强制 COMPARE_ALERT_ENABLED=false, test_defaults 不受 .env 干扰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 10:20:49 +08:00
guke af229e2a7b feat(compare-alert): 飞书消息改行式富文本(明细含手机号/版本/原因/trace超链接)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 10:12:49 +08:00
guke 576b94b4bb fix(compare-alert): 飞书时间标签用北京时区(CN_TZ) + main.py import 排序
final review 发现 window_label 用 datetime.now() 本地时钟,UTC 服务器上飞书标题时间差 8h;改用 CN_TZ。
顺带 ruff --fix 修 main.py 挂载 worker 时引入的 import 排序(isort)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 19:56:48 +08:00
guke 46247fb3a9 feat(compare-alert): lifespan 挂载 compare-alert worker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:38:11 +08:00
guke d0169ffb54 feat(compare-alert): 扫描 worker(水位/冷启动/发送失败不推进)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:34:02 +08:00
guke bc321c1c64 feat(compare-alert): 飞书群机器人 notifier(关键词验证,无签名)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:30:17 +08:00
guke 7891984cd1 feat(compare-alert): 飞书汇总消息格式化(分组/两级截断/关键词)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:28:11 +08:00
guke 20cbc9e35e feat(compare-alert): 记录级报警规则纯函数(T1/T2/T5/T6)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:25:44 +08:00
guke 02d6300442 feat(compare-alert): 加 COMPARE_ALERT_* 配置项与关键词解析
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:22:44 +08:00
guke 46ffa41931 feat(compare-alert): comparison_record 加 updated_at 列+索引+回填(报警水位)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-04 19:20:23 +08:00
guke 01f97e72a4 docs: 新增比价失败报警机制设计文档
结合 comparison_record 全量数据分析(3867行)设计记录级失败报警:
T1系统技术失败 / T2超时启动 / T6商品识别失败 / T5 cancelled深度放弃,
常驻 worker 周期扫描 + updated_at 水位(零漏报) + 飞书汇总。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:04:26 +08:00
guke 5a66c302cb fix(admin): 修 queries import 排序 + 补比价详情 admin 字段断言
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 16:01:24 +08:00
guke 09b9381d03 feat(admin): 比价记录列表/详情下发 admin_status + outcome_hint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:48:55 +08:00
guke 7419f35f4b fix(admin): 口径模块 SQL 侧 nullif 对齐空串,消除 Python/SQL 分歧
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:44:58 +08:00
guke 9de73152ec feat(admin): 比价记录展示口径共享模块(外部缺失判为成功)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 15:35:32 +08:00
8 changed files with 16 additions and 166 deletions
+2 -7
View File
@@ -127,16 +127,13 @@ 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:
@@ -229,8 +226,6 @@ 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:
+1 -30
View File
@@ -64,31 +64,6 @@ 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,
*,
@@ -112,11 +87,7 @@ 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])
# 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 # 纯业务失败,不报
return None
if status == "cancelled":
return classify_cancelled_fallback(
rec,
+1 -26
View File
@@ -180,26 +180,12 @@ _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"},
]
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],
*,
@@ -208,8 +194,6 @@ 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 发送)。
@@ -241,15 +225,6 @@ 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"
+5 -18
View File
@@ -46,22 +46,17 @@ 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)
base = f"{p}·{s}"
if self.detected_page:
base += f"·{self.detected_page}" # 页面暂用 pricebot 原值(英文),无中文映射
return base
return f"{p}·{s}"
@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:
@@ -113,7 +108,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, detected_page=last_pg)
return StuckPoint(platform, last_ps, count, stuck_ms)
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
@@ -123,8 +118,6 @@ 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
@@ -135,15 +128,9 @@ 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, last=last)
return StuckResult(readable=True, points=points)
except OSError:
return StuckResult(readable=False, points=[])
@@ -164,9 +151,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), detected_page=pg) # stuck_ms=None(failed 不算时长)
return StuckPoint(platform, ps, len(step_files)) # stuck_ms=None(failed 不算时长)
except OSError:
return None
+2 -28
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")
@@ -570,29 +570,3 @@ 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
-31
View File
@@ -59,34 +59,3 @@ 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 -3
View File
@@ -46,11 +46,10 @@ 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")
@@ -62,7 +61,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 == "饿了么·进店·store" # 卡点 → 用末帧(平台·环节·页面)标退出前在哪屏
assert hits[0].stuck_point is None # 卡点 → 卡片卡点列显 "-"
def test_cancelled_readable_not_stuck_short_no_report(tmp_path):
+3 -23
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, detected_page="meal_detail_popup")]
assert res.points == [StuckPoint("meituan", "add_one_dish", 20)]
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, detected_page="meal_detail_popup")]
assert res.points == [StuckPoint("meituan", "add_one_dish", 18)]
def test_last_step_returns_busiest_platform_last_env(tmp_path):
@@ -92,27 +92,7 @@ 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, 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 == [] # 没卡死,但末帧照样有
assert sp == StuckPoint("meituan", "add_one_dish", 20)
def test_dir_name_from_trace_url():