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.
125 lines
3.4 KiB
Go
125 lines
3.4 KiB
Go
// Command sentryctl is Sentry's control CLI. Six subcommands now
|
|
// (ping, query, dashboards, alerts) clearly justify splitting dispatch
|
|
// across files -- see cli/README.md's "revisit once there's a real
|
|
// command tree" note -- while keeping the same hand-rolled switch on
|
|
// os.Args, no CLI framework, per that same README.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"text/tabwriter"
|
|
)
|
|
|
|
const (
|
|
defaultAPIURL = "http://localhost:8080"
|
|
defaultAlertingURL = "http://localhost:8081"
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
|
}
|
|
|
|
func run(args []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
usage(stderr)
|
|
return 1
|
|
}
|
|
|
|
switch args[0] {
|
|
case "ping":
|
|
return cmdPing(args[1:], stdout, stderr)
|
|
case "query":
|
|
return cmdQuery(args[1:], stdout, stderr)
|
|
case "dashboards":
|
|
return cmdDashboards(args[1:], stdout, stderr)
|
|
case "alerts":
|
|
return cmdAlerts(args[1:], stdout, stderr)
|
|
case "-h", "--help", "help":
|
|
usage(stdout)
|
|
return 0
|
|
default:
|
|
fmt.Fprintf(stderr, "sentryctl: unknown command %q\n", args[0])
|
|
usage(stderr)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func usage(w io.Writer) {
|
|
fmt.Fprintln(w, `sentryctl: Sentry control CLI
|
|
|
|
Usage:
|
|
sentryctl ping [--api <url>]
|
|
sentryctl query "<query>" [--api <url>] [--language sql|spl] [--json]
|
|
sentryctl dashboards list|get <id>|apply <file> [--api <url>]
|
|
sentryctl alerts list|get <id>|apply <file> [--alerting-api <url>]
|
|
|
|
Commands:
|
|
ping Checks that the api service is reachable via GET /healthz.
|
|
query Runs a query (pipe syntax or SQL) against POST /query and
|
|
prints the result as a table, or as JSON with --json. Quote
|
|
the query in your shell -- pipe syntax uses "|", which your
|
|
shell will otherwise interpret itself.
|
|
dashboards list/get/apply against api's dashboard CRUD endpoints.
|
|
"apply <file>" imports a dashboard exported via the web
|
|
UI's Export JSON button or GET /dashboards/{id}/export --
|
|
the same JSON shape both places, Terraform-friendly.
|
|
alerts list/get/apply against alerting's rule CRUD endpoints.
|
|
"apply <file>" creates a rule from a JSON file with the
|
|
same shape POST /rules accepts.
|
|
|
|
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
|
|
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
|
|
--language overrides auto-detection; omit it for the common case.`)
|
|
}
|
|
|
|
func resolveAPIURL(env func(string) string) string {
|
|
if v := env("SENTRYCTL_API_URL"); v != "" {
|
|
return v
|
|
}
|
|
return defaultAPIURL
|
|
}
|
|
|
|
func resolveAlertingURL(env func(string) string) string {
|
|
if v := env("SENTRYCTL_ALERTING_API_URL"); v != "" {
|
|
return v
|
|
}
|
|
return defaultAlertingURL
|
|
}
|
|
|
|
type errorResponseBody struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func printTable(w io.Writer, columns []string, rows [][]any) {
|
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(tw, strings.Join(columns, "\t"))
|
|
for _, row := range rows {
|
|
cells := make([]string, len(row))
|
|
for i, v := range row {
|
|
cells[i] = formatCell(v)
|
|
}
|
|
fmt.Fprintln(tw, strings.Join(cells, "\t"))
|
|
}
|
|
_ = tw.Flush()
|
|
fmt.Fprintf(w, "(%d row(s))\n", len(rows))
|
|
}
|
|
|
|
func formatCell(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case map[string]any, []any:
|
|
b, err := json.Marshal(t)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
return string(b)
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|