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:
122
apps/api/scripts/audit_ux_sentry.py
Normal file
122
apps/api/scripts/audit_ux_sentry.py
Normal 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())
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user