add telemetry system with analytics and crash reporting (M08)

This commit is contained in:
2026-01-18 21:53:06 +01:00
parent fbcb5c9543
commit a5aa3cc9d7
6 changed files with 1484 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/review"
"github.com/omixlab/mosis-portal/internal/storage"
"github.com/omixlab/mosis-portal/internal/telemetry"
)
// Handler handles web page requests
@@ -16,6 +17,7 @@ type Handler struct {
templates *Templates
store *storage.Storage
review *review.Service
telemetry *telemetry.Service
}
// NewHandler creates a new web handler
@@ -36,6 +38,11 @@ func (h *Handler) SetStorage(store *storage.Storage) {
h.store = store
}
// SetTelemetry sets the telemetry service for the handler
func (h *Handler) SetTelemetry(ts *telemetry.Service) {
h.telemetry = ts
}
// PageData is the base data structure for all pages
type PageData struct {
Title string
@@ -436,3 +443,141 @@ func (h *Handler) AdminValidate(w http.ResponseWriter, r *http.Request) {
// Render validation results partial
h.renderPartial(w, "validation_results", result)
}
// AppAnalytics renders the app analytics page
func (h *Handler) AppAnalytics(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get days parameter
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
// Get analytics data
var overview *telemetry.AnalyticsOverview
var eventStats []telemetry.DailyStats
var crashes []*telemetry.CrashGroup
if h.telemetry != nil {
overview, _ = h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
eventStats, _ = h.telemetry.GetDailyStats(r.Context(), appID, "", days)
crashes, _, _ = h.telemetry.GetCrashGroups(r.Context(), appID, "open", 5, 0)
}
if overview == nil {
overview = &telemetry.AnalyticsOverview{}
}
data := struct {
PageData
App *database.App
Days int
Overview *telemetry.AnalyticsOverview
EventStats []telemetry.DailyStats
Crashes []*telemetry.CrashGroup
}{
PageData: PageData{
Title: app.Name + " - Analytics",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Days: days,
Overview: overview,
EventStats: eventStats,
Crashes: crashes,
}
h.render(w, "app_analytics", data)
}
// AppCrashes renders the app crashes page
func (h *Handler) AppCrashes(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get status filter
status := r.URL.Query().Get("status")
if status == "" {
status = "open"
}
// Pagination
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
// Get crashes
var crashes []*telemetry.CrashGroup
var total int
if h.telemetry != nil {
crashes, total, _ = h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
}
data := struct {
PageData
App *database.App
Status string
Crashes []*telemetry.CrashGroup
Pagination struct {
Page int
Limit int
Total int
TotalPages int
}
}{
PageData: PageData{
Title: app.Name + " - Crashes",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Status: status,
Crashes: crashes,
}
data.Pagination.Page = page
data.Pagination.Limit = limit
data.Pagination.Total = total
data.Pagination.TotalPages = (total + limit - 1) / limit
h.render(w, "app_crashes", data)
}

View File

@@ -0,0 +1,228 @@
{{define "content"}}
<div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
<!-- App Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-gray-100 rounded-xl flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">{{.App.Name}}</h1>
<p class="text-gray-500">{{.App.PackageID}}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<select id="days-select" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-indigo-500 focus:border-indigo-500"
hx-get="/apps/{{.App.ID}}/analytics"
hx-trigger="change"
hx-target="#analytics-content"
hx-select="#analytics-content"
hx-include="this">
<option value="7" {{if eq .Days 7}}selected{{end}}>Last 7 days</option>
<option value="30" {{if eq .Days 30}}selected{{end}}>Last 30 days</option>
<option value="90" {{if eq .Days 90}}selected{{end}}>Last 90 days</option>
</select>
</div>
</div>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8">
<a href="/apps/{{.App.ID}}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Overview
</a>
<a href="/apps/{{.App.ID}}/versions" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Versions
</a>
<a href="/apps/{{.App.ID}}/analytics" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Analytics
</a>
<a href="/apps/{{.App.ID}}/settings" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Settings
</a>
</nav>
</div>
<!-- Analytics Content -->
<div id="analytics-content">
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Daily Active Users</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.DAU}}</p>
</div>
<div class="{{if ge .Overview.DAUChange 0.0}}text-green-500{{else}}text-red-500{{end}}">
{{if ge .Overview.DAUChange 0.0}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
{{else}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
{{end}}
</div>
</div>
<p class="mt-2 text-sm {{if ge .Overview.DAUChange 0.0}}text-green-600{{else}}text-red-600{{end}}">
{{if ge .Overview.DAUChange 0.0}}+{{end}}{{printf "%.1f" .Overview.DAUChange}}% from previous period
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Sessions</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalSessions}}</p>
</div>
<div class="text-indigo-500">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Crash-Free Rate</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{printf "%.1f" .Overview.CrashFreeRate}}%</p>
</div>
<div class="{{if ge .Overview.CrashFreeRate 99.0}}text-green-500{{else if ge .Overview.CrashFreeRate 95.0}}text-yellow-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Crashes</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalCrashes}}</p>
</div>
<div class="{{if eq .Overview.TotalCrashes 0}}text-green-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
{{if gt .Overview.TotalCrashes 0}}
<a href="/apps/{{.App.ID}}/crashes" class="mt-2 text-sm text-indigo-600 hover:text-indigo-700">
View crash reports →
</a>
{{end}}
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- DAU Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Daily Active Users</h3>
<div id="dau-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-dau?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
<!-- Sessions Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Sessions</h3>
<div id="sessions-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-sessions?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Event Types -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Event Distribution</h3>
{{if .EventStats}}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Type</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Devices</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .EventStats}}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{.EventType}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.Count}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.UniqueDevices}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 text-center py-8">No events recorded yet.</p>
{{end}}
</div>
<!-- Recent Crashes -->
{{if .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Recent Crashes</h3>
<a href="/apps/{{.App.ID}}/crashes" class="text-sm text-indigo-600 hover:text-indigo-700">
View all →
</a>
</div>
<div class="space-y-4">
{{range .Crashes}}
<div class="flex items-start p-4 bg-red-50 border border-red-100 rounded-lg">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-red-800">{{.CrashType}}: {{.Message}}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-red-600">
<span>{{.OccurrenceCount}} occurrences</span>
<span>Last seen: {{.LastSeen}}</span>
</div>
</div>
<span class="ml-4 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,148 @@
{{define "content"}}
<div class="mb-6">
<a href="/apps/{{.App.ID}}/analytics" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Analytics
</a>
</div>
<!-- Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">Crash Reports</h1>
<p class="text-gray-500">{{.App.Name}}</p>
</div>
</div>
<!-- Status Filter -->
<div class="flex items-center space-x-2">
<a href="/apps/{{.App.ID}}/crashes?status=open"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "open"}}bg-red-100 text-red-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Open
</a>
<a href="/apps/{{.App.ID}}/crashes?status=resolved"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "resolved"}}bg-green-100 text-green-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Resolved
</a>
<a href="/apps/{{.App.ID}}/crashes?status=ignored"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "ignored"}}bg-gray-200 text-gray-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Ignored
</a>
</div>
</div>
</div>
<!-- Crashes List -->
{{if .Crashes}}
<div class="space-y-4">
{{range .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:border-gray-300 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{{.CrashType}}
</span>
<span class="text-xs text-gray-500">{{.OccurrenceCount}} occurrences</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{.Message}}</h3>
{{if .SampleStackTrace}}
<pre class="mt-3 p-3 bg-gray-50 rounded-lg text-xs text-gray-600 overflow-x-auto max-h-32">{{.SampleStackTrace}}</pre>
{{end}}
<div class="mt-3 flex items-center space-x-4 text-xs text-gray-500">
<span>First seen: {{.FirstSeen}}</span>
<span>Last seen: {{.LastSeen}}</span>
{{if .AffectedVersions}}
<span>Versions: {{range $i, $v := .AffectedVersions}}{{if $i}}, {{end}}{{$v}}{{end}}</span>
{{end}}
</div>
</div>
<div class="ml-6 flex flex-col items-end space-y-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
<div class="flex space-x-2">
{{if eq .Status "open"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/resolve"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-green-600 hover:text-green-700 font-medium">
Mark Resolved
</button>
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/ignore"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-gray-500 hover:text-gray-700 font-medium">
Ignore
</button>
{{else if eq .Status "resolved"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{else}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
</div>
<!-- Pagination -->
{{if gt .Pagination.TotalPages 1}}
<div class="mt-6 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} crashes
</div>
<nav class="flex space-x-2">
{{if gt .Pagination.Page 1}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{sub .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{{end}}
{{if lt .Pagination.Page .Pagination.TotalPages}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{add .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{{end}}
</nav>
</div>
{{end}}
{{else}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No crashes</h3>
<p class="mt-2 text-sm text-gray-500">
{{if eq .Status "open"}}
No open crash reports. Your app is running smoothly!
{{else if eq .Status "resolved"}}
No resolved crashes to show.
{{else}}
No ignored crashes.
{{end}}
</p>
</div>
{{end}}
{{end}}