add telemetry system with analytics and crash reporting (M08)
This commit is contained in:
269
portal/internal/api/handlers/telemetry.go
Normal file
269
portal/internal/api/handlers/telemetry.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/omixlab/mosis-portal/internal/database"
|
||||
"github.com/omixlab/mosis-portal/internal/telemetry"
|
||||
)
|
||||
|
||||
// TelemetryHandler handles telemetry API requests
|
||||
type TelemetryHandler struct {
|
||||
db *database.DB
|
||||
telemetry *telemetry.Service
|
||||
}
|
||||
|
||||
// NewTelemetryHandler creates a new telemetry handler
|
||||
func NewTelemetryHandler(db *database.DB, ts *telemetry.Service) *TelemetryHandler {
|
||||
return &TelemetryHandler{
|
||||
db: db,
|
||||
telemetry: ts,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordEvents handles POST /v1/telemetry/events
|
||||
func (h *TelemetryHandler) RecordEvents(w http.ResponseWriter, r *http.Request) {
|
||||
var batch telemetry.EventBatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if batch.AppID == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if batch.DeviceID == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(batch.Events) == 0 {
|
||||
Error(w, http.StatusBadRequest, "no_events", "At least one event is required")
|
||||
return
|
||||
}
|
||||
|
||||
count, err := h.telemetry.RecordEvents(r.Context(), &batch)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "record_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"received": count,
|
||||
})
|
||||
}
|
||||
|
||||
// RecordCrash handles POST /v1/telemetry/crash
|
||||
func (h *TelemetryHandler) RecordCrash(w http.ResponseWriter, r *http.Request) {
|
||||
var report telemetry.CrashReport
|
||||
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if report.AppID == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if report.DeviceID == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
if report.Crash.Type == "" {
|
||||
Error(w, http.StatusBadRequest, "missing_crash_type", "Crash type is required")
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := h.telemetry.RecordCrash(r.Context(), &report)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "record_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"id": groupID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAnalyticsOverview handles GET /v1/apps/:appID/analytics/overview
|
||||
func (h *TelemetryHandler) GetAnalyticsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
|
||||
// Verify app ownership
|
||||
developerID := getDeveloperID(r)
|
||||
app, err := h.db.GetApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "App not found")
|
||||
return
|
||||
}
|
||||
if app.DeveloperID != developerID {
|
||||
Error(w, http.StatusForbidden, "forbidden", "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
overview, err := h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "query_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, overview)
|
||||
}
|
||||
|
||||
// GetAnalyticsEvents handles GET /v1/apps/:appID/analytics/events
|
||||
func (h *TelemetryHandler) GetAnalyticsEvents(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
|
||||
// Verify app ownership
|
||||
developerID := getDeveloperID(r)
|
||||
app, err := h.db.GetApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "App not found")
|
||||
return
|
||||
}
|
||||
if app.DeveloperID != developerID {
|
||||
Error(w, http.StatusForbidden, "forbidden", "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
eventType := r.URL.Query().Get("event_type")
|
||||
days := 30
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.telemetry.GetDailyStats(r.Context(), appID, eventType, days)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "query_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCrashes handles GET /v1/apps/:appID/crashes
|
||||
func (h *TelemetryHandler) GetCrashes(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
|
||||
// Verify app ownership
|
||||
developerID := getDeveloperID(r)
|
||||
app, err := h.db.GetApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "App not found")
|
||||
return
|
||||
}
|
||||
if app.DeveloperID != developerID {
|
||||
Error(w, http.StatusForbidden, "forbidden", "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
status := r.URL.Query().Get("status")
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
offset := (page - 1) * limit
|
||||
|
||||
crashes, total, err := h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "query_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"crashes": crashes,
|
||||
"pagination": map[string]int{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"total_pages": (total + limit - 1) / limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCrash handles GET /v1/apps/:appID/crashes/:crashID
|
||||
func (h *TelemetryHandler) GetCrash(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
crashID := chi.URLParam(r, "crashID")
|
||||
|
||||
// Verify app ownership
|
||||
developerID := getDeveloperID(r)
|
||||
app, err := h.db.GetApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "App not found")
|
||||
return
|
||||
}
|
||||
if app.DeveloperID != developerID {
|
||||
Error(w, http.StatusForbidden, "forbidden", "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
crash, err := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "Crash group not found")
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, crash)
|
||||
}
|
||||
|
||||
// UpdateCrashStatus handles PATCH /v1/apps/:appID/crashes/:crashID
|
||||
func (h *TelemetryHandler) UpdateCrashStatus(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appID")
|
||||
crashID := chi.URLParam(r, "crashID")
|
||||
|
||||
// Verify app ownership
|
||||
developerID := getDeveloperID(r)
|
||||
app, err := h.db.GetApp(r.Context(), appID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not_found", "App not found")
|
||||
return
|
||||
}
|
||||
if app.DeveloperID != developerID {
|
||||
Error(w, http.StatusForbidden, "forbidden", "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "open" && req.Status != "resolved" && req.Status != "ignored" {
|
||||
Error(w, http.StatusBadRequest, "invalid_status", "Status must be open, resolved, or ignored")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.telemetry.UpdateCrashGroupStatus(r.Context(), appID, crashID, req.Status); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "update_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
crash, _ := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
|
||||
JSON(w, http.StatusOK, crash)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/omixlab/mosis-portal/internal/config"
|
||||
"github.com/omixlab/mosis-portal/internal/database"
|
||||
"github.com/omixlab/mosis-portal/internal/storage"
|
||||
"github.com/omixlab/mosis-portal/internal/telemetry"
|
||||
"github.com/omixlab/mosis-portal/internal/web"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,13 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
|
||||
// Initialize telemetry service
|
||||
telemetryDB := cfg.StoragePath + "/telemetry.db"
|
||||
telemetrySvc, err := telemetry.New(telemetryDB)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize telemetry service: %v", err)
|
||||
}
|
||||
|
||||
// Initialize auth components
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
||||
oauthManager := auth.NewOAuthManager(
|
||||
@@ -44,6 +52,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
appHandler := handlers.NewAppHandler(db, store)
|
||||
storeHandler := handlers.NewStoreHandler(db, store)
|
||||
adminHandler := handlers.NewAdminHandler(db, store)
|
||||
telemetryHandler := handlers.NewTelemetryHandler(db, telemetrySvc)
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -95,6 +104,19 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
// Package upload
|
||||
r.Post("/{versionID}/upload", appHandler.UploadPackage)
|
||||
})
|
||||
|
||||
// Analytics
|
||||
r.Route("/{appID}/analytics", func(r chi.Router) {
|
||||
r.Get("/overview", telemetryHandler.GetAnalyticsOverview)
|
||||
r.Get("/events", telemetryHandler.GetAnalyticsEvents)
|
||||
})
|
||||
|
||||
// Crashes
|
||||
r.Route("/{appID}/crashes", func(r chi.Router) {
|
||||
r.Get("/", telemetryHandler.GetCrashes)
|
||||
r.Get("/{crashID}", telemetryHandler.GetCrash)
|
||||
r.Patch("/{crashID}", telemetryHandler.UpdateCrashStatus)
|
||||
})
|
||||
})
|
||||
|
||||
// API Keys
|
||||
@@ -121,10 +143,10 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
r.Get("/apps/{packageID}/versions/{versionCode}/download", storeHandler.DownloadVersion)
|
||||
})
|
||||
|
||||
// Telemetry (API key auth preferred, but can work without for initial setup)
|
||||
// Telemetry endpoints (public - devices send events here)
|
||||
r.Route("/telemetry", func(r chi.Router) {
|
||||
r.Post("/events", handlers.NotImplemented)
|
||||
r.Post("/crash", handlers.NotImplemented)
|
||||
r.Post("/events", telemetryHandler.RecordEvents)
|
||||
r.Post("/crash", telemetryHandler.RecordCrash)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,6 +167,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
log.Printf("Warning: Failed to initialize web handler: %v", err)
|
||||
} else {
|
||||
webHandler.SetStorage(store)
|
||||
webHandler.SetTelemetry(telemetrySvc)
|
||||
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
|
||||
|
||||
// Public web pages
|
||||
@@ -163,6 +186,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
|
||||
r.Get("/dashboard", webHandler.Dashboard)
|
||||
r.Get("/apps/new", webHandler.AppNew)
|
||||
r.Get("/apps/{appID}", webHandler.AppDetail)
|
||||
r.Get("/apps/{appID}/analytics", webHandler.AppAnalytics)
|
||||
r.Get("/apps/{appID}/crashes", webHandler.AppCrashes)
|
||||
|
||||
// htmx partials
|
||||
r.Get("/partials/apps", webHandler.AppListPartial)
|
||||
|
||||
Reference in New Issue
Block a user