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