diff --git a/apps/api/scripts/audit_ux_sentry.py b/apps/api/scripts/audit_ux_sentry.py new file mode 100644 index 00000000..a06d5596 --- /dev/null +++ b/apps/api/scripts/audit_ux_sentry.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Sentry UX Audit Script +====================== +使用 Sentry Session Replay + Error Data 自動審計 UI/UX 問題 + +執行方式: + cd apps/api + python scripts/audit_ux_sentry.py + +輸出: + - 有錯誤的 Session Replay + - 憤怒點擊 (Rage Clicks) 統計 + - 死亡點擊 (Dead Clicks) 統計 + - UI 相關錯誤列表 + +版本: v1.0 +建立: 2026-03-29 (台北時區) +建立者: Claude Code (Phase 19 UX 監控) +""" + +import asyncio +import json +import sys +from pathlib import Path + +# 添加專案路徑 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.services.sentry_service import SentryService + + +async def main(): + """執行 UX 審計""" + print("=" * 60) + print("AWOOOI UX Audit via Sentry Session Replay") + print("=" * 60) + print() + + service = SentryService() + + # 檢查連線 + projects = await service.list_projects() + if not projects: + print("❌ 無法連線 Sentry API,請檢查:") + print(" - SENTRY_SELF_HOSTED_URL") + print(" - SENTRY_AUTH_TOKEN") + print(" - SENTRY_ORG") + return + + print(f"✅ Sentry 連線成功,找到 {len(projects)} 個專案") + print() + + # 取得 UX 審計摘要 + print("📊 執行 UX 審計...") + print("-" * 60) + summary = await service.get_ux_audit_summary() + + # 輸出摘要 + print() + print("🔍 UX 問題摘要:") + print(f" 有錯誤的 Replay 數: {summary['replays_with_errors']}") + print(f" 憤怒點擊 (Rage Clicks): {summary['rage_clicks']}") + print(f" 死亡點擊 (Dead Clicks): {summary['dead_clicks']}") + print(f" UI 錯誤數: {summary['ui_errors']}") + print() + + # 輸出詳情 + if summary["details"]: + print("📝 問題詳情:") + print("-" * 60) + for i, detail in enumerate(summary["details"][:15], 1): # 最多 15 筆 + detail_type = detail.get("type", "unknown") + + if detail_type == "replay_with_errors": + print(f"{i}. 🎥 Replay 有 {detail.get('error_count', 0)} 個錯誤") + print(f" URL: {detail.get('url', 'N/A')}") + urls = detail.get("urls", []) + if urls: + print(f" 訪問頁面: {', '.join(urls[:2])}") + print() + + elif detail_type == "ui_error": + print(f"{i}. 🐛 UI Error: {detail.get('title', 'N/A')[:60]}...") + print(f" 發生次數: {detail.get('count', 0)}") + print(f" URL: {detail.get('url', 'N/A')}") + print() + else: + print("✨ 太棒了!目前沒有檢測到明顯的 UX 問題") + print() + + # 總結與建議 + print("=" * 60) + total_issues = ( + summary["replays_with_errors"] + + summary["ui_errors"] + ) + + if total_issues > 10: + print("🔴 UX 健康度: 差 - 需要立即處理") + print(" 建議: 優先修復 UI Error,然後檢視 Rage Click Replay") + elif total_issues > 3: + print("🟡 UX 健康度: 普通 - 有改善空間") + print(" 建議: 逐一檢視問題 Replay 找出根因") + else: + print("🟢 UX 健康度: 良好") + print(" 建議: 持續監控,設置 Alert 自動通知") + + print() + print("💡 查看完整 Session Replay:") + print(f" {service.base_url}/organizations/{service.org}/replays/") + print() + + # 輸出 JSON (供 CI 使用) + json_output = Path(__file__).parent / "ux_audit_result.json" + with open(json_output, "w") as f: + json.dump(summary, f, indent=2, ensure_ascii=False) + print(f"📄 JSON 結果已保存: {json_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/api/src/api/v1/errors.py b/apps/api/src/api/v1/errors.py index d4310bca..765ee0ac 100644 --- a/apps/api/src/api/v1/errors.py +++ b/apps/api/src/api/v1/errors.py @@ -280,6 +280,72 @@ async def get_error_trends( ) +# ============================================================================= +# UX Audit (Phase 19 - Session Replay 整合) +# 2026-03-29 Claude Code - 自動化 UI/UX 監控 +# ============================================================================= + + +class UXAuditResponse(BaseModel): + """UX 審計回應""" + + replays_with_errors: int = Field(description="有錯誤的 Replay 數") + rage_clicks: int = Field(description="憤怒點擊數") + dead_clicks: int = Field(description="死亡點擊數") + ui_errors: int = Field(description="UI 錯誤數") + health_score: str = Field(description="健康度評分 (good/moderate/poor)") + details: list[dict] = Field(default=[], description="問題詳情列表") + replay_dashboard_url: str = Field(description="Sentry Replay Dashboard 連結") + + +@router.get("/ux-audit", response_model=UXAuditResponse) +async def get_ux_audit( + project: str | None = Query(default=None, description="專案 slug"), +) -> UXAuditResponse: + """ + UX 審計 API + + 使用 Sentry Session Replay 數據自動檢測 UI/UX 問題: + - 有錯誤的 Replay + - 憤怒點擊 (Rage Clicks) - 使用者快速連擊同一位置 + - 死亡點擊 (Dead Clicks) - 點擊無反應的元素 + - UI 相關錯誤 (TypeError, render errors) + + Returns: + UXAuditResponse: 包含問題統計、健康度評分和詳情 + """ + sentry = get_sentry_service() + summary = await sentry.get_ux_audit_summary(project=project) + + # 計算健康度 + total_issues = summary["replays_with_errors"] + summary["ui_errors"] + if total_issues > 10: + health_score = "poor" + elif total_issues > 3: + health_score = "moderate" + else: + health_score = "good" + + logger.info( + "ux_audit_completed", + health_score=health_score, + replays_with_errors=summary["replays_with_errors"], + rage_clicks=summary["rage_clicks"], + dead_clicks=summary["dead_clicks"], + ui_errors=summary["ui_errors"], + ) + + return UXAuditResponse( + replays_with_errors=summary["replays_with_errors"], + rage_clicks=summary["rage_clicks"], + dead_clicks=summary["dead_clicks"], + ui_errors=summary["ui_errors"], + health_score=health_score, + details=summary["details"], + replay_dashboard_url=f"{sentry.base_url}/organizations/{sentry.org}/replays/", + ) + + @router.post("/issues/{issue_id}/analyze") async def trigger_ai_analysis( issue_id: str, diff --git a/apps/api/src/services/sentry_service.py b/apps/api/src/services/sentry_service.py index fb559524..fbff8369 100644 --- a/apps/api/src/services/sentry_service.py +++ b/apps/api/src/services/sentry_service.py @@ -188,6 +188,157 @@ class SentryService: return await self._request(f"issues/{issue_id}/events/", params=params) + # ========================================================================= + # 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 # ========================================================================= @@ -196,6 +347,10 @@ class SentryService: """取得 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) # =========================================================================