""" 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