Version bump in pyproject and __init__, CHANGELOG dated 2026-09-09, README install lower bound raised to >=1.3.5, and release-prep evidence (remote check, gates, real gateway smoke and one bounded live probe) recorded in the 1.3.5 validation finding.
23 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/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵 | 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 存储兼容、变异矩阵、文档同步)
基线 HEAD 393f2bf(1467 unit 全绿),全部命令在 PolyGateway conda 环境执行。
5.1 PG 存储兼容(真实实验室 Postgres,复用 pg_sandbox)
机械迁移:_EXPECTED_COLUMNS 27 → 37 列、_record_minimal 字段字典补 10 键(与单测同款)、
_PRE_TENANT_COLUMNS 的缺列集由4 列扩到 14 列。新十列一律由 _CALL_OBSERVABILITY_COLUMNS 派生,
两处 manual 档告警的逐字断言改成按 COLUMNS 序派生的 _PRE_TENANT_MISSING_NOTICE——
另抄一份列名必然漂移,而漂移的表现是“manual 档没补列”这条断言假绿。
| 阶段 | 命令 | 结果 |
|---|---|---|
| 机械迁移前(已知必红) | pytest tests/integration/test_postgres_telemetry.py -q -x |
1 failed / 18 passed:manual 档列序断言 Right contains 10 more items, first extra item: 'scope' |
| 机械迁移后 | 同上(无 -x) |
27 passed,EXIT=0,tests/outputs/135/pg-telemetry-after-mechanical.log |
| 新增四项验收后 | 同上 | 30 passed,EXIT=0,tests/outputs/135/pg-telemetry-new-cases.log |
新增 TestCallObservabilityColumnsAcceptance(对应计划 T4 的 PG 四项),共用一张 27 列的 1.3.4 形态旧表(pre_135_schema):
| 验收项 | 用例与关键断言 |
|---|---|
| auto 追加 10 列 + 旧行 NULL | test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null:物理列 27 → 37 且列序 == _EXPECTED_COLUMNS;attempt 行与 terminal 行十列取值整体比对(不是逐条 in);历史行 dict.fromkeys(...) 十列全 NULL |
| manual 缺列裁剪 | test_manual_trims_the_insert_on_a_pre_135_table:表结构逐字不动(== _PRE_135_COLUMNS);裁剪后 26 列逐列等于提交值(防整体错位);恰 1 条告警且含可直接粘贴的首/末列 ALTER;无“写入失败”/“补列失败”,degraded is False |
| 新旧进程混写 | test_old_and_new_writers_share_one_table:新版建表写 36 列 → 旧版进程用 insert_sql("postgres", 26 列集) 写入 → 新版再写;三行共存、表结构不变、旧行新列全 NULL、无写入失败 warning |
| 下游口径验收 | 同上用例尾部:WHERE event_kind = 'terminal_failure' 计得 1;event_kind IS NULL 计得 1(混写期旧行既不误计成失败也不误计成成功) |
纪律:未引用 assert_no_leftovers(它是 test_pg_sandbox.py 的模块级 fixture,对本文件不可见,上提它要改 conftest.py);
未新建沙箱设施、未碰共享表 llm_calls、未读或打印 DSN。新增的只有一个 _execute_args(带参数单语句)与
_minimal_fields(从 _record_minimal 拆出的字段字典,供“旧进程”复用同一份取值)。
5.2 变异矩阵(仓库外副本,主工作区生产代码零改动)
驱动脚本 /tmp/pgw-135-mut/run_mutations.py;每项“还原副本 → 施加单点变异(断言替换命中)→ 跑指定节点 → 还原”。
完整日志 tests/outputs/135/mutations.log,脚本总退出码 EXIT=0。
| # | 变异 | exit | 被杀断言(节选) |
|---|---|---|---|
| M1 | register_attempt() 挪到 transport 成功之后 |
1 | test_failed_retries_are_counted、test_budget_free_429_still_counts_as_an_attempt、test_retry_exhausted_counts_every_attempt |
| M2 | ChatRequest 每次 replace 复制出新上下文 |
1 | TestLogicalCallStats 4 项 + test_retry_exhaustion_writes_exactly_one_terminal_row |
| M3 | _rehydrate 去掉 call_stats=None 覆盖 |
1 | test_historic_dict_never_impersonates_call_stats |
| M4 | Emitter 入口提前 str(exc) 压平 |
1 | 529/503 保真、4 个空超时文案 cause_type、类名前缀策略、结构化有界说明等 9 项 |
| M5 | 终态行复制最后一次 attempt 的 token/cost | 1 | test_terminal_rows_never_contribute_to_cost、test_terminal_row_costs_nothing |
| M6 | 去掉 claim_terminal 去重 |
1 | test_terminal_row_is_written_once_per_logical_call、test_claim_terminal_is_true_once |
| M7 | 装配闸改为捕获 TypeError 后 warning |
1 | test_old_signature_recorder_is_refused_at_assembly、test_uninspectable_recorder_is_a_configuration_error |
还原校验:副本 .py 文件集合散列与纯净态逐字一致(66b58b9a…),还原后同一批节点 172 passed。
方法论陷阱(影响本仓所有变异证据的有效性):只设
PYTHONPATH=<副本>/src是无效的——pyproject.toml的[tool.pytest.ini_options] pythonpath = ["src"]会把仓库内的src抢先塞进sys.path[0], 于是测的仍是原代码。首轮实跑七项变异**全部“存活”(exit=0)**就是这个坑; 改用-o pythonpath=<副本>覆盖后七项全部被杀。今后做变异必须先证“副本真的被导入”, 否则“变异存活”会被误读成“测试不够强”,而真相是变异根本没生效。
5.3 文档同步(字段数一律 inspect 实测,不凭记忆)
实测值:len(inspect.signature(TelemetryRecorder.record_llm_call).parameters) - 1 == 36,len(COLUMNS) == 36,物理列 37。
| 位置 | 改了什么 |
|---|---|
README.md 能力表 |
“必录 26 字段” → 36 字段 + 三类行与诊断列;新增“逻辑调用统计”一行 |
README.md 新小节 |
《1.3.5 逻辑调用统计与失败诊断》:call_stats 读法、SQL 迁移五项、归因查询、存储侧升级 |
README.md cap 覆盖面 |
补“error_body 沿用 summarize_body 上限、结构化说明自带限长,两者都不在 cap 覆盖内” |
ARCHITECTURE.md §7.8 |
必录字段 26 → 36(十列逐个列出);新增“逻辑调用十列”段(列语义表 + 不变量 I3/I4 + operation vs exc.operation 两个语义 + 装配闸);cap 段补两处诊断文本不在覆盖面 |
CHANGELOG.md |
新建《未发布》:公共面四项变更表 + 下游必须做的事(点名 WHERE event_kind 与装配期报错) |
.env.example |
PGW_TELEMETRY_TEXT_CAP 块补 1.3.5 覆盖面例外 |
schemas/llm-calls.md |
标题 26 → 36 字段;十列逐行登记;新增《三类行与失败归因口径》(含归因 SQL 与迁移四条) |
metrics/call-telemetry-coverage.md |
新增《1.3.5 三类行与逻辑调用覆盖》;live 基线列不写伪百分比 |
版本号与发布步骤未动(任务边界);pyproject.toml / __init__.py 仍为 1.3.4。
5.4 本轮收口验证
| 检查 | 命令 | 结果 |
|---|---|---|
| 静态 | make check(ruff format+lint + import-linter) |
见 §7 实跑记录 |
| 全量单测 | pytest tests/unit -q |
见 §7 |
| 目标集成 | pytest tests/integration/test_postgres_telemetry.py -q |
30 passed |
6. 剩余缺口(不归本任务,不自称已关)
| 缺口 | 说明 |
|---|---|
| 独立验证 | 未派全新上下文 verifier(合并前硬门,由父会话前台派) |
| slow / e2e | 未跑,属发布清单第 4 步(CLAUDE §4.4.1) |
| live 覆盖基线 | metrics/call-telemetry-coverage.md 的实际基线列仍待首次生产运行填入 |
| 下游自建 recorder | 装配闸只能证明形状可被接受,证不了函数体真的落这些列(已写进 README/CHANGELOG) |
7. 环境噪音记录(不是缺陷)
自动检查器用系统解释器 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 为准。
T4 轮次同样现象(已逐项复现并关闭):检查器报 tests/integration/test_postgres_telemetry.py “2/3 failed”
与 6 处 Import "asyncpg" could not be resolved。根因与上同:asyncpg>=0.29 是 optional extra
(pyproject.toml:25 的 postgres),检查器解释器里没装。已做确定性复现:
- 那 3 条恰是本文件仅有的不依赖真实 PG 的用例,其中 2 条要造
PostgresRecorder; - 用
/home/iomgaa/miniconda3/bin/python(无 asyncpg)跑这 3 条:2 failed / 1 passed / 0.09s,与检查器报告逐字吹合; - 同 3 条在
conda run -n PolyGateway下:3 passed;整文件 30 passed(真实 PG); - 被标记的 6 行均为本轮未触及的旧行,本轮只新增 1 处同款函数内
import asyncpg。
未为此修改代码:给旧行加 type-ignore 属任务外改动(反 gold-plating),且并非真修复。
8. 1.3.5 发布准备(2026-09-09,唯一 writer 会话)
范围仅发布准备文档与版本号:README 安装下界、CHANGELOG 定版、两处版本、本节记录。生产代码与测试零改动(git diff 只有 CHANGELOG.md / README.md / pyproject.toml / __init__.py 各 1 行,外加本文件追加的这一节)。未 merge/push/tag/构建/上传。
8.1 先查远端占用(改文件之前)
| 检查 | 结果 |
|---|---|
git ls-remote --tags origin |
最高 refs/tags/v1.3.4,无 v1.3.5;远端分支只有 main(af57f93)与 docs/1.3.4-release-evidence |
| 本地 tag | 同样止于 v1.3.4 |
远端未被占用是本次时点的事实,父会话真正 push/tag 前仍须复查以防竞态。
8.2 版本与文档数字(数字一律实测)
| 项 | 结果 |
|---|---|
| 两处版本 | pyproject.toml 与 src/polygateway/__init__.py 同为 1.3.5;由 tests/unit/test_package.py 机械断言一致(6 passed) |
| README 安装下界 | >=1.3.4,<2 → >=1.3.5,<2(本版含装配期形状闸与 SQL 口径迁移,旧下界会让下游装到不含新列的包) |
| README 迁移醒目度 | 《1.3.5 逻辑调用统计与失败诊断》为顶层小节且带 [!WARNING](点名"失败行变多、旧失败 SQL 会多数、自建 recorder 装配期报错"),能力表"逻辑调用统计"一行直链该锚点;沿用 1.3.4 的同款版式,未重排既有 1.3.4 迁移节 |
| CHANGELOG 定版 | ## 未发布 → ## 1.3.5(2026-09-09),日期取自本机 date +%F 实际值,正文未改 |
| 字段数复核 | inspect.signature(TelemetryRecorder.record_llm_call) 去 self 36 参、len(schema.COLUMNS) == 36、探针实测物理列 37——README/CHANGELOG 的 36/37 与实测吻合,非凭记忆 |
README 静态数字未发现错误,故未做任何顺带修改。
8.3 复用既有证据的适用边界(不拿旧证据顶替本版统计)
| 证据 | 是否复用 | 边界 |
|---|---|---|
| 独立验证(全新上下文 verifier,父会话前台派) | 复用结论:0 阻塞 | 审的是本分支 b4812e1 的 1.3.5 变更面;本会话未重复派子代理,也未把它当作 live 覆盖或发布后检查的替代 |
本版最终全套件 make test |
本版实跑,不复用 | final-gates/make-test.log/.exit:1605 passed / 23 skipped / 108 deselected / 95%,exit 0 |
| 本版真实 PG 集成 | 本版实跑,不复用 | final-gates/pg-telemetry-verbose.log/.exit:30 passed,exit 0(含新增四项列兼容验收) |
| 本版 unit | 本版实跑 | 本轮版本号改动后复跑 pytest tests/unit -q:1469 passed,exit 0(release/unit.log/.exit) |
| 1.3.4 模型矩阵/逐型号推理证据 | 按适用条件复用 | 1.3.5 对 thinking.py、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义一字未改(§4 已逐文件确认),故 1.3.4 的型号级结论在其原有边界内继续成立——连同它的 FAIL/UNKNOWN/不可达/缺轮一并继承,不因本版而升格。它不能充当 1.3.5 新增 API/schema 的证据,也不提供本版的测试统计数字 |
| 1.3.4 的全模型矩阵重跑 | 不跑 | 本版未改推理路径;用户已批准不再补全模型矩阵。本会话不发全型号请求 |
本版改变的公共面(CallStats 与四类响应新字段、端口 10 新参、遥测 10 列与 terminal_failure 行)全部由新写的离线真链路用例(三个 client + MockTransport 直到落库)、真实 SQLite 断言、真实 PG 30 项承担,见 §2、§2.4、§5.1。
8.4 本轮受影响路径的真实现场(并发 1、超时不压、无全模型请求)
| 亲跑 | 命令与证据 | 结果 |
|---|---|---|
| 真实网关冒烟(复用既有 e2e,不另造工具) | pytest tests/e2e/test_smoke_gateway.py -m slow -v,tmux pgw135-smoke,release/smoke-gateway.log/.exit |
4 passed,exit 0(流式/非流式/结构化 json/结构化模型四节点,9.55s) |
| 有界现场探针(恰 1 次真实调用,全程走库) | release/probe_call_stats.py → release/probe-call-stats.log/.exit,tmux pgw135-probe |
exit 0:MiniMax-M3/源 minimax_1,call_stats.attempts=1、total_latency_ms=2581 ≥ 单次尝试 2562、logical_call_id 与落库行逐字一致;临时 SQLite 物理列 37、写入面 36、恰 1 条 event_kind='attempt' 行、operation='chat'、scope='LLM'、逻辑两列与四个诊断列全 NULL |
探针脚本只落在 tests/outputs/135/release/(该目录 gitignore,不入库),不新增生产代码、测试或平台设施,未发裸 HTTP,沿用 .env 现有超时与单源配置。
8.5 提交前收口门
| 门 | 证据 | 结果 |
|---|---|---|
make check |
release/check.log/.exit |
exit 0:94 文件格式通过、ruff 通过、import-linter 1 kept / 0 broken |
| 包单测(两处版本一致) | release/package.log/.exit |
6 passed,exit 0 |
| 全量 unit | release/unit.log/.exit |
1469 passed,exit 0 |
23 项 skip 的理由已逐条留档(final-gates/skip-reasons.log):17 项 redis 时间语义由 integration 变体覆盖、6 项需实验室语料 data/soak/chs_images;skip 不计通过。conda 启动器既有 RequestsDependencyWarning 保留,不宣称零告警。
8.6 尚未执行(不得当成已完成)
merge、push、git tag -a v1.3.5、python -m build、twine check/upload、pip download 解包验证、Gitea Release 与包页面/仓库关联检查全部未执行;pytest -m slow 的其余 e2e(本轮只跑了 test_smoke_gateway.py 四节点)与 live 覆盖基线同样未跑。§6 的四项缺口除"独立验证"已由父会话关闭外,其余保持开启。