feat: record call observability columns and terminal failure rows

Grow the telemetry contract from 26 to 36 fields and give every logical
call a failure terminal row, so SQL can finally answer "how many calls
failed" and "why did the whole pool die".

Schema and port move together with the emitter writes in one commit:
splitting them would ship columns that nothing populates.

- schema: append 10 nullable columns (scope, operation, logical_call_id,
  event_kind, http_status_code, error_type, cause_type, error_body,
  attempts, total_latency_ms) to all five definition sites in one order
- ports: 10 keyword-only parameters without defaults; the protocol
  signature is now the single source the assembly gate derives from
- emitter: take domain exception objects instead of pre-flattened text
  and pin down the diagnostics in one helper; a relabelled 503 stays
  503 and success rows leave all five columns NULL
- emitter: reject recorders whose record_llm_call cannot accept the
  current field shape at assembly time, since _record would otherwise
  swallow the TypeError and drop every row while calls keep succeeding
- clients: write at most one terminal row per logical call through a
  single shared exit, deduplicated by the call context; TelemetryMW
  stops writing terminals so the two sites cannot double count
- clients: cancellation stays best effort and propagates, non-domain
  exceptions get no terminal row and keep their classification
- transports: give _status_to_error an explicit operation and fix the
  historically mislabelled embedding HTTP failures
- structured: promote the bounded error formatter so the reask feedback
  and the terminal explanation share one set of limits

