Files
PolyGateway/src/polygateway/middleware/telemetry.py
T
iomgaa c26b34e854 feat: wire the telemetry text cap through settings
`PGW_TELEMETRY_TEXT_CAP` now reaches the emitter on every assembly path.
Unset means no truncation, which stays the default: a truncated row is
no longer audit evidence and cannot be replayed, and downstreams rely on
that today. The flip side — contracts and bids sitting in `llm_calls`
indefinitely, multi-tenant — is spelled out in `.env.example` so readers
can weigh both.

All three `from_settings` paths are wired (chat, embedding, OCR): they
write the same table, so capping only chat would leave half of it
uncontrolled. `TelemetryEmitter.__init__` now rejects `text_cap <= 0`;
it is the single point where the three clients converge, so the direct
construction path — a public assembly route the settings guard never
sees — is covered too. `0` would otherwise reduce every body to a bare
elision marker.
2026-08-19 13:57:15 -04:00

371 lines
15 KiB
Python

"""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 dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.errors import (
GatewayUnavailableError,
GovernanceBackendError,
SourceNotConfiguredError,
)
from polygateway.middleware.cache import digest_messages
from polygateway.types import canonical_sampling_json, merge_sampling
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from typing import Any
from polygateway.ports import CallNext, TelemetryRecorder
from polygateway.pricing import PricingTable
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
"""把调用方自定义维度定型为 JSON 文本(issue #11);空 dict 落字面量 `'{}'`。
`sort_keys=True` 让同一份维度在任意两行里字节一致,可直接等值比对与去重;
`ensure_ascii=False` 保留中文原文,避免落库成 `\\uXXXX` 串而无法肉眼审计。
`allow_nan=False` 是**第二道闸**(主防线是 `types.validate_caller_dimensions`
在公共入口的校验): `json.dumps` 默认把 `nan` 写成裸 `NaN` 字面量,那不是合法
JSON。这道闸真正的价值在 **SQLite 侧**——PG 的 JSONB 本来就会拒收 `NaN`,而
SQLite 的 `meta` 是 TEXT 列**不做任何 JSON 校验**,没有这道闸就会把 `NaN`
这种非法 JSON 静默存进去,污染后续一切按 JSON 解析 meta 的分析。
注意它抛出的 `ValueError` **不会外泄给调用方**: 本函数在 `_record` 的降级
`try` 内被求值,异常会被那里的 `except Exception` 接住 → 落 warning、整行
遥测丢弃。即入口失守时的真实结果是"警告 + 丢一行",不是"报错给调用方"。
"""
if not meta:
return "{}"
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap:
return text
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
def _cap_part(part: Any, cap: int) -> Any:
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
return {**part, "text": _cap_text(part["text"], cap)}
return part
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
且全程无任何报错。
"""
if cap is None:
return messages
capped: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
capped.append({**msg, "content": _cap_text(content, cap)})
elif isinstance(content, list):
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
else:
capped.append(msg)
return capped
@dataclass(frozen=True)
class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为
一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。
"""
response_text: str = ""
thinking: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
usage_source: str = "unavailable"
ttft_ms: float | None = None
max_inter_token_ms: float | None = None
cached_prompt_tokens: int | None = None
model_reported: str | None = None
reasoning_tokens: int | None = None
@classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
"""从响应取用量;`None`(失败尝试)返回全默认视图。"""
if response is None:
return cls()
return cls(
response_text=response.content,
thinking=response.thinking,
prompt_tokens=response.prompt_tokens,
completion_tokens=response.completion_tokens,
usage_source=response.usage_source,
ttft_ms=response.ttft_ms,
max_inter_token_ms=response.max_inter_token_ms,
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
)
class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(
self,
recorder: TelemetryRecorder,
*,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
"""
if text_cap is not None and text_cap <= 0:
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
self._recorder = recorder
self._pricing = pricing
self._text_cap = text_cap
async def emit_attempt(
self,
*,
request: ChatRequest,
source: SourceConfig,
call_id: str,
latency_ms: int,
response: LLMResponse | None,
error: str | None,
) -> None:
"""逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。"""
usage = _AttemptUsage.of(response)
await self._record(
request=request,
call_id=call_id,
model=source.model,
provider=source.provider,
source_name=source.name,
response_text=usage.response_text,
thinking=usage.thinking,
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
usage_source=usage.usage_source,
latency_ms=latency_ms,
ttft_ms=usage.ttft_ms,
max_inter_token_ms=usage.max_inter_token_ms,
cache_hit=False,
error=error,
cached_prompt_tokens=usage.cached_prompt_tokens,
model_reported=usage.model_reported,
reasoning_tokens=usage.reasoning_tokens,
# 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D)
sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)),
tenant_id=request.tenant_id,
meta=request.meta,
)
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,
# 决策 B1: 与 model/prompt_tokens 同一口径,原样回放历史值。
# 统计供应商缓存命中率必须带 WHERE cache_hit = false,否则重复计数。
cached_prompt_tokens=response.cached_prompt_tokens,
model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens,
# 由最外层 TelemetryMW 调用,手上没有 source。缓存命中行无损:
# sampling 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同
sampling=canonical_sampling_json(request.sampling),
# 与上面的 model/prompt_tokens 相反,维度读 request 而非 response:
# 维度回答的是"本次调用由谁发起",不是历史那次。读历史会把本次调用
# 记到上一个租户头上,两边的账同时错且无任何报错(issue #11 设计 §4.3)
tenant_id=request.tenant_id,
meta=request.meta,
)
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="unavailable",
latency_ms=latency_ms,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
error=error,
cached_prompt_tokens=None,
model_reported=None,
reasoning_tokens=None,
# 无具体源,与 model/provider/source_name 置空同一先例(设计决策 D)
sampling=canonical_sampling_json(request.sampling),
# 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的
tenant_id=request.tenant_id,
meta=request.meta,
)
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,
cached_prompt_tokens: int | None,
model_reported: str | None,
sampling: str | None,
reasoning_tokens: int | None,
# issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库)
tenant_id: str | None,
meta: Mapping[str, Any],
) -> None:
try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
# 失败/终态行 None;未注入价格表 = 恒 None(M1 现状)
if cache_hit:
cost: float | None = 0.0
elif usage_source == "unavailable":
# 用量不可得: 宁可算不出成本,也不算错成本(解耦设计 §3.1 不变式)。
# 必须排在 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, cached_prompt_tokens
)
else:
cost = None
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
# 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
messages_json = json.dumps(
_cap_messages(digest_messages(request.messages), self._text_cap),
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=_cap_text(response_text, self._text_cap),
thinking=_cap_text(thinking, self._text_cap),
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=cost,
cached_prompt_tokens=cached_prompt_tokens,
model_reported=model_reported,
sampling=sampling,
reasoning_tokens=reasoning_tokens,
# 空串是哨兵而非 NULL: NULL 的 tenant_id 在 PG 的 RLS policy 下
# 对所有人永久不可见,空串则可用一条 SQL 审计出未归属的行
tenant_id=tenant_id or "",
meta=_canonical_meta_json(meta),
)
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, SourceNotConfiguredError) 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