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:
@@ -20,13 +20,20 @@ import asyncio
|
||||
import subprocess
|
||||
import signal
|
||||
import os
|
||||
from typing import Optional, List
|
||||
from collections import deque
|
||||
from typing import Deque, Optional, List
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..schemas import CrawlerStartRequest, LogEntry
|
||||
from .interpreter import resolve_python_cmd
|
||||
|
||||
# 留住多少行爬虫输出,供 run_and_wait 的调用方诊断失败原因。
|
||||
# 子进程的输出本来只流向日志 WebSocket,监控层只看得到退出码 —— 于是「退出码 1」
|
||||
# 成了运行历史里唯一的信息,真正的报错(比如抖音的 `DataFetchError: account blocked`)
|
||||
# 谁也看不到。留个尾巴,让失败原因能被写进 run.error_message。
|
||||
OUTPUT_TAIL_LINES = 80
|
||||
|
||||
|
||||
class CrawlerManager:
|
||||
"""Crawler process manager"""
|
||||
@@ -49,6 +56,15 @@ class CrawlerManager:
|
||||
# by any concurrent start(), so waiters need an explicit event instead.
|
||||
self._done: asyncio.Event = asyncio.Event()
|
||||
self.last_exit_code: Optional[int] = None
|
||||
# 本次运行输出的末尾若干行。见 OUTPUT_TAIL_LINES。
|
||||
self._output_tail: Deque[str] = deque(maxlen=OUTPUT_TAIL_LINES)
|
||||
|
||||
def get_output_tail(self) -> List[str]:
|
||||
"""最近一次运行的输出尾巴(最早的排前面)。
|
||||
|
||||
只在 run_and_wait() 返回之后读才有意义 —— 它等到读输出的任务收尾才唤醒。
|
||||
"""
|
||||
return list(self._output_tail)
|
||||
|
||||
@property
|
||||
def logs(self) -> List[LogEntry]:
|
||||
@@ -114,6 +130,9 @@ class CrawlerManager:
|
||||
|
||||
async def _push_log(self, entry: LogEntry):
|
||||
"""Push log to queue"""
|
||||
# 这里是所有输出的唯一出口(读循环、收尾、以及管理器自己的提示都走它),
|
||||
# 所以尾巴挂在这儿最省事,也不会漏。
|
||||
self._output_tail.append(entry.message)
|
||||
if self._log_queue is not None:
|
||||
try:
|
||||
self._log_queue.put_nowait(entry)
|
||||
@@ -149,6 +168,8 @@ class CrawlerManager:
|
||||
# Reset completion signalling for this run
|
||||
self._done.clear()
|
||||
self.last_exit_code = None
|
||||
# 尾巴只属于本次运行,否则上一轮的报错会混进这一轮的诊断里。
|
||||
self._output_tail.clear()
|
||||
|
||||
# Clear pending queue (don't replace object to avoid WebSocket broadcast coroutine holding old queue reference)
|
||||
if self._log_queue is None:
|
||||
|
||||
Reference in New Issue
Block a user