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.
9.8 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| finding | finding:2026-09-09-135-call-observability-validation | 1.3.5 T2/T3 验收证据:36 列遥测、失败终态与取消口径 | 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。 基线 HEAD87c261b(T1 已提交,1419 unit 全绿)。全部命令在PolyGatewayconda 环境执行。
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增 keywordoperation: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.py98 passed、全量 1467 passed。
判据以 CLAUDE.md §2 规定的 conda 环境与 make check 为准。