ForcePilot/backend/package/yuxi/channel/message/metrics.py
Kris 0b19470c70 feat(channel/message): 新增消息处理核心模块及相关工具类
本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能:
1.  新增会话围栏类,实现会话并发控制与过期清理
2.  新增媒体清理器,实现过期媒体文件自动清理
3.  新增熔断器组件,实现服务降级与故障隔离
4.  新增消息处理器,完成渠道消息的完整流转处理
5.  新增限流器组件,实现渠道级和账户级流量控制
6.  新增链路追踪模块,集成Langfuse实现调用链路监控
7.  新增指标统计模块,实现消息处理全链路指标采集
8.  新增统一消息模型,封装全渠道消息格式
9.  新增块回复流水线,实现流式回复的合并与去重
10. 新增本地媒体存储模块,实现媒体文件的本地管理
11. 新增回复分发器,实现回复内容的有序发送与延迟处理
12. 完善__init__.py导出所有核心模块与工具类
2026-05-21 10:27:16 +08:00

211 lines
6.7 KiB
Python

import time
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
class MetricType(StrEnum):
COUNTER = "counter"
GAUGE = "gauge"
HISTOGRAM = "histogram"
@dataclass(slots=True)
class _Counter:
value: int = 0
def inc(self, amount: int = 1) -> None:
self.value += amount
@dataclass(slots=True)
class _Gauge:
value: float = 0.0
def set(self, value: float) -> None:
self.value = value
def inc(self, amount: float = 1.0) -> None:
self.value += amount
def dec(self, amount: float = 1.0) -> None:
self.value -= amount
@dataclass(slots=True)
class _Histogram:
buckets: list[float]
values: list[int] = field(default_factory=list)
_sum: float = 0.0
_count: int = 0
def observe(self, value: float) -> None:
self._sum += value
self._count += 1
while len(self.values) < len(self.buckets):
self.values.append(0)
for i, bound in enumerate(self.buckets):
if value <= bound:
self.values[i] += 1
return
def quantile(self, q: float) -> float | None:
if self._count == 0:
return None
if not self.values:
return None
target_rank = q * self._count
cumulative = 0
for i, (bound, count) in enumerate(zip(self.buckets, self.values)):
cumulative += count
if cumulative >= target_rank:
if i == 0:
lower_bound = 0.0
else:
lower_bound = self.buckets[i - 1]
upper_bound = bound
prev_cumulative = cumulative - count
fraction = (target_rank - prev_cumulative) / max(count, 1)
return lower_bound + fraction * (upper_bound - lower_bound)
return float(self.buckets[-1]) if self.buckets else None
class MetricsRegistry:
def __init__(self) -> None:
self._label_values: dict[str, dict[tuple[str, ...], object]] = defaultdict(dict)
def counter(self, name: str, label_keys: tuple[str, ...] = ()) -> Callable[..., None]:
def inc(labels: dict[str, str] | None = None, amount: int = 1) -> None:
key = self._resolve_key(labels, label_keys)
counter = self._label_values[name].get(key)
if counter is None:
counter = _Counter()
self._label_values[name][key] = counter
counter.inc(amount)
return inc
def gauge(self, name: str, label_keys: tuple[str, ...] = ()) -> Callable[..., None]:
def set(value: float, labels: dict[str, str] | None = None) -> None:
key = self._resolve_key(labels, label_keys)
gauge = self._label_values[name].get(key)
if gauge is None:
gauge = _Gauge()
self._label_values[name][key] = gauge
gauge.set(value)
return set
def histogram(self, name: str, buckets: list[float], label_keys: tuple[str, ...] = ()) -> Callable[..., None]:
def observe(value: float, labels: dict[str, str] | None = None) -> None:
key = self._resolve_key(labels, label_keys)
hist = self._label_values[name].get(key)
if hist is None:
hist = _Histogram(buckets=buckets)
self._label_values[name][key] = hist
hist.observe(value)
return observe
@staticmethod
def _resolve_key(labels: dict[str, str] | None, label_keys: tuple[str, ...]) -> tuple[str, ...]:
if not labels:
return tuple("" for _ in label_keys)
return tuple(labels.get(k, "") for k in label_keys)
def snapshot(self) -> dict:
result: dict = {}
for metric_name, entries in self._label_values.items():
result[metric_name] = {}
for label_tuple, metric in entries.items():
if isinstance(metric, _Counter):
result[metric_name][str(label_tuple)] = {"type": "counter", "value": metric.value}
elif isinstance(metric, _Gauge):
result[metric_name][str(label_tuple)] = {"type": "gauge", "value": metric.value}
elif isinstance(metric, _Histogram):
result[metric_name][str(label_tuple)] = {
"type": "histogram",
"buckets": metric.buckets,
"values": metric.values,
"sum": metric._sum,
"count": metric._count,
"p50": metric.quantile(0.50),
"p95": metric.quantile(0.95),
"p99": metric.quantile(0.99),
}
return result
registry = MetricsRegistry()
channel_messages_total = registry.counter(
"channel_messages_total",
("channel_type", "status"),
)
channel_dispatch_duration_ms = registry.histogram(
"channel_dispatch_duration_ms",
buckets=[5, 25, 50, 100, 250, 500, 1000, 2500, 5000],
label_keys=("channel_type",),
)
channel_agent_duration_ms = registry.histogram(
"channel_agent_duration_ms",
buckets=[100, 500, 1000, 2500, 5000, 10000, 30000, 60000],
label_keys=("channel_type",),
)
channel_rate_limit_rejects_total = registry.counter(
"channel_rate_limit_rejects_total",
("channel_type",),
)
channel_messages_inflight = registry.gauge(
"channel_messages_inflight",
)
def record_message(channel_type: str, status: str) -> None:
channel_messages_total(labels={"channel_type": channel_type, "status": status})
def record_dispatch_duration_ms(channel_type: str, duration_ms: float) -> None:
channel_dispatch_duration_ms(duration_ms, labels={"channel_type": channel_type})
def record_agent_duration_ms(channel_type: str, duration_ms: float) -> None:
channel_agent_duration_ms(duration_ms, labels={"channel_type": channel_type})
def record_rate_limit_reject(channel_type: str) -> None:
channel_rate_limit_rejects_total(labels={"channel_type": channel_type})
def set_inflight(count: int) -> None:
channel_messages_inflight(count)
class MetricsTimer:
def __init__(self, on_finish: Callable[[float], None]) -> None:
self._on_finish = on_finish
self._start = 0.0
def __enter__(self) -> "MetricsTimer":
self._start = time.monotonic()
return self
def __exit__(self, *args) -> None:
elapsed = (time.monotonic() - self._start) * 1000
self._on_finish(elapsed)
async def __aenter__(self) -> "MetricsTimer":
self._start = time.monotonic()
return self
async def __aexit__(self, *args) -> None:
elapsed = (time.monotonic() - self._start) * 1000
self._on_finish(elapsed)