ForcePilot/backend/package/yuxi/channels/services/stats_collector.py
Kris 29dac24600 feat(channels): 完成渠道模块多维度功能迭代与优化
本次提交对渠道模块进行了全面升级,包含以下核心改进:
1. 新增二维码登录相关协议方法,完善登录流程
2. 优化配置监听逻辑,增加渠道运行状态前置校验
3. 重构动作注册机制,支持动态注册渠道动作并新增批量操作能力
4. 扩展渠道能力模型,新增广播、文件传输等支持
5. 优化适配器加载路径,新增元宝适配器支持
6. 新增凭证过期检查与告警能力,完善运维监控
7. 重构统计收集器,支持多维度渠道统计数据
8. 优化消息路由策略,新增策略缓存与安全处理逻辑
9. 重构基础适配器,新增凭证管理工具方法
10. 完善状态存储功能,支持凭证数据管理与批量清理
11. 重构渠道管理器,新增配置校验、动态渠道管理、限流能力
12. 优化健康检查与状态上报逻辑,完善审计日志与异常处理
2026-05-14 02:08:36 +08:00

151 lines
5.4 KiB
Python

from __future__ import annotations
import asyncio
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import TYPE_CHECKING
from yuxi.utils.logging_config import logger
if TYPE_CHECKING:
from yuxi.channels.services.runtime_state import RuntimeState
_MAX_RESPONSE_TIME_SAMPLES = 100
@dataclass
class ChannelStats:
channel_id: str
messages_today: int = 0
messages_total: int = 0
errors_total: int = 0
active_users_today: int = 0
latency_p50_ms: float = 0.0
latency_p95_ms: float = 0.0
latency_p99_ms: float = 0.0
avg_latency_ms: float = 0.0
class StatsCollector:
def __init__(self, state: RuntimeState):
self._state = state
self._response_times: deque[float] = deque(maxlen=_MAX_RESPONSE_TIME_SAMPLES)
self._request_count_local: int = 0
self._error_count_local: int = 0
self._last_collect_at = time.monotonic()
self._credential_store_errors: int = 0
self._credential_expiry_alerts: int = 0
self._channel_requests: dict[str, int] = defaultdict(int)
self._channel_errors: dict[str, int] = defaultdict(int)
self._channel_response_times: dict[str, deque[float]] = defaultdict(
lambda: deque(maxlen=_MAX_RESPONSE_TIME_SAMPLES)
)
self._channel_active_users: dict[str, set[str]] = defaultdict(set)
def record_request(self, channel_id: str = "") -> None:
self._state.request_count += 1
self._request_count_local += 1
if channel_id:
self._channel_requests[channel_id] += 1
def record_error(self, channel_id: str = "") -> None:
self._state.error_count += 1
self._error_count_local += 1
if channel_id:
self._channel_errors[channel_id] += 1
def record_credential_store_error(self) -> None:
self._credential_store_errors += 1
def record_credential_expiry_alert(self) -> None:
self._credential_expiry_alerts += 1
def record_response_time(self, ms: float, channel_id: str = "") -> None:
self._response_times.append(ms)
if channel_id:
self._channel_response_times[channel_id].append(ms)
def record_user_activity(self, channel_id: str, user_id: str) -> None:
if channel_id and user_id:
self._channel_active_users[channel_id].add(user_id)
async def run(self, interval: float = 60) -> None:
logger.info("StatsCollector started")
while True:
await asyncio.sleep(interval)
try:
self._collect()
except asyncio.CancelledError:
break
except Exception:
logger.exception("StatsCollector error")
def _collect(self) -> None:
now = time.monotonic()
elapsed = now - self._last_collect_at
self._last_collect_at = now
rps = self._request_count_local / elapsed if elapsed > 0 else 0
eps = self._error_count_local / elapsed if elapsed > 0 else 0
avg_rt = sum(self._response_times) / len(self._response_times) if self._response_times else 0
p95_rt = 0.0
if len(self._response_times) >= 20:
sorted_times = sorted(self._response_times)
p95_idx = int(len(sorted_times) * 0.95)
p95_rt = sorted_times[p95_idx] if p95_idx < len(sorted_times) else sorted_times[-1]
error_rate = self._error_count_local / self._request_count_local if self._request_count_local else 0
logger.info(
f"Stats: channels={self._state.active_channels}, "
f"rps={rps:.1f}, eps={eps:.1f}, errors={self._error_count_local}, "
f"error_rate={error_rate:.2%}, "
f"avg_rt={avg_rt:.0f}ms, p95_rt={p95_rt:.0f}ms"
)
self._request_count_local = 0
self._error_count_local = 0
def get_summary(self) -> dict:
avg_rt = sum(self._response_times) / len(self._response_times) if self._response_times else 0
return {
"active_channels": self._state.active_channels,
"total_requests": self._state.request_count,
"total_errors": self._state.error_count,
"avg_response_time_ms": round(avg_rt, 1),
"phase": self._state.phase,
"credential_store_errors_total": self._credential_store_errors,
"credential_expiry_alert_total": self._credential_expiry_alerts,
}
def get_channel_stats(self, channel_id: str) -> ChannelStats:
rt = self._channel_response_times.get(channel_id, deque())
sorted_rt = sorted(rt) if rt else []
avg = sum(sorted_rt) / len(sorted_rt) if sorted_rt else 0.0
p50 = _calc_percentile(sorted_rt, 50)
p95 = _calc_percentile(sorted_rt, 95)
p99 = _calc_percentile(sorted_rt, 99)
return ChannelStats(
channel_id=channel_id,
messages_today=0,
messages_total=self._channel_requests.get(channel_id, 0),
errors_total=self._channel_errors.get(channel_id, 0),
active_users_today=len(self._channel_active_users.get(channel_id, set())),
latency_p50_ms=p50,
latency_p95_ms=p95,
latency_p99_ms=p99,
avg_latency_ms=round(avg, 1),
)
def _calc_percentile(sorted_values: list[float], pct: int) -> float:
if not sorted_values:
return 0.0
idx = int(len(sorted_values) * pct / 100.0)
idx = min(idx, len(sorted_values) - 1)
return round(sorted_values[idx], 1)