docs: plan both telemetry changes down to the task level

Twelve tasks across the two plans, each with the files it touches, the
evidence it has to produce, and the command that proves it. #13 goes
first: both branches edit config.py and client.py, and #12's
partitioning template leans on the schema SQL helper and the untargeted
conflict clause that #13 introduces.

Writing the cap plan surfaced a trap worth its own guard. digest_messages
appends the very same dict when a message's content is not a list, so
the telemetry copy, the caller's messages and the cache key all share
one object -- capping in place would poison the caller's request and the
cache key at once, silently. Two red-line tests now pin that down, and
the plan asks for an in-place version to be written and run first, to
prove the tests actually catch it.
This commit is contained in:
2026-08-19 09:13:20 -04:00
parent 39fcf2631d
commit 5b2e3ba82d
7 changed files with 406 additions and 2 deletions
@@ -0,0 +1,167 @@
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**
- **前置依赖**: **issue #13 的计划须先合并**。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter``text_cap` 必填 |
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings``telemetry_text_cap` |
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
| `tests/unit/test_config.py` | 修改 | 配置校验 |
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
| `README.md``CHANGELOG.md``.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
---
## 关键接口(跨任务消费,此处定稿)
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
```python
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
"""
```
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
```python
class TelemetryEmitter:
def __init__(
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
text_cap: int | None,
) -> None: ...
```
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
```python
telemetry_text_cap: int | None
```
`tools/telemetry_retention.py` 的 CLI 契约:
```text
--backend sqlite|postgres 必填
--path PATH | --dsn DSN 按 backend 二选一,必填
--older-than-days N 必填,N >= 0
--apply 缺省不带即 dry-run(只统计不删)
--batch-size N 仅 postgres,缺省 1000
--vacuum 仅 sqlite,须与 --apply 同时给
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
```
---
## Task 1: 正文截断与 emitter 参数
- [ ] **文件**: `src/polygateway/middleware/telemetry.py``src/polygateway/client.py``src/polygateway/embedding.py``src/polygateway/ocr.py`;`tests/unit/test_telemetry.py``tests/unit/test_cache.py`
- **行为**:
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text``thinking` 调用 `_cap_text`
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149``embedding.py:131``ocr.py:130`)同步传参;测试内十余处构造点一并补齐。
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
- `embedding.py:73``ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
- **验收**:
- `cap=None` → 落库正文与今天逐字节相同。
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
- 非字符串 content(如 `123``None`、嵌套 dict)不抛异常。
- `response`/`thinking` 同样受 cap。
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
- **测试**:
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
- **提交**: `feat: cap telemetry bodies at a configurable length`
## Task 2: 配置与装配
- [ ] **文件**: `src/polygateway/config.py``.env.example`;`tests/unit/test_config.py`
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings``telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0``ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
- **验收**: 未设 → `None`;`"0"``"-1"``ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: wire the telemetry text cap through settings`
## Task 3: 保留期脚本
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`
- **行为**: 按上文 CLI 契约实现。
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
- **验收**: 见测试。
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
- dry-run 后行数不变,stdout 含将删行数与时间范围。
- `--apply` 后仅超期行被删,未超期行完好。
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
- `--vacuum` 不带 `--apply` 时退出码 1。
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py -v` → PASS。
- **提交**: `feat: add a retention script downstreams can schedule`
## Task 4: 生产部署模板与其机械化验收
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)``pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
- **测试**(集成,真实 PG,沿用 `least_privilege_dsn` 同款临时 schema + 临时角色隔离,teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
- `report` 角色能读、不能写。
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
- 分区表上写入成功且落进当月分区。
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
- **提交**: `docs: ship a production deployment template with its own test`
## Task 5: CHANGELOG 与 wiki
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
- **测试**: 无自动化测试。
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
- **提交**: `docs: record the retention boundary and its knobs`
---
## 完成判据
1. 五个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
@@ -0,0 +1,172 @@
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings``telemetry_auto_migrate` |
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
| `.env.example``README.md``CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
---
## 关键接口(跨任务消费,此处定稿)
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
```python
COLUMNS: tuple[str, ...] # 24 列,顺序即物理列序(call_id 起、meta 止)
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
PG_DDL: str
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # (列名, "TEXT NOT NULL DEFAULT ''")
PG_BACKFILL: tuple[tuple[str, str], ...] # (列名, 完整 ALTER 语句)
```
两个语句构造函数:
```python
def insert_sql(backend: str, columns: Sequence[str]) -> str:
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
"""
def telemetry_schema_sql(backend: str) -> str:
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
```
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
```python
class SQLiteRecorder:
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
class PostgresRecorder:
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
```
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
```python
telemetry_auto_migrate: bool
```
---
## Task 1: 建 `telemetry/schema.py` 单一事实源
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
- **行为**: 把 `sqlite.py``_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS``postgres.py``_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()``telemetry_schema_sql()`
- **验收**:
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`
- `telemetry_schema_sql` 输出包含全部 24 个列名,且列名出现顺序与 `COLUMNS` 一致;未知 backend 抛 `ValueError`
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`conda run -n PolyGateway make lint` → 通过(import-linter 契约不得报新违规: schema.py 只依赖标准库)。
- **提交**: `refactor: make the telemetry schema a single source of truth`
## Task 2: PG 写入去掉冲突目标
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12`created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`;`tests/unit/test_telemetry.py`
- **行为**:
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns``self._insert = insert_sql(backend, effective)`;`record_llm_call``self._columns` 取值。
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
- **验收**: 见测试。
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
- manual + 手工建的 22 列旧表 → 写入成功且能读回、`PRAGMA table_info` 列数**保持 22**(证明未 ALTER)、`caplog` 中恰有一条 warning 且同时含 `tenant_id``meta``ALTER TABLE`
- auto + 同款 22 列旧表 → 列数变 24(现状回归)。
- manual + 全新库 → 建表且 24 列齐全(建表未被停掉)。
-`call_id` 的畸形表 → warning 升级措辞,不抛异常。
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
## Task 4: 配置派生与装配
- [ ] **文件**: `src/polygateway/config.py``src/polygateway/client.py``.env.example`;`tests/unit/test_config.py`
- **行为**:
- `config.py``_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
- `GatewaySettings``telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`
- `client.py``_build_telemetry` 把它透传给两个 recorder。
- `.env.example``PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: derive the schema mode from the telemetry backend`
## Task 5: 公共导出
- [ ] **文件**: `src/polygateway/__init__.py``tests/unit/test_package.py`
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
## Task 6: 真实 Postgres 集成验收
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
- **验收**:
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
- `least_privilege_dsn`(只授 `SELECT, INSERT`,不授 schema CREATE)+ manual → 不再出现 ALTER 失败的 warning,写入照常。
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
- **提交**: `test: prove manual mode leaves a stale table untouched`
## Task 7: 文档与承诺
- [ ] **文件**: `README.md``CHANGELOG.md``research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
- **行为**:
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
- **测试**: 无自动化测试;人工核对 README 片段在真实 PG 上可执行(Task 6 的环境里跑一遍)。
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
- **提交**: `docs: document the schema mode and the expand-contract promise`
---
## 完成判据
1. 七个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
@@ -0,0 +1,17 @@
---
type: plan
node_id: plan:plan-issue12-telemetry-retention
title: "实现计划: issue12-telemetry-retention"
date: 2026-08-19
---
# 实现计划: issue12-telemetry-retention
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标写入)。
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
@@ -0,0 +1,15 @@
---
type: plan
node_id: plan:plan-issue13-schema-mode
title: "实现计划: issue13-schema-mode"
date: 2026-08-19
---
# 实现计划: issue13-schema-mode
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。