Files
cairnobs/api/internal/dashboards/handler.go
T
jcoffey-dev 9435115ab7 Phase 3: dashboards and alerting
Saved, shareable multi-panel dashboards (table/line/bar/single-stat
panels via gridstack + uPlot, global + per-panel time range, JSON
export/import) and threshold/absence alert rules with an
ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery.

- New /metadata component: Postgres control-plane store for dashboards,
  panels, notification targets, alert rules/state, and delivery log --
  see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree
  family isn't a fit for this access pattern (needs real row-level
  locking and read-your-writes consistency).
- api/internal/dashboards: dashboard/panel CRUD, pure -- panel query
  execution stays client-side, reusing the existing /query endpoint.
- New /alerting service: rule/target CRUD, a ticker-driven evaluator
  (claim-then-evaluate concurrency control, transactional-outbox
  delivery, query errors and threshold zero-rows never coerced into a
  false transition) and webhook/Slack/PagerDuty delivery with
  retry/backoff. See docs/phase-3-alerting-design.md for the full
  state-machine design and the four correctness properties it
  implements.
- web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts
  list/get/apply, seeding a future Terraform provider's JSON contract.
- hack/alert-load-test: 500 rules against real ClickHouse data, real
  measured results in docs/phase-3-runbook.md.

Five real bugs found by actually running this against a live stack
(documented in the runbook, not just fixed silently): a latent Phase 2
bug where ClickHouse rejected the timestamp format used for
earliest=/latest= queries; a "now" literal token injected into query
text; a GridStack/uPlot layout-timing race; JS's Date.parse being too
lenient to use as a timestamp-detection heuristic; a rule's "enabled"
field silently defaulting to false when omitted; and the evaluator's
claim-batch-size and worker-pool-concurrency defaulting to the same
value, causing 500 concurrently-due rules to take 125s to cycle through
instead of the configured 60s.
2026-08-13 17:29:38 -07:00

213 lines
6.4 KiB
Go

package dashboards
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
)
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// queryapi's SQLRunner/SearchClient.
type store interface {
CreateDashboard(ctx context.Context, d *Dashboard) error
ListDashboards(ctx context.Context) ([]Dashboard, error)
GetDashboard(ctx context.Context, id string) (*Dashboard, error)
UpdateDashboard(ctx context.Context, d *Dashboard) error
DeleteDashboard(ctx context.Context, id string) error
AddPanel(ctx context.Context, dashboardID string, p *Panel) error
UpdatePanel(ctx context.Context, p *Panel) error
DeletePanel(ctx context.Context, dashboardID, panelID string) error
ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error)
}
type Handler struct {
logger *slog.Logger
store store
}
func NewHandler(logger *slog.Logger, store store) *Handler {
return &Handler{logger: logger, store: store}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /dashboards", h.handleCreate)
mux.HandleFunc("GET /dashboards", h.handleList)
mux.HandleFunc("POST /dashboards/import", h.handleImport)
mux.HandleFunc("GET /dashboards/{id}", h.handleGet)
mux.HandleFunc("PUT /dashboards/{id}", h.handleUpdate)
mux.HandleFunc("DELETE /dashboards/{id}", h.handleDelete)
mux.HandleFunc("GET /dashboards/{id}/export", h.handleExport)
mux.HandleFunc("POST /dashboards/{id}/panels", h.handleAddPanel)
mux.HandleFunc("PUT /dashboards/{id}/panels/{panelId}", h.handleUpdatePanel)
mux.HandleFunc("DELETE /dashboards/{id}/panels/{panelId}", h.handleDeletePanel)
}
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
var d Dashboard
if !decodeJSON(w, r, &d) {
return
}
if d.Name == "" {
writeError(w, http.StatusBadRequest, "name must not be empty")
return
}
if err := h.store.CreateDashboard(r.Context(), &d); err != nil {
h.logger.Error("creating dashboard", "error", err)
writeError(w, http.StatusInternalServerError, "creating dashboard failed")
return
}
writeJSON(w, http.StatusCreated, d)
}
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
list, err := h.store.ListDashboards(r.Context())
if err != nil {
h.logger.Error("listing dashboards", "error", err)
writeError(w, http.StatusInternalServerError, "listing dashboards failed")
return
}
writeJSON(w, http.StatusOK, list)
}
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
d, err := h.store.GetDashboard(r.Context(), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching dashboard")
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) {
// Export is the same document GET /dashboards/{id} returns -- the
// import endpoint below consumes exactly this shape, and so does
// `sentryctl dashboards apply`, so there's one JSON contract used
// from every call site rather than a bespoke export format.
h.handleGet(w, r)
}
func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
var d Dashboard
if !decodeJSON(w, r, &d) {
return
}
if d.Name == "" {
writeError(w, http.StatusBadRequest, "name must not be empty")
return
}
imported, err := h.store.ImportDashboard(r.Context(), &d)
if err != nil {
h.logger.Error("importing dashboard", "error", err)
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, imported)
}
func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) {
var d Dashboard
if !decodeJSON(w, r, &d) {
return
}
if d.Name == "" {
writeError(w, http.StatusBadRequest, "name must not be empty")
return
}
d.ID = r.PathValue("id")
if err := h.store.UpdateDashboard(r.Context(), &d); err != nil {
h.writeStoreErr(w, err, "updating dashboard")
return
}
writeJSON(w, http.StatusOK, d)
}
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteDashboard(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting dashboard")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) handleAddPanel(w http.ResponseWriter, r *http.Request) {
var p Panel
if !decodeJSON(w, r, &p) {
return
}
if err := validatePanel(&p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := h.store.AddPanel(r.Context(), r.PathValue("id"), &p); err != nil {
h.logger.Error("adding panel", "error", err)
writeError(w, http.StatusInternalServerError, "adding panel failed")
return
}
writeJSON(w, http.StatusCreated, p)
}
func (h *Handler) handleUpdatePanel(w http.ResponseWriter, r *http.Request) {
var p Panel
if !decodeJSON(w, r, &p) {
return
}
if err := validatePanel(&p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
p.ID = r.PathValue("panelId")
p.DashboardID = r.PathValue("id")
if err := h.store.UpdatePanel(r.Context(), &p); err != nil {
h.writeStoreErr(w, err, "updating panel")
return
}
writeJSON(w, http.StatusOK, p)
}
func (h *Handler) handleDeletePanel(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeletePanel(r.Context(), r.PathValue("id"), r.PathValue("panelId")); err != nil {
h.writeStoreErr(w, err, "deleting panel")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}