feat: record the observability fields end to end through telemetry
This commit is contained in:
@@ -61,6 +61,8 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms=response.max_inter_token_ms if response else None,
|
||||
cache_hit=False,
|
||||
error=error,
|
||||
cached_prompt_tokens=response.cached_prompt_tokens if response else None,
|
||||
model_reported=response.model_reported if response else None,
|
||||
)
|
||||
|
||||
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
|
||||
@@ -81,6 +83,10 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=True,
|
||||
error=None,
|
||||
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
|
||||
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
|
||||
cached_prompt_tokens=response.cached_prompt_tokens,
|
||||
model_reported=response.model_reported,
|
||||
)
|
||||
|
||||
async def emit_terminal_failure(
|
||||
@@ -103,6 +109,8 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=False,
|
||||
error=error,
|
||||
cached_prompt_tokens=None,
|
||||
model_reported=None,
|
||||
)
|
||||
|
||||
async def _record(
|
||||
@@ -123,6 +131,8 @@ class TelemetryEmitter:
|
||||
max_inter_token_ms: float | None,
|
||||
cache_hit: bool,
|
||||
error: str | None,
|
||||
cached_prompt_tokens: int | None,
|
||||
model_reported: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||
@@ -134,7 +144,9 @@ class TelemetryEmitter:
|
||||
# 必须排在 cache_hit 之后——缓存命中未产生新调用,0.0 是事实而非未知
|
||||
cost = None
|
||||
elif error is None and model and self._pricing is not None:
|
||||
cost = self._pricing.cost(model, prompt_tokens, completion_tokens)
|
||||
cost = self._pricing.cost(
|
||||
model, prompt_tokens, completion_tokens, cached_prompt_tokens
|
||||
)
|
||||
else:
|
||||
cost = None
|
||||
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
|
||||
@@ -158,6 +170,8 @@ class TelemetryEmitter:
|
||||
cache_hit=cache_hit,
|
||||
error=error,
|
||||
cost=cost,
|
||||
cached_prompt_tokens=cached_prompt_tokens,
|
||||
model_reported=model_reported,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
@@ -245,7 +245,11 @@ class StructuredOutputStrategy(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class TelemetryRecorder(Protocol):
|
||||
"""遥测后端;18 字段冻结(M1 设计 §4.4),唯一调用点是 TelemetryEmitter。"""
|
||||
"""遥测后端;20 字段冻结(M1 设计 §4.4 + issue #3),唯一调用点是 TelemetryEmitter。
|
||||
|
||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||
"""
|
||||
|
||||
async def record_llm_call(
|
||||
self,
|
||||
@@ -268,4 +272,6 @@ class TelemetryRecorder(Protocol):
|
||||
cache_hit: bool,
|
||||
error: str | None,
|
||||
cost: float | None,
|
||||
cached_prompt_tokens: int | None,
|
||||
model_reported: str | None,
|
||||
) -> None: ...
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
① 结构性失败(建池/建表)→ warning 一次后永久降级(池置 None 短路);
|
||||
② 运行时单条写失败 → 逐条 warning 丢弃,不降级不重试(连接抖动由
|
||||
asyncpg 池自恢复;避免浸泡开头一次抖动导致后续全程失遥测)。
|
||||
构造不连库(lazy),18 列 schema 与 SQLite 版同名同序。
|
||||
构造不连库(lazy),20 列 schema 与 SQLite 版同名同序。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,10 +39,18 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
cost DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
|
||||
_BACKFILL = (
|
||||
"ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS cached_prompt_tokens INTEGER",
|
||||
"ALTER TABLE llm_calls ADD COLUMN IF NOT EXISTS model_reported TEXT",
|
||||
)
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
@@ -62,6 +70,8 @@ _COLUMNS = (
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
@@ -104,6 +114,9 @@ class PostgresRecorder:
|
||||
self._pool = await asyncpg.create_pool(self._dsn, timeout=10)
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(_DDL)
|
||||
for statement in _BACKFILL:
|
||||
# 已存在的旧表补新列(issue #3);ADD COLUMN IF NOT EXISTS 原生幂等
|
||||
await conn.execute(statement)
|
||||
self._schema_ready = True
|
||||
return self._pool
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -34,10 +34,16 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
||||
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
cost REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cached_prompt_tokens INTEGER,
|
||||
model_reported TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
|
||||
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
|
||||
_BACKFILL_COLUMNS = (("cached_prompt_tokens", "INTEGER"), ("model_reported", "TEXT"))
|
||||
|
||||
_COLUMNS = (
|
||||
"call_id",
|
||||
"parent_call_id",
|
||||
@@ -57,6 +63,8 @@ _COLUMNS = (
|
||||
"cache_hit",
|
||||
"error",
|
||||
"cost",
|
||||
"cached_prompt_tokens",
|
||||
"model_reported",
|
||||
)
|
||||
|
||||
_INSERT = (
|
||||
@@ -82,9 +90,32 @@ class SQLiteRecorder:
|
||||
self._conn = conn
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
|
||||
self._backfill_columns()
|
||||
|
||||
def _backfill_columns(self) -> None:
|
||||
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃。
|
||||
|
||||
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
|
||||
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
|
||||
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
|
||||
比逐行丢弃严重得多。
|
||||
"""
|
||||
if self._conn is None:
|
||||
return
|
||||
try:
|
||||
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
|
||||
for column, decl in _BACKFILL_COLUMNS:
|
||||
if column in existing:
|
||||
continue
|
||||
self._conn.execute(f"ALTER TABLE llm_calls ADD COLUMN {column} {decl}")
|
||||
self._conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
# duplicate column: 多进程共库时后到者必然撞上,属预期竞态,视为成功
|
||||
if "duplicate column" not in str(exc).lower():
|
||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||
|
||||
async def record_llm_call(self, **fields: object) -> None:
|
||||
"""写一行遥测;字段集合即 18 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
"""写一行遥测;字段集合即 20 字段冻结签名(ports.TelemetryRecorder)。"""
|
||||
if self._conn is None:
|
||||
return
|
||||
row = tuple(fields[col] for col in _COLUMNS)
|
||||
|
||||
Reference in New Issue
Block a user