Terminal rows carry no cost and no tokens, so cost aggregation is
unchanged; failure counts must now filter on event_kind.
This commit is contained in:
2026-09-09 11:27:52 -04:00
parent 87c261bf73
commit 393f2bf617
19 changed files with 1628 additions and 194 deletions
@@ -0,0 +1,141 @@
---
type: finding
node_id: finding:2026-09-09-135-call-observability-validation
title: "1.3.5 T2/T3 验收证据:36 列遥测、失败终态与取消口径"
date: 2026-09-09
---
# 1.3.5 T2/T3 验收证据
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
> 设计:`designs/2026-09-09-135-call-observability-design.md`;计划:`plans/2026-09-09-135-call-observability.md`。
> 基线 HEAD `87c261b`T1 已提交,1419 unit 全绿)。全部命令在 `PolyGateway` conda 环境执行。
## 1. 红绿证据链
TDD 纪律要求"先失败后通过",且红必须是**行为红**而非 import 红。下表每行都对应本会话的真实工具输出。
| # | 阶段 | 命令 | 结果 |
| --- | --- | --- | --- |
| 0 | 基线 | `pytest tests/unit -q` | **1419 passed**(对照底) |
| 1 | 红(T2/T3 目标行为) | `pytest tests/unit/test_telemetry.py -k "RowLevelObservability or RecorderShapeGate"` | **26 failed**,证据 `tests/outputs/135/red-01-emitter-rows.txt` |
| 2 | 实现后全量红面 | `pytest tests/unit -q` | **137 failed / 1308 passed**(机械迁移面暴露),`red-02-after-impl.txt` |
| 3 | 迁移中 | 同上 | 60 → 25 → 8 → 4 failed`red-03`/`red-04`/`red-05` |
| 4 | 绿 | `pytest tests/unit -q` | **1467 passed, 0 failed** |
| 5 | 静态门 | `make check`ruff lint+format + import-linter | **Contracts: 1 kept, 0 broken** |
第 1 步的红是行为红而非 import 红:新用例调用的是**已存在**的 `TelemetryEmitter`
失败形态是"缺 `scope=`/`operation=`/`stats=` 关键字"与"断言的列不存在",不是模块导不进来。
补测阶段另有两次真实红(均由 conda 实跑暴露、当场修正,非噪音):
`test_client.py``NameError: ResultInvalidError / ChatRequest`(漏 import)、
`TestTerminalRowSqlSemantics``'coroutine' object has no attribute 'execute'`async helper 漏 await)。
## 2. 核心要求逐条对应
### 2.1 36 列与 Emitter 完整一致(不留"schema 有列、写入缺键"
一次性同批改完五处列定义 + 端口签名 + Emitter 写入,故不存在中间态。
| 判据 | 证据 |
| --- | --- |
| `len(COLUMNS) == 36`、物理列 37 | `test_telemetry.py::TestSchemaModule::test_columns_and_ddl_are_frozen``TestSQLiteSchemaMode` 断言 23 → 37 |
| 五处列序一致(DDL/BACKFILL×2/COLUMNS | 新建库与 ALTER 追加列序同为 `_EXPECTED_COLUMNS` |
| 端口实测 36 字段、10 新参 keyword-only 且无默认值 | `test_ports.py::TestTelemetryRecorderSignature``inspect.signature` 实测,不凭记忆) |
| **Emitter 实参键集合 == `schema.COLUMNS`** | `TestEmitterRecorderContract` 三入口逐个断言 `set(rows[0]) == set(COLUMNS)` |
| 1.2.1 冻结 INSERT 未被改写 | 按 `_PRE_135_COLUMNS`(26 列)重现原文;全量 36 列另按占位符个数断言 |
装配闸(C3):`_assert_recorder_shape``TelemetryEmitter.__init__` 做一次 `signature.bind`
参数名**从协议签名派生**而非手抄第四份清单——`test_gate_derives_parameter_names_from_the_protocol`
用 monkeypatch 换掉协议后闸自动跟随,证明没有硬编码。旧签名 recorder / 无该方法 / 不可 inspect
一律装配期 `ValueError`(不是 warning:降级铁律管的是运行期写失败,不是配置错误)。
### 2.2 三个 client 的**真实链路**失败终态
不是只测 Emitter,而是驱动真实 client + MockTransport 到落库。
| 链路 | 用例 | 断言 |
| --- | --- | --- |
| chat 重试耗尽 | `TestChatTerminalFailureRows::test_retry_exhaustion_writes_exactly_one_terminal_row` | 3 条 attempt + **恰 1 条**终态;终态 `attempts == 3` |
| chat 结构化耗尽 | `test_structured_exhaustion_writes_the_only_failure_row` | attempt 行**全是成功行**,终态是唯一失败记录;error 含有界 `validation=`/`repair=` 且 < 1200 字符、不含 raw_text |
| chat 400 直拒 | `test_request_rejected_now_has_both_an_attempt_and_a_terminal_row` | 尝试行 + 终态行**各 1**(已批准的行数翻倍);两行共享同一 `logical_call_id` |
| embedding | `test_embedding.py::TestReasonlessTelemetryContract::test_failed_attempts_still_have_null_effort` | attempt 数 == 脚本长度,终态恰 1 |
| OCR 两方法 | `test_ocr_client.py` 同名用例(参数化 `recognize_text`/`parse_layout` | 终态恰 1,且 `operation` 为**公开方法名** |
去重:`test_terminal_row_is_written_once_per_logical_call` 连调出口 3 次,SQL 可见仍 1 条(`claim_terminal`)。
非领域异常:`test_non_domain_exception_writes_no_terminal_row` —— `KeyError` 原样传播、**0 条**终态、分类不被改写。
**双写已消除**`TelemetryMW` 的两个终态分支删除,改由三个 client 的公开边界经
`emit_terminal_once` 统一写出;`TestTelemetryMW::test_scope_level_failure_is_not_written_here_anymore`
`test_cancellation_is_not_written_here_anymore` 锁死"本层不再写终态",防回归双计。
### 2.3 取消口径
- chat 取消:`test_cancellation_writes_at_most_one_terminal_row` —— 恰 1 条,`error == "cancelled"`
`error_type` 为 NULL(**字符串不解析猜诊断**)。
- 三链路同策略"尽力写一条、允许 0"`emit_terminal_once` **不 shield、不开后台任务**
快照冻结是同步动作;写入 await 上再被取消则 `CancelledError` 原样传播(与 TelemetryMW 历史行为同款)。
### 2.4 SQL 不双计(按真实 SQLite 落库断言)
`TestTerminalRowSqlSemantics` 用真实 `SQLiteRecorder` 驱动一次失败 chat 后直接查表:
| 迁移影响 | 断言 |
| --- | --- |
| 失败计数判据 | `error IS NOT NULL`**3**2 尝试 + 1 终态),`event_kind='terminal_failure'`**1** |
| 费用不双计 | 终态行 `cost IS NOT NULL` 计数为 **0**;且 `usage_source='unavailable'`、token 全 0 |
| 时延分组 | 终态 `latency_ms` ≥ 任何单次尝试(含退避),故看板必须按 `event_kind` 分组 |
| 双时钟微差 | 终态 `latency_ms == total_latency_ms`(同一份冻结快照) |
| 逻辑两列归属 | 尝试行 `attempts`/`total_latency_ms` 恒 NULL |
| §5 归因查询 | 同一 `logical_call_id` 同时给出整池 reason 文案与逐源 503 现场;终态 `http_status_code` 为 NULLC1 不冒充) |
### 2.5 诊断保真与 operation 修正
- 中转把 529 改写成 503 → **记 503 不猜回 529**;直接 529 记 529。
-`str()` 的 Connect/Read/Write/PoolTimeout → `cause_type` 落对应 httpx 类名,`error` 退回类名。
- 成功行五列全 NULL(**不统一填 200**)。
- `_status_to_error` 增 keyword `operation``embed()` 非 200 改传 `"embedding"`(修正历史误标),
流式与非流式 chat 两处仍 `"chat"`(按实施计划 §2 归属表,未按设计行号误标)。
- 新列 `operation` 恒为公开方法四值,**绝不读 `exc.operation`**
`test_operation_is_given_by_the_call_site_not_the_exception``exc.operation="download_result"` 反证。
- OCR `"类名: msg"` 前缀由出口的显式策略参数 `class_prefixed_error` 承载,不再三处各拼一遍。
## 3. 有界 validation 说明的单一所有者
`structured.py``_MAX_FEEDBACK_ERRORS`/`_MAX_ERROR_CHARS`/`_format_errors` 改名为
公开的 `MAX_FEEDBACK_ERRORS`/`MAX_ERROR_CHARS`/`format_bounded_errors`
由"重问反馈"与"终态结构化说明"两个消费者共同引用,**数值只有一份**。
行为逐字不变(`test_structured.py` 22 项全绿,含反馈文案用例)。
## 4. 未改动确认(防越界)
`errors.py``admission.py``ratelimit.py``breaker.py``sources.py``thinking.py`
`providers.py``telemetry/sqlite.py``telemetry/postgres.py``transports/monkey_ocr.py`
一字未动——两个 recorder 靠 `**fields` + `schema.COLUMNS` 自动吃到新列。
缓存 key 公式、重试预算与退避、429 免预算、stall 算法、取消结算、推理能力表均未触碰。
唯一顺带修正:`RetryMW.__init__``emitter` 注解由 `object | None` 收紧为
`TelemetryEmitter | None`TYPE_CHECKING 导入,同层不破分层契约),因本轮改了它的 `_emit`
## 5. 明确缺口(交 T4 另任务,本轮不做)
| 缺口 | 说明 |
| --- | --- |
| PG 存储兼容 | `tests/integration/test_postgres_telemetry.py``_EXPECTED_COLUMNS` 与字段字典**尚未补 10 列**auto 追加 / manual 缺列裁剪 / 旧行 NULL / 新旧进程混写四项未跑(需真实 PG 沙箱) |
| 变异证据 | 计划 §4 的七项变异(计数位置、上下文复制、缓存回放、提前压平、终态双计费用、去 `claim_terminal`、闸改 warning)未执行 |
| 文档同步 | README「必录 26 字段」、`PGW_TELEMETRY_TEXT_CAP` 覆盖面、ARCHITECTURE §7.8、CHANGELOG、schemas/metrics 未更新 |
| 独立验证 | 未派全新上下文 verifier(合并前硬门) |
| slow / e2e | 未跑,属发布清单第 4 步 |
## 6. 环境噪音记录(不是缺陷)
自动检查器用**系统解释器 Python 3.13.9**(无 `redis`/`pydantic`/`httpx`/`loguru`/`asyncpg` 等依赖),
持续报 `test_client.py` 2 项失败与大量 "Import could not be resolved"、`StrEnum is unknown import symbol`
已核实为环境问题、非本轮引入:
- 失败根因是 `ModuleNotFoundError: No module named 'redis'`optional extra),本轮 diff 对 redis 零改动;
- 在**未改动的 HEAD** 上用同一系统解释器复跑,同样 2 failed / 90 passed
- 项目强制环境 `conda run -n PolyGateway`Python 3.12.13)下:`test_client.py` 98 passed、全量 1467 passed。
判据以 CLAUDE.md §2 规定的 conda 环境与 `make check` 为准。