feat: make telemetry degradation a first-class state

Telemetry degradation used to be a single warning and a private boolean.
In a long-running process that is indistinguishable from telemetry working:
issue #15 was only found by hand-reconciling milestone log lines against
llm_calls rows, after 19 calls had silently gone unrecorded. The SQLite
side was worse — once init failed, every write returned without even a
log line.

Degradation now has one shared owner. TelemetryStatusTracker holds the
state machine (enter/recover/drop/should-retry), announces entry and
recovery once each, and repeats the drop count under a row-and-time
double threshold so a degraded backend neither floods the log nor goes
quiet. Both recorders hold one; both count the rows they drop.

For programmatic consumers, TelemetryStatus is a frozen snapshot exposed
as telemetry_status on all three clients, resolved through a single
isinstance check. It is a separate optional port rather than a member of
TelemetryRecorder: that protocol is @runtime_checkable, so adding an
attribute would make every implementation that only defines
record_llm_call stop satisfying it — downstream isinstance assertions
would break on upgrade. The existing assertion in test_ports.py is what
keeps that decision honest.

Failure criteria are deliberately untouched here: Postgres still treats a
pool failure as permanent, only now visibly. `_failed` and the tracker
therefore both carry the verdict for the span of this one change; the
cooldown rework collapses them into the tracker alone.
This commit is contained in:
2026-08-24 08:57:23 -04:00
parent e69ca4c82c
commit f958138e83
11 changed files with 542 additions and 5 deletions
+15
View File
@@ -20,6 +20,7 @@ from .types import (
OcrTextTransportResult,
SourceConfig,
SourceStats,
TelemetryStatus,
TransportResult,
)
@@ -243,6 +244,20 @@ class StructuredOutputStrategy(Protocol):
def parse(self, text: str) -> Any: ...
@runtime_checkable
class TelemetryStatusProvider(Protocol):
"""可自述可写状态的遥测后端;`TelemetryRecorder` 的**可选**伴生端口(issue #15)。
与 `TelemetryRecorder` 分开而不是给它加成员,是因为后者是 `@runtime_checkable`
而运行时检查按属性存在性做: 加一个属性会让所有只实现 `record_llm_call` 的
实现**当场不再是** `TelemetryRecorder`,下游若有同款 isinstance 断言,升级即断
(设计 §3.3)。消费方一律先 isinstance 再取值,取不到就当没有状态可报。
"""
@property
def telemetry_status(self) -> TelemetryStatus: ...
@runtime_checkable
class TelemetryRecorder(Protocol):
"""遥测后端;24 字段冻结(M1 设计 §4.4 + issue #3/#4/#11),唯一调用点是 TelemetryEmitter。