Postgres requires a partitioned table's unique constraints to cover the partition key, so ranging on created_at forces the primary key to (call_id, created_at) -- and ON CONFLICT (call_id) DO NOTHING then matches no constraint at all. The retention design claimed INSERT stays transparent under partitioning; that holds for the routing, not for the conflict target, and telemetry would have failed outright on any partitioned deployment. The write drops its conflict target, which is byte-equivalent on a plain table and legal on both. The cap design gains the three emitter construction sites it has to touch and the relationship to the 200-char caps embed and OCR already carry: they stay, and the new cap is the stricter of the two. Covering all three call paths is deliberate -- their rows land in one table, and issue #11 settled that argument already.
14 KiB
issue #12 设计: 遥测表的正文体量、保留期与访问控制
状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例) 同批交付: issue #13 遥测 schema 档位
1. 问题
llm_calls 存的是完整正文: messages 落库前只过 digest_messages,而它只对多模态 part 里的 image_url 做 sha256,纯文本原样透传;response 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 tenant_id 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
- 保留期: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
- 访问控制的默认状态: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分已经存在了。
2. 已定决策(人类,2026-08-19)
| # | 决策 | 选择 |
|---|---|---|
| E-a | 正文截断 | 新增可配置上限,缺省不截断(保持现状全文) |
| E-b | 保留期 | 文档模板 + tools/ 独立脚本;库本体不持有 DELETE/DROP 权限 |
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
3. 三个子问题的边界
| 子问题 | 库能做什么 | 性质 |
|---|---|---|
| (a) 正文体量 | 遥测路径可配置截断 | 唯一改库本体代码的,也是唯一预防性手段: 没写进去的数据不需要删 |
| (b) 保留期 | 分区 + retention 模板;tools/ 清理脚本 |
文档 + 可选工具,库不执行 DELETE/DROP |
| (c) 访问控制与不可变性 | 角色划分模板、REVOKE UPDATE, DELETE、分区 |
纯文档 |
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
4. 备选方案对比
| 方案 | 内容 | 权衡 | 结论 |
|---|---|---|---|
| A(采纳) | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 REVOKE UPDATE, DELETE 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
| D | 给 TelemetryRecorder 端口加 purge_before(ts) |
语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
5. 设计: (a) 正文截断
5.1 配置与装配
| 层 | 形态 |
|---|---|
| 环境 | PGW_TELEMETRY_TEXT_CAP(可选键,正整数;未设 = 不截断) |
GatewaySettings |
新增字段 telemetry_text_cap: int | None(无默认值,与既有字段一致);<= 0 报 ValueError |
TelemetryEmitter |
新增 keyword-only 必填参数 text_cap: int | None(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 client.py:149 / embedding.py:131 / ocr.py:130 必须同步传参,否则 TypeError(测试内另有十余处) |
5.2 作用面与切法
截断发生在 TelemetryEmitter._record ——全库唯一的遥测调用点(铁律),在 digest_messages 之后、json.dumps 之前。作用于 messages 的每条文本 content(含多模态 part 中 type == "text" 的 text 字段)、response、thinking。
按每条文本切,而不是切整串 JSON: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 messages 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
头部硬切 + 标记省略字数(形如 …(略 12345 字)),不复用 _http_errors.summarize_body: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
非字符串 content(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
覆盖面的诚实声明: 截断作用于 content 文本,与 digest_messages 的处理面一致。调用方放进 tool_calls.function.arguments 等其他字段的内容不在覆盖范围内,文档须写明。
三条链路全覆盖,不只 chat(Codex 审查提出后核实定稿): _record 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落同一张表,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: embedding.py:73 与 ocr.py:73 各已有 200 字符的自有上限(embed 截 texts、OCR 的 messages 本就是 <ocr:kind image_bytes=N> 占位、response 走 _summarize 截 200),两者保留不动,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
5.3 红线
digest_messages 一个字节都不能碰。 它是缓存 key 与遥测共用的函数(middleware/cache.py:31),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
6. 设计: (b) 保留期
README 模板: PG 侧给 created_at 的 RANGE 月分区 + pg_partman retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 DELETE——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
PostgreSQL 要求分区表上的唯一约束(含主键)必须包含分区键。按 created_at 做 RANGE 分区后,call_id TEXT PRIMARY KEY 不再合法,主键须改为 (call_id, created_at);而库今天的写入语句是 ON CONFLICT (call_id) DO NOTHING,它需要一个恰好匹配 (call_id) 的唯一约束——分区表上不存在,写入会直接报错。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
修法: 库的写入改为无冲突目标的 ON CONFLICT DO NOTHING。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。该改动归入 issue #13 实现——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 telemetry/schema.py,两条分支不应改同一行。
分区部署的语义差异须写进文档: 分区表上幂等键实际是 (call_id, created_at),而 created_at 由数据库 DEFAULT now() 生成,故同一 call_id 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 call_id),但会改变缓存命中行的表现——emit_cache_hit 复用的是响应里的历史 call_id,在普通表上第二次及以后的命中会被 DO NOTHING 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 cache_hit 统计时必须知道。
6.2 模板与工具
分区表必须由下游先手工建,库的 CREATE TABLE 只会建普通表。这正是 issue #13 的 telemetry_schema_sql() 的用途: 下游取到库要求的最小 schema,自己加上 PARTITION BY RANGE (created_at) 再建。库的 to_regclass 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
tools/telemetry_retention.py(独立脚本,不被 import,符合 tools/ 规则):
| 项 | 设计 |
|---|---|
| 参数 | --backend sqlite|postgres、--path/--dsn、--older-than-days N、--apply(默认 dry-run)、--batch-size、--vacuum(仅 SQLite,显式) |
| 输出 | 将删除的行数、created_at 时间范围、按 tenant_id 的分布 |
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时改为提示用 DROP PARTITION 并拒绝 DELETE |
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE) |
| 依赖 | SQLite 走标准库;PG 需 asyncpg,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
7. 设计: (c) 访问控制与不可变性(纯文档)
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 README: sdist 只打包 src/ 与 README(无 MANIFEST.in),放进 wiki 的模板下游 pip install 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
| 内容 | 要点 |
|---|---|
| 三角色 | owner(DDL 与清理)、app(INSERT + 受 RLS 约束读自己租户)、report(只读 + 受 RLS 约束) |
| 不可变性 | REVOKE UPDATE, DELETE ON llm_calls FROM app, report;触发器兜底只防误操作不防恶意(属主可 disable),须写明 |
| 分区 | 与 §6 的 retention 模板同一段落 |
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
权限张力必须写明: 既要 REVOKE DELETE 又要清理,就只能走 DROP PARTITION(owner 操作)而非 DELETE(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
8. 非功能维度
| 维度 | 结论 |
|---|---|
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;_record 既有的 except asyncio.CancelledError: raise 保持在最外层,取消穿透路径不变 |
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 _record 的降级 try 接住 → warning + 丢一行,不冒泡给调用方 |
| 幂等与重复 | 截断是纯函数,同输入同输出;call_id 幂等键与写入语义不变 |
| 持久化与原子性 | 库本体不变;tools/ 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
9. 错误处理与测试策略
| 层 | 用例 |
|---|---|
| unit | cap=None → 正文原样;cap=N → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 text 被截而 image_url 的 sha256 不动;response/thinking 被截;标记含省略字数;非字符串 content 不抛错 |
| unit(红线验收) | 同一组 messages 在 cap 开与关两态下 build_cache_key 输出逐字节相同——机械化钉死"截断不得污染缓存 key" |
| unit | config: 未设 → None;<= 0 → ValueError;合法值透传到 emitter |
| unit | tools/ 脚本: 真实临时 SQLite 上 dry-run 不删任何行、--apply 删除且仅删除超期行、--older-than-days 0 的边界 |
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
| integration(真实 PG) | 无冲突目标的 ON CONFLICT DO NOTHING 在普通表与分区表上都能幂等写入(分区表主键为 (call_id, created_at));此条与 issue #13 的实现同批验收 |
| integration(真实 PG) | README 的模板 SQL 逐条执行: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
遥测路径的一切失败仍不落四分类;配置校验抛裸 ValueError(公共入口先例)。
10. 兼容性、文档与发布
非破坏性(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 text_cap=None 时行为与今天逐字节相同。
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、.env.example、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki 指南-遥测与成本 / 参考-配置键 / 参考-公共API、CHANGELOG。
11. 开放问题
tools/telemetry_retention.py是否需要覆盖"按tenant_id定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。- 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
- Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出合规下游的推荐配置(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。