Files
PolyGateway/research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md
T
iomgaa 61122ce437 docs: design caller-defined dimensions for the telemetry table
Issue #11 asks for a tenant column so a multi-tenant caller can isolate
rows in the database. Widened to caller-defined dimensions in general,
but only the caller's own: model name and friends keep their existing
columns, and the library writes nothing into the new container.

Two independent findings force tenant_id to be a real column rather than
a key inside JSON. An RLS policy on meta->>'tenant_id' parses fine, but
the planner discards statistics for non-LEAKPROOF functions under RLS,
and ->> is not marked leakproof; the pgsql-general report that hit this
ended up moving the indexed column out of JSONB. Separately, the planner
has no usable statistics for JSONB at all -- @> falls back to a
hardcoded 0.1% selectivity.

A configurable promoted-column whitelist is rejected: when two
downstreams infer different types for the same key, the second
ADD COLUMN is silently skipped by IF NOT EXISTS and the wrong type is
written from then on, without an error.

The library stops at the column plus a documented policy template. It
must never enable RLS itself -- with no matching policy that is
default-deny, which would silently fail every write for the two
downstreams that are not multi-tenant.
2026-08-17 05:55:40 -04:00

18 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 长度 str ≤ 256 字符 对齐 Sentry tag 的 200、Langfuse 的 200 量级
保留前缀 key 以 pg_ 开头 → 报错 LangSmith ls_、Traceloop traceloop.、Helicone Helicone- 同款。库本次不写入任何 pg_ key,纯预留防未来撞名

超限必须报错,不得静默丢弃。Langfuse 的做法是 value 超 200 字符直接丢弃——这条不抄,违反 P5「严禁默认值掩盖错误」。报错点选在 chat() 入口而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 overlay 保护键的现有先例同构(client.py:223validate_request_overlay)。

4.3 内部流转

ChatRequesttenant_id: str | None = Nonemeta: Mapping[str, Any] = field(default_factory=dict)(F3)。二者是只读快照,库内中间件永不修改——与 sampling 字段同一纪律(issue #4 决策 A)。

TelemetryEmitter._record 是唯一改动点(F5),向 recorder 多传两个参数;三个 emit 入口(emit_attempt/emit_cache_hit/emit_terminal_failure)统一从 request 读取,不各自组装。

缓存命中行与终态失败行同样带维度: 前者读 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 SECURITYCREATE 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 必须同时写 USINGWITH CHECK,只写前者则租户 A 能插入标着 B 的行。


5. 旧版行为审计

本次不是重写/迁移类任务(无 reference/ 旧模块被替换),但触及三项既有契约,逐条声明:

契约 处置
TelemetryRecorder 22 字段冻结 替换为 24 字段。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改
llm_calls 22 列 / 列序 保留列序纪律,新列追加末尾;旧表经 _BACKFILL 补列,补列失败仍只降级为逐行丢弃(不置 _failed)
chat()/embed() 签名 保留"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动

有意放弃: 无。未声明的隐式丢弃: 无。


6. 非功能维度

并发与取消: 新增字段是不可变快照,随 ChatRequest 在洋葱内流转,无共享可变状态,并发调用互不干扰。取消路径不变——TelemetryMW 捕获 CancelledError 时的 emit_terminal_failure 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验发生在进洋葱之前的同步代码里,不涉及 await,无取消窗口。

降级方向: 分两段,方向相反且都符合铁律。校验失败 → 报错(ValueError,在 chat() 入口,调用方可见);遥测写失败 → 静默降级 warning(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。

幂等与重复: 不变。call_id 仍是主键,ON CONFLICT DO NOTHING/INSERT OR IGNORE 语义不受影响。同一 call_id 重复写入仍被忽略,新增两列不引入新的重复语义。

持久化与原子性: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。meta 的 JSON 序列化在 emitter 内完成,序列化失败被 emitter 的降级 try 捕获成 warning——但这条路径实际不可达,因为值类型已在入口校验为标量子集(§4.2),这是双层防御而非依赖降级兜底。


7. 错误分类与测试策略

错误分类: 校验失败抛 ValueError,不属四分类——与 overlay 保护键的现有先例一致(构造期错误,发生在洋葱之外,RetryMW 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。

测试策略(合并前需先失败后通过的证据):

单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/pg_ 前缀拒绝),每条断言报错而非静默丢弃(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);ChatRequest 快照不可变;三个 emit 入口都带上维度(尤其缓存命中行与终态失败行——这两条最容易被漏,而它们恰是审计刚需)。

集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;旧表(22 列)经 _BACKFILL 补列后能写入,且老行 tenant_id 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。

契约层——TelemetryRecorder 端口 24 字段与两个 recorder 的 _COLUMNS 逐字对齐(现有列序断言测试扩展);meta 空 dict 落 '{}' 而非 NULL。

不测: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。


8. 开放问题(留待人类审批时确认)

  1. meta key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但必须有一个有限上限
  2. tenant_id 长度上限 128 同为推断值。
  3. 调研另外提出「_BACKFILL 自动 ALTER 应降级为默认关闭」(Hangfire EnableHeavyMigrations 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 RuntimeError 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但属独立架构变更,按反 gold-plating 不纳入本次,建议另开 issue。