feat: add sqlite telemetry with single-emitter discipline

This commit is contained in:
2026-07-20 07:16:49 -04:00
parent 0be111d64c
commit 7608958d0e
3 changed files with 478 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
"""TelemetryMW + TelemetryEmitter: 遥测调用点收敛为单一 helper(铁律)。
Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制
15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW
(最外层)只记尝试层看不见的事件——缓存命中、scope 级失败、取消;
RequestRejected/ResultInvalid 已被尝试层记录,最外层放行不重复记。
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.errors import GatewayUnavailableError, GovernanceBackendError
from polygateway.middleware.cache import digest_messages
if TYPE_CHECKING:
from collections.abc import Callable
from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
class TelemetryEmitter:
"""从请求与结果组装 18 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder) -> None:
self._recorder = recorder
async def emit_attempt(
self,
*,
request: ChatRequest,
source: SourceConfig,
call_id: str,
latency_ms: int,
response: LLMResponse | None,
error: str | None,
) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试 usage 按 estimated 记 0。"""
await self._record(
request=request,
call_id=call_id,
model=source.model,
provider=source.provider,
source_name=source.name,
response_text=response.content if response else "",
thinking=response.thinking if response else "",
prompt_tokens=response.prompt_tokens if response else 0,
completion_tokens=response.completion_tokens if response else 0,
usage_source=response.usage_source if response else "estimated",
latency_ms=latency_ms,
ttft_ms=response.ttft_ms if response else None,
max_inter_token_ms=response.max_inter_token_ms if response else None,
cache_hit=False,
error=error,
)
async def emit_cache_hit(self, *, request: ChatRequest, response: LLMResponse) -> None:
"""缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。"""
await self._record(
request=request,
call_id=response.call_id,
model=response.model,
provider=response.provider,
source_name=response.source_name,
response_text=response.content,
thinking=response.thinking,
prompt_tokens=response.prompt_tokens,
completion_tokens=response.completion_tokens,
usage_source=response.usage_source,
latency_ms=0,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=True,
error=None,
)
async def emit_terminal_failure(
self, *, request: ChatRequest, call_id: str, latency_ms: int, error: str
) -> None:
"""scope 级失败/取消记录: 无具体源,溯源字段置空标记。"""
await self._record(
request=request,
call_id=call_id,
model="",
provider="",
source_name="",
response_text="",
thinking="",
prompt_tokens=0,
completion_tokens=0,
usage_source="estimated",
latency_ms=latency_ms,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=error,
)
async def _record(
self,
*,
request: ChatRequest,
call_id: str,
model: str,
provider: str,
source_name: str,
response_text: str,
thinking: str,
prompt_tokens: int,
completion_tokens: int,
usage_source: str,
latency_ms: int,
ttft_ms: float | None,
max_inter_token_ms: float | None,
cache_hit: bool,
error: str | None,
) -> None:
try:
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12)
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False)
await self._recorder.record_llm_call(
call_id=call_id,
parent_call_id=request.parent_call_id,
session_id=request.session_id,
model=model,
provider=provider,
source_name=source_name,
messages=messages_json,
response=response_text,
thinking=thinking,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
usage_source=usage_source,
latency_ms=latency_ms,
ttft_ms=ttft_ms,
max_inter_token_ms=max_inter_token_ms,
cache_hit=cache_hit,
error=error,
cost=None, # M1 无 pricing;M2 换算后填充
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("遥测记录失败(降级不冒泡): {}", exc)
class TelemetryMW:
"""洋葱最外层: 观测尝试层看不见的路径,任何路径都留痕(遥测必录)。"""
def __init__(
self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic
) -> None:
self._emitter = emitter
self._now = now
async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse:
started = self._now()
try:
response = await call_next(request)
except (GatewayUnavailableError, GovernanceBackendError) as exc:
await self._emitter.emit_terminal_failure(
request=request, call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000), error=str(exc),
)
raise
except asyncio.CancelledError:
# 尽力而为: 取消也留痕(§5.1 约定④);随后立即重抛
await self._emitter.emit_terminal_failure(
request=request, call_id=str(uuid.uuid4()),
latency_ms=int((self._now() - started) * 1000), error="cancelled",
)
raise
if response.cache_hit:
await self._emitter.emit_cache_hit(request=request, response=response)
return response
+93
View File
@@ -0,0 +1,93 @@
"""SQLite 遥测后端(默认): WAL + 单持久连接 + to_thread 桥接。
蓝本 VT `adapters/telemetry.py`: 构造期建连接与表,失败降级为 no-op
(记录基础设施不得拖垮业务调用);`INSERT OR IGNORE` 幂等(call_id 主键);
写入经 threading.Lock 串行化后由 `asyncio.to_thread` 执行,不阻塞事件循环。
"""
from __future__ import annotations
import asyncio
import sqlite3
import threading
from pathlib import Path
from loguru import logger
_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
_COLUMNS = (
"call_id", "parent_call_id", "session_id", "model", "provider", "source_name",
"messages", "response", "thinking", "prompt_tokens", "completion_tokens",
"usage_source", "latency_ms", "ttft_ms", "max_inter_token_ms", "cache_hit",
"error", "cost",
)
_INSERT = (
f"INSERT OR IGNORE INTO llm_calls ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join('?' for _ in _COLUMNS)})"
)
class SQLiteRecorder:
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
def __init__(self, db_path: Path | str) -> None:
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
try:
path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(_DDL)
conn.commit()
self._conn = conn
except (OSError, sqlite3.Error) as exc:
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 18 字段冻结签名(ports.TelemetryRecorder)。"""
if self._conn is None:
return
row = tuple(fields[col] for col in _COLUMNS)
try:
await asyncio.to_thread(self._write, row)
except (OSError, sqlite3.Error) as exc:
logger.warning("SQLite 遥测写入失败(降级不冒泡): {}", exc)
def _write(self, row: tuple) -> None:
assert self._conn is not None # 内部不变量: 调用方已判空
with self._lock:
self._conn.execute(_INSERT, row)
self._conn.commit()
def close(self) -> None:
"""幂等关闭持久连接。"""
conn, self._conn = self._conn, None
if conn is not None:
with self._lock:
conn.close()