Files
awoooi/apps/api/src/services/sentry_service.py
OG T d89f0520f9 fix(api): 修復 34 個 Ruff lint 錯誤
- 自動修復 import 排序、unused imports
- 手動修復 raise from、isinstance union、unused variable
- scripts/ 暫時保留 (非 CI 阻擋)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 15:27:49 +08:00

465 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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