feat: gate the automatic ALTER behind an explicit mode

两个 recorder 的 `__init__` 增 keyword-only 必填 `auto_migrate`(设计 D-c:
缺省规则只写在 config 一处,不与类签名漂移),并把写入语句从模块级常量改为
实例级: manual 档探测到旧表缺列时一条 ALTER 都不发,改按现有列裁剪 INSERT,
准备期发一次 warning(逐列点名 + "以下维度不会被记录" + 可直接执行的补列 SQL)。

裁剪是关掉 ALTER 的前提而非增强: 旧表缺列时若既不 ALTER 又不裁剪,每一行
INSERT 都撞 `no column named tenant_id` 被整行丢弃,比自动 ALTER 更严重地
违反"遥测必录"。auto 档行为逐字不变(先探测后 ALTER、duplicate column 视为
成功、失败只 warning 不判死、写入沿用全量列)。

探测失败、或探测结果与 COLUMNS 毫无交集,两档都保守回落全量列——空列集会让
`insert_sql` 产出 `INSERT INTO llm_calls () VALUES ()`(它不拒空列表,空集
技术上是子集)。PG 侧 `_columns`/`_insert` 与 `_schema_ready` 在同一处一起
赋值,不留"已就绪但语句还是旧的"窗口。

同批改 `GatewaySettings.telemetry_auto_migrate`(按后端派生: PG False、
SQLite True)与 `client._build_telemetry` 透传: 签名变更与其唯一调用点必须
落在同一次提交,否则该提交点整条装配路 TypeError。env 键留给下一步。
This commit is contained in:
2026-08-19 11:57:41 -04:00
parent ecc22b34fc
commit e949edb62a
8 changed files with 466 additions and 73 deletions
+200 -14
View File
@@ -115,6 +115,21 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
await recorder.record_llm_call(**fields)
@pytest.fixture
def captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
# 搬迁前(1.2.1)两个 recorder 各自持有的 INSERT 常量原文,逐字冻结在此。
# 这两条字符串是"纯搬迁不改行为"的机械证据: 构造逻辑换了地方,产物必须一字不差。
_FROZEN_SQLITE_INSERT = (
@@ -249,7 +264,7 @@ class TestBackendColumnParity:
class TestSQLiteRecorder:
async def test_schema_has_frozen_columns(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder)
recorder.close()
cols = [
@@ -258,7 +273,7 @@ class TestSQLiteRecorder:
assert cols == _EXPECTED_COLUMNS
async def test_call_id_idempotent(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="dup")
await _record_minimal(recorder, call_id="dup", response="second")
recorder.close()
@@ -270,7 +285,7 @@ class TestSQLiteRecorder:
assert rows == [("ok",)] # INSERT OR IGNORE: 第二次静默忽略
async def test_concurrent_writes_all_land(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await asyncio.gather(*(_record_minimal(recorder, call_id=f"c{i}") for i in range(50)))
recorder.close()
(count,) = (
@@ -279,12 +294,12 @@ class TestSQLiteRecorder:
assert count == 50
async def test_unwritable_path_degrades_silently(self):
recorder = SQLiteRecorder(Path("/nonexistent-root/deep/t.db"))
recorder = SQLiteRecorder(Path("/nonexistent-root/deep/t.db"), auto_migrate=True)
await _record_minimal(recorder) # 不抛
recorder.close()
async def test_observability_columns_round_trip(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="c-hit", cached_prompt_tokens=64)
await _record_minimal(recorder, call_id="c-zero", cached_prompt_tokens=0)
await _record_minimal(recorder, call_id="c-none", model_reported="MiniMax-Text-01")
@@ -300,7 +315,7 @@ class TestSQLiteRecorder:
async def test_reasoning_tokens_column_round_trip(self, tmp_path):
"""issue #6: 7 / 0 / None 三种值各自如实落库,0 与 NULL 不得混同。"""
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="r-some", reasoning_tokens=7)
await _record_minimal(recorder, call_id="r-zero", reasoning_tokens=0)
await _record_minimal(recorder, call_id="r-none", reasoning_tokens=None)
@@ -316,7 +331,7 @@ class TestSQLiteRecorder:
async def test_sampling_column_round_trips(self, tmp_path):
"""issue #4: 采样参数落库,否则事后无法证明某批数据跑在什么温度下。"""
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
await _record_minimal(recorder, call_id="c-s", sampling='{"seed": 42, "temperature": 0}')
await _record_minimal(recorder, call_id="c-plain")
recorder.close()
@@ -363,7 +378,7 @@ class TestSQLiteColumnBackfill:
legacy.commit()
legacy.close()
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, cached_prompt_tokens=7, model_reported="m-real")
recorder.close()
@@ -388,7 +403,7 @@ class TestSQLiteColumnBackfill:
conn.commit()
conn.close()
recorder = SQLiteRecorder(db) # 不得抛
recorder = SQLiteRecorder(db, auto_migrate=True) # 不得抛
assert recorder._conn is not None # 补列失败 ≠ recorder 失能(D1 纪律)
await _record_minimal(recorder) # 不得抛
recorder.close()
@@ -445,7 +460,7 @@ class TestSQLiteCallerDimensionsAcceptance:
async def test_fresh_db_round_trips_the_dimensions(self, tmp_path):
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
db = tmp_path / "fresh.db"
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(
recorder, call_id="c-dim", tenant_id="tenant-a", meta='{"batch": "b7"}'
)
@@ -471,7 +486,7 @@ class TestSQLiteCallerDimensionsAcceptance:
db = tmp_path / "pre_tenant.db"
_make_pre_tenant_db(db)
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
recorder.close()
@@ -510,7 +525,7 @@ class TestSQLiteCallerDimensionsAcceptance:
# finally 还原权限位: 任一断言先失败时,不还原会让 tmp_path 清理连带报错,
# 把"某条断言失败"的真因盖成一个无关的 PermissionError
try:
recorder = SQLiteRecorder(db) # 不得抛
recorder = SQLiteRecorder(db, auto_migrate=True) # 不得抛
assert recorder._conn is not None # 补列失败 ≠ recorder 失能
await _record_minimal(recorder, call_id="doomed") # 只读库写不进,但不得抛
recorder.close()
@@ -523,6 +538,119 @@ class TestSQLiteCallerDimensionsAcceptance:
) # 补列确实没成功,用例不是在只读库上空转
class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
补齐后 24 + `created_at` = 25。混用 INSERT 字段数与物理列数是本处最易错的地方。
"""
def _physical_columns(self, db: Path) -> list[str]:
conn = sqlite3.connect(db)
try:
return [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
finally:
conn.close()
async def test_manual_mode_trims_the_insert_instead_of_altering(
self, tmp_path, captured_warnings
):
"""manual + 22 字段旧表: 一条 ALTER 都不发,写入按现有列裁剪后照样落库。
裁剪是关掉 ALTER 的前提: 不裁剪的话每行 INSERT 都撞 `no column named
tenant_id` 而被整行丢弃——那是把自动补列换成静默全失能。
"""
db = tmp_path / "manual_legacy.db"
_make_pre_tenant_db(db)
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a", meta='{"k": 1}')
recorder.close()
assert len(self._physical_columns(db)) == 23 # 未 ALTER: 物理列数原封不动
conn = sqlite3.connect(db)
assert conn.execute(
"SELECT response, model FROM llm_calls WHERE call_id = 'new-row'"
).fetchone() == ("ok", "m") # 裁剪后的列值仍对得上位
conn.close()
assert len(captured_warnings) == 1 # 缺列只讲一次,不逐行刷屏
message = captured_warnings[0]
assert "tenant_id" in message and "meta" in message # 逐列点名
assert "不会被记录" in message # 讲清后果
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 25。"""
db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="new-row", tenant_id="tenant-a")
recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 25
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,25 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS
conn = sqlite3.connect(db)
assert (
conn.execute("SELECT tenant_id FROM llm_calls WHERE call_id = 'c-fresh'").fetchone()[0]
== "tenant-a"
)
conn.close()
async def test_table_without_call_id_escalates_the_wording(self, tmp_path, captured_warnings):
"""缺主键列 call_id = 该表压根不是本库的 llm_calls: 措辞升级,但库不做二次判定。"""
db = tmp_path / "alien.db"
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE llm_calls (model TEXT, provider TEXT)")
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False) # 不得抛
await _record_minimal(recorder) # 照常尝试写入
recorder.close()
message = "\n".join(captured_warnings)
assert "call_id" in message
assert "不是本库" in message
async def test_no_recognizable_column_falls_back_to_the_full_column_set(
self, tmp_path, captured_warnings
):
"""探测结果与 COLUMNS 毫无交集视同探测异常: 保守回落全量列。
空列集会构造出 `INSERT INTO llm_calls () VALUES ()` 这种语法非法的语句
(`insert_sql` 不拒空列表——空集技术上是子集),故必须在交给它之前拦住。
"""
from polygateway.telemetry.schema import COLUMNS
db = tmp_path / "foreign.db"
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE llm_calls (foo TEXT, bar TEXT)")
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False) # 不得抛
assert recorder._columns == COLUMNS
await _record_minimal(recorder) # 写不进去,但只逐行 warning,不抛
recorder.close()
assert captured_warnings # 沉默地退化成空语句是最坏结果,必须有声
async def test_auto_migrate_is_required_keyword_only(self, tmp_path):
"""关键行为参数不给默认值(P4): 缺省规则只写在 config 一处,不与类签名漂移。"""
with pytest.raises(TypeError):
SQLiteRecorder(tmp_path / "t.db") # type: ignore[call-arg]
class _FakePgConn:
"""记录执行过的语句;可让 ALTER/CREATE/探测抛错以模拟权限不足与抖动。
@@ -601,7 +729,9 @@ class TestPostgresBackfillDiscipline:
def _recorder(self, conn):
from polygateway.telemetry.postgres import PostgresRecorder
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
return PostgresRecorder(
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True
)
async def test_alter_failure_does_not_disable_the_recorder(self):
"""ALTER 失败(如账号只有 INSERT 权限)不得置 _failed —— 那会让遥测全灭。"""
@@ -655,7 +785,9 @@ class TestPostgresTableProbe:
def _recorder(self, conn):
from polygateway.telemetry.postgres import PostgresRecorder
return PostgresRecorder("postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn))
return PostgresRecorder(
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=True
)
def _created(self, conn):
return [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
@@ -703,6 +835,60 @@ class TestPostgresTableProbe:
assert [s for s in conn.statements if s.startswith("INSERT INTO llm_calls")]
class TestPostgresSchemaMode:
"""issue #13: PG 侧 manual 档一条 ALTER 都不发,改按现有列裁剪 INSERT。
真实 PG 的验收在 `tests/integration/test_postgres_telemetry.py`;这里用 fake 连接
锁住"发了哪些语句",无 DSN 环境下集成用例被 skip 时仍有回归保护。
"""
_LEGACY = ["call_id", "cost", "created_at"]
def _recorder(self, conn, *, auto_migrate):
from polygateway.telemetry.postgres import PostgresRecorder
return PostgresRecorder(
"postgresql://u:p@h:5432/polygateway", pool=_FakePgPool(conn), auto_migrate=auto_migrate
)
async def test_manual_mode_trims_the_insert_instead_of_altering(self, captured_warnings):
conn = _FakePgConn(self._LEGACY)
recorder = self._recorder(conn, auto_migrate=False)
await _record_minimal(recorder)
assert not [s for s in conn.statements if s.startswith("ALTER TABLE")]
assert "INSERT INTO llm_calls (call_id, cost) VALUES ($1, $2) ON CONFLICT DO NOTHING" in (
conn.statements
)
assert recorder._columns == ("call_id", "cost")
message = "\n".join(captured_warnings)
assert "tenant_id" in message and "meta" in message # 逐列点名
assert "不会被记录" in message # 讲清后果
assert "ALTER TABLE llm_calls ADD COLUMN tenant_id" in message # 可直接执行的 SQL
async def test_manual_mode_still_creates_a_missing_table(self):
"""manual 只管 ALTER 不管 CREATE: 新建表列已齐全,写入照发全量列。"""
from polygateway.telemetry.schema import COLUMNS
conn = _FakePgConn([])
recorder = self._recorder(conn, auto_migrate=False)
await _record_minimal(recorder)
assert [s for s in conn.statements if s.lstrip().startswith("CREATE TABLE")]
assert recorder._columns == COLUMNS
async def test_auto_mode_still_backfills(self):
"""auto 档现状回归: 缺列照补,补完写全量列。"""
from polygateway.telemetry.schema import COLUMNS, PG_BACKFILL
conn = _FakePgConn(self._LEGACY)
recorder = self._recorder(conn, auto_migrate=True)
await _record_minimal(recorder)
assert len([s for s in conn.statements if s.startswith("ALTER TABLE")]) == len(PG_BACKFILL)
assert recorder._columns == COLUMNS
class _MemoryRecorder:
def __init__(self):
self.rows = []