## 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 | null` **选择**: 使用 `Record` 作为 observation 的共享类型,各 checker 内部定义强类型 interface 用于构造,序列化后类型擦除。 **替代方案**: - Discriminated union(`{ type: "http", ... } | { type: "tcp", ... }`):类型安全但导致共享层与所有 checker 类型耦合,每新增一个 checker 都需要修改共享类型 - `unknown`:过于宽松,缺乏结构约束 **理由**: 折中方案。共享层不关心 observation 的内部结构(只需序列化/反序列化),类型安全由各 checker 内部保证。buildDetail 方法接收 `Record` 并在内部做类型断言。 ### 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 | 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`,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 描述解码问题。