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
+10 -6
View File
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
class TestEmitterCost:
async def test_success_row_costed(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -178,18 +178,19 @@ class TestEmitterCost:
response=_resp(),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] == pytest.approx(7.2)
async def test_cache_hit_row_costs_zero(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True), operation="chat")
assert rec.rows[0]["cost"] == 0.0
async def test_failure_row_cost_none(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -198,12 +199,13 @@ class TestEmitterCost:
response=None,
error="TransientError: boom",
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None
async def test_unknown_model_none_without_blocking(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(model="mystery"),
@@ -212,13 +214,14 @@ class TestEmitterCost:
response=_resp(model="mystery"),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None
async def test_no_pricing_keeps_none(self):
"""未注入价格表 = M1 现状: cost 恒 None(回归)。"""
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None)
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
@@ -227,5 +230,6 @@ class TestEmitterCost:
response=_resp(),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["cost"] is None