- 自動修復 import 排序、unused imports - 手動修復 raise from、isinstance union、unused variable - scripts/ 暫時保留 (非 CI 阻擋) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
14 KiB
Python
465 lines
14 KiB
Python
"""
|
||
Sentry Service - Sentry API 封裝
|
||
================================
|
||
Phase 10: Sentry + OpenClaw + UI 整合
|
||
|
||
遵循 leWOOOgo 積木化原則:
|
||
- Service 層負責外部 API 呼叫
|
||
- Router 層只做 HTTP 轉發
|
||
- 單一職責: 只處理 Sentry API 互動
|
||
|
||
版本: v1.0
|
||
建立: 2026-03-26 21:15 (台北時區)
|
||
建立者: Claude Code (P2 架構改善)
|
||
"""
|
||
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from src.core.config import settings
|
||
from src.core.logging import get_logger
|
||
|
||
logger = get_logger("awoooi.sentry")
|
||
|
||
|
||
class SentryService:
|
||
"""
|
||
Sentry API Service
|
||
|
||
職責:
|
||
1. 封裝 Sentry API 呼叫
|
||
2. 處理認證與錯誤
|
||
3. 提供類型化的資料存取
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str | None = None,
|
||
org: str | None = None,
|
||
project: str | None = None,
|
||
auth_token: str | None = None,
|
||
timeout: float = 10.0,
|
||
) -> None:
|
||
"""
|
||
初始化 Sentry Service
|
||
|
||
Args:
|
||
base_url: Sentry API URL (預設從 settings)
|
||
org: Sentry organization slug
|
||
project: Sentry project slug
|
||
auth_token: API auth token
|
||
timeout: 請求超時秒數
|
||
"""
|
||
self.base_url = base_url or settings.SENTRY_SELF_HOSTED_URL
|
||
self.org = org or settings.SENTRY_ORG
|
||
self.project = project or settings.SENTRY_PROJECT
|
||
self.auth_token = auth_token or settings.SENTRY_AUTH_TOKEN
|
||
self.timeout = timeout
|
||
|
||
async def _request(
|
||
self,
|
||
endpoint: str,
|
||
params: dict[str, Any] | None = None,
|
||
method: str = "GET",
|
||
json_data: dict[str, Any] | None = None,
|
||
) -> dict | list | None:
|
||
"""
|
||
發送 Sentry API 請求
|
||
|
||
Args:
|
||
endpoint: API 端點 (不含 /api/0/ 前綴)
|
||
params: 查詢參數
|
||
method: HTTP 方法 (GET, POST, PUT, DELETE)
|
||
json_data: POST/PUT 請求的 JSON body
|
||
|
||
Returns:
|
||
JSON 回應,失敗返回 None
|
||
|
||
變更: 2026-03-29 v1.1 - 支援 POST 方法 (Wave A.1/A.4 Sentry Comment)
|
||
"""
|
||
headers = {}
|
||
if self.auth_token:
|
||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||
|
||
url = f"{self.base_url}/api/0/{endpoint}"
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||
if method == "GET":
|
||
response = await client.get(url, headers=headers, params=params)
|
||
elif method == "POST":
|
||
response = await client.post(
|
||
url, headers=headers, params=params, json=json_data
|
||
)
|
||
else:
|
||
logger.error("sentry_api_unsupported_method", method=method)
|
||
return None
|
||
|
||
if response.status_code in (200, 201):
|
||
return response.json()
|
||
elif response.status_code == 401:
|
||
logger.warning("sentry_api_unauthorized", endpoint=endpoint)
|
||
return None
|
||
else:
|
||
logger.warning(
|
||
"sentry_api_error",
|
||
status_code=response.status_code,
|
||
endpoint=endpoint,
|
||
response_text=response.text[:200],
|
||
)
|
||
return None
|
||
|
||
except httpx.TimeoutException:
|
||
logger.error("sentry_api_timeout", endpoint=endpoint)
|
||
return None
|
||
except Exception as e:
|
||
logger.error("sentry_api_failed", endpoint=endpoint, error=str(e))
|
||
return None
|
||
|
||
# =========================================================================
|
||
# Organization APIs
|
||
# =========================================================================
|
||
|
||
async def list_projects(self) -> list[dict] | None:
|
||
"""取得組織內所有專案"""
|
||
return await self._request(f"organizations/{self.org}/projects/")
|
||
|
||
# =========================================================================
|
||
# Project APIs
|
||
# =========================================================================
|
||
|
||
async def list_issues(
|
||
self,
|
||
project: str | None = None,
|
||
query: str = "is:unresolved",
|
||
limit: int = 25,
|
||
cursor: str | None = None,
|
||
) -> list[dict] | None:
|
||
"""
|
||
列出專案 Issues
|
||
|
||
Args:
|
||
project: 專案 slug (預設使用設定值)
|
||
query: Sentry 搜尋語法
|
||
limit: 每頁數量
|
||
cursor: 分頁游標
|
||
"""
|
||
project_slug = project or self.project
|
||
params: dict[str, Any] = {"query": query, "limit": limit}
|
||
if cursor:
|
||
params["cursor"] = cursor
|
||
|
||
return await self._request(
|
||
f"projects/{self.org}/{project_slug}/issues/",
|
||
params=params,
|
||
)
|
||
|
||
async def get_project_stats(
|
||
self,
|
||
project: str | None = None,
|
||
stat: str = "received",
|
||
resolution: str = "1h",
|
||
) -> list | None:
|
||
"""
|
||
取得專案統計數據
|
||
|
||
Args:
|
||
project: 專案 slug
|
||
stat: 統計類型 (received, rejected, blacklisted)
|
||
resolution: 時間解析度 (1h, 1d, etc.)
|
||
"""
|
||
project_slug = project or self.project
|
||
return await self._request(
|
||
f"projects/{self.org}/{project_slug}/stats/",
|
||
params={"stat": stat, "resolution": resolution},
|
||
)
|
||
|
||
# =========================================================================
|
||
# Issue APIs
|
||
# =========================================================================
|
||
|
||
async def get_issue(self, issue_id: str) -> dict | None:
|
||
"""取得 Issue 詳情"""
|
||
return await self._request(f"issues/{issue_id}/")
|
||
|
||
async def get_issue_events(
|
||
self,
|
||
issue_id: str,
|
||
limit: int = 1,
|
||
full: bool = False,
|
||
) -> list[dict] | None:
|
||
"""
|
||
取得 Issue 事件
|
||
|
||
Args:
|
||
issue_id: Issue ID
|
||
limit: 事件數量
|
||
full: 是否包含完整堆疊
|
||
"""
|
||
params: dict[str, Any] = {"limit": limit}
|
||
if full:
|
||
params["full"] = "true"
|
||
|
||
return await self._request(f"issues/{issue_id}/events/", params=params)
|
||
|
||
async def post_issue_comment(
|
||
self,
|
||
issue_id: str,
|
||
text: str,
|
||
) -> dict | None:
|
||
"""
|
||
發送 Issue Comment (AI 分析回寫)
|
||
|
||
Args:
|
||
issue_id: Sentry Issue ID
|
||
text: Markdown 格式評論內容
|
||
|
||
Returns:
|
||
成功返回 comment dict,失敗返回 None
|
||
|
||
變更: 2026-03-29 v1.1 - Wave A.4 Sentry Comment 回寫 (ADR-037)
|
||
"""
|
||
if not self.auth_token:
|
||
logger.warning(
|
||
"sentry_comment_skipped",
|
||
issue_id=issue_id,
|
||
reason="SENTRY_AUTH_TOKEN not configured",
|
||
)
|
||
return None
|
||
|
||
result = await self._request(
|
||
f"issues/{issue_id}/comments/",
|
||
method="POST",
|
||
json_data={"text": text},
|
||
)
|
||
|
||
if result:
|
||
logger.info(
|
||
"sentry_comment_posted",
|
||
issue_id=issue_id,
|
||
comment_id=result.get("id"),
|
||
)
|
||
else:
|
||
logger.error("sentry_comment_failed", issue_id=issue_id)
|
||
|
||
return result
|
||
|
||
# =========================================================================
|
||
# Session Replay APIs (2026-03-29 Phase 19 UX 監控)
|
||
# =========================================================================
|
||
|
||
async def list_replays(
|
||
self,
|
||
project: str | None = None,
|
||
query: str = "",
|
||
limit: int = 25,
|
||
sort: str = "-started_at",
|
||
) -> list[dict] | None:
|
||
"""
|
||
列出 Session Replay 錄製
|
||
|
||
Args:
|
||
project: 專案 slug
|
||
query: Sentry Discover 語法 (如 user.email:xxx)
|
||
limit: 每頁數量
|
||
sort: 排序欄位
|
||
|
||
Returns:
|
||
Replay 列表,包含 id, urls, duration, error_count 等
|
||
"""
|
||
project_slug = project or self.project
|
||
params: dict[str, Any] = {"limit": limit, "sort": sort}
|
||
if query:
|
||
params["query"] = query
|
||
|
||
return await self._request(
|
||
f"projects/{self.org}/{project_slug}/replays/",
|
||
params=params,
|
||
)
|
||
|
||
async def get_replay(
|
||
self,
|
||
replay_id: str,
|
||
project: str | None = None,
|
||
) -> dict | None:
|
||
"""取得單一 Replay 詳情"""
|
||
project_slug = project or self.project
|
||
return await self._request(
|
||
f"projects/{self.org}/{project_slug}/replays/{replay_id}/"
|
||
)
|
||
|
||
async def list_ui_errors(
|
||
self,
|
||
project: str | None = None,
|
||
limit: int = 50,
|
||
) -> list[dict] | None:
|
||
"""
|
||
查詢 UI 相關錯誤 (click, interaction, render)
|
||
|
||
專門抓取可能影響 UX 的前端錯誤:
|
||
- TypeError: Cannot read property
|
||
- Unhandled rejection
|
||
- ResizeObserver
|
||
- Click/Touch handler errors
|
||
"""
|
||
ui_query = (
|
||
"is:unresolved "
|
||
"level:error "
|
||
"(message:*click* OR message:*touch* OR message:*render* "
|
||
"OR message:*TypeError* OR message:*Cannot read*)"
|
||
)
|
||
return await self.list_issues(
|
||
project=project,
|
||
query=ui_query,
|
||
limit=limit,
|
||
)
|
||
|
||
async def get_ux_audit_summary(
|
||
self,
|
||
project: str | None = None,
|
||
) -> dict:
|
||
"""
|
||
取得 UX 審計摘要
|
||
|
||
Returns:
|
||
{
|
||
"replays_with_errors": int, # 有錯誤的 replay 數
|
||
"rage_clicks": int, # 憤怒點擊數
|
||
"ui_errors": int, # UI 錯誤數
|
||
"dead_clicks": int, # 無效點擊數
|
||
"details": [...] # 細節列表
|
||
}
|
||
"""
|
||
project_slug = project or self.project
|
||
|
||
# 並行查詢多種 UX 問題
|
||
results = {
|
||
"replays_with_errors": 0,
|
||
"rage_clicks": 0,
|
||
"ui_errors": 0,
|
||
"dead_clicks": 0,
|
||
"details": [],
|
||
}
|
||
|
||
# 1. 查詢有錯誤的 Replay
|
||
error_replays = await self.list_replays(
|
||
project=project_slug,
|
||
query="count_errors:>0",
|
||
limit=10,
|
||
)
|
||
if error_replays:
|
||
results["replays_with_errors"] = len(error_replays)
|
||
for replay in error_replays:
|
||
results["details"].append({
|
||
"type": "replay_with_errors",
|
||
"replay_id": replay.get("id"),
|
||
"url": self.get_replay_url(replay.get("id", "")),
|
||
"error_count": replay.get("count_errors", 0),
|
||
"urls": replay.get("urls", [])[:3], # 前 3 個訪問頁面
|
||
})
|
||
|
||
# 2. 查詢 UI 錯誤
|
||
ui_errors = await self.list_ui_errors(project=project_slug, limit=10)
|
||
if ui_errors:
|
||
results["ui_errors"] = len(ui_errors)
|
||
for issue in ui_errors:
|
||
results["details"].append({
|
||
"type": "ui_error",
|
||
"issue_id": issue.get("id"),
|
||
"url": self.get_issue_url(issue.get("id", "")),
|
||
"title": issue.get("title", "")[:100],
|
||
"count": issue.get("count", 0),
|
||
})
|
||
|
||
# 3. 查詢憤怒點擊 (Rage Clicks)
|
||
rage_replays = await self.list_replays(
|
||
project=project_slug,
|
||
query="count_rage_clicks:>0",
|
||
limit=10,
|
||
)
|
||
if rage_replays:
|
||
results["rage_clicks"] = sum(
|
||
r.get("count_rage_clicks", 0) for r in rage_replays
|
||
)
|
||
|
||
# 4. 查詢死亡點擊 (Dead Clicks)
|
||
dead_replays = await self.list_replays(
|
||
project=project_slug,
|
||
query="count_dead_clicks:>0",
|
||
limit=10,
|
||
)
|
||
if dead_replays:
|
||
results["dead_clicks"] = sum(
|
||
r.get("count_dead_clicks", 0) for r in dead_replays
|
||
)
|
||
|
||
return results
|
||
|
||
# =========================================================================
|
||
# Helper Methods
|
||
# =========================================================================
|
||
|
||
def get_issue_url(self, issue_id: str) -> str:
|
||
"""取得 Issue 在 Sentry UI 的連結"""
|
||
return f"{self.base_url}/organizations/{self.org}/issues/{issue_id}/"
|
||
|
||
def get_replay_url(self, replay_id: str) -> str:
|
||
"""取得 Replay 在 Sentry UI 的連結"""
|
||
return f"{self.base_url}/organizations/{self.org}/replays/{replay_id}/"
|
||
|
||
# =========================================================================
|
||
# Dedup (Phase 10.2.1 - 2026-03-27)
|
||
# =========================================================================
|
||
|
||
async def check_dedup(self, issue_id: str, ttl: int = 600) -> bool:
|
||
"""
|
||
檢查 Sentry Issue 是否已在去重窗口內
|
||
|
||
Args:
|
||
issue_id: Sentry Issue ID
|
||
ttl: 去重窗口秒數 (預設 10 分鐘)
|
||
|
||
Returns:
|
||
bool: True = 應處理, False = 已去重跳過
|
||
"""
|
||
from src.core.redis_client import get_redis
|
||
|
||
if not issue_id:
|
||
return True
|
||
|
||
redis = get_redis()
|
||
key = f"sentry_dedup:{issue_id}"
|
||
|
||
# 檢查是否已存在 (redis-py async)
|
||
exists = await redis.exists(key)
|
||
if exists:
|
||
logger.info("sentry_dedup_hit", issue_id=issue_id)
|
||
return False
|
||
|
||
# 設置去重標記
|
||
await redis.setex(key, ttl, "1")
|
||
logger.debug("sentry_dedup_set", issue_id=issue_id, ttl=ttl)
|
||
return True
|
||
|
||
|
||
# =============================================================================
|
||
# Singleton
|
||
# =============================================================================
|
||
|
||
_sentry_service: SentryService | None = None
|
||
|
||
|
||
def get_sentry_service() -> SentryService:
|
||
"""取得 Sentry Service 實例 (Singleton)"""
|
||
global _sentry_service
|
||
if _sentry_service is None:
|
||
_sentry_service = SentryService()
|
||
return _sentry_service
|
||
|
||
|
||
def set_sentry_service(service: SentryService) -> None:
|
||
"""設定 Sentry Service 實例 (for testing)"""
|
||
global _sentry_service
|
||
_sentry_service = service
|