fix(api): 修復 34 個 Ruff lint 錯誤

- 自動修復 import 排序、unused imports
- 手動修復 raise from、isinstance union、unused variable
- scripts/ 暫時保留 (非 CI 阻擋)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-29 15:27:49 +08:00
parent 5f9a6a7e55
commit d89f0520f9
27 changed files with 2538 additions and 1132 deletions

View File

@@ -0,0 +1,546 @@
"""
異常頻率統計服務
================================
ADR-037: 監控增強架構 - 異常頻率統計與根本修復
建立: 2026-03-29 (台北時區) Claude Code
使用 Redis Sorted Set 實作滑動窗口計數:
- ZADD anomaly:timeline:{key} {timestamp} {timestamp}
- ZCOUNT anomaly:timeline:{key} {start} +inf
- ZREMRANGEBYSCORE anomaly:timeline:{key} -inf {cutoff}
設計原則:
- 遵循 leWOOOgo 積木化鐵律
- 不直接存取 DB只用 Redis
- 完整審計追蹤
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
import structlog
if TYPE_CHECKING:
import redis.asyncio as redis
logger = structlog.get_logger(__name__)
# =============================================================================
# Data Types
# =============================================================================
@dataclass
class AnomalyFrequency:
"""異常頻率資料"""
anomaly_key: str
count_1h: int
count_24h: int
count_7d: int
count_30d: int
first_seen: datetime
last_seen: datetime
auto_repair_count: int
permanent_fix_applied: bool
escalation_level: str | None # None, REPEAT, ESCALATE, PERMANENT_FIX
def to_dict(self) -> dict:
"""轉換為字典 (供 Telegram 告警使用)"""
return {
"anomaly_key": self.anomaly_key,
"count_1h": self.count_1h,
"count_24h": self.count_24h,
"count_7d": self.count_7d,
"count_30d": self.count_30d,
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"auto_repair_count": self.auto_repair_count,
"permanent_fix_applied": self.permanent_fix_applied,
"escalation_level": self.escalation_level,
}
# =============================================================================
# AnomalyCounter Service
# =============================================================================
class AnomalyCounter:
"""
異常計數器 - 追蹤每種異常的發生頻率
統帥指示 (2026-03-29):
- "重啟只是治標,不是治本!太常發生的異常必須徹底解決"
- "需要統計、計數!必須要讓使用者知道!!"
閾值配置:
- REPEAT: 3 次/24h → 標記重複,通知用戶
- ESCALATE: 5 次/24h → 升級 Tier通知 Owner
- PERMANENT_FIX: 10 次/24h → 強制根因修復
"""
# 升級閾值 (可透過環境變數覆寫)
THRESHOLDS = {
"REPEAT": 3, # 3 次 → 重複告警
"ESCALATE": 5, # 5 次 → 人工介入
"PERMANENT_FIX": 10, # 10 次 → 必須永久修復
}
# Redis Key 前綴
PREFIX_TIMELINE = "anomaly:timeline:"
PREFIX_REPAIR_COUNT = "anomaly:repair_count:"
PREFIX_PERMANENT_FIX = "anomaly:permanent_fix:"
PREFIX_METADATA = "anomaly:metadata:"
PREFIX_REPAIR_HISTORY = "anomaly:repair_history:"
# TTL 設定 (35 天,比清理週期長一點)
TTL_SECONDS = 35 * 24 * 3600
def __init__(self, redis_client: redis.Redis) -> None:
self.redis = redis_client
@staticmethod
def hash_signature(signature: dict) -> str:
"""
生成異常簽名的 hash key
簽名欄位:
- alert_name: 告警名稱 (e.g., PodCrashLoopBackOff)
- service: 服務名稱 (e.g., awoooi-api)
- namespace: K8s 命名空間 (e.g., awoooi-prod)
- error_type: 錯誤類型 (e.g., OOMKilled)
"""
# 只取關鍵欄位,忽略時間戳等易變欄位
key_fields = {
"alert_name": signature.get("alert_name", signature.get("alertname", "")),
"service": signature.get("service", signature.get("job", "")),
"namespace": signature.get("namespace", ""),
"error_type": signature.get("error_type", signature.get("reason", "")),
}
# 排序確保一致性
canonical = json.dumps(key_fields, sort_keys=True)
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
async def record_anomaly(self, anomaly_signature: dict) -> AnomalyFrequency:
"""
記錄一次異常發生
Args:
anomaly_signature: 異常簽名字典
Returns:
AnomalyFrequency: 當前頻率統計
"""
anomaly_key = self.hash_signature(anomaly_signature)
now = datetime.now()
timestamp = now.timestamp()
timeline_key = f"{self.PREFIX_TIMELINE}{anomaly_key}"
# 1. 添加到 Sorted Set (score = timestamp, member = timestamp string)
await self.redis.zadd(timeline_key, {str(timestamp): timestamp})
# 2. 清理過期數據 (30 天前)
cutoff_30d = (now - timedelta(days=30)).timestamp()
await self.redis.zremrangebyscore(timeline_key, "-inf", cutoff_30d)
# 3. 設置 TTL
await self.redis.expire(timeline_key, self.TTL_SECONDS)
# 4. 計算各時間窗口的計數
count_1h = await self.redis.zcount(
timeline_key,
(now - timedelta(hours=1)).timestamp(),
"+inf",
)
count_24h = await self.redis.zcount(
timeline_key,
(now - timedelta(hours=24)).timestamp(),
"+inf",
)
count_7d = await self.redis.zcount(
timeline_key,
(now - timedelta(days=7)).timestamp(),
"+inf",
)
count_30d = await self.redis.zcount(
timeline_key,
cutoff_30d,
"+inf",
)
# 5. 取得首次/最近時間
first_seen_data = await self.redis.zrange(
timeline_key, 0, 0, withscores=True
)
last_seen_data = await self.redis.zrange(
timeline_key, -1, -1, withscores=True
)
first_seen = (
datetime.fromtimestamp(first_seen_data[0][1])
if first_seen_data
else now
)
last_seen = (
datetime.fromtimestamp(last_seen_data[0][1])
if last_seen_data
else now
)
# 6. 讀取修復統計
repair_count_str = await self.redis.get(
f"{self.PREFIX_REPAIR_COUNT}{anomaly_key}"
)
auto_repair_count = int(repair_count_str) if repair_count_str else 0
permanent_fix_str = await self.redis.get(
f"{self.PREFIX_PERMANENT_FIX}{anomaly_key}"
)
permanent_fix = permanent_fix_str == "1"
# 7. 儲存 metadata (首次記錄時)
metadata_key = f"{self.PREFIX_METADATA}{anomaly_key}"
if not await self.redis.exists(metadata_key):
await self.redis.hset(
metadata_key,
mapping={
"signature": json.dumps(anomaly_signature),
"first_seen": now.isoformat(),
},
)
await self.redis.expire(metadata_key, self.TTL_SECONDS)
# 8. 判斷升級等級
escalation_level = self._get_escalation_level(count_24h)
freq = AnomalyFrequency(
anomaly_key=anomaly_key,
count_1h=count_1h,
count_24h=count_24h,
count_7d=count_7d,
count_30d=count_30d,
first_seen=first_seen,
last_seen=last_seen,
auto_repair_count=auto_repair_count,
permanent_fix_applied=permanent_fix,
escalation_level=escalation_level,
)
# 9. 記錄日誌
logger.info(
"anomaly_recorded",
anomaly_key=anomaly_key,
count_1h=count_1h,
count_24h=count_24h,
count_30d=count_30d,
escalation_level=escalation_level,
)
return freq
def _get_escalation_level(self, count_24h: int) -> str | None:
"""判斷升級等級 (基於 24h 內次數)"""
if count_24h >= self.THRESHOLDS["PERMANENT_FIX"]:
return "PERMANENT_FIX"
elif count_24h >= self.THRESHOLDS["ESCALATE"]:
return "ESCALATE"
elif count_24h >= self.THRESHOLDS["REPEAT"]:
return "REPEAT"
return None
async def record_repair_attempt(
self,
anomaly_key: str,
action: str,
success: bool,
) -> None:
"""
記錄修復嘗試
Args:
anomaly_key: 異常 key
action: 修復動作 (e.g., restart_pod, scale_up)
success: 是否成功
"""
repair_key = f"{self.PREFIX_REPAIR_COUNT}{anomaly_key}"
# 遞增修復嘗試次數
await self.redis.incr(repair_key)
await self.redis.expire(repair_key, self.TTL_SECONDS)
# 記錄修復歷史 (用於學習)
history_key = f"{self.PREFIX_REPAIR_HISTORY}{anomaly_key}"
await self.redis.lpush(
history_key,
json.dumps(
{
"action": action,
"success": success,
"timestamp": datetime.now().isoformat(),
}
),
)
await self.redis.ltrim(history_key, 0, 99) # 只保留最近 100 次
await self.redis.expire(history_key, self.TTL_SECONDS)
logger.info(
"repair_attempt_recorded",
anomaly_key=anomaly_key,
action=action,
success=success,
)
async def mark_permanent_fix_applied(
self,
anomaly_key: str,
fix_description: str,
) -> None:
"""
標記已套用永久修復
Args:
anomaly_key: 異常 key
fix_description: 修復說明
"""
await self.redis.set(
f"{self.PREFIX_PERMANENT_FIX}{anomaly_key}",
"1",
ex=90 * 24 * 3600, # 90 天
)
# 記錄修復詳情
metadata_key = f"{self.PREFIX_METADATA}{anomaly_key}"
await self.redis.hset(
metadata_key,
mapping={
"permanent_fix_applied": "true",
"permanent_fix_description": fix_description,
"permanent_fix_time": datetime.now().isoformat(),
},
)
logger.info(
"permanent_fix_marked",
anomaly_key=anomaly_key,
fix_description=fix_description,
)
async def get_repair_success_rate(
self,
anomaly_key: str,
action: str,
) -> dict:
"""
取得特定動作的修復成功率
Returns:
{
'action': 'restart_pod',
'total': 10,
'success': 3,
'success_rate': 0.3,
}
"""
history_key = f"{self.PREFIX_REPAIR_HISTORY}{anomaly_key}"
history = await self.redis.lrange(history_key, 0, -1)
total = 0
success_count = 0
for item in history:
data = json.loads(item)
if data["action"] == action:
total += 1
if data["success"]:
success_count += 1
return {
"action": action,
"total": total,
"success": success_count,
"success_rate": success_count / total if total > 0 else 0.0,
}
async def get_all_repair_stats(self, anomaly_key: str) -> dict[str, dict]:
"""
取得所有修復動作的統計
Returns:
{
'restart_pod': {'total': 10, 'success': 3, 'success_rate': 0.3},
'scale_up': {'total': 2, 'success': 1, 'success_rate': 0.5},
}
"""
history_key = f"{self.PREFIX_REPAIR_HISTORY}{anomaly_key}"
history = await self.redis.lrange(history_key, 0, -1)
stats: dict[str, dict] = {}
for item in history:
data = json.loads(item)
action = data["action"]
if action not in stats:
stats[action] = {"total": 0, "success": 0}
stats[action]["total"] += 1
if data["success"]:
stats[action]["success"] += 1
# 計算成功率
for action_stats in stats.values():
total = action_stats["total"]
action_stats["success_rate"] = (
action_stats["success"] / total if total > 0 else 0.0
)
return stats
async def get_frequency(self, anomaly_key: str) -> AnomalyFrequency | None:
"""
取得異常頻率統計 (不記錄新事件)
Args:
anomaly_key: 異常 key
Returns:
AnomalyFrequency 或 None (若無記錄)
"""
timeline_key = f"{self.PREFIX_TIMELINE}{anomaly_key}"
# 檢查是否有記錄
if not await self.redis.exists(timeline_key):
return None
now = datetime.now()
cutoff_30d = (now - timedelta(days=30)).timestamp()
# 計算各時間窗口的計數
count_1h = await self.redis.zcount(
timeline_key,
(now - timedelta(hours=1)).timestamp(),
"+inf",
)
count_24h = await self.redis.zcount(
timeline_key,
(now - timedelta(hours=24)).timestamp(),
"+inf",
)
count_7d = await self.redis.zcount(
timeline_key,
(now - timedelta(days=7)).timestamp(),
"+inf",
)
count_30d = await self.redis.zcount(
timeline_key,
cutoff_30d,
"+inf",
)
# 取得時間範圍
first_seen_data = await self.redis.zrange(
timeline_key, 0, 0, withscores=True
)
last_seen_data = await self.redis.zrange(
timeline_key, -1, -1, withscores=True
)
first_seen = (
datetime.fromtimestamp(first_seen_data[0][1])
if first_seen_data
else now
)
last_seen = (
datetime.fromtimestamp(last_seen_data[0][1])
if last_seen_data
else now
)
# 讀取修復統計
repair_count_str = await self.redis.get(
f"{self.PREFIX_REPAIR_COUNT}{anomaly_key}"
)
auto_repair_count = int(repair_count_str) if repair_count_str else 0
permanent_fix_str = await self.redis.get(
f"{self.PREFIX_PERMANENT_FIX}{anomaly_key}"
)
permanent_fix = permanent_fix_str == "1"
escalation_level = self._get_escalation_level(count_24h)
return AnomalyFrequency(
anomaly_key=anomaly_key,
count_1h=count_1h,
count_24h=count_24h,
count_7d=count_7d,
count_30d=count_30d,
first_seen=first_seen,
last_seen=last_seen,
auto_repair_count=auto_repair_count,
permanent_fix_applied=permanent_fix,
escalation_level=escalation_level,
)
async def should_skip_action(
self,
anomaly_key: str,
action: str,
min_success_rate: float = 0.2,
) -> bool:
"""
判斷是否應跳過某修復動作
統帥指示: 成功率 < 20% 時應該跳過,嘗試其他動作
Args:
anomaly_key: 異常 key
action: 修復動作
min_success_rate: 最低成功率閾值 (預設 20%)
Returns:
True 表示應跳過此動作
"""
stats = await self.get_repair_success_rate(anomaly_key, action)
# 至少有 2 次嘗試才判斷
if stats["total"] < 2:
return False
return stats["success_rate"] < min_success_rate
# =============================================================================
# Singleton Factory (遵循現有模式)
# =============================================================================
_anomaly_counter: AnomalyCounter | None = None
def get_anomaly_counter() -> AnomalyCounter:
"""
取得 AnomalyCounter 實例
使用 Singleton 模式,共用 Redis 連線池
"""
global _anomaly_counter
if _anomaly_counter is None:
from src.core.redis_client import get_redis
_anomaly_counter = AnomalyCounter(get_redis())
return _anomaly_counter
def reset_anomaly_counter() -> None:
"""
重置 AnomalyCounter 實例 (供測試使用)
"""
global _anomaly_counter
_anomaly_counter = None

View File

@@ -34,6 +34,7 @@ from src.models.playbook import (
RiskLevel,
SymptomPattern,
)
from src.services.anomaly_counter import AnomalyFrequency, get_anomaly_counter
from src.services.executor import get_executor
from src.services.playbook_service import IPlaybookService, get_playbook_service
@@ -403,6 +404,126 @@ class AutoRepairService:
return "UNKNOWN_ACTION_TYPE"
# === ADR-037: Tier-based Repair (2026-03-29) ===
# Tier 分級動作映射
TIER_ACTIONS = {
1: ["restart_pod", "restart_container"], # 臨時修復
2: ["scale_up", "increase_memory", "adjust_limits"], # 緩解修復
3: ["apply_hotfix", "update_config", "patch_deployment"], # 根因修復
4: ["create_issue", "notify_team", "schedule_fix"], # 架構修復
}
async def determine_repair_tier(
self,
anomaly_key: str,
frequency: AnomalyFrequency,
) -> int:
"""
根據頻率決定修復 Tier (ADR-037)
統帥指示 (2026-03-29):
- "重啟只是治標,不是治本!太常發生的異常必須徹底解決"
- 根據異常頻率和修復歷史決定應該嘗試的修復層級
Returns:
1: 臨時修復 (重啟)
2: 緩解修復 (擴容)
3: 根因修復 (配置變更)
4: 架構修復 (需開發)
"""
# 取得修復歷史
counter = get_anomaly_counter()
stats = await counter.get_all_repair_stats(anomaly_key)
# 計算重啟次數
restart_count = stats.get("restart_pod", {}).get("total", 0)
restart_count += stats.get("restart_container", {}).get("total", 0)
# Tier 決策邏輯
if frequency.permanent_fix_applied:
# 已有永久修復但仍出問題 → 需架構級修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=4,
reason="permanent_fix_still_failing",
)
return 4
if frequency.escalation_level == "PERMANENT_FIX":
# 24h 內 ≥10 次 → 根因修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=3,
reason="escalation_permanent_fix",
)
return 3
if frequency.escalation_level == "ESCALATE":
# 24h 內 ≥5 次 → 緩解修復
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=2,
reason="escalation_escalate",
)
return 2
if restart_count >= 2:
# 已重啟 2 次 → 升級到緩解
logger.info(
"tier_decision",
anomaly_key=anomaly_key,
tier=2,
reason=f"restart_count_{restart_count}",
)
return 2
# 預設臨時修復
return 1
def get_tier_actions(self, tier: int) -> list[str]:
"""
根據 Tier 返回可用修復動作 (ADR-037)
"""
return self.TIER_ACTIONS.get(tier, self.TIER_ACTIONS[1])
async def record_repair_result(
self,
anomaly_key: str,
action: str,
success: bool,
tier: int = 1,
) -> None:
"""
記錄修復結果到 AnomalyCounter (ADR-037)
Args:
anomaly_key: 異常 key
action: 修復動作
success: 是否成功
tier: 修復 Tier
"""
counter = get_anomaly_counter()
await counter.record_repair_attempt(anomaly_key, action, success)
# 如果是 Tier 3 永久修復成功,標記已套用
if tier >= 3 and success:
await counter.mark_permanent_fix_applied(
anomaly_key=anomaly_key,
fix_description=f"Tier {tier} repair: {action}",
)
logger.info(
"repair_result_recorded",
anomaly_key=anomaly_key,
action=action,
success=success,
tier=tier,
)
# =============================================================================
# Singleton

View File

@@ -408,6 +408,7 @@ class IncidentService:
async def create_incident_from_signal(
self,
signal_data: dict[str, Any],
frequency_stats: dict[str, Any] | None = None,
) -> Incident | None:
"""
從 Signal 建立 Incident 並雙層寫入
@@ -418,8 +419,12 @@ class IncidentService:
3. 寫入 Episodic Memory (PostgreSQL) - 永久保留
4. 標記 persisted_to_pg = True
Phase 21 (ADR-037) 擴展:
5. 含異常頻率統計 (用於 Tier 分級修復策略)
Args:
signal_data: 從 Redis Stream 收到的 Signal 資料
frequency_stats: ADR-037 異常頻率統計 (可選)
Returns:
Incident | None: 成功返回 Incident失敗返回 None
@@ -436,11 +441,27 @@ class IncidentService:
fingerprint=signal_data.get("fingerprint"),
)
# 2. 建立 Incident
# 2. 建立 Incident (含頻率統計)
# ADR-037: 統帥指示「重啟只是治標,太常發生的異常必須徹底解決」
from src.models.incident import IncidentFrequencyStats
freq_stats = None
if frequency_stats:
freq_stats = IncidentFrequencyStats(
anomaly_key=frequency_stats.get("anomaly_key", "unknown"),
count_1h=frequency_stats.get("count_1h", 0),
count_24h=frequency_stats.get("count_24h", 0),
count_7d=frequency_stats.get("count_7d", 0),
count_30d=frequency_stats.get("count_30d", 0),
escalation_level=frequency_stats.get("escalation_level"),
auto_repair_count=frequency_stats.get("auto_repair_count", 0),
)
incident = Incident(
severity=signal.severity,
signals=[signal],
affected_services=[signal_data.get("target", "unknown")],
frequency_stats=freq_stats,
)
logger.info(

View File

@@ -34,7 +34,6 @@ from src.core.telemetry import get_tracer # 2026-03-29 ogt: P1-2 OTEL 追蹤
from src.models.nvidia import (
NvidiaProviderResult,
NvidiaResponse,
NvidiaUsage,
ToolCallValidationResult,
ToolDefinition,
)

View File

@@ -1004,7 +1004,7 @@ class OpenClawService:
# Step 2.5: 2026-03-29 ogt - 強制 confidence 必須由 LLM 輸出
# 如果 LLM 沒有輸出 confidence強制設為 0.5 並標記為 COLLAB
if "confidence" not in data or not isinstance(data["confidence"], (int, float)):
if "confidence" not in data or not isinstance(data["confidence"], int | float):
logger.warning(
"llm_missing_confidence",
raw_confidence=data.get("confidence"),

View File

@@ -61,6 +61,8 @@ class SentryService:
self,
endpoint: str,
params: dict[str, Any] | None = None,
method: str = "GET",
json_data: dict[str, Any] | None = None,
) -> dict | list | None:
"""
發送 Sentry API 請求
@@ -68,9 +70,13 @@ class SentryService:
Args:
endpoint: API 端點 (不含 /api/0/ 前綴)
params: 查詢參數
method: HTTP 方法 (GET, POST, PUT, DELETE)
json_data: POST/PUT 請求的 JSON body
Returns:
JSON 回應,失敗返回 None
變更: 2026-03-29 v1.1 - 支援 POST 方法 (Wave A.1/A.4 Sentry Comment)
"""
headers = {}
if self.auth_token:
@@ -80,9 +86,17 @@ class SentryService:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(url, headers=headers, params=params)
if method == "GET":
response = await client.get(url, headers=headers, params=params)
elif method == "POST":
response = await client.post(
url, headers=headers, params=params, json=json_data
)
else:
logger.error("sentry_api_unsupported_method", method=method)
return None
if response.status_code == 200:
if response.status_code in (200, 201):
return response.json()
elif response.status_code == 401:
logger.warning("sentry_api_unauthorized", endpoint=endpoint)
@@ -92,6 +106,7 @@ class SentryService:
"sentry_api_error",
status_code=response.status_code,
endpoint=endpoint,
response_text=response.text[:200],
)
return None
@@ -188,6 +203,48 @@ class SentryService:
return await self._request(f"issues/{issue_id}/events/", params=params)
async def post_issue_comment(
self,
issue_id: str,
text: str,
) -> dict | None:
"""
發送 Issue Comment (AI 分析回寫)
Args:
issue_id: Sentry Issue ID
text: Markdown 格式評論內容
Returns:
成功返回 comment dict失敗返回 None
變更: 2026-03-29 v1.1 - Wave A.4 Sentry Comment 回寫 (ADR-037)
"""
if not self.auth_token:
logger.warning(
"sentry_comment_skipped",
issue_id=issue_id,
reason="SENTRY_AUTH_TOKEN not configured",
)
return None
result = await self._request(
f"issues/{issue_id}/comments/",
method="POST",
json_data={"text": text},
)
if result:
logger.info(
"sentry_comment_posted",
issue_id=issue_id,
comment_id=result.get("id"),
)
else:
logger.error("sentry_comment_failed", issue_id=issue_id)
return result
# =========================================================================
# Session Replay APIs (2026-03-29 Phase 19 UX 監控)
# =========================================================================

