From 5c95b194b920c3f83d2c08bd0660a9885a4113db Mon Sep 17 00:00:00 2001 From: OG T Date: Wed, 25 Mar 2026 09:12:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20Sentry=20+=20OpenClaw=20+=20Telegr?= =?UTF-8?q?am=20=E6=95=B4=E5=90=88=20(Phase=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Telegram 告警發送 (send_sentry_telegram_alert) - 新增 Approval 創建 (create_sentry_approval) - 整合 analyze_and_comment 流程: 1. OpenClaw AI 分析 2. 建立 Approval 記錄 3. 發送 Telegram 告警 (含 Y/n 按鈕) 4. 回寫 Sentry Comment Memory: project_sentry_openclaw_v2.md Co-Authored-By: Claude Opus 4.5 --- apps/api/src/api/v1/sentry_webhook.py | 192 ++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 13 deletions(-) diff --git a/apps/api/src/api/v1/sentry_webhook.py b/apps/api/src/api/v1/sentry_webhook.py index 2f8b8f007..ed4c58918 100644 --- a/apps/api/src/api/v1/sentry_webhook.py +++ b/apps/api/src/api/v1/sentry_webhook.py @@ -14,15 +14,26 @@ AWOOOI API - Sentry Webhook Handler 🔴 HARD RULE: 時間顯示使用 Asia/Taipei (UTC+8) """ -import logging - -from src.utils.timezone import now_taipei, now_taipei_iso +import uuid import httpx +import structlog + from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from pydantic import BaseModel -logger = logging.getLogger(__name__) +from src.utils.timezone import now_taipei_iso +from src.services.telegram_gateway import get_telegram_gateway +from src.services.approval_db import get_approval_service +from src.models.approval import ( + ApprovalRequestCreate, + RiskLevel, + BlastRadius, + DataImpact, + DryRunCheck, +) + +logger = structlog.get_logger(__name__) router = APIRouter(prefix="/webhooks/sentry", tags=["Sentry Webhook"]) @@ -30,6 +41,14 @@ router = APIRouter(prefix="/webhooks/sentry", tags=["Sentry Webhook"]) OPENCLAW_URL = "http://192.168.0.188:8088" SENTRY_API_URL = "http://192.168.0.110:9000" +# Sentry Level → Risk Level 映射 +SENTRY_LEVEL_TO_RISK = { + "fatal": RiskLevel.CRITICAL, + "error": RiskLevel.HIGH, + "warning": RiskLevel.MEDIUM, + "info": RiskLevel.LOW, +} + class SentryIssuePayload(BaseModel): """Sentry Issue Alert Payload (簡化版)""" @@ -151,27 +170,50 @@ async def analyze_and_comment( project_slug: str ): """ - 背景任務:分析錯誤並回寫 Sentry Comment + 背景任務:分析錯誤 + Telegram 告警 + 建立 Approval + + Phase 10: Sentry + OpenClaw AI 整合 + 1. 呼叫 OpenClaw 分析 + 2. 發送 Telegram 告警 + 3. 建立 Approval 供人工審核 + 4. 回寫 Sentry Comment """ try: - logger.info(f"Starting AI analysis for issue {issue_id}") + logger.info("sentry_analysis_started", issue_id=issue_id) - # 呼叫 OpenClaw 分析 + # 1. 呼叫 OpenClaw 分析 analysis = await call_openclaw_analyzer(error_context) + # 2. 建立 Approval + approval_id = await create_sentry_approval( + error_context=error_context, + analysis=analysis, + ) + + # 3. 發送 Telegram 告警 + await send_sentry_telegram_alert( + error_context=error_context, + analysis=analysis, + approval_id=approval_id, + ) + + # 4. 回寫 Sentry Comment (如果分析成功) if analysis: - # 回寫 Sentry Comment await post_sentry_comment( project_slug=project_slug, issue_id=issue_id, analysis=analysis ) - logger.info(f"Analysis completed for issue {issue_id}") - else: - logger.warning(f"Analysis returned empty for issue {issue_id}") + + logger.info( + "sentry_analysis_completed", + issue_id=issue_id, + approval_id=approval_id, + has_analysis=analysis is not None, + ) except Exception as e: - logger.exception(f"Analysis failed for issue {issue_id}: {e}") + logger.exception("sentry_analysis_failed", issue_id=issue_id, error=str(e)) async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None: @@ -248,4 +290,128 @@ async def post_sentry_comment( # ) except Exception as e: - logger.exception(f"Failed to post Sentry comment: {e}") + logger.exception("sentry_comment_failed", issue_id=issue_id, error=str(e)) + + +# ============================================================================= +# Telegram 告警 (Phase 10: Sentry + OpenClaw) +# ============================================================================= + +async def send_sentry_telegram_alert( + error_context: dict, + analysis: ErrorAnalysisResult | None, + approval_id: str, +): + """ + 發送 Sentry 錯誤告警到 Telegram + + 格式 (project_sentry_openclaw_v2.md): + ═══════════════════════════ + 🐛 SENTRY 錯誤告警 + ═══════════════════════════ + 📍 components/dashboard.tsx:142 + ❌ TypeError: Cannot read property 'x' of null + 👥 影響用戶: 12 + 🔄 發生次數: 42 + ─────────────────────────── + 🧠 OpenClaw 分析: + 「這是 null check 問題...」 + ─────────────────────────── + [ Y 建立修復任務 ] [ n 忽略 ] + """ + try: + telegram = get_telegram_gateway() + await telegram.initialize() + + # 提取錯誤資訊 + title = error_context.get("title", "Unknown Error") + culprit = error_context.get("culprit", "unknown") + level = error_context.get("level", "error") + + # 發送 Sentry 告警卡片 (含 Y/n 按鈕) + await telegram.send_approval_card( + approval_id=approval_id, + risk_level="high" if level in ["fatal", "error"] else "medium", + resource_name=culprit, + root_cause=analysis.root_cause if analysis else title, + suggested_action=analysis.fix_suggestion if analysis else "待分析", + primary_responsibility="FE" if "tsx" in culprit or "jsx" in culprit else "BE", + confidence=analysis.confidence if analysis else 0.0, + namespace="sentry", + ) + + logger.info("sentry_telegram_sent", approval_id=approval_id) + + except Exception as e: + logger.exception("sentry_telegram_failed", error=str(e)) + + +# ============================================================================= +# Approval 創建 (Phase 10: Sentry + OpenClaw) +# ============================================================================= + +async def create_sentry_approval( + error_context: dict, + analysis: ErrorAnalysisResult | None, +) -> str: + """ + 為 Sentry 錯誤建立 Approval 記錄 + + Returns: + str: Approval ID + """ + try: + approval_service = get_approval_service() + + # 決定風險等級 + level = error_context.get("level", "error") + risk_level = SENTRY_LEVEL_TO_RISK.get(level, RiskLevel.MEDIUM) + + # 組裝 Approval 請求 + title = error_context.get("title", "Unknown Error") + culprit = error_context.get("culprit", "unknown") + project = error_context.get("project", "unknown") + issue_id = error_context.get("issue_id", "unknown") + + approval_request = ApprovalRequestCreate( + source="sentry", + alert_type=f"sentry_{level}", + target_resource=culprit, + namespace=project, + risk_level=risk_level, + root_cause=analysis.root_cause if analysis else title, + suggestion=analysis.fix_suggestion if analysis else "待 AI 分析", + blast_radius=BlastRadius.SERVICE, # Sentry 錯誤通常影響單一服務 + data_impact=DataImpact.READ_ONLY, + dry_run_check=DryRunCheck.PASSED, + llm_provider=analysis.analyzed_by if analysis else "pending", + llm_confidence=analysis.confidence if analysis else 0.0, + metadata={ + "sentry_issue_id": issue_id, + "sentry_project": project, + "error_count": error_context.get("count", 1), + "first_seen": error_context.get("first_seen"), + "stacktrace": error_context.get("stacktrace", []), + }, + ) + + # 創建 Approval + approval_id = str(uuid.uuid4()) + await approval_service.create_approval( + approval_id=approval_id, + request=approval_request, + ) + + logger.info( + "sentry_approval_created", + approval_id=approval_id, + issue_id=issue_id, + risk_level=risk_level.value, + ) + + return approval_id + + except Exception as e: + logger.exception("sentry_approval_creation_failed", error=str(e)) + # 即使創建失敗,也返回一個臨時 ID + return f"temp-{uuid.uuid4().hex[:8]}"