9.7 KiB
Context
当前所有 checker(HTTP/TCP/UDP/ICMP/DB/CMD/LLM)在 execute 执行后,将丰富的结构化观测数据压缩为一个 statusDetail: string | null 字符串,然后丢弃原始数据。CheckResult 仅有 5 个字段(durationMs、failure、matched、statusDetail、timestamp),无法承载排障所需的上下文信息,也无法支持历史观测数据的结构化分析。
现有代码中,LLM checker 已有执行期 LlmCheckObservation 对象且 expect.output 依赖其中的完整 outputText,ICMP/Ping checker 已有 PingStats 结构,其余 checker 的观测数据散布在 execute 函数的局部变量中。
前端通过 statusDetail 字符串直接展示摘要,仅在 history-table-columns.tsx 和 OverviewTab.tsx 两处引用。
项目未上线,不需要考虑向前兼容和数据迁移。
现有 openspec/specs/probe-data-store/spec.md 中 check_results.target_id 仍写为 INTEGER,但当前代码中的 targets.id 和 check_results.target_id 均为 TEXT。本 change 的 probe-data-store delta 在替换 status_detail 时同步修正该过时 spec 描述,不引入额外代码类型变更。
Goals / Non-Goals
Goals:
- 在 CheckResult 中引入结构化 observation 字段,持久化完整观测数据
- 用
detail字段替代statusDetail,从 observation 动态构造,不再持久化 - 各 checker 定义各自的 observation schema 和 detail 构造逻辑
- 对大文本/集合字段执行截断,控制存储膨胀
- 前端展示行为不变(仍显示人可读摘要字符串)
Non-Goals:
- 前端 observation 详情展开 UI(后续单独做)
- observation 的 JSON 深层查询(不需要 json_extract 等)
- 数据脱敏(仅截断,不脱敏)
- 引入新的序列化依赖(使用原生 JSON)
Decisions
Decision 1: observation 字段类型使用 Record<string, unknown> | null
选择: 使用 Record<string, unknown> 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。
替代方案:
- Discriminated union(
{ type: "http", ... } | { type: "tcp", ... }):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型 unknown:过于宽松,缺乏结构约束
理由: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 Record<string, unknown> 并在内部做类型断言。
Decision 2: 存储格式使用 JSON TEXT
选择: observation 列使用 SQLite TEXT 类型,JSON.stringify 写入,JSON.parse 读出。
替代方案:
- BLOB + MessagePack:更紧凑,但需要引入
@msgpack/msgpack依赖 - BLOB + JSON Buffer:不引入依赖,但 sqlite3 CLI 无法直接查看内容
理由: 不引入新依赖(项目规范);开发者可以用 sqlite3 CLI 直接查看和调试 observation 数据。空间换可读性,实际膨胀有限(截断策略控制了上界)。
Decision 3: detail 在 API 序列化层动态构造
选择: detail 不持久化到数据库,在 API 路由的 mapCheckResult 中通过 checkerRegistry.get(type).buildDetail(observation) 动态生成。
替代方案:
- 在 execute 中同时生成 detail 并持久化:简单但存储冗余数据,且 detail 格式变更需要回填
- 在前端从 observation 构造:前端需要了解所有 checker 类型的 detail 格式,违反关注点分离
理由: detail 是 observation 的派生数据,不应独立存储。API 层已经能获取 target type(dashboard 路由遍历 targets,history 路由已查出 target),调用 buildDetail 的成本极低。mapCheckResult 函数签名调整为接收 type 参数。
Decision 4: statusDetail 重命名为 detail
选择: API 合约中 statusDetail 字段重命名为 detail。
理由: 更简洁。项目未上线,无兼容性负担。
Decision 5: CheckerDefinition 接口新增 buildDetail 方法
选择: 在 CheckerDefinition 接口中新增 buildDetail(observation: Record<string, unknown>): string | null。
替代方案:
- 独立的 detailBuilder registry:过度设计,且打破 checker 内聚性
- 在 observation 中嵌入 detail 字段:混淆数据和展示
理由: buildDetail 是 checker 领域知识的一部分,与 execute/serialize 同属 checker 职责。放在接口中保持 checker 内聚。
Decision 6: 可收集的负向结果仍写入 observation
选择: observation 为 null 仅表示无法形成有意义的领域观测,例如进程 spawn 失败、执行框架内部异常、请求在拿到响应前失败且没有可记录元数据。TCP 连接拒绝、UDP 未收到响应、Ping 不可达、DB 连接失败、CMD 非零退出、HTTP 非 2xx/expect 不匹配、LLM 返回错误状态等可收集上下文的负向结果 SHALL 返回结构化 observation,并通过 failure 表示失败原因。
理由: observation 的目标是排障和趋势分析。可预期的负向结果本身就是关键观测数据,如果统一写 null 会丢失连接错误、丢包率、stderr、HTTP status/body 等上下文。
Decision 7: HTTP 成功拿到响应后始终采集 bodyPreview
选择: HTTP checker 在 fetch 成功返回 Response 后,无论是否配置 body expect,都读取响应体前 1024 字符作为 bodyPreview。当配置 body expect 时,仍按现有 maxBodyBytes 读取完整可校验范围,并从已读取文本派生 bodyPreview。
理由: 主要排障诉求包含“HTTP 502 返回了什么 body”。如果仅在配置 body expect 时读取 body,默认 HTTP 探测无法提供失败响应正文。该行为会增加一次响应体读取成本,但 preview 上限较小,且 HTTP 响应体不会被后续其他逻辑复用。
Decision 8: 截断策略
各 checker 的截断上限:
| 字段 | 上限 | 说明 |
|---|---|---|
| HTTP bodyPreview | 1024 chars | 错误页面/API 错误体足够排障 |
| HTTP headers | 前 20 个 | 避免大量自定义 header 膨胀 |
| TCP banner | 256 chars | 与现有 truncateBanner 逻辑一致 |
| UDP responsePreview | 512 chars | 协议响应通常较短 |
| DB rowsPreview | 前 5 行 | 验证查询结果形态即可 |
| CMD stdoutPreview | 1024 chars | 错误日志/诊断输出的前段 |
| CMD stderrPreview | 1024 chars | 同上 |
| LLM outputPreview | 512 chars | 输出文本摘要即可 |
| LLM headers | 前 20 个 | 同 HTTP |
Decision 9: execute 返回值变更策略
各 checker 的 execute 方法改造方式:
- LLM: 执行期继续使用包含完整
outputText的LlmCheckObservation支撑 expect 校验;写入 CheckResult.observation 前派生持久化 observation,将outputText转为outputPreview和outputLength,并截断 HTTP headers。不能直接把执行期outputText替换为 preview,否则会破坏expect.output。 - ICMP: 已有
PingStats,直接作为 observation 基础,补充 error 字段。 - HTTP/TCP/UDP/DB/CMD: 在 execute 函数中将散布的局部变量聚合为 observation 对象。现有的
buildStatusDetail辅助函数逻辑迁移到buildDetail方法中,输入改为 observation。
Decision 10: 数据流架构
Checker.execute()
│
├─ observation: { statusCode: 200, headers: {...}, ... }
├─ detail: 不设置
├─ matched / failure / durationMs / timestamp
│
▼
Engine.writeResult()
│
├─ observation → JSON.stringify → TEXT 列
├─ failure → JSON.stringify → TEXT 列
├─ matched / durationMs / timestamp → 原样写入
│
▼
API Route (dashboard / history)
│
├─ 已知 target.type
├─ observation → JSON.parse
├─ detail = checkerRegistry.get(type).buildDetail(obs)
│
▼
API Response (CheckResult)
│
├─ observation: { ... } ← 结构化数据,前端可用于未来排障 UI
├─ detail: "HTTP 200" ← 人可读摘要,前端直接展示
├─ matched / failure / durationMs / timestamp
│
▼
Frontend
│
├─ 显示 detail(与原 statusDetail 行为一致)
└─ observation 暂不使用
Risks / Trade-offs
[存储膨胀] → observation JSON 比原 statusDetail 字符串占用更多空间。通过截断策略控制上界:单条 observation 最大约 5-10KB(含 CMD stdout + stderr 各 1024 chars),远小于 SQLite 单行存储上限。配合已有的 data-retention prune 机制,整体可控。
[buildDetail 性能] → 每次 API 请求都需要 JSON.parse + buildDetail 调用。对于 dashboard(仅取 latest check per target)和 history(分页,默认 20 条/页),开销极小。如果未来需要批量处理大量记录,可以考虑缓存或批量优化。
[类型安全断层] → observation 在共享层是 Record<string, unknown>,buildDetail 内部需要做类型断言。如果 observation 结构与 buildDetail 期望不一致,会产生运行时错误。通过 checker 内部 execute、持久化 observation 派生函数和 buildDetail 共享同一个 observation interface(仅 checker 内部可见)来降低风险。
[前端字段重命名] → statusDetail → detail 需要修改前端 2 处引用。变更量小,但需要确保前端编译通过。
[HTTP body 读取成本] → HTTP checker 将在拿到响应后读取 body preview,即使未配置 body expect。通过 1024 字符 preview 上限控制额外内存占用;如果 body 解码失败,仍应保留 status/headers/contentLength 等已收集 observation,并通过 failure 描述解码问题。