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.
This commit is contained in:
2026-09-09 12:17:50 -04:00
parent 7b2f6105f3
commit 067b15be48
7 changed files with 237 additions and 13 deletions
+16 -2
View File
@@ -562,7 +562,7 @@ flowchart TB
### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 26 个 INSERT 字段,物理表列 27 = 26 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**。
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
@@ -581,6 +581,20 @@ flowchart TB
实际档分析须 `cache_hit=false AND error IS NULL`。embeddingOCR textlayout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。
**逻辑调用十列(2026-09-09,issue #19/#23,端口 26 → 36)**: 本版把治理单位从"一次尝试"补齐到"一次逻辑调用"。十列按同一顺序追加在 `reasoning_effort` 之后,全部可空、无默认值、不回填旧行(NULL 表达的是"补列之前根本没记过这件事",与任何哨兵值不同;这一点与 `tenant_id` 故意相反——后者是 RLS 可见性需要哨兵,前者是归因需要区分真实缺口)。
| 列 | 语义 |
| --- | --- |
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
| `error_type` | 该行自身错误的类名;取消路径传字符串,故为 NULL |
| `attempts` / `total_latency_ms` | 只属终态行(同一份冻结快照,与该行 `latency_ms` 同值),其余两类行 NULL |
两条不变量: **I3** —— 每次领域失败至多一条终态行(`_CallContext.claim_terminal()` 去重;recorder 写失败仍只 warning,故 SQL 可见 ≤ 1);**I4** —— 非领域异常(编程错)**零条**终态行、原样传播、分类不被改写。终态行仍 `cost=NULL``usage_source='unavailable'`、token 0,费用聚合口径不变。诊断值的提取收敛在 Emitter 内一个纯 helper(`_error_fields`),只读领域异常的既有属性,**不遍历任意对象、不解析字符串猜诊断**。另立**装配期形状闸**: 旧签名 recorder 在构造 `TelemetryEmitter` 时当场 `ValueError`——降级方向的铁律管的是**运行期写失败**,装配错误放行的后果是下游 100% 丢遥测且调用照常成功。
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
**recorder 收到的必须是裸 `str` 而非枚举实例**: `TelemetryEmitter``_AttemptUsage` 内部持 `ThinkingObservation` 类型,`_record` 下沉时取 `.value``StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对 `str` 子类不保证接受,而遥测写失败只降级为一条 warning——这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化放在 emitter 侧,与 `tenant_id`/`meta`/`sampling` 由 emitter 定型后再交 recorder 是同一分工(recorder 只落库,不做语义判断)。列序纪律同上: 新列排在最末,两端 DDL 与两份 backfill 同步。
@@ -589,7 +603,7 @@ flowchart TB
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。**1.3.5 新增的两处诊断文本同样不在 cap 覆盖内**: `error_body` 沿用 `summarize_body` 的既有上限,结构化耗尽终态行的 `error` 说明自带有界限长(`repair=` 至多 200 字符 + `validation=` 至多 3 条 × 200 字符,**不含 `raw_text`**——模型正文的预算已由 attempt 行的 `response` 列承担)。两段限长常量与 `StructuredMW` 重问反馈共用同一份(`format_bounded_errors`),数值只有一份定义。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。