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:
@@ -42,7 +42,7 @@ from polygateway.middleware.admission import SourceAdmission, settle_and_release
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
|
||||
from polygateway.types import (
|
||||
ChatRequest,
|
||||
EmbeddingResponse,
|
||||
@@ -129,7 +129,9 @@ class EmbeddingClient:
|
||||
self._transport = transport
|
||||
self._retry = retry
|
||||
self._emitter = (
|
||||
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
|
||||
TelemetryEmitter(telemetry, scope=self._scope, pricing=pricing, text_cap=text_cap)
|
||||
if telemetry
|
||||
else None
|
||||
)
|
||||
self._telemetry = telemetry
|
||||
# 限流/熔断后端在此之外只以 QuotaGate/BreakerGate 的形态存在,自持一份
|
||||
@@ -203,6 +205,38 @@ class EmbeddingClient:
|
||||
source_name="",
|
||||
call_stats=context.snapshot(),
|
||||
)
|
||||
try:
|
||||
return await self._embed_all(
|
||||
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
|
||||
)
|
||||
except PolyGatewayError as exc:
|
||||
await self._emit_terminal(
|
||||
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context, exc
|
||||
)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
# 三条链路同一口径尽力写一条(允许 0 条);取消优先,不 shield
|
||||
await self._emit_terminal(
|
||||
texts,
|
||||
session_id,
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
context,
|
||||
"cancelled",
|
||||
)
|
||||
raise
|
||||
|
||||
async def _embed_all(
|
||||
self,
|
||||
texts: list[str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
context: _CallContext,
|
||||
) -> EmbeddingResponse:
|
||||
"""切批串行执行并合并;无源的 raise 必须在本方法内——否则无源终态行写不出。"""
|
||||
if not self._sources:
|
||||
raise AllSourcesExhausted(scope=self._scope, reason="no_sources", retry_after_s=0.0)
|
||||
outcomes = []
|
||||
@@ -212,14 +246,50 @@ class EmbeddingClient:
|
||||
texts[start : start + self._batch_size],
|
||||
session_id,
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
tenant_id,
|
||||
meta,
|
||||
context,
|
||||
)
|
||||
)
|
||||
# 全批共享同一上下文,故分批是实现细节而非 N 次独立逻辑调用
|
||||
return dataclasses.replace(self._merge(outcomes), call_stats=context.snapshot())
|
||||
|
||||
async def _emit_terminal(
|
||||
self,
|
||||
texts: list[str],
|
||||
session_id: str | None,
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
context: _CallContext,
|
||||
error: PolyGatewayError | str,
|
||||
) -> None:
|
||||
"""终态行的请求摘要(设计 §6 M4): 计数占位 + 第一批截断文本。
|
||||
|
||||
描述的是**本次调用的整体输入**但不扩大单行正文预算: 失败批的具体文本由同
|
||||
`logical_call_id` 的 attempt 行给出,终态行不保存全量原输入。
|
||||
"""
|
||||
batches = math.ceil(len(texts) / self._batch_size)
|
||||
messages = [{"role": "user", "content": f"<embed texts={len(texts)} batches={batches}>"}]
|
||||
# 与逐批行同款构造(至多 `batch_size` 条、每条 200 字符)
|
||||
messages += [
|
||||
{"role": "user", "content": t[:_TELEMETRY_TEXT_CAP]} for t in texts[: self._batch_size]
|
||||
]
|
||||
await emit_terminal_once(
|
||||
self._emitter,
|
||||
request=ChatRequest(
|
||||
messages=messages,
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
call_context=context,
|
||||
),
|
||||
context=context,
|
||||
error=error,
|
||||
operation="embed",
|
||||
)
|
||||
|
||||
# —— 治理循环(与 RetryMW 同构;设计 §7.1 已声明的有限重复)——
|
||||
|
||||
async def _embed_batch(
|
||||
@@ -300,6 +370,7 @@ class EmbeddingClient:
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
context,
|
||||
result,
|
||||
)
|
||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||
@@ -314,6 +385,7 @@ class EmbeddingClient:
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
context,
|
||||
error=exc,
|
||||
)
|
||||
raise
|
||||
@@ -329,6 +401,7 @@ class EmbeddingClient:
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
context,
|
||||
error="cancelled",
|
||||
)
|
||||
raise
|
||||
@@ -349,6 +422,7 @@ class EmbeddingClient:
|
||||
parent_call_id,
|
||||
tenant_id,
|
||||
meta,
|
||||
context,
|
||||
error=exc,
|
||||
)
|
||||
return _FailedBatch(exc, immediate=dead)
|
||||
@@ -385,8 +459,9 @@ class EmbeddingClient:
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
context: _CallContext,
|
||||
result: EmbeddingTransportResult | None = None,
|
||||
error: object | None = None,
|
||||
error: PolyGatewayError | str | None = None,
|
||||
) -> None:
|
||||
"""逐批遥测(经同一 Emitter): messages=截断 texts、向量绝不入库。"""
|
||||
if self._emitter is None:
|
||||
@@ -399,6 +474,7 @@ class EmbeddingClient:
|
||||
parent_call_id=parent_call_id,
|
||||
tenant_id=tenant_id,
|
||||
meta=meta,
|
||||
call_context=context,
|
||||
)
|
||||
response = None
|
||||
if result is not None:
|
||||
@@ -423,10 +499,12 @@ class EmbeddingClient:
|
||||
call_id=call_id,
|
||||
latency_ms=int((self._now() - started) * 1000),
|
||||
response=response,
|
||||
error=None if error is None else str(error),
|
||||
# 异常对象原样下传: 状态码/底层异常类型/网关正文在 Emitter 内定型
|
||||
error=error,
|
||||
# embedding payload 硬编码 {model, input},从不带推理参数;源上即便
|
||||
# 误配了 ENABLE_THINKING,记一个档也是替这次调用声称它没做过的事
|
||||
reasoning_applies=False,
|
||||
operation="embed",
|
||||
)
|
||||
|
||||
def _merge(self, outcomes: list[_BatchOutcome]) -> EmbeddingResponse:
|
||||
|
||||
Reference in New Issue
Block a user