""" Metrics Service - 黃金指標服務層 ================================= Phase 17 技術債修復: Router 層違規抽取 職責: - 聚合 SignOz + DB 數據 - 計算健康狀態 (healthy/warning/critical) - 生成趨勢數據 設計原則: - Service 透過 Protocol 依賴 Repository (DI) - 業務邏輯集中於此層 - Router 只做 HTTP 轉發 版本: v1.0 建立: 2026-03-26 (台北時區) 建立者: Claude Code (Phase 17 技術債修復) """ from datetime import UTC, datetime from typing import Any import structlog from src.repositories.interfaces import IMetricsRepository from src.repositories.metrics_repository import get_metrics_repository from src.services.signoz_client import get_signoz_client logger = structlog.get_logger(__name__) # ============================================================================= # Data Classes # ============================================================================= class GoldMetricResult: """單一黃金指標結果""" def __init__( self, label: str, value: float | str, unit: str | None, trend: list[float], status: str, ) -> None: self.label = label self.value = value self.unit = unit self.trend = trend self.status = status class GoldMetricsResult: """黃金指標聚合結果""" def __init__( self, timestamp: datetime, service_name: str, metrics: list[GoldMetricResult], raw_data: dict[str, Any] | None = None, ) -> None: self.timestamp = timestamp self.service_name = service_name self.metrics = metrics self.raw_data = raw_data # ============================================================================= # MetricsService # ============================================================================= class MetricsService: """ 黃金指標服務 職責: 1. 從 SignOz 取得 RPS, Error Rate, P99 2. 從 Repository 取得 AI Success Rate 3. 計算健康狀態 4. 組合成統一的 GoldMetrics 回應 使用方式: service = MetricsService() result = await service.get_gold_metrics("awoooi-api", 10) """ def __init__( self, metrics_repo: IMetricsRepository | None = None, ) -> None: """ 依賴注入建構函數 Args: metrics_repo: Metrics Repository (預設使用 Singleton) """ self._metrics_repo = metrics_repo or get_metrics_repository() # ========================================================================= # Gold Metrics # ========================================================================= async def get_gold_metrics( self, service_name: str = "awoooi-api", time_window_minutes: int = 10, ) -> GoldMetricsResult: """ 獲取黃金指標 (Gold Metrics) 統帥鐵律: - 所有數據必須來自 SignOz 真實血脈 - AI Success 來自 AuditLog 真實統計 - 無數據時顯示 0,嚴禁造假 Returns: GoldMetricsResult with RPS, Error Rate, P99, AI Success """ logger.info( "gold_metrics_fetch", service=service_name, window_minutes=time_window_minutes, ) metrics_list: list[GoldMetricResult] = [] raw_data: dict[str, Any] = {} # ===================================================================== # 1. SignOz Gold Metrics (RPS, Error Rate, P99) # ===================================================================== signoz_metrics = await self._fetch_signoz_metrics( service_name, time_window_minutes, ) metrics_list.extend(signoz_metrics["metrics"]) raw_data.update(signoz_metrics["raw_data"]) # ===================================================================== # 2. AI Success Rate (from Repository) # ===================================================================== ai_metrics = await self._fetch_ai_success_metrics(hours=24) metrics_list.append(ai_metrics["metric"]) raw_data.update(ai_metrics["raw_data"]) return GoldMetricsResult( timestamp=datetime.now(UTC), service_name=service_name, metrics=metrics_list, raw_data=raw_data, ) async def _fetch_signoz_metrics( self, service_name: str, time_window_minutes: int, ) -> dict[str, Any]: """ 從 SignOz 取得 RPS, Error Rate, P99 統帥鐵律: SignOz 斷線時顯示 0,非假數據 """ metrics: list[GoldMetricResult] = [] raw_data: dict[str, Any] = {} try: signoz = get_signoz_client() gold = await signoz.get_gold_metrics( service_name=service_name, time_window_minutes=time_window_minutes, ) # RPS rps_status = self._calculate_rps_status(gold.rps) rps_trend = self._generate_simulated_trend(gold.rps) metrics.append(GoldMetricResult( label="RPS", value=round(gold.rps, 1), unit="req/s", trend=rps_trend, status=rps_status, )) # Error Rate error_status = self._calculate_error_status(gold.error_rate) error_trend = self._generate_simulated_trend(gold.error_rate) metrics.append(GoldMetricResult( label="Error Rate", value=round(gold.error_rate, 2), unit="%", trend=error_trend, status=error_status, )) # P99 Latency p99_status = self._calculate_latency_status(gold.p99_latency_ms) p99_trend = self._generate_simulated_trend(gold.p99_latency_ms) metrics.append(GoldMetricResult( label="P99 Latency", value=round(gold.p99_latency_ms, 0), unit="ms", trend=p99_trend, status=p99_status, )) raw_data["signoz"] = { "rps": gold.rps, "error_rate": gold.error_rate, "p99_latency_ms": gold.p99_latency_ms, "total_requests": gold.total_requests, "error_count": gold.error_count, } except Exception as e: logger.warning("signoz_metrics_error", error=str(e)) # 統帥鐵律: SignOz 斷線時顯示 0,非假數據 metrics.extend([ GoldMetricResult( label="RPS", value=0, unit="req/s", trend=[0] * 10, status="critical", ), GoldMetricResult( label="Error Rate", value=0, unit="%", trend=[0] * 10, status="critical", ), GoldMetricResult( label="P99 Latency", value=0, unit="ms", trend=[0] * 10, status="critical", ), ]) raw_data["signoz_error"] = str(e) return {"metrics": metrics, "raw_data": raw_data} async def _fetch_ai_success_metrics( self, hours: int = 24, ) -> dict[str, Any]: """ 從 Repository 取得 AI Success Rate 統帥鐵律: 若無數據,回傳真實的 0,嚴禁造假 """ # 從 Repository 取得數據 success_rate, executed, total = await self._metrics_repo.get_ai_success_rate( hours=hours, ) trend = await self._metrics_repo.get_ai_success_trend( hours=hours, points=10, ) ai_status = self._calculate_ai_success_status(success_rate) metric = GoldMetricResult( label="AI Success", value=round(success_rate, 1), unit="%", trend=trend, status=ai_status, ) raw_data = { "ai_success": { "rate": success_rate, "executed": executed, "total": total, "hours": hours, } } logger.info( "ai_success_rate_calculated", success_rate=success_rate, executed=executed, total=total, hours=hours, ) return {"metric": metric, "raw_data": raw_data} # ========================================================================= # Health Check # ========================================================================= async def check_health(self) -> dict[str, Any]: """ Metrics 子系統健康檢查 快速檢查 SignOz 連線狀態 """ try: signoz = get_signoz_client() results = await signoz._query_clickhouse("SELECT 1") clickhouse_ok = len(results) > 0 except Exception as e: clickhouse_ok = False logger.warning("clickhouse_health_check_failed", error=str(e)) return { "status": "healthy" if clickhouse_ok else "degraded", "clickhouse": "connected" if clickhouse_ok else "disconnected", "timestamp": datetime.now(UTC).isoformat(), } # ========================================================================= # Status Calculation Helpers # ========================================================================= @staticmethod def _calculate_rps_status(rps: float) -> str: """計算 RPS 健康狀態""" if rps < 1000: return "healthy" if rps < 5000: return "warning" return "critical" @staticmethod def _calculate_error_status(error_rate: float) -> str: """計算 Error Rate 健康狀態""" if error_rate < 1: return "healthy" if error_rate < 5: return "warning" return "critical" @staticmethod def _calculate_latency_status(p99_ms: float) -> str: """計算 P99 Latency 健康狀態""" if p99_ms < 200: return "healthy" if p99_ms < 500: return "warning" return "critical" @staticmethod def _calculate_ai_success_status(success_rate: float) -> str: """計算 AI Success Rate 健康狀態""" if success_rate >= 90: return "healthy" if success_rate >= 70: return "warning" return "critical" @staticmethod def _generate_simulated_trend(base_value: float, points: int = 10) -> list[float]: """ 生成模擬趨勢數據 (SignOz 不提供歷史數據時使用) 注意: 這是暫時方案,未來應從 SignOz 取得真實歷史數據 """ return [base_value * (0.9 + i * 0.02) for i in range(points)] # ============================================================================= # Singleton # ============================================================================= _metrics_service: MetricsService | None = None def get_metrics_service() -> MetricsService: """取得 MetricsService 實例 (Singleton)""" global _metrics_service if _metrics_service is None: _metrics_service = MetricsService() return _metrics_service