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
+6 -6
View File
@@ -119,7 +119,7 @@ class TestBreakerRecoveryFullChain:
class TestCancellationThroughStack:
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
entered = asyncio.Event()
async def hanging_handler(request):
@@ -144,7 +144,7 @@ class TestCancellationThroughStack:
class TestTelemetryAcrossPaths:
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
@@ -155,7 +155,7 @@ class TestTelemetryAcrossPaths:
assert hits == 1 and total == 2
async def test_transient_attempts_each_recorded(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
calls = {"n": 0}
def flaky(request):
@@ -193,7 +193,7 @@ class TestRejectionReasonIsQueryable:
)
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client(
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
)
@@ -257,7 +257,7 @@ class TestSamplingThroughStack:
return _sse()
db = tmp_path / "t.db"
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
client = _full_client(handler, telemetry=recorder)
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
recorder.close()
@@ -276,7 +276,7 @@ class TestSamplingThroughStack:
src = dataclasses.replace(_source(), extra_body={"temperature": 0})
db = tmp_path / "t.db"
recorder = SQLiteRecorder(db)
recorder = SQLiteRecorder(db, auto_migrate=True)
client = GatewayClient(
scope="llm",
sources=[src],