feat(api): Add sync-from-approvals endpoint for incident backfill
Fixes existing approvals created before b645981 that lack
corresponding incidents. Ensures "活躍事件" count matches
"待簽核" count.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
247
apps/api/src/api/v1/sentry_webhook.py
Normal file
247
apps/api/src/api/v1/sentry_webhook.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
AWOOOI API - Sentry Webhook Handler
|
||||
====================================
|
||||
接收 Sentry Issue Alert,轉發給 OpenClaw 進行 AI 分析
|
||||
|
||||
整合流程:
|
||||
1. Sentry Alert → AWOOOI API Webhook
|
||||
2. 組裝錯誤上下文
|
||||
3. 呼叫 OpenClaw Error Analyzer Agent
|
||||
4. 結果回寫 Sentry Issue Comment
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhooks/sentry", tags=["Sentry Webhook"])
|
||||
|
||||
# OpenClaw 配置
|
||||
OPENCLAW_URL = "http://192.168.0.188:8088"
|
||||
SENTRY_API_URL = "http://192.168.0.110:9000"
|
||||
|
||||
|
||||
class SentryIssuePayload(BaseModel):
|
||||
"""Sentry Issue Alert Payload (簡化版)"""
|
||||
action: str # created, resolved, etc.
|
||||
data: dict
|
||||
actor: dict | None = None
|
||||
|
||||
|
||||
class ErrorAnalysisResult(BaseModel):
|
||||
"""錯誤分析結果"""
|
||||
root_cause: str
|
||||
impact: str
|
||||
fix_suggestion: str
|
||||
prevention: str
|
||||
confidence: float
|
||||
analyzed_by: str # ollama, claude
|
||||
|
||||
|
||||
@router.post("/error")
|
||||
async def handle_sentry_error(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Sentry Issue Webhook Handler
|
||||
|
||||
觸發條件:
|
||||
- Issue 新建 (action=created)
|
||||
- Level: error 或 fatal
|
||||
|
||||
處理流程:
|
||||
1. 解析 Sentry payload
|
||||
2. 組裝錯誤上下文
|
||||
3. 背景執行 OpenClaw 分析
|
||||
4. 回寫 Sentry Comment
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
logger.info(f"Received Sentry webhook: action={payload.get('action')}")
|
||||
|
||||
# 只處理新建的 issue
|
||||
if payload.get("action") != "triggered":
|
||||
return {"status": "ignored", "reason": "action is not triggered"}
|
||||
|
||||
# 提取錯誤資訊
|
||||
issue_data = payload.get("data", {}).get("issue", {})
|
||||
event_data = payload.get("data", {}).get("event", {})
|
||||
|
||||
error_context = {
|
||||
"issue_id": issue_data.get("id"),
|
||||
"title": issue_data.get("title"),
|
||||
"culprit": issue_data.get("culprit"),
|
||||
"level": issue_data.get("level"),
|
||||
"first_seen": issue_data.get("firstSeen"),
|
||||
"count": issue_data.get("count"),
|
||||
"project": issue_data.get("project", {}).get("slug"),
|
||||
|
||||
# 事件詳情
|
||||
"message": event_data.get("message"),
|
||||
"platform": event_data.get("platform"),
|
||||
"tags": event_data.get("tags", []),
|
||||
|
||||
# Stack trace (最後5個 frame)
|
||||
"stacktrace": _extract_stacktrace(event_data),
|
||||
}
|
||||
|
||||
# 判斷是否需要 AI 分析
|
||||
level = issue_data.get("level", "error")
|
||||
if level not in ["error", "fatal"]:
|
||||
return {"status": "ignored", "reason": f"level {level} does not require analysis"}
|
||||
|
||||
# 背景執行分析
|
||||
background_tasks.add_task(
|
||||
analyze_and_comment,
|
||||
error_context=error_context,
|
||||
issue_id=issue_data.get("id"),
|
||||
project_slug=issue_data.get("project", {}).get("slug"),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "accepted",
|
||||
"issue_id": error_context["issue_id"],
|
||||
"message": "Analysis scheduled"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Sentry webhook processing failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _extract_stacktrace(event_data: dict) -> list[dict]:
|
||||
"""提取 Stack Trace (最後5個 frame)"""
|
||||
try:
|
||||
exception = event_data.get("exception", {})
|
||||
values = exception.get("values", [])
|
||||
if not values:
|
||||
return []
|
||||
|
||||
stacktrace = values[0].get("stacktrace", {})
|
||||
frames = stacktrace.get("frames", [])
|
||||
|
||||
# 取最後5個 frame,只保留關鍵資訊
|
||||
return [
|
||||
{
|
||||
"filename": f.get("filename"),
|
||||
"function": f.get("function"),
|
||||
"lineno": f.get("lineno"),
|
||||
"context_line": f.get("context_line"),
|
||||
}
|
||||
for f in frames[-5:]
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def analyze_and_comment(
|
||||
error_context: dict,
|
||||
issue_id: str,
|
||||
project_slug: str
|
||||
):
|
||||
"""
|
||||
背景任務:分析錯誤並回寫 Sentry Comment
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting AI analysis for issue {issue_id}")
|
||||
|
||||
# 呼叫 OpenClaw 分析
|
||||
analysis = await call_openclaw_analyzer(error_context)
|
||||
|
||||
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}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Analysis failed for issue {issue_id}: {e}")
|
||||
|
||||
|
||||
async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None:
|
||||
"""
|
||||
呼叫 OpenClaw Error Analyzer Agent
|
||||
|
||||
優先使用 Ollama (本地,零成本)
|
||||
Fallback: Claude (高嚴重性)
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{OPENCLAW_URL}/api/v1/analyze/error",
|
||||
json={
|
||||
"error_context": error_context,
|
||||
"prefer_local": True, # 優先 Ollama
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return ErrorAnalysisResult(**data)
|
||||
else:
|
||||
logger.warning(f"OpenClaw returned {response.status_code}")
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("OpenClaw analysis timeout, trying fallback prompt")
|
||||
# TODO: 直接呼叫 Ollama/Claude 作為 fallback
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"OpenClaw call failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def post_sentry_comment(
|
||||
project_slug: str,
|
||||
issue_id: str,
|
||||
analysis: ErrorAnalysisResult
|
||||
):
|
||||
"""
|
||||
回寫分析結果到 Sentry Issue Comment
|
||||
|
||||
API: POST /api/0/issues/{issue_id}/comments/
|
||||
"""
|
||||
comment_text = f"""## AI 錯誤分析 (by {analysis.analyzed_by})
|
||||
|
||||
**根本原因 (Root Cause)**
|
||||
{analysis.root_cause}
|
||||
|
||||
**影響範圍 (Impact)**
|
||||
{analysis.impact}
|
||||
|
||||
**建議修復 (Fix Suggestion)**
|
||||
{analysis.fix_suggestion}
|
||||
|
||||
**預防措施 (Prevention)**
|
||||
{analysis.prevention}
|
||||
|
||||
---
|
||||
*分析信心度: {analysis.confidence:.0%} | 分析時間: {datetime.now().isoformat()}*
|
||||
"""
|
||||
|
||||
try:
|
||||
# TODO: 需要 Sentry API Token
|
||||
# 目前先 log 出來
|
||||
logger.info(f"Would post comment to issue {issue_id}:\n{comment_text}")
|
||||
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# response = await client.post(
|
||||
# f"{SENTRY_API_URL}/api/0/issues/{issue_id}/comments/",
|
||||
# headers={"Authorization": f"Bearer {SENTRY_API_TOKEN}"},
|
||||
# json={"text": comment_text}
|
||||
# )
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to post Sentry comment: {e}")
|
||||
Reference in New Issue
Block a user