"""TelemetryMW + TelemetryEmitter: 遥测调用点收敛为单一 helper(铁律)。 Emitter 是全库**唯一**调用 `record_llm_call` 的地方(三项目 4 处逐字复制 15 参调用的教训)。分工: RetryMW 经 Emitter 逐次记录每次尝试;TelemetryMW (最外层)只记尝试层看不见的缓存命中;而**终态失败行**由三个 client 的公开 边界经 `emit_terminal_once` 统一写出(1.3.5)——两处同时写就会双计。 一行遥测属于三类事件之一(`event_kind`): `attempt`(一次尝试)、`cache_hit` (未产生网关调用)、`terminal_failure`(一次**逻辑调用**的失败终态)。后两者与 前者**不是重复事实**,故统计失败调用次数只能取 `terminal_failure`, 不得按 `error IS NOT NULL` 跨两类直接计数(设计 §6/§8)。 """ from __future__ import annotations import asyncio import inspect import json import time import uuid from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger from polygateway.errors import PolyGatewayError, ResultInvalidError from polygateway.middleware.cache import digest_messages from polygateway.middleware.structured import MAX_ERROR_CHARS, format_bounded_errors from polygateway.ports import TelemetryRecorder from polygateway.thinking import effective_effort from polygateway.types import Effort, ThinkingObservation, canonical_sampling_json, merge_sampling if TYPE_CHECKING: from collections.abc import Callable, Mapping from typing import Any from polygateway.ports import CallNext from polygateway.pricing import PricingTable from polygateway.types import ( CallOperation, CallStats, ChatRequest, EventKind, LLMResponse, SourceConfig, _CallContext, ) 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 _normalize_observation(raw: object) -> str: """三态裁定 → 落库用的裸 str;不是枚举也不在取值域时降级为 `unknown` 并告警。 **不写 `raw.value`**: `LLMResponse` 是无运行时校验的 frozen dataclass,下游 (尤其迁移期的测试替身)写 `LLMResponse(..., thinking_observation="observed")` 完全自然、`==` 比较照常成立,而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成一条泛化 warning —— 丢的不是这一列,是**整行**,而 "遥测必录"是铁律。 域外取值同样只降级不抛: 直接 `ThinkingObservation(raw)` 会抛 `ValueError`, 落到同一个 `except` 上、同样丢整行,那只修好了裸 str 一半(口误值对测试替身 一样自然)。降级到 `unknown` 是诚实的——库确实判不出这个取值的含义,而单独 一条点名取值的 warning 保证它不被掩盖(P5 不许默认值掩盖错误)。 """ try: return ThinkingObservation(raw).value except ValueError: logger.warning( "thinking_observation 取值 {!r} 不在取值域内,本行降级记为 unknown" "(其余列照常落库);调用方应传 ThinkingObservation 成员", raw, ) return ThinkingObservation.UNKNOWN.value def _normalize_effort(raw: object) -> str | None: """实际档位 → 落库用的裸 str;不表态与域外取值都落 `NULL`。 **不写 `raw.value`**,理由与 `_normalize_observation` 逐字相同: `LLMResponse` 是无运行时校验的 frozen dataclass,测试替身写 `applied_effort="low"` 完全自然, 而 `.value` 会当场抛 `AttributeError`,被 `_record` 的 `except Exception` 吞成 一条泛化 warning —— 丢的不是这一列,是**整行**。 域外取值降级为 `None` 而不抛,方向与 `CacheMW._coerce_applied_effort` 一致 (设计 §4.4): 多项目共用一套后端时,更新版本的进程可能带来本版没有的档位名, 归因字段不该有能力废掉一整行遥测。降级到 `None` 也是唯一诚实的说法——库确实 不知道这次跑在哪档,随便挑一档等于替上游声称了一件它没说过的事。 注意 `None` 在本列有**两个**来源(不表态 / 读不懂),二者都不可折叠进 `'none'`: `'none'` 是"明确要求不推理",是一次表态。 """ if raw is None: return None try: return Effort(raw).value except ValueError: logger.warning( "推理档位取值 {!r} 不在本版档位词汇内,本行 reasoning_effort 降级记为 NULL" "(其余列照常落库)", raw, ) return None def _attempt_effort( *, request: ChatRequest, source: SourceConfig, response: LLMResponse | None, applies: bool, ) -> str | None: """一次尝试该记哪一档: 成功读**实发档**,失败退回**请求档**(设计 §6)。 成功行一律读 `response.applied_effort` 而**绝不重算**: 源上开了 `EFFORT_FALLBACK=nearest` 时,请求 `medium` 而模型只有 low/high/max,实发的是 `low`;此处重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出 过的分组下——而两个值在没开映射的源上恒等,这个错在本地跑不出来。 失败尝试没有响应,实发档无从得知,故退回请求档并**接受这层含义差别**: 开了映射 的源上,成功行是映射后的档、失败行是请求档,两种行不是同一把尺子。仍然记而不是 留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒, 这类行记的正是**被拒绝的那一档**——"哪一档配错了"是压测与排障要的信号。 回落走 `effective_effort` 而非裸读两个字段: `enable_thinking` 也是一次表态 (语法糖),漏掉它就会把一次明确要求推理的调用记成"没表态"。 """ if not applies: return None if response is not None: return _normalize_effort(response.applied_effort) return _normalize_effort( effective_effort( request_effort=request.reasoning_effort, source_effort=source.reasoning_effort, enable_thinking=source.enable_thinking, ) ) 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 # 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。 # 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎 thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN @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, thinking_observation=response.thinking_observation, ) @dataclass(frozen=True) class _ErrorFields: """一行遥测的错误列;未知一律 `None`。 存在的理由是把"三种入参形态 × 两类行"的定型规则收敛到**一处**: 改前调用方先 `str(exc)` 压平,状态码、底层异常类型与网关正文全部丢失。 """ error: str | None = None error_type: str | None = None cause_type: str | None = None http_status_code: int | None = None error_body: str | None = None def _structured_detail(exc: ResultInvalidError) -> str: """结构化阶梯耗尽的**有界**说明(设计 §5 C2)。 `ResultInvalidError("结构化输出阶梯耗尽")` 的 message 不含校验与修复错误,而该 失败发生在 StructuredMW 之上——RetryMW 侧的 attempt 行全是**成功行**,终态行是 唯一记录。故把说明并入现有 `error` 串。 **不含 `raw_text`**: 它是模型正文,attempt 行的 `response` 列已按 `text_cap` 记过 一份;再存一份等于绕过既有的正文预算。条数与限长复用 `structured.py` 的同一 套常量(重问反馈与本说明同一口径),数值只有一份。 """ parts: list[str] = [] if exc.repair_error: parts.append(f"repair={exc.repair_error[:MAX_ERROR_CHARS]}") if exc.validation_errors: parts.append(f"validation={format_bounded_errors(exc.validation_errors)}") return " | ".join(parts) def _error_fields( error: PolyGatewayError | str | None, *, event_kind: EventKind, class_prefixed: bool, ) -> _ErrorFields: """三种入参形态的唯一定型点(设计 §5/§6)。 - `None` → 全 None(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了")。 - `str`(取消路径的 `"cancelled"`)→ 原样落 `error`,**不解析字符串猜诊断**。 - 领域异常 → 只读它既有的属性,不遍历任意对象、不猜正文。 **终态行的三列恒为 NULL(C1 红线)**: `GatewayUnavailableError` 家族从不携带 状态码与响应体,NULL 正是它自身的真实状态——把最后一次 attempt 的状态码与正文 搬上来,就是拿最后一个源冒充整池归因。逐源现场由同一 `logical_call_id` 的 attempt 行给出。 """ if error is None: return _ErrorFields() if isinstance(error, str): return _ErrorFields(error=error) name = type(error).__name__ # 空 `str()` 退回类名(httpx 的 Connect/Read/Write/PoolTimeout 文案就是空的); # `class_prefixed` 是 OCR 的既有口径(按类名归组的 metric),故逐字保留它的拼法 text = f"{name}: {error}" if class_prefixed else (str(error) or name) if event_kind == "terminal_failure": if isinstance(error, ResultInvalidError): detail = _structured_detail(error) if detail: text = f"{text} | {detail}" return _ErrorFields(error=text, error_type=name) cause = error.__cause__ return _ErrorFields( error=text, error_type=name, cause_type=type(cause).__name__ if cause is not None else None, # getattr 而非直读: 本函数在 `_record` 的降级 try **之外**求值, # 一个非领域异常误传进来不得把一次真实失败换成 AttributeError http_status_code=getattr(error, "status_code", None), # 空串归 None: 既有 `body_text` 的缺省就是空串,而本列的语义是"未知" error_body=getattr(error, "body_text", "") or None, ) def _assert_recorder_shape(recorder: TelemetryRecorder) -> None: """装配期一次 `signature.bind` 形状校验: 不执行写入,只证明该形状能被接受。 `_record` 的 `except Exception` 会把旧 recorder 的 `TypeError` 吞成 warning, 后果是自定义 recorder 在下游升级后**100% 丢遥测且调用照常成功**——正是 "遥测必录"要防的形态,而文档级迁移清单挡不住它。故在装配期当场报错 (不是 warning: 降级方向的铁律管的是**运行期写失败**,不是装配错误)。 参数名从 `TelemetryRecorder.record_llm_call` 的协议签名**派生**(不手抄第四份 字段清单),绑定用哨兵 `None`,不读任何真实请求数据;`**kwargs` (VAR_KEYWORD)自动通过。不可 inspect(C 实现等)同样按配置错误报错——宁可 装配不起来,不进入"运行期静默丢行"。 边界诚实声明: 它只证明该形状能被接受,**不能证明函数体真的落这些列**。 Raises: ValueError: 签名不符、不可 inspect,或协议本身不可 inspect。 """ try: # 模块全局查找而非常量快照: 协议改了,闸就跟着改(测试可据此机械验证) protocol = inspect.signature(TelemetryRecorder.record_llm_call).parameters except (TypeError, ValueError) as exc: # pragma: no cover - 协议一向可 inspect raise ValueError(f"TelemetryRecorder.record_llm_call 签名不可读取: {exc}") from exc sentinels = {name: None for name in protocol if name != "self"} label = type(recorder).__name__ method = getattr(recorder, "record_llm_call", None) if method is None: # 连方法都没有: 比旧签名更明确的配置错误。不让它以裸 AttributeError # 逆流而上——那不属错误四分类,且现场离"注错了东西"这个真因很远 raise ValueError( f"注入的遥测 recorder {label} 没有 record_llm_call 方法,不满足 TelemetryRecorder 端口" ) try: signature = inspect.signature(method) except (TypeError, ValueError) as exc: raise ValueError( f"遥测 recorder {label} 的 record_llm_call 不可 inspect(如 C 实现)," "无法在装配期确认它接受当前字段形状;请换成 Python 实现或包一层" ) from exc try: signature.bind(**sentinels) except TypeError as exc: raise ValueError( f"遥测 recorder {label} 的 record_llm_call 签名与 TelemetryRecorder 不符" f"(当前 {len(sentinels)} 个字段): {exc}。" "这一条故意在装配期报错——放行的后果是每行遥测都被降级成 warning 后丢弃" ) from exc class TelemetryEmitter: """从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。""" def __init__( self, recorder: TelemetryRecorder, *, scope: str, pricing: PricingTable | None = None, text_cap: int | None, ) -> None: """`text_cap` 与 `scope` 无默认值是有意的: 两者都是关键行为参数。 `text_cap` 漏传即静默改变落库正文;`scope` 漏传则三类行都失去池名 ——终态失败可能根本没选出源,但 scope 始终已知,不拿 `source_name` 顶替。 本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。 同理,值域校验与**装配闸**都放在这一处: 三个 Client 全部汇流到这里, `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}") _assert_recorder_shape(recorder) self._recorder = recorder self._scope = scope 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: PolyGatewayError | str | None, reasoning_applies: bool, operation: CallOperation, class_prefixed_error: bool = False, ) -> None: """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 `reasoning_applies` 声明**这条调用路径有没有推理语义**: chat 路径为 `True`,embedding / OCR 路径为 `False`。它不能由 emitter 自己推断——三条路径 共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源 会让下面的回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的 档。**不设默认值**: 与 `TelemetryRecorder` 同一约定,库外无第三方调用者,漏传 当场 TypeError,好过被静默当成"没表态"。`operation` 同理且另有一层: 它只能由调用点给定,**绝不读 `exc.operation`**(后者是 HTTP 子操作)。 `error` 收**领域异常对象**而非预先 `str()` 压平的文本: 状态码/底层异常类型/ 网关正文在此提取成四列(设计 §5)。取消路径仍传既有字符串 `"cancelled"`。 """ 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, errors=_error_fields(error, event_kind="attempt", class_prefixed=class_prefixed_error), 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, meta=request.meta, reasoning_effort=_attempt_effort( request=request, source=source, response=response, applies=reasoning_applies ), operation=operation, event_kind="attempt", attempts=None, total_latency_ms=None, ) async def emit_cache_hit( self, *, request: ChatRequest, response: LLMResponse, operation: CallOperation ) -> None: """缓存命中记录: cache_hit=True、latency_ms=0(VT 同款)。 逻辑计数两列恒 NULL: 本行描述的是"一次命中",不是一次逻辑调用的终态。 """ 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, errors=_ErrorFields(), # 决策 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, # 与 model/prompt_tokens 同一口径: 原样回放历史那次的裁定结果 thinking_observation=response.thinking_observation, # 由最外层 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, # 与 sampling 同一口径: 命中行没有选中源,源级档位与 `nearest` 映射 # 都无从谈起,只记调用方这次要的档(response 里那个是历史那次实发的) reasoning_effort=_normalize_effort(request.reasoning_effort), operation=operation, event_kind="cache_hit", attempts=None, total_latency_ms=None, ) async def emit_terminal_failure( self, *, request: ChatRequest, call_id: str, error: PolyGatewayError | str, operation: CallOperation, stats: CallStats, class_prefixed_error: bool = False, ) -> None: """一次**逻辑调用**的失败终态: 无具体源,溯源字段置空标记。 `latency_ms` 与 `total_latency_ms` 同取**同一份冻结快照**,避免双时钟微差; 故本方法不再收 `latency_ms`。token 与 cost 一律不从 attempt 行复制 (费用聚合仍只由 attempt / cache_hit 行决定,口径不变)。 """ 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=stats.total_latency_ms, ttft_ms=None, max_inter_token_ms=None, cache_hit=False, errors=_error_fields( error, event_kind="terminal_failure", class_prefixed=class_prefixed_error ), 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), # 源不可知,但租户归属是已知的——终态失败行恰是审计最需要的 tenant_id=request.tenant_id, meta=request.meta, # 可能根本没选出源,故与 sampling 同样只取请求档 reasoning_effort=_normalize_effort(request.reasoning_effort), operation=operation, event_kind="terminal_failure", attempts=stats.attempts, total_latency_ms=stats.total_latency_ms, ) 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, # 1.3.5: 错误四列已由 `_error_fields` 定型(三种入参形态 × 两类行的唯一规则所有者), # 本方法只搬运——拆成五个平铺参数就是把"一处定型"换回"三处各自拼" errors: _ErrorFields, cached_prompt_tokens: int | None, model_reported: str | None, sampling: str | None, reasoning_tokens: int | None, # issue #16: 枚举形态进来,归一化成裸 str 后才下沉(收口在 `_record` 内)。 # 注解是契约,但 `LLMResponse` 无运行时校验,故 `_normalize_observation` # 仍按外部输入防御——违约的代价不该是丢掉整行遥测 thinking_observation: ThinkingObservation, # issue #11: 未归一化的调用方维度,归一化在本方法内收口(recorder 只落库) tenant_id: str | None, meta: Mapping[str, Any], # issue #20: 已由各入口按自己的口径定型成裸 str/None(口径差别见三个入口的 # 注释),本方法只搬运——把定型放这里就得再传一遍 response/source,等于把 # "唯一 record_llm_call 调用点"换成"两处口径判断",那正是要避免的复制 reasoning_effort: str | None, # —— 1.3.5: 行形态与逻辑调用快照 —— operation: CallOperation, event_kind: EventKind, attempts: int | None, total_latency_ms: int | None, ) -> 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 errors.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, ) context = request.call_context 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=errors.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), # 落裸 str: `StrEnum` 虽是 `str` 子类,asyncpg 的参数编码对子类不 # 保证接受,而遥测写失败只降级成一条 warning——不会当场炸,只会让 # Postgres 那一路悄悄少一列数据 thinking_observation=_normalize_observation(thinking_observation), reasoning_effort=reasoning_effort, # —— 1.3.5 十列 —— scope=self._scope, # 调用点给定的公开方法四值,**绝不读 `exc.operation`**(设计 §5 I1/I2) operation=operation, # 上下文缺席(库内现场构造的 ChatRequest)→ NULL,**不造 ID**(I5) logical_call_id=None if context is None else context.logical_call_id, event_kind=event_kind, http_status_code=errors.http_status_code, error_type=errors.error_type, cause_type=errors.cause_type, error_body=errors.error_body, attempts=attempts, total_latency_ms=total_latency_ms, ) except asyncio.CancelledError: raise except Exception as exc: logger.warning("遥测记录失败(降级不冒泡): {}", exc) async def emit_terminal_once( emitter: TelemetryEmitter | None, *, request: ChatRequest, context: _CallContext, error: PolyGatewayError | str, operation: CallOperation, class_prefixed_error: bool = False, ) -> None: """三个 client 共用的**终态唯一出口**: 去重 + 同步冻结快照 + best effort 写入。 去重由 `claim_terminal()` 承担(每逻辑调用至多一条终态行);`emitter is None` 或已写过 → 直接返回。 **降级范围包含诊断字段的提取与构建**,不只是写入那一步: `_record` 内的 `except Exception` 只兜住落库,而 `_error_fields` / `canonical_sampling_json` 在它**之外**求值——下游经公共端口(自实现 `StructuredOutputStrategy` 或 transport)构造出的 `ResultInvalidError(validation_errors=(非 str,))` 会让提取期 抛 `TypeError` 顶替调用方本该收到的领域异常,错误四分类被击穿且终态行照样丢。 故在此整段兜底,与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款写法。 终态行按 best effort: 兜底命中时本次逻辑调用**没有**终态行(`claim_terminal()` 已消耗,不补写、不重试——重写一遍只会把同一个提取期异常再抛一次)。 **`CancelledError` 原样传播**(取消优先,不 shield、不开后台任务): 这一次 `await` 本身就是新的取消点,外部取消落在它上时调用方会看到 `CancelledError` 而非领域错误——与 TelemetryMW 的历史行为同款,已经人类批准(设计 §6/§10)。 快照冻结是**同步**动作,故终态行不含它自身的写入耗时。 """ if emitter is None or not context.claim_terminal(): return try: stats = context.snapshot() await emitter.emit_terminal_failure( request=request, call_id=str(uuid.uuid4()), error=error, operation=operation, stats=stats, class_prefixed_error=class_prefixed_error, ) except asyncio.CancelledError: raise except Exception as exc: logger.warning("终态遥测记录失败(降级不冒泡): {}", exc) class TelemetryMW: """洋葱最外层: 只观测尝试层看不见的**缓存命中**。 1.3.5 起不再在此写终态失败行: 终态由 `GatewayClient.chat` 的公开边界经 `emit_terminal_once` 统一写出。两处同时写会让同一次失败出两条终态行, 而下游正是按 `event_kind = 'terminal_failure'` 计失败调用次数的。 """ def __init__( self, emitter: TelemetryEmitter, now: Callable[[], float] = time.monotonic ) -> None: self._emitter = emitter # `now` 自 1.3.5 起本类不再读取(终态行迁到公开边界后无耗时可测),但形参保留: # 删它会平白打断 `TelemetryMW(emitter, now=...)` 这一既有装配写法 async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse: response = await call_next(request) if response.cache_hit: await self._emitter.emit_cache_hit(request=request, response=response, operation="chat") return response