feat(usage): add event analytics dashboard

This commit is contained in:
Goon committed 2026-06-12 17:51:12 +07:00
1 parent b4c63e4afb
commit 4002bb4c13
31 files changed
+2316 -24

No files matched your search

+132 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
@@ -14,18 +15,22 @@ import (
// UsageHandler serves pre-computed usage analytics from snapshots.
type UsageHandler struct {
snapshots store.SnapshotStore
db *sql.DB
snapshots store.SnapshotStore
usageEvents store.UsageEventStore
db *sql.DB
}
func NewUsageHandler(snapshots store.SnapshotStore, db *sql.DB) *UsageHandler {
return &UsageHandler{snapshots: snapshots, db: db}
func NewUsageHandler(snapshots store.SnapshotStore, usageEvents store.UsageEventStore, db *sql.DB) *UsageHandler {
return &UsageHandler{snapshots: snapshots, usageEvents: usageEvents, db: db}
}
func (h *UsageHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/usage/timeseries", h.authMiddleware(h.handleTimeSeries))
mux.HandleFunc("GET /v1/usage/breakdown", h.authMiddleware(h.handleBreakdown))
mux.HandleFunc("GET /v1/usage/summary", h.authMiddleware(h.handleSummary))
mux.HandleFunc("GET /v1/usage/events/timeseries", h.authMiddleware(h.handleEventTimeSeries))
mux.HandleFunc("GET /v1/usage/events/breakdown", h.authMiddleware(h.handleEventBreakdown))
mux.HandleFunc("GET /v1/usage/events/summary", h.authMiddleware(h.handleEventSummary))
}
func (h *UsageHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
@@ -128,6 +133,81 @@ func (h *UsageHandler) handleSummary(w http.ResponseWriter, r *http.Request) {
})
}
func (h *UsageHandler) handleEventTimeSeries(w http.ResponseWriter, r *http.Request) {
if h.usageEvents == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "usage event analytics unavailable"})
return
}
q, err := parseUsageEventFilters(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if q.From.IsZero() || q.To.IsZero() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to are required"})
return
}
if q.GroupBy == "" {
q.GroupBy = "hour"
}
points, err := h.usageEvents.GetEventTimeSeries(r.Context(), q)
if err != nil {
slog.Error("usage.events.timeseries query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"points": points})
}
func (h *UsageHandler) handleEventBreakdown(w http.ResponseWriter, r *http.Request) {
if h.usageEvents == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "usage event analytics unavailable"})
return
}
q, err := parseUsageEventFilters(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if q.From.IsZero() || q.To.IsZero() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to are required"})
return
}
if q.GroupBy == "" {
q.GroupBy = "resource"
}
rows, err := h.usageEvents.GetEventBreakdown(r.Context(), q)
if err != nil {
slog.Error("usage.events.breakdown query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
}
func (h *UsageHandler) handleEventSummary(w http.ResponseWriter, r *http.Request) {
if h.usageEvents == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "usage event analytics unavailable"})
return
}
q, err := parseUsageEventFilters(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if q.From.IsZero() || q.To.IsZero() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from and to are required"})
return
}
summary, err := h.usageEvents.GetEventSummary(r.Context(), q)
if err != nil {
slog.Error("usage.events.summary query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"summary": summary})
}
// usageSummary is the response shape for summary endpoint.
type usageSummary struct {
Requests int `json:"requests"`
@@ -246,3 +326,51 @@ func parseSnapshotFilters(r *http.Request) store.SnapshotQuery {
return q
}
func parseUsageEventFilters(r *http.Request) (store.UsageEventQuery, error) {
q := store.UsageEventQuery{}
values := r.URL.Query()
if values.Get("user_id") != "" {
return q, fmt.Errorf("user_id filter is not supported")
}
if v := values.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return q, fmt.Errorf("invalid from")
}
q.From = t
}
if v := values.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return q, fmt.Errorf("invalid to")
}
q.To = t
}
if !q.From.IsZero() && !q.To.IsZero() && !q.To.After(q.From) {
return q, fmt.Errorf("to must be after from")
}
if v := values.Get("agent_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
return q, fmt.Errorf("invalid agent_id")
}
q.AgentID = &id
}
q.Channel = values.Get("channel")
q.EventType = values.Get("event_type")
q.ResourceType = values.Get("resource_type")
q.ResourceName = values.Get("resource_name")
q.Provider = values.Get("provider")
q.Model = values.Get("model")
q.Status = values.Get("status")
q.Source = values.Get("source")
q.GroupBy = values.Get("group_by")
if v := values.Get("limit"); v != "" {
limit, err := strconv.Atoi(v)
if err != nil || limit < 0 {
return q, fmt.Errorf("invalid limit")
}
q.Limit = limit
}
return q, nil
}