feat(monitor): 运行历史要写清楚失败原因,不能只写「退出码 1」
用户的要求:运行历史的说明要写清楚。现在确实写不清楚 —— 抖音那次失败,运行历史里 只有一句 `Crawler exited with code 1`,而真正的报错 `DataFetchError: account blocked` 埋在子进程的 stderr 里,谁也看不到。 那两者本来是断开的两条路:子进程的输出只流向日志 WebSocket(前端 Terminal 看得到), 而监控层调 run_and_wait() 只拿得到一个退出码。 * crawler_manager 在 _push_log() 里留一份输出尾巴(80 行,每次 start 清空)—— 那是所有输出的唯一出口,挂这儿不会漏。新增 get_output_tail()。 * ingest 新增 diagnose_failure():倒着找第一行像异常的行(traceback 的末行), 认不出就退回最后一行;管理器自己补的「Crawler exited with code」不是原因,排除掉。 * describe_exit_code() 接受这个原因并附在消息里;失败事件的标题也带上,这样企业微信 通知和事件流不用翻日志就能看懂。 * runner 把尾巴交给 ingest;「超时/没起来」那条分支同样带上原因 —— -1 同时代表两种 情况,而要查的东西完全不同。 * 运行历史那一格是截断的(240px),而失败原因现在有一整行 —— 补上 title 悬停显示, 并放宽到 320px。没有悬停提示等于把最要紧的半句藏起来。 测试 +5:能挑出异常行、不会把管理器自己的话当成原因、没有输出时不报错、认不出时退回 最后一行、以及失败运行同时记下退出码与真因(含事件标题)。
This commit is contained in:
@@ -37,7 +37,12 @@ from sqlalchemy.pool import StaticPool
|
||||
from tools.time_util import get_current_timestamp
|
||||
|
||||
from api.monitor import adapters
|
||||
from api.monitor.ingest import describe_exit_code, ingest_run, parse_count
|
||||
from api.monitor.ingest import (
|
||||
describe_exit_code,
|
||||
diagnose_failure,
|
||||
ingest_run,
|
||||
parse_count,
|
||||
)
|
||||
from api.monitor.models import (
|
||||
EVENT_AUTH_FAILURE,
|
||||
EVENT_METRIC_DELTA,
|
||||
@@ -750,3 +755,55 @@ class TestNicknameRefresh:
|
||||
assert (
|
||||
len(list((await db.scalars(select(MonitorComment))).all())) == 1
|
||||
)
|
||||
|
||||
|
||||
class TestFailureDiagnosis:
|
||||
"""失败原因要能被人看懂。
|
||||
|
||||
只写「退出码 1」等于什么都没说 —— 真正的报错埋在子进程的 stderr 里,而运行历史
|
||||
里那一格显示的正是 run.error_message。
|
||||
"""
|
||||
|
||||
TAIL = [
|
||||
"2026-10-10 15:18:34 MediaCrawler INFO (core.py:385) - [DouYinCrawler] CDP浏览器信息",
|
||||
"Traceback (most recent call last):",
|
||||
' File "/app/main.py", line 114, in main',
|
||||
" await crawler.start()",
|
||||
"media_platform.douyin.exception.DataFetchError: account blocked, ",
|
||||
]
|
||||
|
||||
def test_the_exception_line_is_picked_out_of_the_tail(self):
|
||||
assert (
|
||||
diagnose_failure(self.TAIL)
|
||||
== "media_platform.douyin.exception.DataFetchError: account blocked,"
|
||||
)
|
||||
|
||||
def test_the_managers_own_lines_are_not_mistaken_for_the_cause(self):
|
||||
"""管理器自己补的那两句不是爬虫的报错,别被当成失败原因。"""
|
||||
assert diagnose_failure(["Crawler exited with code: 1"]) is None
|
||||
assert diagnose_failure(["Crawler completed successfully"]) is None
|
||||
|
||||
def test_nothing_to_say_is_not_an_error(self):
|
||||
assert diagnose_failure(None) is None
|
||||
assert diagnose_failure([]) is None
|
||||
|
||||
def test_it_falls_back_to_the_last_line(self):
|
||||
assert (
|
||||
diagnose_failure(["started fine", "then something odd"])
|
||||
== "then something odd"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_run_records_both_the_code_and_the_cause(self, db, tmp_path):
|
||||
task = await _make_task(db)
|
||||
run = await _make_run(db, task, started_at=1, exit_code=1)
|
||||
|
||||
result = await ingest_run(db, run, task, tmp_path, output_tail=self.TAIL)
|
||||
|
||||
assert result.status == RUN_FAILED
|
||||
# 退出码和真因都要在,缺一个都还得去翻日志。
|
||||
assert "code 1" in run.error_message
|
||||
assert "account blocked" in run.error_message
|
||||
|
||||
events = await _events(db, EVENT_RUN_FAILED)
|
||||
assert "account blocked" in events[0].title
|
||||
|
||||
Reference in New Issue
Block a user