feat: track logical call statistics across governed calls

This commit is contained in:
2026-09-09 10:04:39 -04:00
parent 300ced5dbd
commit 87c261bf73
13 changed files with 650 additions and 10 deletions
+23 -5
View File
@@ -40,12 +40,14 @@ from polygateway.middleware.retry import StallClock, _failure_reason, backoff_de
from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import OutcomeAwareSelector
from polygateway.types import (
CallStats,
ChatRequest,
LLMResponse,
OcrLayoutResult,
OcrTextResult,
TelemetryStatus,
Usage,
_CallContext,
strip_unsupported_extra_body,
validate_caller_dimensions,
)
@@ -176,7 +178,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="recognize_text(tenant_id=..., meta=...)"
)
outcome = await self._call(
outcome, call_stats = await self._call(
"text", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
@@ -187,6 +189,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def parse_layout(
@@ -206,7 +209,7 @@ class OcrClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_layout(tenant_id=..., meta=...)"
)
outcome = await self._call(
outcome, call_stats = await self._call(
"layout", image, session_id, parent_call_id, dimension_tenant_id, dimensions
)
result = outcome.result
@@ -218,6 +221,7 @@ class OcrClient:
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def check_health(self) -> dict[str, bool]:
@@ -242,11 +246,14 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
) -> _AttemptOutcome:
) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes):
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
if not image:
raise ValueError("image 不能为空")
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now)
if not self._sources:
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
fails = 0
@@ -260,10 +267,18 @@ class OcrClient:
continue
async with clock.attempting():
outcome = await self._attempt(
kind, image, *picked, reasons, session_id, parent_call_id, tenant_id, meta
kind,
image,
*picked,
reasons,
session_id,
parent_call_id,
tenant_id,
meta,
context,
)
if isinstance(outcome, _AttemptOutcome):
return outcome
return outcome, context.snapshot()
fails += 1
if fails >= self._retry.max_attempts:
raise AllSourcesExhausted(
@@ -287,11 +302,14 @@ class OcrClient:
parent_call_id: str | None,
tenant_id: str | None,
meta: dict[str, Any],
context: _CallContext,
) -> _AttemptOutcome | _FailedAttempt:
call_id = str(uuid.uuid4())
started = self._now()
# 四个 emit 分支(成功/终态拒绝/取消/可重试失败)都必须带调用方维度:
# 失败行与取消行同样需要租户归属,漏掉任一分支就会写出无归属的行
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt()
try:
result = await self._invoke(kind, image, source, call_id)
await self._record_quietly(self._breaker.record_success(entry))