feat: record call observability columns and terminal failure rows

Grow the telemetry contract from 26 to 36 fields and give every logical
call a failure terminal row, so SQL can finally answer "how many calls
failed" and "why did the whole pool die".

Schema and port move together with the emitter writes in one commit:
splitting them would ship columns that nothing populates.

- schema: append 10 nullable columns (scope, operation, logical_call_id,
  event_kind, http_status_code, error_type, cause_type, error_body,
  attempts, total_latency_ms) to all five definition sites in one order
- ports: 10 keyword-only parameters without defaults; the protocol
  signature is now the single source the assembly gate derives from
- emitter: take domain exception objects instead of pre-flattened text
  and pin down the diagnostics in one helper; a relabelled 503 stays
  503 and success rows leave all five columns NULL
- emitter: reject recorders whose record_llm_call cannot accept the
  current field shape at assembly time, since _record would otherwise
  swallow the TypeError and drop every row while calls keep succeeding
- clients: write at most one terminal row per logical call through a
  single shared exit, deduplicated by the call context; TelemetryMW
  stops writing terminals so the two sites cannot double count
- clients: cancellation stays best effort and propagates, non-domain
  exceptions get no terminal row and keep their classification
- transports: give _status_to_error an explicit operation and fix the
  historically mislabelled embedding HTTP failures
- structured: promote the bounded error formatter so the reask feedback
  and the terminal explanation share one set of limits

Terminal rows carry no cost and no tokens, so cost aggregation is
unchanged; failure counts must now filter on event_kind.
This commit is contained in:
2026-09-09 11:27:52 -04:00
parent 87c261bf73
commit 393f2bf617
19 changed files with 1628 additions and 194 deletions
+22 -5
View File
@@ -154,18 +154,28 @@ def _classify(status: int) -> tuple[type[PolyGatewayError], str]:
def _status_to_error(
source: SourceConfig, status: int, body_text: str, headers: Mapping[str, str]
source: SourceConfig,
status: int,
body_text: str,
headers: Mapping[str, str],
*,
operation: str,
) -> Exception:
"""非 2xx → 领域错误,**全部分支**携带响应体摘要(issue #10)。
摘要只算一次,message 与 `body_text` 共用同一份串: 两份不同长度会让"遥测里
看到的"与"下游 catch 到的"对不上,排查时反而多一层困惑。
`operation` 是 **HTTP 子操作**词表(`chat` / `embedding` / `ocr_text` / ...),由
调用点显式给定。它曾被硬编码成 `"chat"`,而 `embed()` 的非 200 分支也走它
——于是现存所有 embedding HTTP 失败的 `exc.operation` 都是错的(1.3.5 设计 §5 I1)。
注意它与遥测新列 `operation`(公开方法四值)是**两个语义**,不做自动转换。
"""
summary = summarize_body(body_text)
ctx: dict[str, Any] = {
"source_name": source.name,
"status_code": status,
"operation": "chat",
"operation": operation,
"body_text": summary,
}
if status == 429:
@@ -509,7 +519,10 @@ class OpenAICompatTransport:
except httpx.TransportError as exc:
raise TransientError(f"{source.name} 网络错误: {exc}", **ctx) from exc
if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
# 历史误标修正: 本分支属 `embed()`,与上方 ctx 同为 `"embedding"`
raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="embedding"
)
return _parse_embedding_payload(resp, source, len(texts))
async def _complete_stream(
@@ -524,7 +537,9 @@ class OpenAICompatTransport:
async with client.stream("POST", url, json=payload) as resp:
if resp.status_code != 200:
body = (await resp.aread()).decode("utf-8", errors="replace")
raise _status_to_error(source, resp.status_code, body, resp.headers)
raise _status_to_error(
source, resp.status_code, body, resp.headers, operation="chat"
)
sink: dict[str, Any] = {}
guarded = stream_with_liveness_timeouts(
_iter_sse_deltas(resp.aiter_lines(), sink),
@@ -622,7 +637,9 @@ class OpenAICompatTransport:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
resp = await client.post(url, json=payload)
if resp.status_code != 200:
raise _status_to_error(source, resp.status_code, resp.text, resp.headers)
raise _status_to_error(
source, resp.status_code, resp.text, resp.headers, operation="chat"
)
try:
body = resp.json()
except json.JSONDecodeError as exc: