The embedding client does not go through the chat onion -- embed() runs its own chain down to _emit(), which builds a ChatRequest on the spot and so far only fills session_id and parent_call_id. Changing chat() alone would have left every embed row with empty dimensions, which is exactly what the issue's second request asks for. The bigger find: the draft claimed serialization could not fail because the entry check already restricts values to scalars. It can. A float passes a naive type check and json.dumps writes it as the literal NaN, which is not valid JSON and which JSONB rejects; the failure then lands in the emitter's degrade path and turns a caller's input error into silently dropped telemetry. Now rejected at the entry with isfinite and again at serialization with allow_nan=False. Also states the validation runs at both public entries, not just chat(), and adds the RLS template the design had promised but never wrote down.
21 KiB
调用方自定义维度设计(issue #11)
- 状态: 待 Codex 审 → 待人类审批
- 触发: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
- 范围: 公共 API(
chat/embed签名)、ports.TelemetryRecorder端口、types.ChatRequest、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。
1. 需求与边界
1.1 issue 原始诉求
GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 PGW_TELEMETRY_BACKEND=postgres。落库是审计链的一半证据(另一半业务事实在其自有库,靠 call_id 缝合)。阻塞点: llm_calls 22 列没有任何租户维度,能区分来源的只有 session_id/parent_call_id 两个调用方自填、库内不校验的自由字符串。而这张表存完整正文(digest_messages 只对多模态 image_url 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。
诉求四条: ①真实租户列(不是藏在 session_id 里);②chat() 与 embed() 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(issue 明说可另开,本设计不含)。
不可逆性是本 issue 的核心论点,且成立: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。
1.2 本次放大的范围(2026-08-17 人类决策)
issue 只要租户维度。人类决定放大为调用方自定义维度的通用能力,但明确收窄了两处:
- 只做调用方自定义的维度。请求自带信息(模型名、供应商、源名)继续走现有
model/provider/source_name/model_reported列,库不往新容器写任何自采信息。 - 保留期与访问控制不在本次范围(issue 第 4 条,另开)。
1.3 明确不做
不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 ENABLE ROW LEVEL SECURITY;不自动建索引;不改动 _BACKFILL 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,另开 issue)。
2. 关键既有事实(设计必须服从的约束)
| # | 事实 | 出处 | 对本设计的约束 |
|---|---|---|---|
| F1 | cache_namespace 已是必填的租户/项目隔离维度,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 |
ARCH §7.5 | 缓存层租户隔离已完成,缺口只在遥测层。新维度不得再进缓存 key |
| F2 | chat() 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 |
ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | ChatRequest 新字段必须有默认 |
| F4 | TelemetryRecorder 端口 22 字段冻结,且新增参数不设默认值(库外无第三方实现者) |
ports.py:247 |
端口扩到 24 字段,不给默认值 |
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 TelemetryEmitter._record 一处 |
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在进洋葱之前报错,否则被降级吞掉 |
| F7 | SQLite 补列探测用 PRAGMA table_info;新列必须排在 created_at 之后(列序不得分叉) |
sqlite.py:53-60,123 |
新列追加到现有 22 列末尾 |
| F8 | PG 侧建表/补列先探测后 DDL(权限检查早于 IF NOT EXISTS) |
postgres.py:174-210, issue #3/#9 |
复用现有机制,不新增 DDL 路径 |
3. 备选方案对比
方案 A: tenant_id 提列 + meta JSON 容器(推荐)
llm_calls 增两列: tenant_id(真实列,可挂 RLS、可建复合索引)与 meta(JSON 容器,承载任意调用方自定义 KV,默认不建索引)。API 增两个 keyword-only 参数。
支持证据: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 LiteLLM_SpendLogs 正是此形态——team_id/organization_id/end_user/user/session_id 全部提列并索引,而 metadata/request_tags 两个 Json 列没有任何索引。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。
代价: 下游若想再提一个高频维度(如 business_id)要等库发新版。这是有意接受的——见方案 C。
方案 B: 纯 meta JSON,不提任何列
最小改动、最通用。否决,两条独立的实证:
① RLS 会静默退化。策略挂 meta->>'tenant_id' 语法合法,但 PG 的 Planner Statistics and Security 规则在 RLS 场景下对非 LEAKPROOF 函数当作没有统计信息来规划,而 ->>(jsonb_object_field_text)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 not using statistics because function ... is not leak-proof),Tom Lane 确认根因,报告者最终解法就是把索引列改成非 JSONB;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。
② planner 对 JSONB 本就没有可用统计(与 RLS 无关的独立问题)。@> 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 584 秒。
对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。
方案 C: 可配置提升列白名单(下游声明 promoted_keys=[...],库据此建列)
最通用,下游不必等库发版。否决,理由分三层:
① 业界一致禁止。dbt(on_schema_change 默认 ignore,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE 把值置 NULL,官方唯一方案是建视图)——三家数据集成工具立场完全一致。
② 本库场景的失败模式更糟。下游 A 配 ["tenant"]、B 配 ["dataset"] 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对同名 key 推断出不同类型(A 认为 run_id 是 TEXT、B 是 BIGINT),第二个到达者的 ADD COLUMN 会被 IF NOT EXISTS 静默跳过,从此一直静默写错类型——不报错、数据持续污染,是最坏的失败形态。
③ 与端口契约冲突。_COLUMNS/_INSERT 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;TelemetryRecorder 的「22 字段冻结」与列序断言测试全部失效。
旁证: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 total_fields.limit=1000,超限整个写入请求报错;不治理则 master 节点 heap 飙升、put-mapping 队列堵塞)。其补救开关 ignore_dynamic_beyond_limit 默认仍为 false——官方宁可拒写也不默默膨胀。
推荐
方案 A。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 meta 里某个 key 加速时,路径是表达式索引(CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k')),无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。
4. 设计细节
4.1 公共 API
两条路径对称新增两个 keyword-only 参数(F2/F3):
chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None),embed(texts, *, ..., 同两参数)。
为什么 tenant_id 独立成参而不是 meta 里的一个约定 key: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 meta 里则需要库偷偷抽取一个魔法 key,调用方拼错 tenant_id/tenantId 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。
为什么不复用 cache_namespace: 语义不同。namespace 是缓存隔离单位(可以是项目名),租户是数据归属;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。
4.2 校验规则(全部在进洋葱之前报 ValueError)
| 项 | 规则 | 依据 |
|---|---|---|
tenant_id |
非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
meta key |
非空;[a-z0-9_.] 且长度 ≤ 64 |
照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
meta key 数量 |
≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
meta value |
仅 str/int/float/bool;嵌套需调用方自行序列化 |
OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
meta value: float |
必须 math.isfinite;nan/inf/-inf 报错 |
见下 |
meta value 长度 |
str ≤ 256 字符 |
对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
| 保留前缀 | key 以 pg_ 开头 → 报错 |
LangSmith ls_、Traceloop traceloop.、Helicone Helicone- 同款。库本次不写入任何 pg_ key,纯预留防未来撞名 |
非有限 float 必须在入口拒绝(Codex 审查发现)。json.dumps({'k': float('nan')}) 产出 {"k": NaN}——这是 Python 的扩展语法,不是合法 JSON,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即把调用方的 bug 转化为静默丢数据,恰好违反 P5。故两处同时收口: 入口用 math.isfinite 校验,序列化用 json.dumps(..., allow_nan=False)(实测该参数会对非有限值抛 ValueError),后者是入口失守时的第二道闸而非主防线。
超限必须报错,不得静默丢弃。Langfuse 的做法是 value 超 200 字符直接丢弃——这条不抄,违反 P5「严禁默认值掩盖错误」。报错点选在两个公共入口(chat() 与 embed())而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 overlay 保护键的现有先例同构(client.py:223 的 validate_request_overlay),校验函数同样共用一份,不在两个入口各写一遍。
4.3 内部流转: 两条独立链路
ChatRequest 增 tenant_id: str | None = None 与 meta: Mapping[str, Any] = field(default_factory=dict)(F3)。二者是只读快照,库内中间件永不修改——与 sampling 字段同一纪律(issue #4 决策 A)。
TelemetryEmitter._record 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(emit_attempt/emit_cache_hit/emit_terminal_failure)统一从 request 读取,不各自组装。
embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)。EmbeddingClient 不经过 chat 洋葱: embed() → _embed_batch() → _attempt() → _emit(),而 _emit() 在 embedding.py:360 现场构造 ChatRequest 仅为复用同一个 Emitter,当前只填了 session_id/parent_call_id。若只改 chat(),结果是 chat 行有维度而 embed 行恒为空——恰好落空 issue 第 2 条诉求(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 _emit() 构造 ChatRequest 时填入。
不顺手重构这条链的参数列表: 该链已在逐层传 session_id/parent_call_id,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。
缓存命中行与终态失败行同样带维度: 前者读 request 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。
不进缓存 key(F1)。三条理由: ①cache_namespace 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③meta 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 batch_id 不应导致 miss。
4.4 存储层
两端各追加两列到现有 22 列末尾(F7),经现有 _BACKFILL 机制补列(F8):
- Postgres:
tenant_id TEXT NOT NULL DEFAULT ''+meta JSONB NOT NULL DEFAULT '{}'::jsonb - SQLite:
tenant_id TEXT NOT NULL DEFAULT ''+meta TEXT NOT NULL DEFAULT '{}'
为什么 NOT NULL DEFAULT '' 而不是可空: PG 的 USING 表达式返回 false 或 null 的行都不可见,且静默跳过不报错。NULL 的 tenant_id 在任何 policy 下都不是"未归属",而是对所有人永久不可见的黑洞。用哨兵空串则老行归属显式可查(COUNT(*) WHERE tenant_id = '' 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列不重写全表(值存进 pg_attribute.attmissingval),SQLite 加列是元数据操作,且 SQLite 硬性要求 NOT NULL 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。
meta 序列化: json.dumps(ensure_ascii=False),与既有 messages 列同口径。空 dict 落 '{}' 而非 NULL,保持"缺省即空容器"的单一语义。
库不自动建索引。CREATE INDEX 是 DDL,非 CONCURRENTLY 会锁写,而 CONCURRENTLY 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: (tenant_id, created_at) 复合索引——列序判据是启用 RLS 后 policy 会给每一条查询隐式追加 tenant_id = ... 等值谓词,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。
4.5 RLS: 库止步于列 + 模板
库绝不执行 ENABLE/FORCE ROW LEVEL SECURITY 与 CREATE POLICY,只在文档提供可复制的 DDL 模板。四条理由:
① default-deny 会击穿非多租户下游。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测全量写失败,再叠加 F6 的静默降级 = 无声全局丢数据。这才是本 issue「不可逆」担忧的真正落点。
② 库无权知道角色拓扑。policy 必须绑定角色,且 AWS 官方要求应用角色非属主且无 BYPASSRLS;库拿到的只是一条连接串。
③ 权限不对等。CREATE POLICY/ALTER TABLE 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。
④ SQLite 无 RLS,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。
同类先例一致: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。
文档必须同时告知三个陷阱: 表属主默认豁免 RLS(需 FORCE);租户上下文只能用 set_config(..., true) 且必须在显式事务内(asyncpg 默认 autocommit,单发 SET LOCAL 会当场失效而 PG 只发 warning 不报错,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 USING 与 WITH CHECK,只写前者则租户 A 能插入标着 B 的行。
模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
ON llm_calls (tenant_id, created_at);
current_setting(..., true) 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 NULLIF 把空串归一为 NULL——两者合起来使未设租户 = 零行(fail-closed),而不是全部行。
5. 旧版行为审计
本次不是重写/迁移类任务(无 reference/ 旧模块被替换),但触及三项既有契约,逐条声明:
| 契约 | 处置 |
|---|---|
TelemetryRecorder 22 字段冻结 |
替换为 24 字段。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
llm_calls 22 列 / 列序 |
保留列序纪律,新列追加末尾;旧表经 _BACKFILL 补列,补列失败仍只降级为逐行丢弃(不置 _failed) |
chat()/embed() 签名 |
保留"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |
有意放弃: 无。未声明的隐式丢弃: 无。
6. 非功能维度
并发与取消: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——TelemetryMW 捕获 CancelledError 时的 emit_terminal_failure 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。
降级方向: 分两段,方向相反且都符合铁律。校验失败 → 报错(ValueError,在 chat()/embed() 入口,调用方可见);遥测写失败 → 静默降级 warning(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。
幂等与重复: 不变。call_id 仍是主键,ON CONFLICT DO NOTHING/INSERT OR IGNORE 语义不受影响。同一 call_id 重复写入仍被忽略,新增两列不引入新的重复语义。
持久化与原子性: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。
meta 的 JSON 序列化在 emitter 内完成。初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正: 非有限 float 能通过"值是 float"这类朴素类型检查,却产出 PG 拒收的 NaN/Infinity 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 math.isfinite 拒绝(主防线,调用方可见),序列化 allow_nan=False(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。
7. 错误分类与测试策略
错误分类: 校验失败抛 ValueError,不属四分类——与 overlay 保护键的现有先例一致(构造期错误,发生在洋葱之外,RetryMW 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。
测试策略(合并前需先失败后通过的证据):
单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/pg_ 前缀拒绝/非有限 float),每条断言报错而非静默丢弃(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);ChatRequest 快照不可变;三个 emit 入口都带上维度(尤其缓存命中行与终态失败行——这两条最容易被漏,而它们恰是审计刚需)。
两条链路各测一遍——chat() 与 embed() 都必须有"传入维度 → 遥测行带该维度"的用例。embed 侧尤其不能省: 它经 _embed_batch/_attempt/_emit 四层透传,任一层漏传都不会报错、只会让维度恒为空,而这正是 issue 第 2 条诉求的落点。批量切批时每一批的行都应带同一份维度(维度属于本次 embed() 调用,不随批次变化)。
非有限 float 单独一条: 断言 chat(meta={"x": float("nan")}) 抛 ValueError 而不是写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。
集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;旧表(22 列)经 _BACKFILL 补列后能写入,且老行 tenant_id 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。
契约层——TelemetryRecorder 端口 24 字段与两个 recorder 的 _COLUMNS 逐字对齐(现有列序断言测试扩展);meta 空 dict 落 '{}' 而非 NULL。
不测: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。
8. 开放问题(留待人类审批时确认)
metakey 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但必须有一个有限上限。tenant_id长度上限 128 同为推断值。- 调研另外提出「
_BACKFILL自动 ALTER 应降级为默认关闭」(HangfireEnableHeavyMigrations先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接RuntimeError拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但属独立架构变更,按反 gold-plating 不纳入本次,建议另开 issue。