160 lines
9.7 KiB
Markdown
160 lines
9.7 KiB
Markdown
## 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 描述解码问题。
|