dsh-log-triage
内网容器日志巡检与根因定位插件(DSH 0.2.1 线)。只读:只跑 docker ps/inspect/logs/stats、dmesg、df、free 与 systemctl 状态查询,不重启、不改配置、不写服务器。
安装
需要 DSH >=0.2.1-rc.1 <0.3.0-0 与 Node >=20。本仓库不发布到 npm("private": true),所以用 Git 地址或本地路径装,不要用裸包名:
dsh plugin --profile web add github:xinstart-1/dsh-log-triage
装完必须重启 DSH 才生效(host 半侧每进程只加载一次),然后到 设置 → 容器日志巡检 填 SSH 主机。
内网 / 离线机器:本包零依赖、无 build 步骤,npm pack 出一个 223KB 的 tarball 拷进去即可
(dsh plugin --profile web add file:C:/path/to/dsh-log-triage-0.2.0.tgz,实测 --offline 零下载)。
注意 GitHub 的 Download ZIP 不能用 —— 安装器只认 git 地址、file: 路径和 .tgz,没有 zip 分支。
图形界面路径、离线装法、验证步骤、只读边界与卸载,见 docs/INSTALL.md。
它解决什么
内网 Linux 服务器上日志通常没有落文件,docker logs(容器 stdout/stderr)是唯一来源。而它有两个硬伤:受 log driver 轮转限制、容器重建后彻底丢失。
这个插件让 harness 自己去分析服务器上的报错并定位错误,而不是等你把日志粘进对话:
模型调用 log_triage 工具
→ SSH 到目标机执行只读探针(base64 传输,无需远端落文件)
→ 返回「帧化」的结构化快照(容器状态 / 退出码 / OOM / 磁盘 / 负载 / 日志)
→ 本地聚类成错误指纹(计数 + 首末时间)
→ 从 Java / Python / Node 栈里抽**最深的 Caused by**作根因
→ 脱敏(内网 IP、账号、token)
→ 给模型几百 token 的结论,而不是几万行日志
关键点:整份日志从不进上下文。一个 docker logs 动辄几万行,工具只回摘要、指纹表和有行号引用的证据。
界面与工具
| 入口 | 作用 |
|---|---|
模型工具 log_triage |
让模型自己在对话里定位报错(本插件的主线) |
模型工具 log_source_read |
报错定位到「文件:行号」后,模型自己决定读多少源码 |
| AI 分析(模型结论) | 规则负责定位,模型负责解释「为什么 / 怎么改」 |
| 侧栏「日志巡检」按钮 | 立即分析 / 测连通 / 只看变化 / 历史报告 |
| 设置 → 容器日志巡检 | 配置编辑(主机、只检查哪些容器、无人值守告警、AI 模型) |
| 定时巡检 | 默认关闭;intervalMinutes > 0 才启用,且不会重叠执行 |
| 告警通知 | 默认关闭;开启后异常记进面板告警列表(带未读徽标),同一告警冷却期内只提醒一次 |
| 报告归档 | 每轮自动存 reports/<时间>.md,保留最近 200 份,面板「历史报告」可回看 |
模型与按钮走同一条代码路径,所以界面看到的报告和模型读到的摘要是同一份,不会互相矛盾。
「只看变化」是干什么的
巡检每隔几分钟跑一次时,一台正常机器的指纹表每轮都一样。每次都重读那十条老指纹,人会很快学会无视这个面板——这是巡检功能最常见的死法。所以有「只看变化」:
立即分析 → 本轮的全部指纹(完整证据)
只看变化 → 过滤最新报告,只留「刚出现的」「次数突增的」「容器刚坏掉的」
它是过滤器,不是第二次巡检。 早期版本把它做成了第二个按钮:点一下就完整跑一轮 SSH 探针,只为看上一轮已经拿到的数据。这有两个代价:每次点击都消耗一次完整巡检(实测 5 容器一轮约 2.7s / 933KB 传输);更糟的是每次点击都会重置指纹基线,同一个老错误在下一轮又被当成「新指纹」重新报警。现在它只是把最新报告按 diff 过滤一遍——瞬时、零服务器开销,判定口径与告警用的是同一份 diff。
至于「持续监听」:插件已经有这个能力,就是定时巡检(intervalMinutes > 0),它就是程序在后台持续盯着容器,且默认关闭、不重叠执行。要不要常驻监听取决于你对服务器影响的容忍度,实测数据(5 容器 / openEuler 虚拟机):
| 项 | 实测 |
|---|---|
| 一轮完整巡检(本机视角) | ~2.7s,传输 933KB(5 容器)/2.5s,133KB(2 容器白名单) |
远端 docker logs --tail 4000(单容器) |
~21ms |
docker ps |
~20ms |
| 巡检期间容器 CPU | <1.2%(被测容器自身负载) |
| 每 5 分钟一轮的代价 | 每天 288 次 SSH 会话,服务器侧每次只读约 3MB 的 json log 文件头部 |
结论:轮询式监听对这台虚拟机没有可感知影响(CPU 峰值 <2%、每 5 分钟 2.7 秒的一次性读 IO)。真正要防的是三件事:高频轮询叠加(已防:上一轮没跑完就跳过本轮)、无上限的 docker logs(已防:tail 上限 + 行数预算)、以及把全量日志拉进上下文(已防:只回摘要)。如果你要的是秒级感知(真·docker events 流式监听),那是一个常驻连接 + 常驻进程的架构,与「服务器上不装任何东西」的设计原则冲突,目前刻意没做。
判定口径写死在 diffHost() 里,只有四种算“变化”:
| 变化 | 判定 |
|---|---|
| 新指纹 | 上一轮基线里没有这个 key(按容器隔离——同一个消息在两个服务里是两件事) |
| 突增 | 本轮次数 >= max(3, 上轮 × 2),避免 1→2 就报 |
| 容器刚坏 | 上轮还正常,这轮 broken |
| 错误持续不消失 | 连续 N 轮都在(默认 3),只在跨过阈值那一轮提醒一次 |
| 两轮之间重启过 | 重启计数上涨且现已恢复——这是轮询唯一的真实盲区,见下 |
轮询会不会错过 5 分钟间隙里的问题
日志类报错不会错过。 这是 --since 3h --tail 4000 的语义决定的:不是“只看这 5 分钟”,而是“回看 3 小时内的日志”。一条错误只要在 3 小时窗口内出现过,无论发生在哪个 5 分钟间隙,下一轮巡检都能看到,且 firstSeen/lastSeen 带着真实时间戳。按错误的时间性分三类:
| 错误类型 | 会不会错过 |
|---|---|
| 持续性错误(DNS 挂、连接拒绝、每次请求都报) | 不会。间隙里看不到,下一轮一定还在(WeKnora-app 的 dial tcp 就是这种) |
| 瞬时错误(启动失败、一次 panic 后恢复) | 基本不会。日志留在文件里,下一轮 --since 仍能看到。只有 3 小时窗口也滚过去才会丢——把 since 配大即可 |
| 容器状态变化(挂了又自动恢复) | 曾会漏。下一轮读到的是“healthy 的当前状态”,像什么都没发生过——已修,见下 |
真正的盲区是第三种:容器在两轮之间崩溃又被 Docker 自动拉起。下一轮巡检时它 Up 3 minutes、状态健康、指纹全是老面孔——所有规则都沉默,一次真实的死亡就 invisibly 消失了。
修复靠一个持久化基线:Docker 的 RestartCount 是累积计数,把它存进 state.json,下一轮对比。计数上涨 = “它至少死过一次”,哪怕现在已经恢复。触发 critical 级告警「在两轮巡检之间重启过」,详情给出 3 -> 4 的跳变,并提示“原因要看那一轮的日志”。「只看变化」也会保留这类容器(它的日志证据正是要看的)。
两条边界:仍然 broken 的容器走「刚坏」规则(一次死亡只报一次,不重复);告警 key 带上计数值,所以死两次会报两次,不会被冷却期吞掉第二次。
如果需要秒级感知(docker events 流式订阅),那是常驻连接 + 常驻进程的架构,与「服务器上不装任何东西」冲突,目前刻意不做。5 分钟轮询 + 3 小时窗口 + 重启基线,是不装任何东西的前提下最接近实时的方式。
两条容易踩的坑(都已修,有回归测试):
- 「没有变化」不等于「没有问题」。 之前 delta 视图的标题抄了全量巡检的措辞 “No container is in a failed state”,等于告诉运维“全集群健康”——可实际上一个早就挂了的容器依然是挂的,只是这一轮“没有变化”。现在分三种措辞:有异常 / 全健康 / 无变化。
- 容器刚坏时,它的指纹往往是老面孔。 一个服务用了几周的
Connection refused死掉,不产生任何“新指纹”或“突增”;只按这两种过滤会把根因一并删掉,报告于是显示「400 行日志,没有错误模式」。现在“刚坏”本身就是一个变化,根因和指纹都保留,并标注「本轮刚进入异常状态」。
定时巡检与告警走同一套 diff,所以「只看变化」看到的和告警判定的是同一件事,不会出现“按钮说没变、告警却响了”。## AI 分析:规则定位,模型解释
聚类、栈解析、源码定位都是确定性的——它们能回答「哪里、什么」,回答不了「为什么、该改什么」。那部分交给模型。
指纹表 + 根因 + 源码地址 → [设置里选的模型] → 根因判断 + 依据引用 + 处置建议
| 决策 | 为什么 |
|---|---|
| 默认关 | 每轮巡检花一次模型调用,不该由插件替用户决定。设置页「AI 分析」里显式开启 |
| prompt 只喂摘要,绝不喂日志 | 否则前面所有「日志不进上下文」的努力白做,且成本反超 |
| 禁止编造 | system prompt 明确要求引用输入里真实出现的异常类名 / 指纹 / 文件:行号,信息不足就说「不确定」。自信的错误答案比没有答案更糟,因为运维会照做 |
| 失败降级为一行 | 供应商超时、key 过期、空回答,一律变成「分析未完成:…」。巡检本身不会因为模型不可用而失败 |
| 模型清单同源 | 设置页下拉框读 /models(ctx.llm.listProviders + listModels),与对话框右下角的模型选择器同一份清单,不会让你选到一个宿主解析不了的模型 |
| 「跟随当前对话」 | provider/model 都留空 = 用发起这次分析时的对话模型。指定了模型但调用失败,自动回落重试一次(attempts:2) |
配置形态(~/.dsh/log-triage/config.json):
"analysis": { "enabled": false, "provider": "", "model": "" }
只有 enabled === true(严格布尔)才算开启;provider/model 只有成对出现才生效——半截的 {provider, model:""} 会被丢掉,否则每轮都会去请求一条解析不了的路由。
源码:只给地址,不给正文
早期版本会把出错位置上下 ±6 行的源码原文塞进报告。这有两个问题:费 token,而且替模型决定了"读多少代码"——真正要看的往往是整个函数,或者相邻的那个调用者。
现在拆成两步:
log_triage → source: `get_model_path()` -- /app/.../retrieval/utils.py:425 (1200 lines total)
nearby definitions: def embed_texts() @ 395 · def get_model_path() @ 410
read it yourself: log_source_read(container: "open-webui", file: "...", start_line: 410, end_line: 454)
log_source_read → 只返回你要的那一段,带绝对行号
source-locate-py.sh 只回传 @@@FILE@@@(行数)、@@@DEFS@@@(只有行号+名字,无签名无代码)、@@@ANCHOR@@@(出错那一行,单行)。窗口由 log_source_read 按 start_line/end_line(或 around_line + context_lines)取,max_lines 硬上限 400。报告里的窗口建议取外围 def 的跨度,因为那通常是最小可推理单元;模型想放宽就自己改参数。
| 反例 → 现状 | 效果 |
|---|---|
| 早先:每个容器内联 40 行源码 | 一轮巡检多花数千 token,且经常贴错区段 |
| 现在:地址 + 建议窗口 + 可复制的调用 | 只有真的要看代码时才付费,读多少由模型定 |
超长报错日志(链式 traceback)怎么处理
Python 的 raise X from Y 会让一条日志里出现 3~4 段 Traceback,用 The above exception was the direct cause of the following exception: 串起来。open-webui 这类栈里,长度动辄 60 行、最深根因埋在 40 行处。
两个地方都必须按"链"而不是按"块"处理:
| 层 | 之前的错法 | 现在 |
|---|---|---|
| 根因抽取 | 整块当一个 traceback,rootCause = 最外层包装(LocalEntryNotFoundError) |
按 Traceback 切块、按桥接行建链,取最深一环(socket.gaierror);exceptionChain[].causeRelation 区分 cause 与 context |
| 指纹聚类 | Traceback (most recent call last): 自己成了指纹(×4),真根因 severity 1 排最后 |
结构行折叠为上下文不再占位;DNS/网络类根因(gaierror/EAI_AGAIN/ENOTFOUND/no such host…)severity 3 且排第一 |
own 帧判定 |
socket.gaierror 这类小写结尾的异常类被整行丢弃,这才导致根因认成包装层 |
异常头正则接受 [Ee]rror 等小写形式,仍只吃 0 列行 |
实测 test/fixtures/chained-python-openwebui.log(按截图逐字重建):根因=socket.gaierror: [Errno -3] Temporary failure in name resolution,链 L0→L3 完整,/app/... 自有帧不再混进根因链。
errno 是有意保留的:[Errno -3](DNS 挂了)和 [Errno 111](端口没人听)是两种故障、两种修复动作,归一化成 [Errno -<n>] 会把它们误合并;而 attempt 41 这类每次变的数字照旧归一化。
只检查关心的容器(重要)
一台机器上跑几十个容器时,「全部检查」既慢又吵。实测单容器日志读取约 0.37s,docker ps/inspect 很便宜,但早期版本每个容器还要跑一次 docker stats(约 2s/个)——7 个容器就是 14 秒,而这批数据在任何地方都没被使用。现在:
| 优化 | 效果 |
|---|---|
移除无消费方的 docker stats(改为按需 INCLUDE_STATS=1) |
7 容器一轮 19s → 2.5s |
| 容器白名单:设置页从主机拉取实际容器列表并勾选 | 报告只含被检查的容器,不再把没读的容器算作「已检查」 |
| PS 帧随白名单收窄 | 面板不会出现「7 个容器」但只有 2 个有数据 |
| 勾选而非手打容器名 | 手打拼错会让巡检「什么都不匹配」,看起来像一切正常 |
留空 = 全部容器。设置页点「从主机读取容器列表」(约 400ms,一次 SSH 往返)即可按实际容器勾选,不用手打名字。
无人值守告警(只在插件界面提醒)
开启后,每轮巡检结束会把值得惊动人的变化记进告警列表,面板右上角的「告警」按钮显示未读数,点开查看。不发邮件、短信或任何外部 webhook——提醒就在插件里,紧挨着证据。
| 事件 | 级别 | 说明 |
|---|---|---|
| 容器刚进入异常状态 | 严重 | 上一轮还正常,这一轮坏了 |
| 出现新错误指纹 | 警告 | 受 minCount 门槛控制 |
| 错误量突增 | 警告 | 计数至少翻倍 |
| 错误一直不消失 | 警告 | 连续 N 轮巡检都在(默认 3 轮),只在跨过阈值那一轮提醒一次 |
| 主机读不到 | 严重 | 连不上本身就是事故 |
为什么需要「一直不消失」这条:前三条规则结构上抓不到一种最常见的真实故障——服务一直报错但容器活着。第一次巡检时它是「新指纹」(报一次),此后再也不新、计数也不涨、容器也没挂,于是永远沉默。实测 WeKnora-app 就是这种:每轮稳定 36 条 dial tcp: lookup langfuse-web: no such host,而告警列表里一片安静。
实现上按「连续轮次」计数(state.json 里存 streaks),只在跨过阈值那一轮报一次。如果写成 streak >= 阈值 就报,一个没人会修的后台错误会每轮响一次——那正是训练用户无视面板的做法。中间断过一轮就重新计数(错误消失又出现 = 新的一集,不是「第 9 轮」)。
为什么需要落盘:定时巡检是在面板关着的时候跑的。如果告警只写日志,你永远不会看到它。所以告警会写进 inbox.json,面板每 30 秒轮询一次,打开面板时历史告警仍在列表里。
去重是关键:同一告警在冷却期(默认 60 分钟)内只发一次。指纹里的数字(耗时、计数、ID)会先归一化,否则「took 1523ms」和「took 1891ms」会被当成两个新错误,冷却形同虚设。冷却窗口不会因为反复出现而滑动——否则一个持续报错的服务会永远不再告警。
告警记录失败不影响报告生成——报告永远先落盘。
服务器上不装任何东西(架构)
「主动记录 → 自动分析 → 自动生成报告」不需要常驻服务器,也不该常驻:
服务器 你这台 Windows
────── ──────────────
容器 stdout(docker 本来就在写) │
SSH 只读拉取(用完即断)
↓
聚类 / 根因 / 脱敏
↓
report.md + reports/*.md
| 环节 | 在哪发生 | 服务器侧痕迹 |
|---|---|---|
| 记录日志 | 服务器(docker 自身的 log driver) | 无(本来就存在) |
| 抓取 / 分析 / 生成报告 | 本机 | 无 |
| 归档历史 | 本机 reports/ |
无 |
服务器上没有 agent、没有 cron、没有常驻进程。每轮只做:SSH 登录 → 跑只读命令 → 拉回日志 → 断开。临时文件落在 /tmp 且用完即删。
对服务器的影响(实测,7 个容器的机器):
| 项 | 实测 |
|---|---|
| 单次 SSH 连接 | ~400ms |
| 一轮完整巡检 | ~12–19s,期间有可感知的磁盘读 + 一次 docker stats CPU 采样 |
| 常驻进程 | 0 |
| 磁盘写入 | /tmp 下几 KB,用后删除 |
| 网络流量 | 一轮约 150–300 KB |
| 超时保护 | 远端 timeout -k 5 兜底,不留孤儿进程 |
唯一真实影响是巡检那一刻的负载。业务高峰期建议手动触发,或把定时间隔设成业务低谷(例如
intervalMinutes: 1440每天一次)。
铁律(写死在实现里)
- 只读:探针脚本只读,任何
restart/rm/pull/写文件都没有; - 有界输出:单容器日志
tail上限 + 整轮行数预算 + 渲染字符上限(12000); - 结论可回溯:根因带
文件:行号级别的evidence;源码只给地址与范围,正文由模型按需调log_source_read取; - 脱敏后才出站:内网 IP →
<ip>,密码/token →<redacted>,手机号/身份证同样替换; - 降级不中断:
dmesg被拒、systemctl不存在、docker stats无输出,都只让对应帧变空,不失败; - 空转不打扰:
intervalMinutes: 0(默认)意味着零后台活动; - 不重叠:上一轮没跑完时,定时器跳过本轮,避免把负载翻倍压在已经吃力的机器上。
指纹静音(勾选同类报错不再显示)
面板里反复出现但你知道原因的错误(第三方组件噪音、已排期的问题),可以静音:报告、告警、AI 分析三处同时不再出现——静音了还会响的静音是谎言。
"muted": [ { "key": "USER_AGENT environment variable not set", "note": "langchain 噪音,已反馈上游", "at": "2026-11-03T21:40:00Z" } ]
| 设计点 | 为什么 |
|---|---|
| 子串匹配、大小写不敏感 | 面板给的是归一化后的完整 key,但设置页手填 lookup langfuse-web 也应生效——静音是运维决策,不是启发式 |
| 隐藏不删除 | 被静音的行移到 mutedFingerprints,报告末尾一行 _已静音 N 条_;完全静默会让人以为噪音从未存在 |
| note 强烈建议填 | 半年后「这条为什么被藏了」需要答案;没有 note 的静音是列表腐烂的开始 |
| 告警侧同样过滤 | selectAlerts 在 diff 上过滤,静音的指纹永远不能触发告警 |
| 上限 200 条 | 没有上限的列表是对「静音」这两个字的滥用 |
分析过程可见(写入宿主会话)
AI 分析原先是一次性调用:结论贴到面板,你看不到它读了什么、依据是什么。看不见的推理不值得信任,所以每次分析都会写进一个真实的宿主会话——侧边栏「未分组」下会多出:
未分组
日志巡检 · AI 分析 ← 每轮巡检的提问 + 模型回复
数据库探查 · AI 分析 ← db_inspect 相关的模型判断
浏览器报错 · AI 分析 ← 前端报错的模型解读
点开任何一个,能看到当时喂给模型的完整摘要和它的原文回复,配合面板上的结论逐句对照。
关于「分组」的诚实说明:DSH 没有给插件的「创建分组」API——CreateSessionOptions.meta 只有 cwd / parentSession / isSeeded / origin / delegationDepth / agentPreset,没有 group 字段,侧边栏的分组是宿主自己的数据。插件能做的是创建不带分组的会话,它们会自动落进「未分组」。效果就是你要的那个,但实现路径是「创建独立会话」而不是「先建分组再往里塞」。
| 机制 | 说明 |
|---|---|
| 按组件分会话 | 三个槽位 dlt-analyze-log / -db / -web,各自的对话不会串 |
| 80% 换新会话 | 累计 token 超过窗口的 80% 时,下一次分析改用 dlt-analyze-log-1;旧会话原样保留,历史可查。单次分析不会被从中间切开 |
| 软失败 | 宿主没有会话服务(headless profile)时静默降级:分析照常,只是没有记录。绝不因为写会话失败而让巡检失败 |
| 默认开启 | 你要求默认开。设置页可关闭,并可看到各组件当前正在写入哪个会话、累计多少 token、换过几次 |
曾经的真 bug(已修):会话一直是空的。
user/message和assistant/message在dsh-session里属于 surface-eligible 事件类型, 写入时必须带第三个参数{ surfaceOp: 'append' },否则会抛:session event "user/message" is surface-eligible and requires a surfaceOp marker插件当时没带这个标记 → 每次 append 都抛 → 异常被吞掉 → 返回
{ok:false, writes:0}, 而状态接口仍然显示「分析会话:dlt-analyze-log」且lastError: null。 面板显示一切正常,实际一个字都没写进去。现在两处都修了:append 带上
surfaceOp,并且写入失败会把 store 的原话记进lastError, 不再显示成健康的零。测试也换成了严格 mock——旧测试用的假 session 接受任何参数, 所以功能全坏时 739 条测试依然全绿。现在npm run verify:transcript会直接用真实安装的@deepseek-ai/dsh-session跑一遍,确认事件真的进了日志、能读回、且旧形状会被拒绝。
第二个 bug,同一个功能:事件写进了内存,却永远不落盘。
修好
surfaceOp之后writes涨到 2,但侧边栏依然是空的、~/.dsh/sessions里没有文件。 原因是「能写」和「能看见」是两件事,而插件只做了前一半:
步骤 作用 谁在做 ctx.sessions.create(id)会话进入内存, append()可用插件(做了) ctx.sessionPersistence.create(header)取得写所有权,注册写入器 只有 dsh-agent-loop(插件没做)持久化层只监听
session/event,而session/event只在已接管的会话上派发。没有第二步, 事件就派发给「没有人」,JSONL 后端永远收不到,文件永远不存在。净效果: 应用自己创建的会话都在磁盘上,插件手工创建的会话是内存幽灵——只在我们的计数器里存在。现在的修法:
ctx.inject(['sessionPersistence'])单独注入,拿到后先persistence.create(handle.header)认领会话,写完再ctx.sessions.flush(session)落盘。两个服务独立降级——只有sessions没有sessionPersistence时,分析照常、内存照写,但lastError会明确说「这个会话只在内存里, 不会出现在侧边栏」,而不是假装成功。
npm run verify:transcript现在会验证三件事:真实 store 接受 append 形状、事件内容可读回、 以及持久化层确实认领了该会话(transcript-durable.mjs)。第三件事正是前两轮漏掉的那个问题。
第三个 bug,还是同一个功能:会话落盘了,但落错了目录。
修好持久化之后
~/.dsh/sessions/_no-cwd/dlt-analyze-log出现了——一个空目录。 JSONL 后端按sessions/<工作区>/<id>分目录存放,而 header 里没有cwd时会落进字面量_no-cwd。侧边栏只列当前工作区,所以这个会话写了磁盘、依然看不见。修法:创建会话时把
cwd传进 header(调用方显式指定的优先,否则用宿主进程的工作目录, 也就是当前工作区)。scripts/transcript-cwd.mjs会直接检查 header 里到底有没有 cwd、 是不是_no-cwd。同一个症状背后连着三个 bug,每个都能让「看起来修好了」但侧边栏依旧是空的:
# 症状 根因 1 writes: 0,lastError: null缺 surfaceOp标记,append 抛异常被吞2 writes: 2,磁盘无文件没调 persistence.create(),事件派发给「没人」3 磁盘有文件,侧边栏没有 header 缺 cwd,落进_no-cwd
浏览器端报错采集(网页端也是证据)
服务器容器日志永远看不到用户浏览器里发生的事:JS 异常、未处理的 Promise 拒绝、前端收到的 5xx。当「每个容器都是绿的、页面却是坏的」时,浏览器里的报错往往是唯一线索——后端日志一个 ERROR 都没有,故障却真实存在。
开启设置里的「浏览器端报错采集」后,插件面板在当前这个页面安装捕获钩子(error / unhandledrejection / fetch 包装),报错经本机回环存进 browser-errors.jsonl,巡检时并入报告,作为独立小节「浏览器端报错」渲染——刻意不与容器指纹混排:「服务端全绿、浏览器端全红」这个对比本身就是诊断结论。
| 红线 | 为什么 |
|---|---|
| 只采集当前页面 | 不注入任何业务站点、不做 <script> SDK——插件不做跨站采集器 |
| 默认关闭 | 与告警同一条纪律:没有明确同意就没有后台活动 |
| 只记 path+status,不记响应体 | 响应体可能巨大且含请求时密钥 |
| 采集器自身静默 | 上报失败被吞掉——采集钩子若抛错,就变成它自己要捕获的错误 |
| 双层上限 | 单条 8KB、整个存储 512KB,最旧先淘汰;同一条报错 60 秒内只记一次(渲染循环里崩溃每秒百次,是同一件事) |
| 入盘即脱敏 | URL 里的 token 在写盘前过 redact |
聚类归一化与容器指纹同源:消息里的易变数字(React key id、计数)折叠成 <n>,同一个警告出现 50 次是一行 count=50,不是五十行噪音。
数据库只读探查(db_inspect)
定位「数据为什么不对」这类问题需要看库。新增 db_inspect 工具:模型可以直接查库,但结构上写不进去——防写不是提示词约定,是三层独立机制:
| 层 | 机制 | 失守时 |
|---|---|---|
| 1. 语句校验(本机) | 白名单:仅 SELECT/SHOW/EXPLAIN/\d 类元命令(redis 仅 GET/SCAN/INFO 等读命令,任意 ; 拒绝)。INSERT/UPDATE/DELETE/DROP/GRANT/FLUSHALL/EVAL/SHUTDOWN… 一律拒绝 |
被拒语句根本不离开本机 |
| 2. 会话只读 | postgres SET default_transaction_read_only=on;mysql SET SESSION TRANSACTION READ ONLY=1(与查询同一次 stdin) |
数据库服务端拒绝 |
| 3. 事务回滚(仅 postgres) | 整个会话包在 BEGIN READ ONLY; … ROLLBACK; 里 |
即便前两层全失守,事务结束自动回滚 |
redis 没有会话级只读开关,所以它的唯一防线是第 1 层——校验因此最严(EVAL/SCRIPT/CONFIG/任意 ; 全拒)。这是诚实的不对称:测试覆盖了三层的独立有效性。
实测(真实主机 postgres 17.11):BEGIN READ ONLY; INSERT … 返回 ERROR: cannot execute INSERT in a read-only transaction;DELETE FROM … 与 SELECT 1; DROP TABLE x 在本机第 1 层即被拒,从未到达服务器。
选服务器 → 下拉选库 → 填密码:对话框里选好「所在主机」后,会自动扫描那台服务器上的数据库,
把 容器 · 类型 · 库名 作为一个下拉列表给你挑。你不需要知道容器叫什么、库叫什么——那恰恰是没人记得住、
也最容易填错的部分。
扫描是只读的(docker ps + 各容器内的 psql -c 'SELECT datname …' / redis-cli INFO keyspace),
所以列表不会和现实脱节。三种情况会被区分开,而不是都显示成"空的":
| 下拉里的样子 | 含义 |
|---|---|
WeKnora-postgres · postgres · WeKnora |
真的查到库名,直接选 |
WeKnora-redis · redis ·(需要密码才能列出库名) |
这库要认证。填上密码后重新扫描就能列出 keyspace |
web-mysql · mysql ·(容器已停止,启动后才能列出库名) |
容器没在跑,不是"没有这个库" |
选中一条会顺手把容器名、类型、用户、库名填好,并把连接方式设成 socket(扫描本来就是走 docker exec 成功的,
所以这是已知能通的连法)。填完仍然是全部可改的:扫描只告诉你"有什么",不限制你想要什么。
一个刻意的边界:如果数据库是嵌在应用镜像里的(比如
web-tlias这种 Spring 应用,库配置在 jar 包里), 镜像名和环境变量里都没有数据库线索,扫描就不会报告它——只能靠猜,而猜错比不猜更糟。 这种库请手动填容器名和库名。
两种连接方式(设置页「数据库配置编辑」里选,模型也能用 mode 指定):
| 方式 | 怎么连 | 什么时候用 |
|---|---|---|
socket(默认) |
docker exec 进容器,走容器内本地 socket |
没发布端口的数据库。Docker 网络内的库通常不 publish,SSH 主机又没有 psql/redis-cli,这是唯一可行的连法 |
tcp |
连 dbHost:dbPort;container 非空时客户端在那个容器里跑 |
数据库不在容器里、或需要验证端口真的对外开着 |
tcp 模式里 container 是「客户端住在哪」,不是被连的对象——这是实测出来的:WeKnora-postgres 的
5432 没有 publish,从 SSH 主机直连被拒,而且那台主机根本没装 psql。所以 tcp + 容器名 = 容器里的客户端
连容器网络的数据库;容器名留空才表示「SSH 主机自己有客户端」。
传输卫生:query 经 base64 进 env,永不进 argv;脚本本身(可能带着 base64 密码)经 stdin 管道送进
bash -s——不是 argv、不是临时文件、不是环境变量,所以 ps 在两端都看不到它。输出过 redact;上限 32KB。
仍是服务器上不装任何东西:客户端随镜像自带(已验证 postgres/redis/mysql 镜像均内置 CLI),一次 SSH 用完即断。
曾经的真 bug(已修):密码被写进本机临时文件、把路径当
DB_PW_FILE传给远端cat。在 Windows 上那个路径是C:\Users\…\Temp\…\pw,远端 Linux 上永远不存在 —— 于是密码认证从来没生效过。它没被发现,是因为 postgres 的pg_hba对 127.0.0.1 是trust:错的密码也能连上,看起来一切正常。抓到它的是 redis(它强制认证)。 现在密码经过 stdin 送进脚本、在内存里 base64 解码,远端不落盘,且加密后的形态也不进 argv。
target 与 log_triage 同义:既可以是设置页里的主机名称(openeuler-vm),也可以是裸 user@host。
名称会在本机解析成 user@host 并带上端口与 askpass 密码——早期版本把名称原样丢给 ssh,于是
「日志巡检刚连上的那台机器,查库却说无法解析主机名」。
设置页的「数据库连接」是简写:每个条目是一整条连接(name/host/mode/container/dbHost/dbPort/kind/database/user/password/color/note),
模型只要把 name 当 target 传(或只传 container),缺的参数由这条记录补齐。显式参数永远优先。
「测试连接」和模型调用走同一个 resolveDbConnection——否则会出现「面板说连上了、模型连不上」,
而绿色对勾比报错更没人查。这两半曾经都没接上:表单能填能存,db_inspect 从不读那张表。
配置
落在 ~/.dsh/log-triage/config.json:
{
"hosts": [
{ "name": "app-01", "ssh": "root@10.0.0.11", "since": "3h", "tail": 4000, "containers": [] },
{ "name": "lab-vm", "host": "10.20.30.40", "user": "root", "port": 22, "password": "", "note": "", "color": "blue" }
],
"intervalMinutes": 0,
"since": "3h",
"tail": 4000,
"top": 12,
"concurrency": 3,
"timeoutMs": 180000
}
containers: []表示该机全部容器;填名字则只看这些;- 主机行既可以是紧凑字符串
ssh: "user@host",也可以是结构化字段host/user/port/password/note/color(设置页「SSH配置编辑」对话框保存的形态),两种形态双向兼容,加载时缺哪边就派生哪边; - 密码认证:
password非空时走 OpenSSH 的SSH_ASKPASS机制——密码只放在 ssh 子进程的环境变量里,不进命令行、不落到远端,临时 askpass 脚本用后即删;留空则照旧走本机密钥免密; port缺省 22;IPv6 目标写[::1]:22这种带方括号的形态;- 状态与报告同目录:
state.json(上一轮指纹基线)、report.md(最新一份)、reports/<时间>.md(历史归档,保留最近 200 份)。
前置条件
- 本机 → 目标机可达,二选一:
- SSH 免密(推荐):
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh root@10.0.0.11 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"- 在设置页给这台主机填密码(SSH_ASKPASS 通道,见上)。要求 OpenSSH ≥ 8.4(
SSH_ASKPASS_REQUIRE=force),Windows 10/11 自带 OpenSSH 与 openEuler 24.03 均满足。
- 远端账号能跑
docker(在docker组即可,不需要 root;dmesg需要 root 或有CAP_SYSLOG,拿不到就自动跳过)。
路由(仅本机回环可访问)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /plugins/dsh-log-triage/env |
配置(密码已剥离)、路径、主机数、上次运行 |
| POST | /plugins/dsh-log-triage/run |
跑一轮分析,返回 Markdown + 结构化结果 + 归档路径 |
| POST | /plugins/dsh-log-triage/check |
只测 SSH 连通性(一次往返,~400ms,不跑探针) |
| POST | /plugins/dsh-log-triage/containers |
列出某台主机的容器名(供设置页勾选,~400ms) |
| GET/POST | /plugins/dsh-log-triage/config |
读 / 存配置 |
| GET | /plugins/dsh-log-triage/report |
最新 Markdown 报告;?name=<归档文件名> 读历史;?structure=1 读结构化快照(面板刷新用) |
| GET | /plugins/dsh-log-triage/reports |
归档报告列表(?limit=,默认 50) |
| GET/POST | /plugins/dsh-log-triage/alerts |
告警收件箱:GET 读列表与未读数,POST 标记已读 |
开发
npm run check # 遍历 lib/ 下每个 .js 做语法检查(不再手维护文件清单)
npm test # 内核单测(node --test,742 项)
npm run e2e # 端到端:离线 fixture + 真机探针 + 工具调用
npm run verify # 路由冒烟 + 工具实跑(空配置,不需要真机)
npm run verify:live # 同上,但用真实配置打真机
npm run verify:db # 数据库配置:/db-check 与 db_inspect 走同一条解析,含错误密码必须失败
npm run verify:discover # 下拉选库:/db-discover 真的扫出库,且扫出来的库真的能查
npm run verify:transcript # 分析会话:真实 store 接受 append 形状、持久化层认领、cwd 正确
check 曾经是一串手写的 node --check a && node --check b …:lib/ 有 26 个文件,它只列了 15 个,而
漏掉的正好是当时新加的 tool/db-inspect.js、runtime/db.js、runtime/transcript.js 等——新增文件里
有语法错也照样通过。现在是 scripts/check-syntax.mjs 遍历目录,新模块一落地就被覆盖。
为什么需要 verify:host 半侧每个进程只加载一次,改了 lib/ 之后正在运行的 DSH 仍在跑旧代码,
于是「面板报 500」永远有两种解释——代码错了,还是进程旧了?scripts/route-smoke.mjs 在新进程里
用真实 apply() 注册路由、逐个调用 handler,并把返回值按 registry 的规则校验一遍,一次就能分清。
这一类 bug 的共同形态
本轮真机测试暴露的 6 个问题,形态是同一个:每层都"看起来实现了",但两半没有接上。
| 表面 | 实际 | 后果 |
|---|---|---|
log_triage 返回 sourceEvidence |
output.schema 没声明这个字段 |
registry 用 additionalProperties:false 校验,每次调用都失败 |
runSweep 读 sessionContext |
变量声明在 apply() 里 |
POST /run 恒 500(ReferenceError),面板一个卡片都画不出来 |
db_inspect 收 target |
没走 resolveHosts |
设置页里的主机名被当主机名丢给 ssh → Could not resolve hostname |
| 设置页「数据库连接」可编辑可保存 | db_inspect 从不读 config.databases |
列表是只写字段,模型仍要每次手填四个参数 |
设置页可改 analysisTranscript / alertOnPersistent / persistSweeps |
validateDraft 不 emit 这三项 |
保存后静默回退到默认值,界面还显示「已保存」 |
/run 收 analyze 参数 |
runSweep 从不读 overrides.analyze |
面板点「AI 分析」得到的是纯规则报告 |
共同点是没有任何一处测试把两半放在一起跑:单测覆盖了每个函数自己的契约,没有一条断言 "真实返回值能通过声明的 schema"或"表单里改过的字段保存后还在"。所以补的不是 6 个补丁,是三层断言:
test/core/tool-output-schema.test.js—— 把 registry 的assertSupportedJsonSchema/validateJsonSchemaValue移植成本地版本(同一套词表、同一句报错),每个工具的真实返回值都过一遍;test/integration/client-load.test.mjs——validateDraft的往返断言:表单编辑的每个字段都必须 emit 出来,且数字被夹到 store 认可的区间;test/integration/plugin-load.test.mjs—— 模块级函数不得引用apply()作用域的变量(就是sessionContext那类 bug),外加真调一次POST /run。
端口不被接受这件事也有结构原因:本插件不 import @deepseek-ai/*,所以工具定义是手写普通对象直接
register()。registry 只校验 output.schema,不校验 parameters——defineTool() 才会做的入参校验
被绕过去了,于是每个 required: [] 都只是注释。现在 lib/tool/args.js 补上了这一层:参数错就报参数错,
不会等到"返回值不符合 schema"才暴露。
另有一个专项脚本,把截图里那条链式报错从原始日志一路跑到模型结论:
node scripts/chained-log-e2e.mjs # 15 项断言,离线可跑
端到端脚本分三层,真机不可达时跳过而不是失败,所以离开内网也能跑:
- 离线:
test/fixtures/real-lab-host/*.log(真机抓下来的日志); - 真机:SSH 跑探针,校验帧完整性与无
docker inspect模板错误; - 工具:按 harness 的调用方式执行
log_triage,断言答案在预算内、定位到错误、且内网 IP 与密码没有泄漏。
开发时的本地加载(link 形态)
改 lib/ 的时候,把 profile 指向本地目录比反复打包省事。dsh plugin add file:<目录>
就是这个形态(装的是软链接,改完源码重启即生效):
dsh plugin --profile web add file:C:/path/to/dsh-log-triage
# 重启 DSH(host 半侧每个进程只加载一次)
卸载回退:
dsh plugin --profile web remove dsh-log-triage
本仓库不含
scripts/link-fork.ps1/unlink-fork.ps1。作者本机用的是仓库外的一份 PowerShell 辅助脚本,它做的事就是上面这两条命令的手工版(改 profile 的dependencies写link:、把包名塞进dsh.profile.bundles、再跑一次pnpm install)。 装了本插件之外的读者不需要它。完整安装说明见 docs/INSTALL.md。
实现要点(踩过的坑)
- 不 import
@deepseek-ai/*:第三方插件不保证能解析到宿主私有依赖(实测@deepseek-ai/dsh-tools从 profile 的node_modules解析失败)。所以工具定义是手写的普通对象,schema 直接写成编译后的 JSON Schema(对象级required: []数组,而不是属性级的required: true)。已用 DSH 真实的assertSupportedJsonSchema/validateJsonSchemaValue校验通过。详见 docs/dsh-plugin-api.md。 docker inspect没有顶层.ExitCode:openEuler + Docker 26 上{{.ExitCode}}会报map has no entry for key,必须用{{.State.ExitCode}}。df在中文 locale 下表头是中文:解析一律LC_ALL=C或按列位取值,绝不匹配英文表头。docker logs --since 3h对几个月前退出的容器是 0 行:探针会在窗口为空时回退到全量 tail,并强制插入<window empty since=3h; showing older log history instead>标记,避免把旧日志误当新日志。- 容器名要加
--终止符,否则容器名以-开头时会被当成 flag;名字里的@@@会伪造帧边界,探针已统一净化。
已知限制
- 未做告警推送(可接 QQ/IM 通道);
- 未做趋势图(
state.json已保存每轮指纹,可扩展); - 定时巡检的「主动汇报」目前只写日志与
report.md,没有主动唤醒会话(ctx.schedule方案见文档,属未验证路径); - 指纹粒度是「归一化后的错误行」,长堆栈会被切成「每层一个指纹」——这是刻意的,模型能看到异常链的深度,但同一条栈会占多个席位;
- 改完
lib/必须重启 DSH 才生效:host 半侧每进程只加载一次,client 半侧也没有dev:webwatcher。 所以"面板还是 500 / 工具还是报错"要先确认进程是否已经重启过——npm run verify就是在新进程里 跑同一份代码,用它区分"代码错"和"进程旧"。
许可证
MIT,见 LICENSE。
No comments yet. Be the first to write one.