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:
OG T
2026-03-25 00:09:44 +08:00
parent 41bd213a8c
commit 3b8638b350
149 changed files with 454 additions and 7 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"412c1507-44d4-4702-bb80-f37e97b804a7","pid":5408,"acquiredAt":1774326092203}

View File

@@ -370,7 +370,44 @@
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 最近 20 行 ===\"\" && tail -20 /tmp/sentry-install.log')",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 關鍵階段 ===\"\" && grep -E \"\"^▶|✓|Error|Creating|Starting|Building|DONE\"\" /tmp/sentry-install.log | tail -30')",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'echo \"\"=== 日誌行數 ===\"\" && wc -l /tmp/sentry-install.log && echo \"\"\"\" && echo \"\"=== 最近關鍵階段 ===\"\" && grep -E \"\"^▶|✓|Error|Creating|Starting|DONE|Completed|success\"\" /tmp/sentry-install.log | tail -25')",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'grep -E \"\"^▶|✓|Error|Completed|success|fail\"\" /tmp/sentry-install.log | tail -15')"
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.110 'grep -E \"\"^▶|✓|Error|Completed|success|fail\"\" /tmp/sentry-install.log | tail -15')",
"Bash(redis-cli -h 192.168.0.188 -p 6380 KEYS incident:*)",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/momo-pro/monitoring/alertmanager.yml 2>/dev/null || cat /etc/alertmanager/alertmanager.yml 2>/dev/null || echo ''Config not found''\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"docker logs clawbot --tail 30 2>&1\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"docker logs clawbot --tail 20 2>&1 | grep -iE ''telegram|send|alert|incident|error''\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/clawbot-v5/.env | grep -E ''TELEGRAM|TG_'' | head -5\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"cat /home/ollama/clawbot-v5/.env | grep -E ''REDIS|POSTGRES|DATABASE'' | head -5\")",
"Bash(ssh ollama@192.168.0.188 'curl -s \"\"http://localhost:9093/api/v2/alerts?active=true\"\" | python3 -c \"\"import sys,json; alerts=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Active alerts: {len\\(alerts\\)}\\\\\"\"\\)\"\"')",
"Bash(ssh ollama@192.168.0.188 'curl -s \"\"http://localhost:9093/api/v2/alerts\"\" | python3 -c \"\"import sys,json; alerts=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Total alerts: {len\\(alerts\\)}\\\\\"\"\\); [print\\(a[\\\\\"\"labels\\\\\"\"][\\\\\"\"alertname\\\\\"\"]\\) for a in alerts[:5]]\"\"')",
"Bash(ssh ollama@192.168.0.188 'redis-cli -p 6380 -n 0 GET incident:INC-20260324-36AF55 | python3 -c \"\"import sys,json; d=json.load\\(sys.stdin\\); print\\(f\\\\\"\"Status: {d.get\\(\\\\\"\"status\\\\\"\"\\)}\\\\\"\"\\); print\\(f\\\\\"\"message_id: {d.get\\(\\\\\"\"message_id\\\\\"\", \\\\\"\"NONE\\\\\"\"\\)}\\\\\"\"\\); print\\(f\\\\\"\"chat_id: {d.get\\(\\\\\"\"chat_id\\\\\"\", \\\\\"\"NONE\\\\\"\"\\)}\\\\\"\"\\)\"\"')",
"Bash(ssh ollama@192.168.0.188 'redis-cli -p 6380 -n 0 GET incident:INC-20260324-36AF55 | python3 -c \"\"import sys,json; d=json.load\\(sys.stdin\\); print\\(f\\\\\"\"status: {d.get\\('status'\\)}\\\\\"\"\\); print\\(f\\\\\"\"message_id: {d.get\\('message_id'\\)}\\\\\"\"\\); print\\(f\\\\\"\"created_at: {d.get\\('created_at'\\)}\\\\\"\"\\)\"\"')",
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *approval*)",
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *incident*)",
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *pending*)",
"Bash(redis-cli -h 192.168.0.188 -p 6380 -n 0 KEYS *)",
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl get pods -n awoooi-prod -o wide)",
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].image}')",
"Bash(kubectl --kubeconfig=/Users/ogt/awoooi/k3s-prod.yaml get deployment awoooi-api -n awoooi-prod -o jsonpath='{.spec.template.spec.containers[0].image}')",
"Bash(python3 -c \":*)",
"Bash(/tmp/awoooi-tg-secret.yaml:*)",
"Bash(KUBECONFIG=/Users/ogt/awoooi/k3s-prod.yaml kubectl apply -f /tmp/awoooi-tg-secret.yaml)",
"Bash(for pod:*)",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no wooo@192.168.0.188 \"curl -fsSL https://ollama.com/install.sh | sh\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no -o PreferredAuthentications=password wooo@192.168.0.188 \"echo connected && ollama --version\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no -o PreferredAuthentications=password ollama@192.168.0.188 \"curl -fsSL https://ollama.com/install.sh | sh\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S curl -fsSL https://ollama.com/install.sh | sudo -S sh\")",
"Bash(sshpass -p '0936223270' ssh -o StrictHostKeyChecking=no ollama@192.168.0.188 \"ollama --version\")",
"Bash(__NEW_LINE_95e9df111552805b__ echo:*)",
"Bash(sshpass -p '0936223270' scp /Users/ogt/awoooi/k8s/nginx/awoooi-prod.conf ollama@192.168.0.188:/tmp/awoooi-prod.conf)",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S cp /tmp/awoooi-prod.conf /etc/nginx/conf.d/awoooi-prod.conf && echo ''0936223270'' | sudo -S nginx -t 2>&1\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S ls -la /etc/nginx/ssl/ 2>/dev/null || echo ''No ssl dir''; echo ''0936223270'' | sudo -S ls -la /etc/nginx/conf.d/ 2>/dev/null | head -10\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S grep -r ''ssl_certificate'' /etc/nginx/ 2>/dev/null | head -5\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S grep -A 20 ''server_name awoooi'' /etc/nginx/sites-enabled/all-sites.conf 2>/dev/null | head -30\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S ls -la /etc/nginx/sites-enabled/ 2>/dev/null\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S cat /etc/nginx/sites-available/awoooi.wooo.work.conf 2>/dev/null\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S rm /etc/nginx/conf.d/awoooi-prod.conf && echo ''0936223270'' | sudo -S nginx -t 2>&1\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S nginx -s reload 2>&1\")",
"Bash(sshpass -p '0936223270' ssh ollama@192.168.0.188 \"echo ''0936223270'' | sudo -S systemctl reload nginx 2>&1\")"
],
"deny": [
"Bash(rm -rf *)",

Submodule .claude/worktrees/reverent-gauss added at 9f353343c9

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

View File

@@ -622,3 +622,153 @@ async def debug_resolve_incident(incident_id: str) -> dict[str, Any]:
"updates": {"redis": redis_updated, "db": db_updated},
"error": error_msg,
}
# =============================================================================
# POST /api/v1/incidents/sync-from-approvals - 補建 Incidents
# =============================================================================
class SyncResult(BaseModel):
"""同步結果"""
synced: int
skipped: int
errors: list[str]
details: list[dict[str, Any]]
@router.post(
"/sync-from-approvals",
response_model=SyncResult,
summary="從現有 Approvals 補建 Incidents",
description="""
修復歷史數據:為沒有對應 Incident 的 Approvals 補建。
用途:
- 修復 b645981 之前創建的 Approvals無 Incident
- 確保「活躍事件」與「待簽核」數量一致
""",
)
async def sync_incidents_from_approvals() -> SyncResult:
"""
掃描所有 pending Approvals為缺少 Incident 的補建
"""
from uuid import UUID
from src.models.incident import Signal
from src.services.approval_db import get_approval_service
redis_client = get_redis()
approval_service = get_approval_service()
synced = 0
skipped = 0
errors: list[str] = []
details: list[dict[str, Any]] = []
# 取得所有 pending approvals
pending_approvals = await approval_service.get_pending_approvals()
for approval in pending_approvals:
approval_id = str(approval.id)
# 檢查是否已有 Incident 關聯此 Approval
has_incident = False
cursor = 0
while True:
cursor, keys = await redis_client.scan(
cursor=cursor,
match="incident:INC-*",
count=100,
)
for key in keys:
try:
data = await redis_client.get(key)
if data:
incident = Incident.model_validate_json(data)
if UUID(approval_id) in incident.proposal_ids:
has_incident = True
break
except Exception:
pass
if has_incident or cursor == 0:
break
if has_incident:
skipped += 1
continue
# 補建 Incident
try:
# 映射 risk_level -> severity
risk_map = {
"critical": Severity.P0,
"high": Severity.P1,
"medium": Severity.P2,
"low": Severity.P3,
}
severity = risk_map.get(approval.risk_level.value.lower(), Severity.P2)
# 解析 action 取得資源名稱
action_parts = approval.action.split("|")
resource_name = action_parts[0].strip() if action_parts else "unknown"
signal = Signal(
alert_name="sync_from_approval",
severity=severity,
source="backfill",
fired_at=approval.created_at,
labels={"approval_id": approval_id},
annotations={"description": approval.description or ""},
)
incident = Incident(
status=IncidentStatus.INVESTIGATING,
severity=severity,
signals=[signal],
affected_services=[resource_name],
proposal_ids=[UUID(approval_id)],
created_at=approval.created_at,
updated_at=datetime.now(UTC),
)
key = f"incident:{incident.incident_id}"
await redis_client.set(
key,
incident.model_dump_json(),
ex=604800, # 7 days
)
synced += 1
details.append({
"approval_id": approval_id,
"incident_id": incident.incident_id,
"severity": severity.value,
})
logger.info(
"incident_synced_from_approval",
approval_id=approval_id,
incident_id=incident.incident_id,
)
except Exception as e:
errors.append(f"{approval_id}: {str(e)}")
logger.error(
"incident_sync_error",
approval_id=approval_id,
error=str(e),
)
logger.info(
"sync_from_approvals_completed",
synced=synced,
skipped=skipped,
errors=len(errors),
)
return SyncResult(
synced=synced,
skipped=skipped,
errors=errors,
details=details,
)

View 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}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Some files were not shown because too many files have changed in this diff Show More