297 lines
9.7 KiB
Python
297 lines
9.7 KiB
Python
"""
|
||
Weekly Report Service - Phase 21.3 定期報告
|
||
============================================
|
||
|
||
整合 StatsService + K3sMonitorService,生成週報並推送 Telegram。
|
||
|
||
數據來源:
|
||
- StatsService (告警/AI 效能統計)
|
||
- K3sMonitorService (K3s 健康)
|
||
- Git API (開發活動)
|
||
|
||
符合 leWOOOgo 積木化規範:
|
||
- Service 層封裝所有邏輯
|
||
- 透過 DI 注入依賴
|
||
|
||
@author Claude Code (首席架構師)
|
||
@version 1.0.0
|
||
@date 2026-03-31 (台北時間)
|
||
@see ADR-041-periodic-reporting-architecture.md
|
||
"""
|
||
|
||
import asyncio
|
||
import subprocess
|
||
from datetime import datetime, timedelta
|
||
from typing import Protocol, runtime_checkable
|
||
|
||
import structlog
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from src.services.stats_service import StatsService, get_stats_service
|
||
from src.services.k3s_monitor_service import K3sMonitorService, get_k3s_monitor_service
|
||
from src.services.telegram_gateway import WeeklyReportMessage, get_telegram_gateway
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# 台北時區
|
||
TZ_TAIPEI = ZoneInfo("Asia/Taipei")
|
||
|
||
|
||
# =============================================================================
|
||
# Protocol (Interface)
|
||
# =============================================================================
|
||
|
||
|
||
@runtime_checkable
|
||
class IWeeklyReportService(Protocol):
|
||
"""
|
||
週報服務介面
|
||
|
||
Phase 21.3: 定義 Protocol 供依賴注入
|
||
"""
|
||
|
||
async def generate_report(self) -> WeeklyReportMessage:
|
||
"""生成週報"""
|
||
...
|
||
|
||
async def send_weekly_report(self) -> bool:
|
||
"""發送週報"""
|
||
...
|
||
|
||
|
||
# =============================================================================
|
||
# Implementation
|
||
# =============================================================================
|
||
|
||
|
||
class WeeklyReportService:
|
||
"""
|
||
週報服務實作
|
||
|
||
整合多個數據來源生成週報
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
stats_service: StatsService | None = None,
|
||
k3s_monitor: K3sMonitorService | None = None,
|
||
):
|
||
self._stats_service = stats_service or get_stats_service()
|
||
self._k3s_monitor = k3s_monitor or get_k3s_monitor_service()
|
||
|
||
def _get_week_range(self) -> tuple[str, datetime, datetime]:
|
||
"""取得本週範圍"""
|
||
now = datetime.now(TZ_TAIPEI)
|
||
# 找到本週一
|
||
monday = now - timedelta(days=now.weekday())
|
||
monday = monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
# 本週日
|
||
sunday = monday + timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||
|
||
# ISO 週次
|
||
week_num = now.isocalendar()[1]
|
||
week_range = f"{now.year}-W{week_num:02d}"
|
||
|
||
return week_range, monday, sunday
|
||
|
||
def _get_git_stats(self, since: datetime) -> tuple[int, int]:
|
||
"""取得 Git 統計 (commits, deploys)"""
|
||
try:
|
||
# 取得本週 commits 數量
|
||
since_str = since.strftime("%Y-%m-%d")
|
||
result = subprocess.run(
|
||
["git", "log", f"--since={since_str}", "--oneline"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
cwd="/app", # K8s 容器內的工作目錄
|
||
)
|
||
commits = len(result.stdout.strip().split("\n")) if result.stdout.strip() else 0
|
||
|
||
# 取得部署次數 (計算含 "deploy" 或 "CD" 的 commits)
|
||
result_deploy = subprocess.run(
|
||
["git", "log", f"--since={since_str}", "--oneline", "--grep=deploy", "--grep=CD", "-i"],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=10,
|
||
cwd="/app",
|
||
)
|
||
deploys = len(result_deploy.stdout.strip().split("\n")) if result_deploy.stdout.strip() else 0
|
||
|
||
return commits, deploys
|
||
except Exception as e:
|
||
logger.warning("git_stats_failed", error=str(e))
|
||
return 0, 0
|
||
|
||
async def generate_report(self) -> WeeklyReportMessage:
|
||
"""
|
||
生成週報
|
||
|
||
整合多個數據來源
|
||
"""
|
||
now = datetime.now(TZ_TAIPEI)
|
||
week_range, monday, sunday = self._get_week_range()
|
||
report_date = now.strftime("%Y-%m-%d %H:%M")
|
||
|
||
# 取得統計數據 (7 天)
|
||
try:
|
||
incident_summary = await self._stats_service.get_incident_summary(days=7)
|
||
resolution_stats = await self._stats_service.get_resolution_stats(days=7)
|
||
ai_performance = await self._stats_service.get_ai_performance(days=7)
|
||
except Exception as e:
|
||
logger.warning("stats_fetch_failed", error=str(e))
|
||
incident_summary = {}
|
||
resolution_stats = {}
|
||
ai_performance = {}
|
||
|
||
# 取得 K3s 狀態
|
||
try:
|
||
k3s_status = await self._k3s_monitor.collect_cluster_status()
|
||
except Exception as e:
|
||
logger.warning("k3s_fetch_failed", error=str(e))
|
||
k3s_status = None
|
||
|
||
# 取得 Git 統計
|
||
commits, deploys = self._get_git_stats(monday)
|
||
|
||
# 計算指標
|
||
total_incidents = incident_summary.get("total_incidents", 0)
|
||
resolved_rate = incident_summary.get("resolved_rate", 0.0)
|
||
|
||
# 從嚴重度分佈中取得 Critical 數量
|
||
severity_dist = incident_summary.get("severity_distribution", [])
|
||
critical_count = sum(
|
||
s.get("count", 0) for s in severity_dist
|
||
if s.get("severity", "").upper() in ["P0", "CRITICAL"]
|
||
)
|
||
|
||
# AI 效能
|
||
ai_proposals = ai_performance.get("total_proposals", 0)
|
||
ai_executed = ai_performance.get("executed_count", 0)
|
||
ai_success_rate = ai_performance.get("success_rate", 0.0)
|
||
|
||
# 平均回應時間
|
||
avg_response = resolution_stats.get("avg_minutes") or 0.0
|
||
|
||
# K3s 指標
|
||
k3s_uptime = 99.9 # 假設值,實際應從 Prometheus 取得
|
||
pod_restarts = k3s_status.pod_restart_48h if k3s_status else 0
|
||
hpa_events = 0 # 需要從 Prometheus 取得 HPA 事件
|
||
|
||
# 2026-04-07 Claude Code: Sprint 4 F1 — 取得處置分佈
|
||
disp_auto = disp_human = disp_manual = disp_cold = disp_total = 0
|
||
try:
|
||
from src.services.anomaly_counter import get_anomaly_counter
|
||
counter = get_anomaly_counter()
|
||
disp_summary, _ = await counter.get_all_disposition_stats()
|
||
disp_auto = disp_summary.get("auto_repair", 0)
|
||
disp_human = disp_summary.get("human_approved", 0)
|
||
disp_manual = disp_summary.get("manual_resolved", 0)
|
||
disp_cold = disp_summary.get("cold_start_trust", 0)
|
||
disp_total = disp_summary.get("total", 0)
|
||
except Exception as _disp_e:
|
||
logger.warning("weekly_report_disposition_failed", error=str(_disp_e))
|
||
|
||
# 組裝週報
|
||
report = WeeklyReportMessage(
|
||
week_range=week_range,
|
||
report_date=report_date,
|
||
alert_total=total_incidents,
|
||
alert_critical=critical_count,
|
||
alert_resolved=int(total_incidents * resolved_rate / 100) if total_incidents > 0 else 0,
|
||
resolved_rate=resolved_rate,
|
||
ai_proposal_count=ai_proposals,
|
||
ai_executed_count=ai_executed,
|
||
ai_success_rate=ai_success_rate,
|
||
avg_response_minutes=avg_response,
|
||
k3s_uptime_pct=k3s_uptime,
|
||
pod_restart_total=pod_restarts,
|
||
hpa_scale_events=hpa_events,
|
||
commits_count=commits,
|
||
deploy_count=deploys,
|
||
ai_cost_week=0.0, # 需要從 AI 成本追蹤取得
|
||
ai_tokens_week=0, # 需要從 AI 成本追蹤取得
|
||
disposition_auto=disp_auto,
|
||
disposition_human=disp_human,
|
||
disposition_manual=disp_manual,
|
||
disposition_cold_start=disp_cold,
|
||
disposition_total=disp_total,
|
||
)
|
||
|
||
logger.info(
|
||
"weekly_report_generated",
|
||
week=week_range,
|
||
alerts=total_incidents,
|
||
commits=commits,
|
||
)
|
||
|
||
return report
|
||
|
||
async def send_weekly_report(self) -> bool:
|
||
"""
|
||
發送週報
|
||
|
||
生成週報並推送到 Telegram
|
||
"""
|
||
try:
|
||
# 生成週報
|
||
report = await self.generate_report()
|
||
|
||
# 取得 Telegram Gateway
|
||
gateway = get_telegram_gateway()
|
||
if not gateway._initialized:
|
||
await gateway.initialize()
|
||
|
||
# 發送訊息
|
||
formatted = report.format()
|
||
result = await gateway.send_text(formatted)
|
||
|
||
if result:
|
||
logger.info("weekly_report_sent", week=report.week_range)
|
||
return True
|
||
else:
|
||
logger.error("weekly_report_failed", week=report.week_range)
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error("weekly_report_error", error=str(e))
|
||
return False
|
||
|
||
|
||
# =============================================================================
|
||
# Dependency Injection
|
||
# =============================================================================
|
||
|
||
_weekly_report_service: WeeklyReportService | None = None
|
||
|
||
|
||
def get_weekly_report_service() -> WeeklyReportService:
|
||
"""取得 WeeklyReportService 實例 (Singleton)"""
|
||
global _weekly_report_service
|
||
if _weekly_report_service is None:
|
||
_weekly_report_service = WeeklyReportService()
|
||
return _weekly_report_service
|
||
|
||
|
||
# =============================================================================
|
||
# CLI Entry Point (for CronJob)
|
||
# =============================================================================
|
||
|
||
|
||
async def main():
|
||
"""
|
||
CLI 入口點
|
||
|
||
供 K8s CronJob 調用:
|
||
python -m src.services.weekly_report_service
|
||
"""
|
||
service = get_weekly_report_service()
|
||
success = await service.send_weekly_report()
|
||
return 0 if success else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
exit_code = asyncio.run(main())
|
||
sys.exit(exit_code)
|