--- 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` 为 NULL(C1 不冒充) | ### 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` 为准。