56acb8f3ac
This issue surfaced only because someone ran a slow suite that is excluded by default and had not been run for eighteen days. As a column it becomes a query: which model stopped being observable, and when. The emitter unwraps the enum to a plain str at the single _record exit. asyncpg makes no promise about encoding a str subclass, and a telemetry write that fails is downgraded to one warning — it would not crash, it would just quietly cost the Postgres path a column. Normalising at the emitter follows what tenant_id, meta and sampling already do. The column is appended last in COLUMNS and in both DDLs. An existing table can only take ALTER at the end, so putting it anywhere else forks the physical column order between a freshly built database and a backfilled one.
309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。
|
|
|
|
两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块。
|
|
收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源——常量在
|
|
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
|
|
|
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
|
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 +
|
|
`created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。
|
|
|
|
本模块只依赖标准库: `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 '{}',
|
|
thinking_observation TEXT
|
|
);
|
|
"""
|
|
|
|
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,
|
|
thinking_observation TEXT
|
|
);
|
|
"""
|
|
|
|
# 新列必须排在 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 '{}'"),
|
|
# 可空: 补列之前的行没有裁定结果,NULL 如实表达"这行根本没记过这件事",
|
|
# 与哨兵串 'unknown'(库确实裁过但判不出来)是两回事,不得混同
|
|
("thinking_observation", "TEXT"),
|
|
)
|
|
|
|
# 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"),
|
|
# 可空,理由同 SQLITE_BACKFILL 同名项
|
|
("thinking_observation", "TEXT"),
|
|
)
|
|
|
|
# 新列排在 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",
|
|
"thinking_observation",
|
|
)
|
|
|
|
_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, ""])
|