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
+58 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 26 个 INSERT 字段 +
`created_at` = 27;列数断言一律按物理列数写,两套口径混用是最易错处。
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 +
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。
@@ -52,7 +52,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
@@ -84,7 +94,17 @@ CREATE TABLE IF NOT EXISTS llm_calls (
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
thinking_observation TEXT,
reasoning_effort TEXT
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
@@ -105,6 +125,18 @@ SQLITE_BACKFILL = (
# 同样可空,但这里 NULL 表达的是"调用方没表态"(issue #20): 它与 'none'
# (明确要求不推理)是两回事,折叠成任一档都等于替上游声称了它没说过的事
("reasoning_effort", "TEXT"),
# 1.3.5 十列: 全部可空且无默认值——旧行的 NULL 表达的是"补列之前根本没记过
# 这件事",与任何哨兵值都不是一回事,故不回填(设计 §5)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -120,6 +152,17 @@ _PG_BACKFILL_DECLS = (
# 可空,理由同 SQLITE_BACKFILL 同名项
("thinking_observation", "TEXT"),
("reasoning_effort", "TEXT"),
# 1.3.5 十列,列序与 SQLITE_BACKFILL 逐项对齐(两条路径的物理列序不许分叉)
("scope", "TEXT"),
("operation", "TEXT"),
("logical_call_id", "TEXT"),
("event_kind", "TEXT"),
("http_status_code", "INTEGER"),
("error_type", "TEXT"),
("cause_type", "TEXT"),
("error_body", "TEXT"),
("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -158,6 +201,17 @@ COLUMNS = (
"meta",
"thinking_observation",
"reasoning_effort",
# —— 1.3.5 逻辑调用统计与结构化失败诊断(issue #19/#23)——
"scope",
"operation",
"logical_call_id",
"event_kind",
"http_status_code",
"error_type",
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
)
_COLUMN_SET = frozenset(COLUMNS)