Files
PolyGateway/research-wiki/findings/2026-09-09-135-call-observability-validation.md
T
iomgaa 067b15be48 docs: document logical call telemetry and migration impact
Field counts come from inspect, not memory: record_llm_call takes 36
parameters, COLUMNS has 36 entries, the physical table has 37.

- README: capability table says 36 fields and names the three row kinds;
  new section covers reading call_stats, the five SQL migration items,
  the attribution query and the storage-side upgrade
- README/.env.example/ARCHITECTURE: error_body follows the summarize_body
  limit and the structured-exhaustion error carries its own bounded
  explanation, so neither is inside PGW_TELEMETRY_TEXT_CAP coverage
- ARCHITECTURE 7.8: the ten columns with per-column semantics, the I3/I4
  invariants, operation versus exc.operation, and the assembly gate
- CHANGELOG: unreleased section listing the four public changes and what
  downstream must do, in particular counting failures by event_kind and
  the assembly-time error for custom recorders
- schemas/llm-calls: the ten columns plus a three-row-kind section
- metrics/call-telemetry-coverage: 1.3.5 coverage contract, real live
  baselines left unfilled rather than stating a fake percentage

Validation record records the T4 evidence: mechanical migration red then
green, the four PG acceptance cases, the seven-item mutation matrix with
all seven killed and the copy restored to an identical digest, plus the
PYTHONPATH pitfall that made the first mutation round silently test the
original source.

Version numbers and release steps are deliberately untouched.
2026-09-09 12:17:50 -04:00

17 KiB
Raw Blame History

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。 基线 HEAD 87c261bT1 已提交,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 failedred-03/red-04/red-05
4 绿 pytest tests/unit -q 1467 passed, 0 failed
5 静态门 make checkruff lint+format + import-linter Contracts: 1 kept, 0 broken

第 1 步的红是行为红而非 import 红:新用例调用的是已存在TelemetryEmitter 失败形态是"缺 scope=/operation=/stats= 关键字"与"断言的列不存在",不是模块导不进来。

补测阶段另有两次真实红(均由 conda 实跑暴露、当场修正,非噪音): test_client.pyNameError: 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_frozenTestSQLiteSchemaMode 断言 23 → 37
五处列序一致(DDL/BACKFILL×2/COLUMNS 新建库与 ALTER 追加列序同为 _EXPECTED_COLUMNS
端口实测 36 字段、10 新参 keyword-only 且无默认值 test_ports.py::TestTelemetryRecorderSignatureinspect.signature 实测,不凭记忆)
Emitter 实参键集合 == schema.COLUMNS TestEmitterRecorderContract 三入口逐个断言 set(rows[0]) == set(COLUMNS)
1.2.1 冻结 INSERT 未被改写 _PRE_135_COLUMNS(26 列)重现原文;全量 36 列另按占位符个数断言

装配闸(C3):_assert_recorder_shapeTelemetryEmitter.__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_anymoretest_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 NULL32 尝试 + 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 operationembed() 非 200 改传 "embedding"(修正历史误标), 流式与非流式 chat 两处仍 "chat"(按实施计划 §2 归属表,未按设计行号误标)。
  • 新列 operation 恒为公开方法四值,绝不读 exc.operation test_operation_is_given_by_the_call_site_not_the_exceptionexc.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.pyadmission.pyratelimit.pybreaker.pysources.pythinking.pyproviders.pytelemetry/sqlite.pytelemetry/postgres.pytransports/monkey_ocr.py 一字未动——两个 recorder 靠 **fields + schema.COLUMNS 自动吃到新列。 缓存 key 公式、重试预算与退避、429 免预算、stall 算法、取消结算、推理能力表均未触碰。 唯一顺带修正:RetryMW.__init__emitter 注解由 object | None 收紧为 TelemetryEmitter | NoneTYPE_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 passedmanual 档列序断言 Right contains 10 more items, first extra item: 'scope'
机械迁移后 同上(无 -x 27 passedEXIT=0tests/outputs/135/pg-telemetry-after-mechanical.log
新增四项验收后 同上 30 passedEXIT=0tests/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_COLUMNSattempt 行与 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' 计得 1event_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_countedtest_budget_free_429_still_counts_as_an_attempttest_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_costtest_terminal_row_costs_nothing
M6 去掉 claim_terminal 去重 1 test_terminal_row_is_written_once_per_logical_calltest_claim_terminal_is_true_once
M7 装配闸改为捕获 TypeError 后 warning 1 test_old_signature_recorder_is_refused_at_assemblytest_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 == 36len(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 checkruff 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 PolyGatewayPython 3.12.13)下:test_client.py 98 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:25postgres),检查器解释器里没装。已做确定性复现:

  • 那 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),且并非真修复。