mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 12:14:17 +00:00
feat(usage): add event analytics dashboard
This commit is contained in:
1 parent
b4c63e4afb
commit
4002bb4c13
31 files changed
+2316
-24
No files matched your search
+132
-4
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user