feat: record stream end mode in telemetry

llm_calls gains a nullable stream_end TEXT column (auto backfill,
manual-mode trimming as in 1.3.8). TelemetryRecorder.record_llm_call
takes a new required keyword stream_end; the emitter reads it from the
response on attempt rows and writes NULL for failed, cache-hit and
terminal rows.

Red: 18 tests in tests/unit/test_telemetry.py and test_ports.py failed
against the pre-change source (missing column, 38-field signature,
stream_end absent from emitted rows).
This commit is contained in:
2026-10-14 23:54:11 -04:00
parent 48ba36dd94
commit 779a9b29a3
7 changed files with 152 additions and 25 deletions
+13 -1
View File
@@ -221,6 +221,9 @@ class _AttemptUsage:
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
stall_ms: int | None = None
stall_count: int | None = None
# 1.3.10(#31): 与 stall 相反,结束方式**从 response 取**——LLMResponse 本次有
# 该字段,免去约 50 处 emit_attempt 调用点加参;失败尝试 None = 无流可言
stream_end: str | None = None
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -239,6 +242,7 @@ class _AttemptUsage:
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation,
stream_end=response.stream_end,
)
@@ -370,7 +374,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
class TelemetryEmitter:
"""从请求与结果组装 38 字段并写入 recorder;一切写失败降级 warning。"""
"""从请求与结果组装 39 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
@@ -464,6 +468,7 @@ class TelemetryEmitter:
total_latency_ms=None,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=usage.stream_end,
)
async def emit_cache_hit(
@@ -514,6 +519,8 @@ class TelemetryEmitter:
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
)
async def emit_terminal_failure(
@@ -569,6 +576,8 @@ class TelemetryEmitter:
# 终态行无具体源与 transport 观测: 显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
)
async def _record(
@@ -615,6 +624,8 @@ class TelemetryEmitter:
# 本方法只搬运(同 reasoning_effort 先例)——
stall_ms: int | None,
stall_count: int | None,
# —— 1.3.10: 结束方式(#31);入口已定型,本方法只搬运 ——
stream_end: str | None,
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -685,6 +696,7 @@ class TelemetryEmitter:
total_latency_ms=total_latency_ms,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=stream_end,
)
except asyncio.CancelledError:
raise
+2 -1
View File
@@ -290,7 +290,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;38 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8),唯一调用点是 TelemetryEmitter。
"""遥测后端;39 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8/1.3.10),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
@@ -357,4 +357,5 @@ class TelemetryRecorder(Protocol):
total_latency_ms: int | None,
stall_ms: int | None,
stall_count: int | None,
stream_end: str | None,
) -> None: ...
+12 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 38 个 INSERT 字段 +
`created_at` = 39;列数断言一律按物理列数写,两套口径混用是最易错处。
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 39 个 INSERT 字段 +
`created_at` = 40;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。
@@ -64,7 +64,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
attempts INTEGER,
total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
stall_count INTEGER,
stream_end TEXT
);
"""
@@ -108,7 +109,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
attempts INTEGER,
total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
stall_count INTEGER,
stream_end TEXT
);
"""
@@ -145,6 +147,8 @@ SQLITE_BACKFILL = (
# 没记过这件事",与"观测过且零沉默"(0)是两回事
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -174,6 +178,8 @@ _PG_BACKFILL_DECLS = (
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -226,6 +232,8 @@ COLUMNS = (
# —— 1.3.8 沉默归因(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
)
_COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。
"""写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。