1
0
Files
DiAL/openspec/changes/checker-observation/design.md

9.7 KiB
Raw Blame History

Context

当前所有 checkerHTTP/TCP/UDP/ICMP/DB/CMD/LLM在 execute 执行后,将丰富的结构化观测数据压缩为一个 statusDetail: string | null 字符串,然后丢弃原始数据。CheckResult 仅有 5 个字段durationMs、failure、matched、statusDetail、timestamp无法承载排障所需的上下文信息也无法支持历史观测数据的结构化分析。

现有代码中LLM checker 已有执行期 LlmCheckObservation 对象且 expect.output 依赖其中的完整 outputTextICMP/Ping checker 已有 PingStats 结构,其余 checker 的观测数据散布在 execute 函数的局部变量中。

前端通过 statusDetail 字符串直接展示摘要,仅在 history-table-columns.tsxOverviewTab.tsx 两处引用。

项目未上线,不需要考虑向前兼容和数据迁移。

现有 openspec/specs/probe-data-store/spec.mdcheck_results.target_id 仍写为 INTEGER但当前代码中的 targets.idcheck_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 typedashboard 路由遍历 targetshistory 路由已查出 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: 执行期继续使用包含完整 outputTextLlmCheckObservation 支撑 expect 校验;写入 CheckResult.observation 前派生持久化 observationoutputText 转为 outputPreviewoutputLength,并截断 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 描述解码问题。