feat(api): Sentry Session Replay UX 自動監控

Phase 19 UX 監控 - 善用 Sentry Session Replay:
- SentryService: 新增 list_replays, get_ux_audit_summary
- 偵測: 憤怒點擊 (Rage Clicks) + 死亡點擊 (Dead Clicks)
- 偵測: 有錯誤的 Session Replay
- 偵測: UI 相關錯誤 (TypeError/render)
- API: GET /api/v1/errors/ux-audit 端點
- 腳本: audit_ux_sentry.py CLI 工具

統帥回饋: "AI都要全自動化!" 

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-29 01:48:59 +08:00
parent 8fa99209c3
commit d9a6f9d066
3 changed files with 343 additions and 0 deletions

View File

@@ -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())

View File

@@ -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,

View File

@@ -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)
# =========================================================================