feat: record the reasoning verdict in telemetry
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.
This commit is contained in:
@@ -23,7 +23,7 @@ from polygateway.errors import (
|
||||
SourceNotConfiguredError,
|
||||
)
|
||||
from polygateway.middleware.cache import digest_messages
|
||||
from polygateway.types import canonical_sampling_json, merge_sampling
|
||||
from polygateway.types import ThinkingObservation, canonical_sampling_json, merge_sampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
@@ -111,6 +111,9 @@ class _AttemptUsage:
|
||||
cached_prompt_tokens: int | None = None
|
||||
model_reported: str | None = None
|
||||
reasoning_tokens: int | None = None
|
||||
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
||||
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||
|
||||
@classmethod
|
||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||
@@ -128,11 +131,12 @@ class _AttemptUsage:
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
thinking_observation=response.thinking_observation,
|
||||
)
|
||||
|
||||
|
||||
class TelemetryEmitter:
|
||||
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
"""从请求与结果组装 25 字段并写入 recorder;一切写失败降级 warning。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -185,6 +189,7 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=usage.cached_prompt_tokens,
|
||||
model_reported=usage.model_reported,
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
thinking_observation=usage.thinking_observation,
|
||||
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
|
||||
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
|
||||
tenant_id=request.tenant_id,
|
||||
@@ -214,6 +219,8 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
reasoning_tokens=response.reasoning_tokens,
|
||||
# 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果
|
||||
thinking_observation=response.thinking_observation,
|
||||
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
|
||||
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
@@ -247,6 +254,8 @@ class TelemetryEmitter:
|
||||
cached_prompt_tokens=None,
|
||||
model_reported=None,
|
||||
reasoning_tokens=None,
|
||||
# 无响应可言,故裁不出结果;UNKNOWN 正是"观测不到"本身,不是伪装的"没推理"
|
||||
thinking_observation=ThinkingObservation.UNKNOWN,
|
||||
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
|
||||
sampling=canonical_sampling_json(request.sampling),
|
||||
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
|
||||
@@ -276,6 +285,8 @@ class TelemetryEmitter:
|
||||
model_reported: str | None,
|
||||
sampling: str | None,
|
||||
reasoning_tokens: int | None,
|
||||
# issue #16: 枚举形态进来,取 `.value` 后才下沉(归一化同样在本方法内收口)
|
||||
thinking_observation: ThinkingObservation,
|
||||
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
|
||||
tenant_id: str | None,
|
||||
meta: Mapping[str, Any],
|
||||
@@ -328,6 +339,10 @@ class TelemetryEmitter:
|
||||
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
|
||||
tenant_id=tenant_id or "",
|
||||
meta=_canonical_meta_json(meta),
|
||||
# 取 `.value` 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数
|
||||
# 编码对子类不保证接受,而遥测写失败只降级成一条 warning——不会当场
|
||||
# 炸,只会让 Postgres 那一路悄悄少一列数据
|
||||
thinking_observation=thinking_observation.value,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
@@ -260,13 +260,16 @@ class TelemetryStatusProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。
|
||||
"""遥测后端;25 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
|
||||
`tenant_id` 与 `meta` 到达 recorder 时**已由 emitter 归一化**——`tenant_id`
|
||||
的 `None` 已转空串,`meta` 已序列化为 JSON 字符串(空 dict 为 `'{}'`)。
|
||||
`thinking_observation` 同理: emitter 已把 `ThinkingObservation` 取成 `.value`
|
||||
的裸 `str`(`StrEnum` 是 `str` 子类,而 asyncpg 的参数编码对子类不保证接受,
|
||||
遥测写失败又只降级成 warning——PG 那一路会静默少一列数据)。
|
||||
recorder 只负责落库,不做任何语义判断,与 `sampling` 列由
|
||||
`canonical_sampling_json()` 在 emitter 侧定型是同一先例。
|
||||
"""
|
||||
@@ -298,4 +301,5 @@ class TelemetryRecorder(Protocol):
|
||||
reasoning_tokens: int | None,
|
||||
tenant_id: str,
|
||||
meta: str,
|
||||
thinking_observation: str,
|
||||
) -> None: ...
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||
|
||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
|
||||
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 25 个 INSERT 字段 +
|
||||
`created_at` = 26;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||
|
||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||
互不依赖(import-linter 契约执法)。
|
||||
@@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
thinking_observation TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -80,7 +81,8 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
sampling TEXT,
|
||||
reasoning_tokens INTEGER,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
thinking_observation TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -95,6 +97,9 @@ SQLITE_BACKFILL = (
|
||||
# ("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 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||
@@ -107,6 +112,8 @@ _PG_BACKFILL_DECLS = (
|
||||
# 两个默认值都是非易失常量,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 同款注释)。
|
||||
@@ -143,6 +150,7 @@ COLUMNS = (
|
||||
"reasoning_tokens",
|
||||
"tenant_id",
|
||||
"meta",
|
||||
"thinking_observation",
|
||||
)
|
||||
|
||||
_COLUMN_SET = frozenset(COLUMNS)
|
||||
|
||||
@@ -143,7 +143,7 @@ class SQLiteRecorder:
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
|
||||
"""写一行遥测;字段集合即 25 字段冻结签名(ports.TelemetryRecorder)。
|
||||
|
||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||
|
||||
Reference in New Issue
Block a user