Both open issues ask the same question from opposite sides: how much power the library holds over a downstream database. #13 wants the structural writes back, #12 wants the data retention back. The two designs share one boundary -- the library does SELECT and INSERT plus an optional CREATE, and everything that alters structure or deletes rows belongs to the downstream, with the library obliged to print the exact SQL they need to run. Two findings shape #13 beyond what the issue argues. The precedents it cites (Hangfire's lock queue, Prefect's multi-instance race, Alembic's audit trail) all live on a shared production Postgres, while the SQLite side is a local file with no DBA and no migration tool, so the defaults split by backend rather than uniformly. And turning ALTER off only works together with trimming the INSERT to the columns that exist: without it a stale table drops every row instead of two columns, which breaks the telemetry rule harder than the automatic ALTER ever did. For #12 only the body cap touches library code; retention and access control land in the README, because the sdist carries src and the README alone -- a template that lives in the wiki is one a downstream pip install cannot reach.
10 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 同一纪律: 关键行为参数不给默认值) |
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 等其他字段的内容不在覆盖范围内,文档须写明。
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 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
分区表必须由下游先手工建,库的 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 的边界 |
| 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(本设计: 纳入,但明确标注它只防误操作)。