View File

@@ -2,23 +2,33 @@
Stats Service - Phase 17 P0 Router 層違規修復
=============================================
封裝統計 API 的快取邏輯,消除 Router 層直接存取 Redis。
封裝統計 API 的快取邏輯與資料庫查詢,消除 Router 層直接存取 Redis/DB
功能:
- 快取包裝器 (Redis)
- 統計計算 (透過 Repository)
- 統計計算 (透過 SQLAlchemy)
符合 leWOOOgo 積木化規範:
- Router -> Service -> Redis/Repository
@author Claude Code (首席架構師)
@version 2.0.0
@date 2026-03-28 (台北時間)
@see feedback_lewooogo_modular_enforcement.md
"""
import json
from collections.abc import Callable, Coroutine
from typing import Any
from datetime import datetime, timedelta
from typing import Any, Protocol, runtime_checkable
import structlog
from sqlalchemy import func, select
from src.core.redis_client import get_redis
from src.db.base import get_db_context
from src.db.models import IncidentRecord
from src.models.incident import IncidentStatus
logger = structlog.get_logger(__name__)
@@ -26,13 +36,72 @@ logger = structlog.get_logger(__name__)
STATS_CACHE_TTL = 300 # 5 分鐘
# =============================================================================
# Protocol (Interface)
# =============================================================================
@runtime_checkable
class IStatsService(Protocol):
"""
統計服務介面
Phase 17 P1: 定義 Protocol 供依賴注入
"""
async def get_incident_summary(
self, days: int = 30
) -> dict[str, Any]:
"""取得事件總覽統計"""
...
async def get_resolution_stats(
self, days: int = 30
) -> dict[str, Any]:
"""取得解決時間統計"""
...
async def get_ai_performance(
self, days: int = 30
) -> dict[str, Any]:
"""取得 AI 效能統計"""
...
async def get_affected_services(
self, days: int = 30, limit: int = 10
) -> list[dict[str, Any]]:
"""取得受影響服務排名"""
...
async def get_incident_trends(
self, days: int = 30, period: str = "daily"
) -> dict[str, Any]:
"""取得事件趨勢"""
...
async def get_feedback_summary(
self, days: int = 30
) -> dict[str, Any]:
"""取得人類回饋摘要"""
...
# =============================================================================
# Implementation
# =============================================================================
class StatsService:
"""
統計服務
統計服務實作
封裝統計 API 的快取邏輯
封裝統計 API 的快取邏輯與資料庫查詢
"""
# -------------------------------------------------------------------------
# 快取相關
# -------------------------------------------------------------------------
async def get_cached_or_compute(
self,
cache_key: str,
@@ -43,14 +112,6 @@ class StatsService:
快取包裝器: 先查 Redis沒有則計算並快取
Phase 17: 從 Router 層遷移至 Service 層
Args:
cache_key: Redis key
compute_fn: 計算函數 (async callable)
ttl: 快取時間 (秒)
Returns:
快取或計算結果
"""
redis_client = get_redis()
@@ -76,15 +137,7 @@ class StatsService:
return result
async def invalidate_cache(self, cache_key: str) -> bool:
"""
清除指定快取
Args:
cache_key: Redis key
Returns:
是否成功清除
"""
"""清除指定快取"""
redis_client = get_redis()
try:
await redis_client.delete(cache_key)
@@ -94,9 +147,378 @@ class StatsService:
logger.warning("stats_cache_invalidate_error", key=cache_key, error=str(e))
return False
# -------------------------------------------------------------------------
# 統計查詢 (Phase 17 P1: 從 Router 層遷移)
# -------------------------------------------------------------------------
async def get_incident_summary(self, days: int = 30) -> dict[str, Any]:
"""
取得事件總覽統計
包含: 總事件數、狀態分佈、嚴重度分佈、解決率
"""
cache_key = f"stats:incident_summary:{days}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
# 總數
total_result = await db.execute(
select(func.count(IncidentRecord.incident_id)).where(
IncidentRecord.created_at >= since
)
)
total = total_result.scalar() or 0
# 狀態分佈
status_result = await db.execute(
select(IncidentRecord.status, func.count(IncidentRecord.incident_id))
.where(IncidentRecord.created_at >= since)
.group_by(IncidentRecord.status)
)
status_dist = [
{"status": str(row[0]), "count": row[1]}
for row in status_result.all()
]
# 嚴重度分佈
severity_result = await db.execute(
select(IncidentRecord.severity, func.count(IncidentRecord.incident_id))
.where(IncidentRecord.created_at >= since)
.group_by(IncidentRecord.severity)
)
severity_dist = [
{"severity": str(row[0]), "count": row[1]}
for row in severity_result.all()
]
# 解決率
resolved_result = await db.execute(
select(func.count(IncidentRecord.incident_id)).where(
IncidentRecord.created_at >= since,
IncidentRecord.status.in_(
[IncidentStatus.RESOLVED, IncidentStatus.CLOSED]
),
)
)
resolved_count = resolved_result.scalar() or 0
resolved_rate = (resolved_count / total * 100) if total > 0 else 0.0
# 平均告警聚合數
signals_result = await db.execute(
select(func.avg(func.json_array_length(IncidentRecord.signals))).where(
IncidentRecord.created_at >= since
)
)
avg_signals = signals_result.scalar() or 0.0
logger.info(
"stats_incident_summary",
total=total,
resolved_rate=resolved_rate,
days=days,
)
return {
"total_incidents": total,
"status_distribution": status_dist,
"severity_distribution": severity_dist,
"resolved_rate": round(resolved_rate, 2),
"avg_signals_per_incident": round(float(avg_signals), 2),
}
return await self.get_cached_or_compute(cache_key, compute)
async def get_resolution_stats(self, days: int = 30) -> dict[str, Any]:
"""
取得解決時間統計
計算: 平均、P50、P95、最快、最慢解決時間
"""
cache_key = f"stats:resolution:{days}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
IncidentRecord.created_at,
IncidentRecord.resolved_at,
).where(
IncidentRecord.created_at >= since,
IncidentRecord.resolved_at.isnot(None),
)
)
rows = result.all()
if not rows:
return {
"avg_minutes": None,
"p50_minutes": None,
"p95_minutes": None,
"fastest_minutes": None,
"slowest_minutes": None,
"sample_size": 0,
}
durations = []
for row in rows:
if row.resolved_at and row.created_at:
delta = row.resolved_at - row.created_at
durations.append(delta.total_seconds() / 60)
if not durations:
return {
"avg_minutes": None,
"p50_minutes": None,
"p95_minutes": None,
"fastest_minutes": None,
"slowest_minutes": None,
"sample_size": 0,
}
durations.sort()
n = len(durations)
return {
"avg_minutes": round(sum(durations) / n, 2),
"p50_minutes": round(durations[n // 2], 2),
"p95_minutes": round(durations[min(int(n * 0.95), n - 1)], 2),
"fastest_minutes": round(min(durations), 2),
"slowest_minutes": round(max(durations), 2),
"sample_size": n,
}
return await self.get_cached_or_compute(cache_key, compute)
async def get_ai_performance(self, days: int = 30) -> dict[str, Any]:
"""
取得 AI 提案效能統計
評估: 提案執行率、成功率、有效性評分
"""
cache_key = f"stats:ai_performance:{days}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(IncidentRecord.outcome).where(
IncidentRecord.created_at >= since,
IncidentRecord.outcome.isnot(None),
)
)
outcomes = [row[0] for row in result.all() if row[0]]
total = len(outcomes)
executed = sum(1 for o in outcomes if o.get("proposal_executed"))
success = sum(
1 for o in outcomes if o.get("proposal_executed") and o.get("execution_success")
)
effectiveness_dist: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
scores = []
for o in outcomes:
score = o.get("effectiveness_score")
if score and 1 <= score <= 5:
effectiveness_dist[score] += 1
scores.append(score)
avg_effectiveness = sum(scores) / len(scores) if scores else None
return {
"total_proposals": total,
"executed_count": executed,
"execution_rate": round((executed / total * 100) if total > 0 else 0, 2),
"success_count": success,
"success_rate": round((success / executed * 100) if executed > 0 else 0, 2),
"avg_effectiveness": round(avg_effectiveness, 2) if avg_effectiveness else None,
"effectiveness_distribution": effectiveness_dist,
}
return await self.get_cached_or_compute(cache_key, compute)
async def get_affected_services(
self, days: int = 30, limit: int = 10
) -> list[dict[str, Any]]:
"""
取得最常受影響的服務排名
"""
cache_key = f"stats:affected_services:{days}:{limit}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(
IncidentRecord.affected_services,
IncidentRecord.severity,
).where(IncidentRecord.created_at >= since)
)
service_stats: dict[str, dict[str, Any]] = {}
for row in result.all():
services = row[0] or []
severity = str(row[1])
for svc in services:
if svc not in service_stats:
service_stats[svc] = {"count": 0, "severity": {}}
service_stats[svc]["count"] += 1
service_stats[svc]["severity"][severity] = (
service_stats[svc]["severity"].get(severity, 0) + 1
)
sorted_services = sorted(
service_stats.items(), key=lambda x: x[1]["count"], reverse=True
)[:limit]
return {
"services": [
{
"service": svc,
"incident_count": stats["count"],
"severity_breakdown": stats["severity"],
}
for svc, stats in sorted_services
]
}
result = await self.get_cached_or_compute(cache_key, compute)
return result.get("services", [])
async def get_incident_trends(
self, days: int = 30, period: str = "daily"
) -> dict[str, Any]:
"""
取得事件趨勢數據 (SQL GROUP BY 優化版)
"""
cache_key = f"stats:incident_trends:{days}:{period}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
trunc_unit = {"daily": "day", "weekly": "week", "monthly": "month"}.get(
period, "day"
)
result = await db.execute(
select(
func.date_trunc(trunc_unit, IncidentRecord.created_at).label("period"),
func.count(IncidentRecord.incident_id).label("count"),
)
.where(IncidentRecord.created_at >= since)
.group_by(func.date_trunc(trunc_unit, IncidentRecord.created_at))
.order_by(func.date_trunc(trunc_unit, IncidentRecord.created_at))
)
rows = result.all()
trend_data = []
for row in rows:
if row.period:
if period == "daily":
date_str = row.period.strftime("%Y-%m-%d")
elif period == "weekly":
date_str = row.period.strftime("%Y-W%W")
else:
date_str = row.period.strftime("%Y-%m")
trend_data.append({"date": date_str, "count": row.count})
logger.info(
"stats_incident_trends",
period=period,
days=days,
data_points=len(trend_data),
)
return {"period": period, "data": trend_data}
return await self.get_cached_or_compute(cache_key, compute)
async def get_feedback_summary(self, days: int = 30) -> dict[str, Any]:
"""
取得人類回饋統計
"""
cache_key = f"stats:feedback_summary:{days}"
async def compute() -> dict[str, Any]:
async with get_db_context() as db:
since = datetime.utcnow() - timedelta(days=days)
result = await db.execute(
select(IncidentRecord.outcome).where(
IncidentRecord.created_at >= since,
IncidentRecord.outcome.isnot(None),
)
)
outcomes = [row[0] for row in result.all() if row[0]]
positive = 0
neutral = 0
negative = 0
themes: dict[str, int] = {}
for o in outcomes:
score = o.get("effectiveness_score") or o.get("feedback_score")
if score:
if score >= 4:
positive += 1
elif score == 3:
neutral += 1
else:
negative += 1
notes = o.get("learning_notes") or o.get("notes") or ""
if notes:
notes_lower = notes.lower()
theme_keywords = {
"timeout": ["timeout", "超時", "timed out", "deadline"],
"latency": ["latency", "延遲", "slow", "", "p99", "p95"],
"memory": ["memory", "記憶體", "oom", "heap", "內存"],
"cpu": ["cpu", "處理器", "high load", "負載"],
"network": ["network", "網路", "dns", "connection refused"],
"connection": ["connection", "連線", "socket", "tcp"],
"disk": ["disk", "磁碟", "storage", "io", "iops"],
"database": ["database", "資料庫", "db", "query", "deadlock"],
"pod": ["pod", "container", "restart", "crashloop"],
"scaling": ["scale", "擴容", "replica", "hpa"],
"error": ["error", "錯誤", "exception", "fail"],
"config": ["config", "配置", "env", "secret"],
}
for theme, keywords in theme_keywords.items():
if any(kw in notes_lower for kw in keywords):
themes[theme] = themes.get(theme, 0) + 1
sorted_themes = sorted(themes.items(), key=lambda x: x[1], reverse=True)[:5]
common_themes = [t[0] for t in sorted_themes]
total = positive + neutral + negative
logger.info(
"stats_feedback_summary",
total=total,
positive=positive,
negative=negative,
days=days,
)
return {
"total_feedback": total,
"positive_count": positive,
"neutral_count": neutral,
"negative_count": negative,
"common_themes": common_themes,
}
return await self.get_cached_or_compute(cache_key, compute)
# =============================================================================
# Singleton
# Dependency Injection
# =============================================================================
_stats_service: StatsService | None = None