"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。 两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块。 收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源——常量在 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 `DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 + `created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 互不依赖(import-linter 契约执法)。 """ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence TABLE = "llm_calls" # 支持的后端;`insert_sql` / `telemetry_schema_sql` 的取值域 _BACKENDS = ("sqlite", "postgres") SQLITE_DDL = """ CREATE TABLE IF NOT EXISTS llm_calls ( call_id TEXT PRIMARY KEY, parent_call_id TEXT, session_id TEXT, model TEXT NOT NULL, provider TEXT NOT NULL, source_name TEXT NOT NULL, messages TEXT NOT NULL, response TEXT NOT NULL, thinking TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL, completion_tokens INTEGER NOT NULL, usage_source TEXT NOT NULL, latency_ms INTEGER NOT NULL, ttft_ms REAL, max_inter_token_ms REAL, cache_hit INTEGER NOT NULL DEFAULT 0, error TEXT, cost REAL, created_at TEXT NOT NULL DEFAULT (datetime('now')), cached_prompt_tokens INTEGER, model_reported TEXT, sampling TEXT, reasoning_tokens INTEGER, tenant_id TEXT NOT NULL DEFAULT '', meta TEXT NOT NULL DEFAULT '{}' ); """ PG_DDL = """ CREATE TABLE IF NOT EXISTS llm_calls ( call_id TEXT PRIMARY KEY, parent_call_id TEXT, session_id TEXT, model TEXT NOT NULL, provider TEXT NOT NULL, source_name TEXT NOT NULL, messages TEXT NOT NULL, response TEXT NOT NULL, thinking TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL, completion_tokens INTEGER NOT NULL, usage_source TEXT NOT NULL, latency_ms INTEGER NOT NULL, ttft_ms DOUBLE PRECISION, max_inter_token_ms DOUBLE PRECISION, cache_hit BOOLEAN NOT NULL DEFAULT FALSE, error TEXT, cost DOUBLE PRECISION, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), cached_prompt_tokens INTEGER, model_reported TEXT, sampling TEXT, reasoning_tokens INTEGER, tenant_id TEXT NOT NULL DEFAULT '', meta JSONB NOT NULL DEFAULT '{}'::jsonb ); """ # 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们 # 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。 SQLITE_BACKFILL = ( ("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT"), ("sampling", "TEXT"), ("reasoning_tokens", "INTEGER"), # NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER # ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。 ("tenant_id", "TEXT NOT NULL DEFAULT ''"), ("meta", "TEXT NOT NULL DEFAULT '{}'"), ) # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # 下游的那份"的列集合与列定义**无法分叉**——本模块存在的全部理由就是不许漂移。 _PG_BACKFILL_DECLS = ( ("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT"), ("sampling", "TEXT"), ("reasoning_tokens", "INTEGER"), # 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级 ("tenant_id", "TEXT NOT NULL DEFAULT ''"), ("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"), ) # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # **库内执行的这份有意不带 `IF NOT EXISTS`**: PG 对它即便列已存在也会先取 ACCESS # EXCLUSIVE 锁,而遥测是业务路径上的内联 await,故库侧一律"先探测后 ALTER" # (postgres.py `_backfill_columns` 记有实测)。给人执行的那份见 `telemetry_schema_sql`。 PG_BACKFILL = tuple( (column, f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}") for column, decl in _PG_BACKFILL_DECLS ) COLUMNS = ( "call_id", "parent_call_id", "session_id", "model", "provider", "source_name", "messages", "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", ) _COLUMN_SET = frozenset(COLUMNS) def insert_sql(backend: str, columns: Sequence[str]) -> str: """按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。 子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部 字符串拼进 SQL(占位符只保护值,保护不了列名)。空集同样来自探测结果,而 `INSERT INTO llm_calls () VALUES ()` 两端都语法非法——本函数自己拒,不把这个 不变量押在调用方身上。sqlite 用 `?`、postgres 用 `$n`, 两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)。 **PG 的 `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求 分区表的唯一约束必须包含分区键,按 `created_at` 分区(issue #12 的保留期方案)后 主键变成 `(call_id, created_at)`,带目标的语句匹配不到任何约束,PG 直接拒收 ("there is no unique or exclusion constraint matching the ON CONFLICT specification"),而遥测写失败只逐行 warning——分区部署下会全线静默丢数据。 无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。 Args: backend: `"sqlite"` 或 `"postgres"`。 columns: 要写入的列,顺序即占位符顺序(调用方须按同序取值)。 Returns: 完整的 INSERT 语句。 Raises: ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名。 """ if backend not in _BACKENDS: raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}") selected = tuple(columns) if not selected: raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL") unknown = [column for column in selected if column not in _COLUMN_SET] if unknown: raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}") names = ", ".join(selected) if backend == "sqlite": placeholders = ", ".join("?" for _ in selected) return f"INSERT OR IGNORE INTO {TABLE} ({names}) VALUES ({placeholders})" placeholders = ", ".join(f"${i + 1}" for i in range(len(selected))) return f"INSERT INTO {TABLE} ({names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING" # 缺列告警要打印的补列语句: 库内执行的那份怎么写,打印给人的就怎么写(同源不许漂移)。 # SQLite 侧常量只有列定义,故在此按 TABLE 拼成整条 ALTER;PG 侧常量本就是整条语句。 _ALTER_BY_BACKEND = { "sqlite": { column: f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}" for column, decl in SQLITE_BACKFILL }, "postgres": dict(PG_BACKFILL), } _BACKEND_LABELS = {"sqlite": "SQLite", "postgres": "Postgres"} # PG 的 ALTER 取 ACCESS EXCLUSIVE 锁,执行时机得由 DBA 自己挑;SQLite 是下游本地文件,无此顾虑 _EXECUTION_NOTES = {"sqlite": "", "postgres": "(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁)"} def missing_columns_warning(backend: str, missing: Sequence[str], *, alien_table: bool) -> str: """拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。 只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错, 看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。 **住在本模块而不是两个 recorder 里**: 这条消息拼的是给人执行的 DDL,与库自己 执行的 ALTER 必须同源——本模块存在的全部理由就是不许这两者漂移。 Args: backend: `"sqlite"` 或 `"postgres"`。 missing: 缺失的列名(按 `COLUMNS` 保序)。 alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。 Returns: 单条 warning 的完整文本(库只在准备期发一次,不逐行发)。 Raises: ValueError: backend 不在取值域内。 """ if backend not in _BACKENDS: raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}") alters = _ALTER_BY_BACKEND[backend] selected = tuple(missing) statements = [f"{alters[column]};" for column in selected if column in alters] unknown = [column for column in selected if column not in alters] if unknown: # 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本 statements.append( f"-- 另缺 {', '.join(unknown)};完整建表脚本见 " f'polygateway.telemetry_schema_sql("{backend}")' ) label = _BACKEND_LABELS[backend] head = ( f"{label} 遥测表 {TABLE} 缺主键列 call_id,很可能不是本库的遥测表" "(库不做二次判定,仍照常尝试写入)" if alien_table else f"{label} 遥测表 {TABLE} 缺列,且 auto_migrate=False(库不发任何 DDL)" ) return ( f"{head};以下维度不会被记录: {', '.join(selected)}。" f"补列请自行执行{_EXECUTION_NOTES[backend]}:\n" + "\n".join(statements) ) def telemetry_schema_sql(backend: str) -> str: """返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。 给不愿意让库在自己的生产表上发 DDL 的下游用: 输出与库运行时执行的 DDL 同源, 照它建完表,库探测到的列就是齐的。 **补列语句与库内执行的那份是两套文本,不是一份**: 这份给人执行,必须可重复执行, 故 PG 变体带 `ADD COLUMN IF NOT EXISTS`(它会先取 ACCESS EXCLUSIVE 锁,但执行时机 由 DBA 自己挑,锁风险可控);库内那份不带,靠先探测后 ALTER 规避锁。SQLite 没有 `ADD COLUMN IF NOT EXISTS` 语法,只能以注释交代"仅当该列不存在时执行"。 Args: backend: `"sqlite"` 或 `"postgres"`。 Returns: 含注释的完整 SQL 脚本。 Raises: ValueError: backend 不在取值域内。 """ if backend not in _BACKENDS: raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}") if backend == "sqlite": ddl = SQLITE_DDL notes = ( f"-- 旧表补列(库升级后新增的列)。SQLite 无 ADD COLUMN IF NOT EXISTS 语法,\n" f"-- 以下每条**仅当该列不存在时执行**(先 PRAGMA table_info({TABLE}) 对照)。" ) alters = [ f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl};" for column, decl in SQLITE_BACKFILL ] else: ddl = PG_DDL notes = ( "-- 旧表补列(库升级后新增的列)。带 IF NOT EXISTS,整段可重复执行;\n" "-- 注意它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,请挑低峰执行。" ) alters = [ f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS {column} {decl};" for column, decl in _PG_BACKFILL_DECLS ] header = ( f"-- PolyGateway 遥测表 {TABLE}({backend})\n" f'-- 由 polygateway.telemetry_schema_sql("{backend}") 生成,与库运行时执行的 DDL 同源。\n' "-- 新建库执行整段;已有旧表则建表语句自动跳过,只需关注下方补列语句。" ) return "\n".join([header, "", ddl.strip(), "", notes, *alters, ""])