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>
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"412c1507-44d4-4702-bb80-f37e97b804a7","pid":5408,"acquiredAt":1774326092203}
|
||||||
@@ -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 \"\"=== 最近 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|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 '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": [
|
"deny": [
|
||||||
"Bash(rm -rf *)",
|
"Bash(rm -rf *)",
|
||||||
|
|||||||
1
.claude/worktrees/reverent-gauss
Submodule
BIN
Gemini_Generated_Image_sxbfrvsxbfrvsxbf (2).png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
Gemini_Generated_Image_sxbfrvsxbfrvsxbf.png
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
@@ -622,3 +622,153 @@ async def debug_resolve_incident(incident_id: str) -> dict[str, Any]:
|
|||||||
"updates": {"redis": redis_updated, "db": db_updated},
|
"updates": {"redis": redis_updated, "db": db_updated},
|
||||||
"error": error_msg,
|
"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,
|
||||||
|
)
|
||||||
|
|||||||
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}")
|
||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 51 KiB |
BIN
apps/web/test-results/approval-card-check.png
Normal file
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/web/test-results/cpo102-fullpage-zh-TW.png
Normal file
|
After Width: | Height: | Size: 885 KiB |
BIN
apps/web/test-results/cpo102-hitl-section-zh-TW.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/web/test-results/cpo102-status-section-zh-TW.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/web/test-results/debug-error.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/web/test-results/phase3-hitl-section-final.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/test-results/phase3-rbac-permission-badge.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/web/test-results/phase3-user-role-display.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
apps/web/test-results/phase4-01-initial.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
apps/web/test-results/phase4-02-ai-decision.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
apps/web/test-results/phase4-03-approval-cards.png
Normal file
|
After Width: | Height: | Size: 154 KiB |