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.
This commit is contained in:
2026-08-13 17:29:38 -07:00
parent fb5049a747
commit 9435115ab7
88 changed files with 7463 additions and 298 deletions
+3
View File
@@ -7,8 +7,11 @@ search/target/
/ingest/ingest
/api/api
/cli/sentryctl
/alerting/alerting
/hack/windows-fixture/windows-fixture
/hack/benchmark-fixture/benchmark-fixture
/hack/alert-load-test/alert-load-test
/hack/webhook-sink/webhook-sink
# Node / SvelteKit (web/ has its own more detailed .gitignore too)
web/node_modules/
+36
View File
@@ -95,6 +95,42 @@ ClickHouse/Tantivy routing strategy, and
`/docs/query-language-reference.md` for the user-facing syntax reference
once built.
## What "done" looks like for Phase 3
A user can build a multi-panel dashboard from saved Phase 2 queries (at
least a line chart panel and a table panel, working end-to-end against
live data), save an alert rule that fires a Slack webhook when a
condition is met (threshold comparison, or "absence" — the query returned
zero rows in its own time window), and see the delivery attempt logged —
all from the web UI, without touching the API directly. See
`/docs/phase-3-dashboard-design.md` and `/docs/phase-3-alerting-design.md`
for the data models and the alerting evaluator's firing/resolved state
machine, and `/docs/phase-3-runbook.md` for the live-stack verification,
including a load test of the alert evaluator against ~500 concurrent
rules.
This phase adds PostgreSQL as a new pinned-stack component (see the
dashboard design doc for why ClickHouse can't do this job — dashboards
and alert state need real row-level locking and transactional
read-modify-write, which ClickHouse's MergeTree family doesn't provide),
scoped strictly to control-plane config: dashboards, panels, notification
targets, alert rules, alert state, delivery log. Log data itself stays on
ClickHouse/Tantivy only, unchanged.
Non-goals for this phase (same discipline as every phase so far):
- No multi-tenancy enforcement and no `enterprise/` module work — single
tenant/org assumed. New tables carry a `tenant_id` column so Phase 4's
retrofit doesn't require a schema migration + backfill, but nothing
reads or enforces it yet.
- No raw-SQL dashboard panels (time-range injection isn't reliable
against arbitrary SQL) — pipe-syntax queries only.
- No per-group/multi-row threshold alerting (e.g. "alert separately per
host") — a threshold rule's query must resolve to a single row.
- No debounce on the way down — a firing alert resolves on the first
false evaluation, no symmetric "stay firing for N more minutes" hold.
- No Kubernetes Operator/Helm deployment work — still docker-compose,
`/deploy` remains stubbed.
## When in doubt
Ask before: changing the pinned stack, adding a new external dependency
that pulls in a large transitive tree, or making an architectural decision
+12
View File
@@ -0,0 +1,12 @@
# alerting never needs /proto (it talks to /api over plain HTTP, no gRPC),
# so unlike api/ingest/search this build context is just alerting/ itself,
# same shape as cli/Dockerfile:
# docker build -f alerting/Dockerfile -t sentry-alerting alerting/
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/alerting ./cmd/alerting
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/alerting /alerting
ENTRYPOINT ["/alerting"]
+98
View File
@@ -0,0 +1,98 @@
# alerting
Alert rule CRUD, the ticker-driven evaluator, and webhook/Slack/PagerDuty
delivery. See `/docs/phase-3-alerting-design.md` for the full design
(data model, the ok/pending/firing state machine, and the four
correctness properties this implementation follows exactly).
## Running
```sh
POSTGRES_PASSWORD=sentry-dev-only API_QUERY_URL=http://localhost:8080 go run ./cmd/alerting
```
Talks to the same `sentry_metadata` Postgres database as `/api`
(different tables — see `/metadata/README.md`), and to `/api`'s
`POST /query` over plain HTTP for rule evaluation. Never connects to
ClickHouse or Tantivy directly.
## HTTP API
```
POST /rules create a rule
GET /rules list rules (with current state)
GET /rules/{id} get a rule (with current state)
DELETE /rules/{id}
GET /rules/{id}/deliveries delivery log for a rule, most recent first
POST /targets create a notification target
GET /targets
GET /targets/{id}
DELETE /targets/{id}
GET /healthz
```
A rule's `condition_type` is `"threshold"` (requires `comparator` +
`threshold_value`, and the query must resolve to exactly one row) or
`"absence"` (fires when the query returns zero rows in its own
`earliest=`/`latest=` window — no separate window field). A notification
target's `kind` is `"webhook"`, `"slack"`, or `"pagerduty"` — all three
deliver via the same HTTP POST + retry/backoff mechanism
(`internal/delivery/webhook.go`); slack/pagerduty are payload formatters
only, not separate delivery paths.
## Environment variables
| Var | Default |
|---|---|
| `HTTP_LISTEN_ADDR` | `:8081` |
| `POSTGRES_ADDR` | `localhost:5432` |
| `POSTGRES_DATABASE` | `sentry_metadata` |
| `POSTGRES_USERNAME` | `sentry` |
| `POSTGRES_PASSWORD` | (empty — must be set) |
| `API_QUERY_URL` | `http://localhost:8080` |
| `CORS_ALLOWED_ORIGIN` | `*` |
| `EVALUATOR_TICK_SECONDS` | `5` — how often the scheduler checks for due rules |
| `EVALUATOR_CLAIM_BATCH_SIZE` | `1000` — how many due rules one tick can pull off the queue |
| `EVALUATOR_WORKER_POOL_SIZE` | `20` — bounded concurrency for `/query` calls within a claimed batch |
| `EVALUATOR_QUERY_TIMEOUT_SECONDS` | `30` — per-evaluation `POST /query` timeout |
`EVALUATOR_CLAIM_BATCH_SIZE` and `EVALUATOR_WORKER_POOL_SIZE` are
deliberately separate knobs, not the same number — see
`internal/config/config.go`'s doc comment for the real bug this
separation fixes (found by `hack/alert-load-test`, see
`/docs/phase-3-runbook.md`): with both capped at 20, 500 rules due at
once took 125s to cycle through instead of the configured 60s.
## Package layout
```
cmd/alerting/ wires config, Postgres pool, api client; runs the
HTTP server + evaluator + delivery worker concurrently (errgroup)
internal/httpapi/ REST handlers -- Handler/RegisterRoutes, same shape as api/internal/dashboards
internal/rulestore/ pgx CRUD for alert_rules + alert_state; ClaimDueRules
(fix 1's atomic claim) and ApplyTransition (fix 2's transactional outbox)
internal/notifystore/ pgx CRUD for notification_targets
internal/queryclient/ thin HTTP client to api's POST /query -- no querylang import here
internal/evaluator/ the ticker + worker pool; transitions.go is the pure,
exhaustively-tested ok/pending/firing state machine;
condition.go implements fixes 3/4 (errors never
coerced to "condition false"; threshold zero-rows
is an error, not a 0)
internal/delivery/ webhook.go is the claim-and-send worker (all three
kinds go through it); slack.go/pagerduty.go are
payload formatters only
```
## Building & testing
```sh
go build ./...
go vet ./...
go test ./...
```
```sh
docker build -f Dockerfile -t sentry-alerting . # context is alerting/, not the repo root -- no /proto needed
```
+141
View File
@@ -0,0 +1,141 @@
// Command alerting is Sentry's alert rule evaluator and delivery
// service: rule/target CRUD, the ticker-driven ok/pending/firing
// evaluator, and the webhook/Slack/PagerDuty delivery worker. See
// /docs/phase-3-alerting-design.md. Never talks to ClickHouse/Tantivy
// directly -- rule queries run through /api's POST /query
// (internal/queryclient), same precedent sentryctl query and the web
// UI's dashboard panels already set.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
"github.com/sentry/sentry/alerting/internal/config"
"github.com/sentry/sentry/alerting/internal/delivery"
"github.com/sentry/sentry/alerting/internal/evaluator"
"github.com/sentry/sentry/alerting/internal/httpapi"
"github.com/sentry/sentry/alerting/internal/httpserver"
"github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("loading config", "error", err)
os.Exit(1)
}
// -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors
// api/cmd/api/main.go's runHealthcheck -- this image is distroless too
// (no shell, no wget), so the compose healthcheck execs this binary
// against itself.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
pgPool, err := pgxpool.New(ctx, pgDSN)
if err != nil {
logger.Error("opening postgres pool", "error", err)
os.Exit(1)
}
defer pgPool.Close()
if err := pgPool.Ping(ctx); err != nil {
logger.Error("pinging postgres", "error", err)
os.Exit(1)
}
rules := rulestore.NewStore(pgPool)
targets := notifystore.NewStore(pgPool)
qc := queryclient.New(cfg.APIQueryURL)
handler := httpapi.NewHandler(logger, rules, targets, rules)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
}
eval := evaluator.New(rules, targets, qc, cfg.Evaluator.QueryTimeout, cfg.Evaluator.ClaimBatchSize, cfg.Evaluator.WorkerPoolSize, logger)
deliveryWorker := delivery.NewWorker(pgPool, targets, logger)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
logger.Info("alerting listening", "addr", cfg.HTTPListenAddr)
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return nil
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("http server exited: %w", err)
}
return nil
}
})
g.Go(func() error {
logger.Info("evaluator started", "tick_interval", cfg.Evaluator.TickInterval, "worker_pool_size", cfg.Evaluator.WorkerPoolSize)
if err := eval.Run(ctx, cfg.Evaluator.TickInterval); err != nil && ctx.Err() == nil {
return fmt.Errorf("evaluator exited: %w", err)
}
return nil
})
g.Go(func() error {
logger.Info("delivery worker started")
if err := deliveryWorker.Run(ctx, cfg.Evaluator.TickInterval); err != nil && ctx.Err() == nil {
return fmt.Errorf("delivery worker exited: %w", err)
}
return nil
})
if err := g.Wait(); err != nil {
logger.Error("alerting exited with error", "error", err)
os.Exit(1)
}
}
func runHealthcheck(listenAddr string) int {
addr := listenAddr
if strings.HasPrefix(addr, ":") {
addr = "localhost" + addr
}
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + addr + "/healthz")
if err != nil {
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}
+16
View File
@@ -0,0 +1,16 @@
module github.com/sentry/sentry/alerting
go 1.25.0
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/sync v0.22.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/text v0.29.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+96
View File
@@ -0,0 +1,96 @@
// Package config loads alerting's configuration from environment
// variables, same convention as /api and /ingest: no config file format.
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
HTTPListenAddr string
Postgres PostgresConfig
APIQueryURL string // base URL of /api, e.g. http://api:8080 -- alerting never talks to ClickHouse/Tantivy directly
CORSAllowedOrigin string
Evaluator EvaluatorConfig
}
type PostgresConfig struct {
Addr string
Database string
Username string
Password string
}
type EvaluatorConfig struct {
TickInterval time.Duration // how often the scheduler checks for due rules
// ClaimBatchSize and WorkerPoolSize are deliberately separate knobs,
// not the same number: ClaimBatchSize bounds how many due rules one
// tick pulls off the queue (needs to be large enough to drain a
// backlog when many rules share a due time, e.g. right after bulk
// creation), while WorkerPoolSize bounds concurrent /query calls
// within that batch. Found by actually running hack/alert-load-test
// with 500 rules and both numbers defaulted to the same value
// (20): the evaluator took 125s to cycle through 500 due rules
// instead of the configured 60s eval_interval_seconds, because each
// 5s tick could only claim 20 rules regardless of how many more were
// already due -- see /docs/phase-3-runbook.md's load-test section.
ClaimBatchSize int
WorkerPoolSize int
QueryTimeout time.Duration // per-evaluation POST /query timeout
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"),
Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"),
Password: getenv("POSTGRES_PASSWORD", ""),
},
APIQueryURL: getenv("API_QUERY_URL", "http://localhost:8080"),
// Same "no auth yet" tradeoff as api's CORSAllowedOrigin default --
// see api/internal/config/config.go's comment, same reasoning here.
CORSAllowedOrigin: getenv("CORS_ALLOWED_ORIGIN", "*"),
}
tickSec, err := strconv.Atoi(getenv("EVALUATOR_TICK_SECONDS", "5"))
if err != nil {
return Config{}, fmt.Errorf("EVALUATOR_TICK_SECONDS: %w", err)
}
cfg.Evaluator.TickInterval = time.Duration(tickSec) * time.Second
poolSize, err := strconv.Atoi(getenv("EVALUATOR_WORKER_POOL_SIZE", "20"))
if err != nil {
return Config{}, fmt.Errorf("EVALUATOR_WORKER_POOL_SIZE: %w", err)
}
cfg.Evaluator.WorkerPoolSize = poolSize
// Default well above WorkerPoolSize: this is "how many due rules can
// one tick pull off the queue," not a concurrency limit -- the
// worker pool below still bounds actual concurrent /query calls
// regardless of how large a batch gets claimed.
claimBatchSize, err := strconv.Atoi(getenv("EVALUATOR_CLAIM_BATCH_SIZE", "1000"))
if err != nil {
return Config{}, fmt.Errorf("EVALUATOR_CLAIM_BATCH_SIZE: %w", err)
}
cfg.Evaluator.ClaimBatchSize = claimBatchSize
queryTimeoutSec, err := strconv.Atoi(getenv("EVALUATOR_QUERY_TIMEOUT_SECONDS", "30"))
if err != nil {
return Config{}, fmt.Errorf("EVALUATOR_QUERY_TIMEOUT_SECONDS: %w", err)
}
cfg.Evaluator.QueryTimeout = time.Duration(queryTimeoutSec) * time.Second
return cfg, nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,88 @@
package delivery
import (
"encoding/json"
"testing"
"time"
"github.com/sentry/sentry/alerting/internal/notifystore"
)
func TestBuildPayloadGenericDefaultShape(t *testing.T) {
target := notifystore.Target{Kind: notifystore.KindWebhook}
value := 142.0
payload, err := BuildPayload(target, Event{
RuleID: "r1", RuleName: "High error rate", EventType: "firing",
Value: &value, Timestamp: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("BuildPayload: %v", err)
}
var got defaultGenericPayload
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("unmarshaling payload: %v", err)
}
if got.RuleName != "High error rate" || got.EventType != "firing" || got.Value == nil || *got.Value != 142.0 {
t.Fatalf("unexpected payload: %+v", got)
}
}
func TestBuildPayloadGenericUsesTemplate(t *testing.T) {
tmpl := `{"custom": "{{.RuleName}} is {{.EventType}}"}`
target := notifystore.Target{Kind: notifystore.KindWebhook, PayloadTemplate: &tmpl}
payload, err := BuildPayload(target, Event{RuleName: "disk full", EventType: "firing"})
if err != nil {
t.Fatalf("BuildPayload: %v", err)
}
var got map[string]string
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("unmarshaling templated payload: %v", err)
}
if got["custom"] != "disk full is firing" {
t.Fatalf("got %q", got["custom"])
}
}
func TestBuildPayloadSlackShape(t *testing.T) {
target := notifystore.Target{Kind: notifystore.KindSlack}
payload, err := BuildPayload(target, Event{RuleName: "High error rate", EventType: "resolved"})
if err != nil {
t.Fatalf("BuildPayload: %v", err)
}
var got slackPayload
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("unmarshaling slack payload: %v", err)
}
if got.Text == "" {
t.Fatalf("expected non-empty Slack text")
}
}
func TestBuildPayloadPagerDutyMapsEventActionAndRoutingKey(t *testing.T) {
secret := "R0UT1NGKEY"
target := notifystore.Target{Kind: notifystore.KindPagerDuty, Secret: &secret}
firing, err := BuildPayload(target, Event{RuleID: "r1", RuleName: "disk full", EventType: "firing"})
if err != nil {
t.Fatalf("BuildPayload firing: %v", err)
}
var gotFiring pagerDutyPayload
if err := json.Unmarshal(firing, &gotFiring); err != nil {
t.Fatalf("unmarshaling: %v", err)
}
if gotFiring.EventAction != "trigger" || gotFiring.RoutingKey != secret || gotFiring.DedupKey != "r1" {
t.Fatalf("unexpected firing payload: %+v", gotFiring)
}
resolved, err := BuildPayload(target, Event{RuleID: "r1", RuleName: "disk full", EventType: "resolved"})
if err != nil {
t.Fatalf("BuildPayload resolved: %v", err)
}
var gotResolved pagerDutyPayload
if err := json.Unmarshal(resolved, &gotResolved); err != nil {
t.Fatalf("unmarshaling: %v", err)
}
if gotResolved.EventAction != "resolve" {
t.Fatalf("expected event_action=resolve, got %q", gotResolved.EventAction)
}
}
+57
View File
@@ -0,0 +1,57 @@
package delivery
import (
"encoding/json"
"fmt"
"github.com/sentry/sentry/alerting/internal/notifystore"
)
// pagerDutyPayload is PagerDuty's Events API v2 shape. routing_key comes
// from the target's `secret` field -- PagerDuty calls this an
// "integration key," not a delivery credential in the auth sense, but
// it's stored in the same plaintext `secret` column as any other
// target's credential (see /docs/phase-3-alerting-design.md's "Known
// gaps").
type pagerDutyPayload struct {
RoutingKey string `json:"routing_key"`
EventAction string `json:"event_action"` // "trigger" | "resolve"
DedupKey string `json:"dedup_key"` // rule_id -- lets PagerDuty correlate trigger/resolve as one incident
Payload pagerDutyEventPayload `json:"payload"`
}
type pagerDutyEventPayload struct {
Summary string `json:"summary"`
Source string `json:"source"`
Severity string `json:"severity"`
}
func buildPagerDutyPayload(target notifystore.Target, event Event) ([]byte, error) {
action := "trigger"
severity := "critical"
if event.EventType == "resolved" {
action = "resolve"
severity = "info"
}
var routingKey string
if target.Secret != nil {
routingKey = *target.Secret
}
summary := fmt.Sprintf("%s: %s", event.RuleName, event.EventType)
if event.Value != nil && event.ThresholdValue != nil {
summary = fmt.Sprintf("%s (value %.4g %s %.4g)", summary, *event.Value, comparatorSymbol(event.Comparator), *event.ThresholdValue)
}
return json.Marshal(pagerDutyPayload{
RoutingKey: routingKey,
EventAction: action,
DedupKey: event.RuleID,
Payload: pagerDutyEventPayload{
Summary: summary,
Source: "sentry",
Severity: severity,
},
})
}
+48
View File
@@ -0,0 +1,48 @@
package delivery
import (
"encoding/json"
"fmt"
)
// slackPayload is Slack's incoming-webhook shape: {"text": "..."}. No
// user template for this kind -- see webhook.go's BuildPayload doc
// comment for why (thin formatter, not a user-configurable path).
type slackPayload struct {
Text string `json:"text"`
}
func buildSlackPayload(event Event) ([]byte, error) {
var text string
switch event.EventType {
case "firing":
text = fmt.Sprintf(":rotating_light: *%s* is firing", event.RuleName)
if event.Value != nil && event.ThresholdValue != nil {
text += fmt.Sprintf(" (value %.4g %s %.4g)", *event.Value, comparatorSymbol(event.Comparator), *event.ThresholdValue)
}
case "resolved":
text = fmt.Sprintf(":white_check_mark: *%s* resolved", event.RuleName)
default:
text = fmt.Sprintf("%s: %s", event.RuleName, event.EventType)
}
return json.Marshal(slackPayload{Text: text})
}
func comparatorSymbol(c string) string {
switch c {
case "gt":
return ">"
case "gte":
return ">="
case "lt":
return "<"
case "lte":
return "<="
case "eq":
return "=="
case "ne":
return "!="
default:
return c
}
}
+229
View File
@@ -0,0 +1,229 @@
// Package delivery sends notifications for firing/resolved alert
// events. All three notification_targets.kind values -- webhook, slack,
// pagerduty -- go through the exact same claim-then-POST-with-backoff
// mechanism in this file; slack.go and pagerduty.go are payload
// *formatters* only, never a separate delivery path, per
// /docs/phase-3-alerting-design.md's "thin wrappers over the generic
// webhook" requirement.
package delivery
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"text/template"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/alerting/internal/notifystore"
)
// Event is the firing/resolved occurrence a payload is rendered from.
type Event struct {
RuleID string
RuleName string
EventType string // "firing" | "resolved"
ConditionType string
Comparator string
ThresholdValue *float64
Value *float64
Timestamp time.Time
}
// defaultGenericPayload is used when a webhook target has no
// payload_template.
type defaultGenericPayload struct {
RuleID string `json:"rule_id"`
RuleName string `json:"rule_name"`
EventType string `json:"event_type"`
Value *float64 `json:"value,omitempty"`
Timestamp string `json:"timestamp"`
}
// BuildPayload dispatches by target.Kind to the right formatter. This is
// the only place kind-specific formatting logic lives -- everything
// downstream of this (worker.go's send loop) is kind-agnostic.
func BuildPayload(target notifystore.Target, event Event) ([]byte, error) {
switch target.Kind {
case notifystore.KindSlack:
return buildSlackPayload(event)
case notifystore.KindPagerDuty:
return buildPagerDutyPayload(target, event)
default: // webhook
return buildGenericPayload(target, event)
}
}
func buildGenericPayload(target notifystore.Target, event Event) ([]byte, error) {
if target.PayloadTemplate == nil || *target.PayloadTemplate == "" {
return json.Marshal(defaultGenericPayload{
RuleID: event.RuleID, RuleName: event.RuleName, EventType: event.EventType,
Value: event.Value, Timestamp: event.Timestamp.UTC().Format(time.RFC3339),
})
}
tmpl, err := template.New("payload").Parse(*target.PayloadTemplate)
if err != nil {
return nil, fmt.Errorf("parsing payload_template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, event); err != nil {
return nil, fmt.Errorf("rendering payload_template: %w", err)
}
return buf.Bytes(), nil
}
// Worker claims pending/retrying delivery_log rows and sends them.
// Deliberately separate from the evaluator: "decided to notify"
// (rulestore.ApplyTransition's transactional outbox insert) is
// transactionally certain; "the HTTP call succeeded" is best-effort with
// retries, handled entirely here.
type Worker struct {
pool *pgxpool.Pool
notifications *notifystore.Store
http *http.Client
logger *slog.Logger
}
func NewWorker(pool *pgxpool.Pool, notifications *notifystore.Store, logger *slog.Logger) *Worker {
return &Worker{pool: pool, notifications: notifications, http: &http.Client{Timeout: 10 * time.Second}, logger: logger}
}
// Run claims and sends due deliveries every tickInterval until ctx is
// cancelled.
func (w *Worker) Run(ctx context.Context, tickInterval time.Duration) error {
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
w.processDue(ctx)
}
}
}
type claimedDelivery struct {
id int64
ruleID string
notificationTargetID string
eventType string
attemptCount int
maxAttempts int
payload []byte
}
// processDue claims due rows (same SKIP LOCKED pattern as the
// evaluator's rule claim -- see rulestore.ClaimDueRules) and attempts
// delivery for each. One claim batch per tick; a stuck/slow target
// doesn't block others since each send happens independently.
func (w *Worker) processDue(ctx context.Context) {
rows, err := w.pool.Query(ctx, `
WITH due AS (
SELECT id FROM delivery_log
WHERE status IN ('pending', 'retrying') AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY created_at
LIMIT 50
FOR UPDATE SKIP LOCKED
)
UPDATE delivery_log d
SET last_attempt_at = now()
FROM due
WHERE d.id = due.id
RETURNING d.id, d.rule_id, d.notification_target_id, d.event_type, d.attempt_count, d.max_attempts, d.payload`)
if err != nil {
w.logger.Error("claiming due deliveries", "error", err)
return
}
var claimed []claimedDelivery
for rows.Next() {
var c claimedDelivery
if err := rows.Scan(&c.id, &c.ruleID, &c.notificationTargetID, &c.eventType, &c.attemptCount, &c.maxAttempts, &c.payload); err != nil {
w.logger.Error("scanning claimed delivery", "error", err)
continue
}
claimed = append(claimed, c)
}
rows.Close()
for _, c := range claimed {
w.attempt(ctx, c)
}
}
func (w *Worker) attempt(ctx context.Context, c claimedDelivery) {
target, err := w.notifications.Get(ctx, c.notificationTargetID)
if err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("looking up notification target: %v", err))
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL, bytes.NewReader(c.payload))
if err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err))
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := w.http.Do(req)
if err != nil {
w.fail(ctx, c, 0, err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
w.markSent(ctx, c.id, resp.StatusCode)
return
}
w.fail(ctx, c, resp.StatusCode, fmt.Sprintf("non-2xx response: %d", resp.StatusCode))
}
func (w *Worker) markSent(ctx context.Context, id int64, statusCode int) {
_, err := w.pool.Exec(ctx, `
UPDATE delivery_log SET status = 'sent', attempt_count = attempt_count + 1, response_status = $1 WHERE id = $2`,
statusCode, id)
if err != nil {
w.logger.Error("marking delivery sent", "delivery_id", id, "error", err)
}
}
// fail records a failed attempt. If attempts remain, it's rescheduled
// with exponential backoff (2^attempt_count seconds, capped at 1 hour);
// otherwise it's marked permanently failed.
func (w *Worker) fail(ctx context.Context, c claimedDelivery, statusCode int, errMsg string) {
nextAttempt := c.attemptCount + 1
if nextAttempt >= c.maxAttempts {
_, err := w.pool.Exec(ctx, `
UPDATE delivery_log SET status = 'failed', attempt_count = attempt_count + 1, response_status = $1, last_error = $2 WHERE id = $3`,
nullIfZero(statusCode), errMsg, c.id)
if err != nil {
w.logger.Error("marking delivery failed", "delivery_id", c.id, "error", err)
}
return
}
backoff := time.Duration(1<<uint(nextAttempt)) * time.Second
if backoff > time.Hour {
backoff = time.Hour
}
_, err := w.pool.Exec(ctx, `
UPDATE delivery_log
SET status = 'retrying', attempt_count = attempt_count + 1, response_status = $1, last_error = $2, next_attempt_at = now() + $3
WHERE id = $4`,
nullIfZero(statusCode), errMsg, backoff, c.id)
if err != nil {
w.logger.Error("scheduling delivery retry", "delivery_id", c.id, "error", err)
}
}
func nullIfZero(n int) *int {
if n == 0 {
return nil
}
return &n
}
+74
View File
@@ -0,0 +1,74 @@
package evaluator
import (
"fmt"
"github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
// evaluateCondition implements fixes 3 and 4 from
// /docs/phase-3-alerting-design.md: a threshold rule's query must
// resolve to exactly one row, and zero (or more than one) rows is
// returned as an error, never coerced to a value -- "nothing ran" and
// "ran and found nothing" are different failure/success modes, and
// conflating them can hide the more alarming case. This function never
// itself decides "condition false" on an error; it returns an error, and
// the caller (evaluator.go) routes that to rulestore.RecordError, never
// to ComputeTransition.
func evaluateCondition(rule rulestore.Rule, result *queryclient.Result) (conditionTrue bool, value *float64, err error) {
switch rule.ConditionType {
case rulestore.ConditionAbsence:
// The window is whatever earliest=/latest= the rule's own query
// already expresses -- no separate window field, per the design doc.
return len(result.Rows) == 0, nil, nil
case rulestore.ConditionThreshold:
if len(result.Rows) != 1 {
return false, nil, fmt.Errorf("threshold rule query returned %d rows, want exactly 1", len(result.Rows))
}
row := result.Rows[0]
if len(row) == 0 {
return false, nil, fmt.Errorf("threshold rule query returned a row with no columns")
}
v, ok := toFloat64(row[0])
if !ok {
return false, nil, fmt.Errorf("threshold rule's first column value %v is not numeric", row[0])
}
if rule.Comparator == nil || rule.ThresholdValue == nil {
return false, nil, fmt.Errorf("threshold rule is missing comparator or threshold_value")
}
return compare(v, *rule.Comparator, *rule.ThresholdValue), &v, nil
default:
return false, nil, fmt.Errorf("unknown condition_type %q", rule.ConditionType)
}
}
func compare(value float64, comparator rulestore.Comparator, threshold float64) bool {
switch comparator {
case rulestore.Gt:
return value > threshold
case rulestore.Gte:
return value >= threshold
case rulestore.Lt:
return value < threshold
case rulestore.Lte:
return value <= threshold
case rulestore.Eq:
return value == threshold
case rulestore.Ne:
return value != threshold
default:
return false
}
}
// toFloat64 handles the one shape /query responses actually come back
// as: Go's encoding/json always decodes JSON numbers into interface{}
// as float64, regardless of whether ClickHouse serialized an integer or
// a float -- there's no separate int64 case to handle.
func toFloat64(v any) (float64, bool) {
f, ok := v.(float64)
return f, ok
}
@@ -0,0 +1,118 @@
package evaluator
import (
"testing"
"github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
func thresholdRule(comparator rulestore.Comparator, threshold float64) rulestore.Rule {
return rulestore.Rule{
ConditionType: rulestore.ConditionThreshold,
Comparator: &comparator,
ThresholdValue: &threshold,
}
}
func TestEvaluateConditionThresholdTrue(t *testing.T) {
rule := thresholdRule(rulestore.Gt, 100)
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{150.0}}}
got, value, err := evaluateCondition(rule, result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Fatalf("expected condition true for 150 > 100")
}
if value == nil || *value != 150.0 {
t.Fatalf("expected value=150, got %v", value)
}
}
func TestEvaluateConditionThresholdFalse(t *testing.T) {
rule := thresholdRule(rulestore.Gt, 100)
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{50.0}}}
got, _, err := evaluateCondition(rule, result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Fatalf("expected condition false for 50 > 100")
}
}
// TestEvaluateConditionThresholdZeroRowsIsError pins down fix 4: zero
// rows on a threshold rule must be an error, not silently coerced to 0
// (which would make `count > 100` falsely report "fine" when actually
// nothing ran).
func TestEvaluateConditionThresholdZeroRowsIsError(t *testing.T) {
rule := thresholdRule(rulestore.Gt, 100)
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{}}
_, value, err := evaluateCondition(rule, result)
if err == nil {
t.Fatalf("expected an error for zero rows on a threshold rule, got condition evaluated with value=%v", value)
}
}
func TestEvaluateConditionThresholdMultipleRowsIsError(t *testing.T) {
rule := thresholdRule(rulestore.Gt, 100)
result := &queryclient.Result{Columns: []string{"host", "count"}, Rows: [][]any{{"h1", 50.0}, {"h2", 200.0}}}
_, _, err := evaluateCondition(rule, result)
if err == nil {
t.Fatalf("expected an error for multiple rows on a threshold rule (no per-group alerting, a named non-goal)")
}
}
func TestEvaluateConditionThresholdNonNumericIsError(t *testing.T) {
rule := thresholdRule(rulestore.Gt, 100)
result := &queryclient.Result{Columns: []string{"host"}, Rows: [][]any{{"host-01"}}}
_, _, err := evaluateCondition(rule, result)
if err == nil {
t.Fatalf("expected an error for a non-numeric first column")
}
}
func TestEvaluateConditionAbsenceTrueWhenZeroRows(t *testing.T) {
rule := rulestore.Rule{ConditionType: rulestore.ConditionAbsence}
result := &queryclient.Result{Columns: []string{"message"}, Rows: [][]any{}}
got, value, err := evaluateCondition(rule, result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Fatalf("expected absence condition true for zero rows")
}
if value != nil {
t.Fatalf("expected nil value for an absence rule, got %v", value)
}
}
func TestEvaluateConditionAbsenceFalseWhenRowsPresent(t *testing.T) {
rule := rulestore.Rule{ConditionType: rulestore.ConditionAbsence}
result := &queryclient.Result{Columns: []string{"message"}, Rows: [][]any{{"something happened"}}}
got, _, err := evaluateCondition(rule, result)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got {
t.Fatalf("expected absence condition false when rows are present")
}
}
func TestEvaluateConditionUnknownTypeIsError(t *testing.T) {
rule := rulestore.Rule{ConditionType: "bogus"}
result := &queryclient.Result{Columns: []string{"count"}, Rows: [][]any{{1.0}}}
_, _, err := evaluateCondition(rule, result)
if err == nil {
t.Fatalf("expected an error for an unknown condition_type")
}
}
+170
View File
@@ -0,0 +1,170 @@
package evaluator
import (
"context"
"log/slog"
"sync"
"time"
"github.com/sentry/sentry/alerting/internal/delivery"
"github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
// Evaluator is the ticker-driven scheduler -- a bounded worker pool, not
// a workflow engine, per the design doc's explicit instruction. Each
// tick claims up to claimBatchSize due rules (rulestore.ClaimDueRules,
// fix 1's atomic claim) and evaluates them concurrently up to
// workerPoolSize at a time. These are deliberately different numbers --
// see config.EvaluatorConfig's doc comment for the real bug this fixes
// (500 rules due at once, both capped at 20, took 125s to cycle through
// instead of the configured 60s).
type Evaluator struct {
rules *rulestore.Store
notifications *notifystore.Store
queryClient *queryclient.Client
queryTimeout time.Duration
claimBatchSize int
workerPoolSize int
logger *slog.Logger
}
func New(rules *rulestore.Store, notifications *notifystore.Store, queryClient *queryclient.Client, queryTimeout time.Duration, claimBatchSize, workerPoolSize int, logger *slog.Logger) *Evaluator {
return &Evaluator{
rules: rules, notifications: notifications, queryClient: queryClient,
queryTimeout: queryTimeout, claimBatchSize: claimBatchSize, workerPoolSize: workerPoolSize, logger: logger,
}
}
// Run ticks every tickInterval until ctx is cancelled.
func (e *Evaluator) Run(ctx context.Context, tickInterval time.Duration) error {
ticker := time.NewTicker(tickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
e.tick(ctx)
}
}
}
func (e *Evaluator) tick(ctx context.Context) {
claimed, err := e.rules.ClaimDueRules(ctx, e.claimBatchSize)
if err != nil {
e.logger.Error("claiming due rules", "error", err)
return
}
if len(claimed) == 0 {
return
}
sem := make(chan struct{}, e.workerPoolSize)
var wg sync.WaitGroup
for _, rule := range claimed {
wg.Add(1)
sem <- struct{}{}
go func(rule rulestore.RuleWithState) {
defer wg.Done()
defer func() { <-sem }()
e.evaluateOne(ctx, rule)
}(rule)
}
wg.Wait()
}
func (e *Evaluator) evaluateOne(ctx context.Context, rule rulestore.RuleWithState) {
result, err := e.queryClient.Query(ctx, rule.Query, rule.QueryLanguage, e.queryTimeout)
if err != nil {
// A failed /query call is an evaluation error, never
// "condition false" -- fix 3. Recording it here, not routing it
// through ComputeTransition at all, is what makes that guarantee
// hold structurally rather than by convention.
e.recordError(ctx, rule.ID, err.Error())
return
}
conditionTrue, value, evalErr := evaluateCondition(rule.Rule, result)
if evalErr != nil {
e.recordError(ctx, rule.ID, evalErr.Error())
return
}
var renotify *time.Duration
if rule.RenotifyIntervalMinutes != nil {
d := time.Duration(*rule.RenotifyIntervalMinutes) * time.Minute
renotify = &d
}
now := time.Now().UTC()
transition := ComputeTransition(TransitionInput{
CurrentState: rule.State.State,
ConditionTrueSince: rule.State.ConditionTrueSince,
FiredAt: rule.State.FiredAt,
LastNotifiedAt: rule.State.LastNotifiedAt,
ForMinutes: time.Duration(rule.ForMinutes) * time.Minute,
RenotifyInterval: renotify,
Now: now,
ConditionTrue: conditionTrue,
})
next := rulestore.AlertState{
State: transition.NextState,
ConditionTrueSince: transition.NextConditionTrueSince,
FiredAt: transition.NextFiredAt,
LastNotifiedAt: transition.NextLastNotifiedAt,
LastValue: value,
}
notify := e.buildNotifyEvent(ctx, rule, transition, value, now)
if err := e.rules.ApplyTransition(ctx, rule.ID, next, notify); err != nil {
e.logger.Error("applying alert state transition", "rule_id", rule.ID, "error", err)
}
}
// buildNotifyEvent resolves the notification target and renders the
// payload for a firing/resolved transition. A lookup or template
// failure here is logged and treated as "no notification this time" --
// the state still transitions correctly (that's the more important
// guarantee), it just means a misconfigured target/template silently
// drops one notification rather than blocking the whole evaluation.
func (e *Evaluator) buildNotifyEvent(ctx context.Context, rule rulestore.RuleWithState, transition TransitionResult, value *float64, now time.Time) *rulestore.NotifyEvent {
if transition.Notify == nil {
return nil
}
target, err := e.notifications.Get(ctx, rule.NotificationTargetID)
if err != nil {
e.logger.Error("looking up notification target", "rule_id", rule.ID, "target_id", rule.NotificationTargetID, "error", err)
return nil
}
comparator := ""
if rule.Comparator != nil {
comparator = string(*rule.Comparator)
}
payload, err := delivery.BuildPayload(*target, delivery.Event{
RuleID: rule.ID, RuleName: rule.Name, EventType: *transition.Notify,
ConditionType: string(rule.ConditionType), Comparator: comparator,
ThresholdValue: rule.ThresholdValue, Value: value, Timestamp: now,
})
if err != nil {
e.logger.Error("building notification payload", "rule_id", rule.ID, "error", err)
return nil
}
return &rulestore.NotifyEvent{
NotificationTargetID: rule.NotificationTargetID,
EventType: *transition.Notify,
Payload: payload,
}
}
func (e *Evaluator) recordError(ctx context.Context, ruleID, msg string) {
if err := e.rules.RecordError(ctx, ruleID, msg); err != nil {
e.logger.Error("recording evaluation error", "rule_id", ruleID, "error", err)
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package evaluator implements the ticker-driven rule scheduler and the
// firing/resolved state machine described in
// /docs/phase-3-alerting-design.md. transitions.go is deliberately pure
// (no DB, no HTTP, no clock reads beyond the `Now` passed in) --
// evaluator.go is the only caller, and it's the single place worth
// exhaustive table-driven testing given how easy this state machine is
// to get subtly wrong.
package evaluator
import (
"time"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
// TransitionInput is everything ComputeTransition needs: the rule's
// current persisted state plus this evaluation's outcome. Evaluation
// *errors* never reach this function at all -- per fix 3 in the design
// doc, an error is recorded separately (rulestore.RecordError) and never
// treated as ConditionTrue: false.
type TransitionInput struct {
CurrentState rulestore.State
ConditionTrueSince *time.Time
FiredAt *time.Time
LastNotifiedAt *time.Time
ForMinutes time.Duration
RenotifyInterval *time.Duration // nil = notify once per firing episode, never again
Now time.Time
ConditionTrue bool
}
// TransitionResult is what to persist (via rulestore.ApplyTransition)
// and, if Notify is non-nil, which event ("firing" or "resolved") to
// enqueue in the same transaction.
type TransitionResult struct {
NextState rulestore.State
NextConditionTrueSince *time.Time
NextFiredAt *time.Time
NextLastNotifiedAt *time.Time
Notify *string
}
// ComputeTransition implements the ok/pending/firing state machine.
// ConditionTrueSince is always a wall-clock timestamp, never a
// consecutive-evaluation counter -- this is what makes the for_minutes
// debounce survive evaluator restarts correctly (a counter would
// silently lose progress across downtime; wall-clock math resumes
// exactly where it left off). Don't "simplify" this into a counter.
func ComputeTransition(in TransitionInput) TransitionResult {
if in.ConditionTrue {
return computeConditionTrue(in)
}
return computeConditionFalse(in)
}
func computeConditionTrue(in TransitionInput) TransitionResult {
switch in.CurrentState {
case rulestore.StatePending:
since := in.ConditionTrueSince
if since == nil {
// Defensive: pending with no recorded since is inconsistent
// persisted state (shouldn't happen -- ok->pending always sets
// it) -- treat as if the condition just became true rather than
// dereferencing a nil or panicking.
now := in.Now
since = &now
}
if in.Now.Sub(*since) >= in.ForMinutes {
return fire(*since, in.Now)
}
return TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: since}
case rulestore.StateFiring:
result := TransitionResult{
NextState: rulestore.StateFiring,
NextConditionTrueSince: in.ConditionTrueSince,
NextFiredAt: in.FiredAt,
NextLastNotifiedAt: in.LastNotifiedAt,
}
if in.RenotifyInterval != nil && in.LastNotifiedAt != nil && in.Now.Sub(*in.LastNotifiedAt) >= *in.RenotifyInterval {
now := in.Now
event := "firing"
result.NextLastNotifiedAt = &now
result.Notify = &event
}
return result
default: // ok (or any unexpected value -- the DB CHECK constraint keeps this to 3 values)
since := in.Now
if in.ForMinutes <= 0 {
// for_minutes=0 means "fire on first true evaluation" --
// literally, not after one extra tick spent in `pending`.
return fire(since, in.Now)
}
return TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: &since}
}
}
func computeConditionFalse(in TransitionInput) TransitionResult {
switch in.CurrentState {
case rulestore.StateFiring:
event := "resolved"
return TransitionResult{NextState: rulestore.StateOK, Notify: &event}
default: // ok or pending: a false evaluation while pending is a blip
// inside the debounce window -- deliberately no notification, that's
// the entire point of for_minutes existing.
return TransitionResult{NextState: rulestore.StateOK}
}
}
func fire(since, now time.Time) TransitionResult {
firedAt := now
event := "firing"
return TransitionResult{
NextState: rulestore.StateFiring,
NextConditionTrueSince: &since,
NextFiredAt: &firedAt,
NextLastNotifiedAt: &firedAt,
Notify: &event,
}
}
@@ -0,0 +1,193 @@
package evaluator
import (
"testing"
"time"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
var t0 = time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC)
func ptr[T any](v T) *T { return &v }
func TestComputeTransition(t *testing.T) {
tests := []struct {
name string
in TransitionInput
want TransitionResult
}{
{
name: "ok, condition true, for_minutes>0 -> pending, no notify",
in: TransitionInput{
CurrentState: rulestore.StateOK, ForMinutes: 5 * time.Minute,
Now: t0, ConditionTrue: true,
},
want: TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: ptr(t0)},
},
{
name: "ok, condition true, for_minutes=0 -> fires immediately, no extra tick in pending",
in: TransitionInput{
CurrentState: rulestore.StateOK, ForMinutes: 0,
Now: t0, ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: ptr("firing"),
},
},
{
name: "pending, still within for_minutes window -> stays pending, no notify",
in: TransitionInput{
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
ForMinutes: 5 * time.Minute, Now: t0.Add(2 * time.Minute), ConditionTrue: true,
},
want: TransitionResult{NextState: rulestore.StatePending, NextConditionTrueSince: ptr(t0)},
},
{
name: "pending, exactly at for_minutes boundary -> fires (>=, not >)",
in: TransitionInput{
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
ForMinutes: 5 * time.Minute, Now: t0.Add(5 * time.Minute), ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
NextFiredAt: ptr(t0.Add(5 * time.Minute)), NextLastNotifiedAt: ptr(t0.Add(5 * time.Minute)), Notify: ptr("firing"),
},
},
{
name: "pending, past for_minutes -> fires",
in: TransitionInput{
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
ForMinutes: 5 * time.Minute, Now: t0.Add(10 * time.Minute), ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
NextFiredAt: ptr(t0.Add(10 * time.Minute)), NextLastNotifiedAt: ptr(t0.Add(10 * time.Minute)), Notify: ptr("firing"),
},
},
{
name: "firing, condition still true, no renotify interval -> stays firing, silent, preserves fired_at/last_notified",
in: TransitionInput{
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
RenotifyInterval: nil, Now: t0.Add(time.Hour), ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: nil,
},
},
{
name: "firing, condition still true, renotify interval not yet elapsed -> stays firing, silent",
in: TransitionInput{
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
RenotifyInterval: ptr(4 * time.Hour), Now: t0.Add(time.Hour), ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0), Notify: nil,
},
},
{
name: "firing, condition still true, renotify interval elapsed -> re-fires, updates last_notified only",
in: TransitionInput{
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
RenotifyInterval: ptr(4 * time.Hour), Now: t0.Add(5 * time.Hour), ConditionTrue: true,
},
want: TransitionResult{
NextState: rulestore.StateFiring, NextConditionTrueSince: ptr(t0),
// fired_at (the episode start) is NOT bumped on a renotify -- only last_notified_at moves.
NextFiredAt: ptr(t0), NextLastNotifiedAt: ptr(t0.Add(5 * time.Hour)), Notify: ptr("firing"),
},
},
{
name: "ok, condition false -> stays ok, no-op",
in: TransitionInput{CurrentState: rulestore.StateOK, Now: t0, ConditionTrue: false},
want: TransitionResult{NextState: rulestore.StateOK},
},
{
name: "pending, condition false -> back to ok, NO notification (a blip inside the debounce window)",
in: TransitionInput{
CurrentState: rulestore.StatePending, ConditionTrueSince: ptr(t0),
Now: t0.Add(time.Minute), ConditionTrue: false,
},
want: TransitionResult{NextState: rulestore.StateOK},
},
{
name: "firing, condition false -> resolves, sends resolved notification",
in: TransitionInput{
CurrentState: rulestore.StateFiring, ConditionTrueSince: ptr(t0), FiredAt: ptr(t0), LastNotifiedAt: ptr(t0),
Now: t0.Add(time.Hour), ConditionTrue: false,
},
want: TransitionResult{NextState: rulestore.StateOK, Notify: ptr("resolved")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeTransition(tt.in)
if got.NextState != tt.want.NextState {
t.Errorf("NextState = %v, want %v", got.NextState, tt.want.NextState)
}
if !timePtrEqual(got.NextConditionTrueSince, tt.want.NextConditionTrueSince) {
t.Errorf("NextConditionTrueSince = %v, want %v", got.NextConditionTrueSince, tt.want.NextConditionTrueSince)
}
if !timePtrEqual(got.NextFiredAt, tt.want.NextFiredAt) {
t.Errorf("NextFiredAt = %v, want %v", got.NextFiredAt, tt.want.NextFiredAt)
}
if !timePtrEqual(got.NextLastNotifiedAt, tt.want.NextLastNotifiedAt) {
t.Errorf("NextLastNotifiedAt = %v, want %v", got.NextLastNotifiedAt, tt.want.NextLastNotifiedAt)
}
if !strPtrEqual(got.Notify, tt.want.Notify) {
t.Errorf("Notify = %v, want %v", strPtrDeref(got.Notify), strPtrDeref(tt.want.Notify))
}
})
}
}
// TestPendingConditionTrueSinceIsWallClockNotCounter pins down the
// specific property the design doc calls out as easy to accidentally
// "simplify" away: debounce progress survives an evaluator gap (e.g.
// downtime) because condition_true_since is a timestamp, not a tick
// count. Two evaluations three hours apart, both condition=true, with a
// for_minutes=5m debounce -- the second evaluation must fire immediately
// because wall-clock time already satisfies the debounce, regardless of
// how many (or how few) evaluations happened in between.
func TestPendingConditionTrueSinceIsWallClockNotCounter(t *testing.T) {
first := ComputeTransition(TransitionInput{
CurrentState: rulestore.StateOK, ForMinutes: 5 * time.Minute, Now: t0, ConditionTrue: true,
})
if first.NextState != rulestore.StatePending {
t.Fatalf("first eval: state = %v, want pending", first.NextState)
}
// Simulate a large gap (evaluator downtime) before the next evaluation.
second := ComputeTransition(TransitionInput{
CurrentState: rulestore.StatePending, ConditionTrueSince: first.NextConditionTrueSince,
ForMinutes: 5 * time.Minute, Now: t0.Add(3 * time.Hour), ConditionTrue: true,
})
if second.NextState != rulestore.StateFiring {
t.Fatalf("second eval after gap: state = %v, want firing (wall-clock debounce should already be satisfied)", second.NextState)
}
}
func timePtrEqual(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}
func strPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func strPtrDeref(s *string) string {
if s == nil {
return "<nil>"
}
return *s
}
+261
View File
@@ -0,0 +1,261 @@
// Package httpapi is alerting's REST surface: rule CRUD, notification
// target CRUD, and a read-only delivery log -- mirrors
// api/internal/dashboards' Handler/Store shape (narrow interfaces,
// pgx-backed production implementation, fakes in tests).
package httpapi
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
type ruleStore interface {
Create(ctx context.Context, r *rulestore.Rule) error
List(ctx context.Context) ([]rulestore.RuleWithState, error)
Get(ctx context.Context, id string) (*rulestore.RuleWithState, error)
Delete(ctx context.Context, id string) error
}
type targetStore interface {
Create(ctx context.Context, t *notifystore.Target) error
List(ctx context.Context) ([]notifystore.Target, error)
Get(ctx context.Context, id string) (*notifystore.Target, error)
Delete(ctx context.Context, id string) error
}
type deliveryReader interface {
ListForRule(ctx context.Context, ruleID string, limit int) ([]rulestore.DeliveryLogEntry, error)
}
type Handler struct {
logger *slog.Logger
rules ruleStore
targets targetStore
deliveries deliveryReader
}
func NewHandler(logger *slog.Logger, rules ruleStore, targets targetStore, deliveries deliveryReader) *Handler {
return &Handler{logger: logger, rules: rules, targets: targets, deliveries: deliveries}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", h.handleHealthz)
mux.HandleFunc("POST /rules", h.handleCreateRule)
mux.HandleFunc("GET /rules", h.handleListRules)
mux.HandleFunc("GET /rules/{id}", h.handleGetRule)
mux.HandleFunc("DELETE /rules/{id}", h.handleDeleteRule)
mux.HandleFunc("GET /rules/{id}/deliveries", h.handleListDeliveries)
mux.HandleFunc("POST /targets", h.handleCreateTarget)
mux.HandleFunc("GET /targets", h.handleListTargets)
mux.HandleFunc("GET /targets/{id}", h.handleGetTarget)
mux.HandleFunc("DELETE /targets/{id}", h.handleDeleteTarget)
}
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as api/internal/queryapi and dashboards
func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
// createRuleRequest mirrors rulestore.Rule except Enabled is a pointer:
// a plain bool can't distinguish "omitted" from "explicitly false" on
// decode, and Go's zero value for bool is false -- without this, a
// create request that simply doesn't mention "enabled" would silently
// create a rule the evaluator's claim query never picks up (found by
// actually creating a rule through this endpoint and checking the
// response). Omitted means enabled; only an explicit "enabled": false
// creates a disabled rule.
type createRuleRequest struct {
rulestore.Rule
Enabled *bool `json:"enabled"`
}
func (h *Handler) handleCreateRule(w http.ResponseWriter, r *http.Request) {
var req createRuleRequest
if !decodeJSON(w, r, &req) {
return
}
rule := req.Rule
rule.Enabled = req.Enabled == nil || *req.Enabled
if err := validateRule(&rule); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := h.rules.Create(r.Context(), &rule); err != nil {
h.logger.Error("creating rule", "error", err)
writeError(w, http.StatusInternalServerError, "creating rule failed")
return
}
writeJSON(w, http.StatusCreated, rule)
}
func (h *Handler) handleListRules(w http.ResponseWriter, r *http.Request) {
rules, err := h.rules.List(r.Context())
if err != nil {
h.logger.Error("listing rules", "error", err)
writeError(w, http.StatusInternalServerError, "listing rules failed")
return
}
writeJSON(w, http.StatusOK, rules)
}
func (h *Handler) handleGetRule(w http.ResponseWriter, r *http.Request) {
rule, err := h.rules.Get(r.Context(), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching rule")
return
}
writeJSON(w, http.StatusOK, rule)
}
func (h *Handler) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
if err := h.rules.Delete(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting rule")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) handleListDeliveries(w http.ResponseWriter, r *http.Request) {
entries, err := h.deliveries.ListForRule(r.Context(), r.PathValue("id"), 100)
if err != nil {
h.logger.Error("listing deliveries", "rule_id", r.PathValue("id"), "error", err)
writeError(w, http.StatusInternalServerError, "listing deliveries failed")
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
var target notifystore.Target
if !decodeJSON(w, r, &target) {
return
}
if target.Name == "" {
writeError(w, http.StatusBadRequest, "name must not be empty")
return
}
if !notifystore.ValidKind(target.Kind) {
writeError(w, http.StatusBadRequest, "kind must be one of webhook, slack, pagerduty")
return
}
if target.WebhookURL == "" {
writeError(w, http.StatusBadRequest, "webhook_url must not be empty")
return
}
if err := h.targets.Create(r.Context(), &target); err != nil {
h.logger.Error("creating notification target", "error", err)
writeError(w, http.StatusInternalServerError, "creating notification target failed")
return
}
writeJSON(w, http.StatusCreated, target)
}
func (h *Handler) handleListTargets(w http.ResponseWriter, r *http.Request) {
targets, err := h.targets.List(r.Context())
if err != nil {
h.logger.Error("listing notification targets", "error", err)
writeError(w, http.StatusInternalServerError, "listing notification targets failed")
return
}
writeJSON(w, http.StatusOK, targets)
}
func (h *Handler) handleGetTarget(w http.ResponseWriter, r *http.Request) {
target, err := h.targets.Get(r.Context(), r.PathValue("id"))
if err != nil {
h.writeStoreErr(w, err, "fetching notification target")
return
}
writeJSON(w, http.StatusOK, target)
}
func (h *Handler) handleDeleteTarget(w http.ResponseWriter, r *http.Request) {
if err := h.targets.Delete(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting notification target")
return
}
w.WriteHeader(http.StatusNoContent)
}
// validateRule enforces the shape the evaluator assumes:
// condition_type-appropriate fields present, a sane interval floor.
// Mirrors the DB CHECK constraints so bad input is rejected with a
// clear message here rather than surfacing as an opaque constraint
// violation from Postgres.
func validateRule(r *rulestore.Rule) error {
if r.Name == "" {
return errBadRequest("name must not be empty")
}
if r.Query == "" {
return errBadRequest("query must not be empty")
}
if r.NotificationTargetID == "" {
return errBadRequest("notification_target_id must not be empty")
}
if r.EvalIntervalSeconds < 30 {
return errBadRequest("eval_interval_seconds must be at least 30")
}
switch r.ConditionType {
case rulestore.ConditionThreshold:
if r.Comparator == nil || !rulestore.ValidComparator(*r.Comparator) {
return errBadRequest("threshold rules require a valid comparator (gt, gte, lt, lte, eq, ne)")
}
if r.ThresholdValue == nil {
return errBadRequest("threshold rules require threshold_value")
}
case rulestore.ConditionAbsence:
// No comparator/threshold_value needed -- absence is "the query
// returned zero rows in its own earliest=/latest= window."
default:
return errBadRequest("condition_type must be \"threshold\" or \"absence\"")
}
return nil
}
type badRequestError string
func (e badRequestError) Error() string { return string(e) }
func errBadRequest(msg string) error { return badRequestError(msg) }
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, rulestore.ErrNotFound) || errors.Is(err, notifystore.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})
}
+261
View File
@@ -0,0 +1,261 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/rulestore"
)
type fakeRuleStore struct {
rules map[string]*rulestore.RuleWithState
}
func newFakeRuleStore() *fakeRuleStore {
return &fakeRuleStore{rules: map[string]*rulestore.RuleWithState{}}
}
func (f *fakeRuleStore) Create(_ context.Context, r *rulestore.Rule) error {
r.ID = "rule-1"
f.rules[r.ID] = &rulestore.RuleWithState{Rule: *r, State: rulestore.AlertState{RuleID: r.ID, State: rulestore.StateOK}}
return nil
}
func (f *fakeRuleStore) List(_ context.Context) ([]rulestore.RuleWithState, error) {
var out []rulestore.RuleWithState
for _, r := range f.rules {
out = append(out, *r)
}
return out, nil
}
func (f *fakeRuleStore) Get(_ context.Context, id string) (*rulestore.RuleWithState, error) {
r, ok := f.rules[id]
if !ok {
return nil, rulestore.ErrNotFound
}
return r, nil
}
func (f *fakeRuleStore) Delete(_ context.Context, id string) error {
if _, ok := f.rules[id]; !ok {
return rulestore.ErrNotFound
}
delete(f.rules, id)
return nil
}
type fakeTargetStore struct {
targets map[string]*notifystore.Target
}
func newFakeTargetStore() *fakeTargetStore {
return &fakeTargetStore{targets: map[string]*notifystore.Target{}}
}
func (f *fakeTargetStore) Create(_ context.Context, t *notifystore.Target) error {
t.ID = "target-1"
f.targets[t.ID] = t
return nil
}
func (f *fakeTargetStore) List(_ context.Context) ([]notifystore.Target, error) {
var out []notifystore.Target
for _, t := range f.targets {
out = append(out, *t)
}
return out, nil
}
func (f *fakeTargetStore) Get(_ context.Context, id string) (*notifystore.Target, error) {
t, ok := f.targets[id]
if !ok {
return nil, notifystore.ErrNotFound
}
return t, nil
}
func (f *fakeTargetStore) Delete(_ context.Context, id string) error {
if _, ok := f.targets[id]; !ok {
return notifystore.ErrNotFound
}
delete(f.targets, id)
return nil
}
type fakeDeliveryReader struct {
entries []rulestore.DeliveryLogEntry
}
func (f *fakeDeliveryReader) ListForRule(_ context.Context, _ string, _ int) ([]rulestore.DeliveryLogEntry, error) {
return f.entries, nil
}
func newTestMux(rules ruleStore, targets targetStore, deliveries deliveryReader) *http.ServeMux {
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), rules, targets, deliveries)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestCreateThresholdRule(t *testing.T) {
targets := newFakeTargetStore()
targets.targets["target-1"] = &notifystore.Target{ID: "target-1"}
mux := newTestMux(newFakeRuleStore(), targets, &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "High error rate", "query": "service=api | where status>=500 | stats count",
"condition_type": "threshold", "comparator": "gt", "threshold_value": 100,
"eval_interval_seconds": 60, "notification_target_id": "target-1"
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
}
// TestCreateRuleDefaultsToEnabledWhenOmitted guards against a real bug
// caught by actually calling this endpoint: a plain `bool` JSON field
// can't distinguish "omitted" from "explicitly false," and Go's zero
// value for bool is false -- without createRuleRequest's *bool handling,
// a create request that simply didn't mention "enabled" silently created
// a rule the evaluator's claim query would never pick up.
func TestCreateRuleDefaultsToEnabledWhenOmitted(t *testing.T) {
rules := newFakeRuleStore()
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "no enabled field", "query": "service=api", "condition_type": "absence",
"eval_interval_seconds": 60, "notification_target_id": "target-1"
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if !rules.rules["rule-1"].Enabled {
t.Fatalf("expected a rule created without an explicit \"enabled\" field to default to enabled=true")
}
}
func TestCreateRuleRespectsExplicitDisabled(t *testing.T) {
rules := newFakeRuleStore()
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "explicitly disabled", "query": "service=api", "condition_type": "absence",
"eval_interval_seconds": 60, "notification_target_id": "target-1", "enabled": false
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if rules.rules["rule-1"].Enabled {
t.Fatalf("expected an explicit \"enabled\": false to be respected")
}
}
func TestCreateThresholdRuleRejectsMissingComparator(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "bad rule", "query": "service=api", "condition_type": "threshold",
"eval_interval_seconds": 60, "notification_target_id": "target-1"
}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateAbsenceRuleDoesNotRequireComparator(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "no heartbeat", "query": "service=payments earliest=-5m", "condition_type": "absence",
"eval_interval_seconds": 60, "notification_target_id": "target-1"
}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateRuleRejectsShortInterval(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/rules", `{
"name": "too fast", "query": "service=api", "condition_type": "absence",
"eval_interval_seconds": 5, "notification_target_id": "target-1"
}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestGetRuleNotFound(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodGet, "/rules/nope", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestDeleteRule(t *testing.T) {
rules := newFakeRuleStore()
rules.rules["rule-1"] = &rulestore.RuleWithState{Rule: rulestore.Rule{ID: "rule-1"}}
mux := newTestMux(rules, newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodDelete, "/rules/rule-1", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if _, ok := rules.rules["rule-1"]; ok {
t.Fatalf("expected rule to be deleted")
}
}
func TestCreateTargetRejectsInvalidKind(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "carrier-pigeon", "webhook_url": "https://example.com"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateSlackTarget(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://hooks.slack.com/services/x"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
}
func TestListDeliveriesForRule(t *testing.T) {
deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{
{ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"},
}}
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), deliveries)
rec := doRequest(t, mux, http.MethodGet, "/rules/rule-1/deliveries", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"status":"sent"`) {
t.Fatalf("expected delivery entry in response, got: %s", rec.Body.String())
}
}
func TestHandleHealthz(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodGet, "/healthz", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Package httpserver holds cross-handler HTTP concerns for /alerting.
// Deliberately duplicated from api/internal/httpserver rather than
// shared -- see /docs/phase-3-alerting-design.md's component-boundary
// note: no shared Go store/HTTP code between api and alerting, the same
// repo convention that only /proto is shared code.
package httpserver
import "net/http"
// WithCORS is deliberately permissive by default, same posture and
// reasoning as api's: no auth yet, the SvelteKit dev server runs on a
// different origin. Tighten alongside adding real auth.
func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+114
View File
@@ -0,0 +1,114 @@
// Package notifystore is pgx-backed CRUD for notification_targets.
package notifystore
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
type Kind string
const (
KindWebhook Kind = "webhook"
KindSlack Kind = "slack"
KindPagerDuty Kind = "pagerduty"
)
func ValidKind(k Kind) bool {
switch k {
case KindWebhook, KindSlack, KindPagerDuty:
return true
default:
return false
}
}
type Target struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Kind Kind `json:"kind"`
WebhookURL string `json:"webhook_url"`
PayloadTemplate *string `json:"payload_template,omitempty"`
Headers json.RawMessage `json:"headers,omitempty"`
Secret *string `json:"secret,omitempty"`
CreatedBy string `json:"created_by"`
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Create(ctx context.Context, t *Target) error {
t.ID = uuid.NewString()
if t.TenantID == "" {
t.TenantID = "default"
}
if t.CreatedBy == "" {
t.CreatedBy = "anonymous"
}
if len(t.Headers) == 0 {
t.Headers = json.RawMessage(`{}`)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO notification_targets (id, tenant_id, name, kind, webhook_url, payload_template, headers, secret, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
t.ID, t.TenantID, t.Name, t.Kind, t.WebhookURL, t.PayloadTemplate, t.Headers, t.Secret, t.CreatedBy)
return err
}
func (s *Store) List(ctx context.Context) ([]Target, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, name, kind, webhook_url, payload_template, headers, secret, created_by
FROM notification_targets ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.ID, &t.TenantID, &t.Name, &t.Kind, &t.WebhookURL, &t.PayloadTemplate, &t.Headers, &t.Secret, &t.CreatedBy); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id string) (*Target, error) {
var t Target
row := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, kind, webhook_url, payload_template, headers, secret, created_by
FROM notification_targets WHERE id = $1`, id)
if err := row.Scan(&t.ID, &t.TenantID, &t.Name, &t.Kind, &t.WebhookURL, &t.PayloadTemplate, &t.Headers, &t.Secret, &t.CreatedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &t, nil
}
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM notification_targets WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
// Package queryclient is a thin HTTP client to /api's POST /query --
// alerting never imports querylang or talks to ClickHouse/Tantivy
// directly, same precedent sentryctl query and the web UI's dashboard
// panels already set: one query-execution path, reused everywhere.
package queryclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Result struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
}
type errorResponse struct {
Error string `json:"error"`
}
type Client struct {
baseURL string
http *http.Client
}
func New(baseURL string) *Client {
return &Client{baseURL: baseURL, http: &http.Client{}}
}
// Query runs query (already time-range-injected by the caller, if
// applicable) against /api's POST /query and returns the result. A
// non-2xx response or a request/decode failure is returned as an error
// -- callers (the evaluator) treat any error here as an evaluation
// error, never as "condition false" (see
// /docs/phase-3-alerting-design.md's fix 3).
func (c *Client) Query(ctx context.Context, query, language string, timeout time.Duration) (*Result, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
body, err := json.Marshal(map[string]string{"query": query, "language": language})
if err != nil {
return nil, fmt.Errorf("encoding query request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/query", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("building query request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("calling api /query: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errBody errorResponse
_ = json.NewDecoder(resp.Body).Decode(&errBody)
if errBody.Error != "" {
return nil, fmt.Errorf("api /query failed (%d): %s", resp.StatusCode, errBody.Error)
}
return nil, fmt.Errorf("api /query failed with status %d", resp.StatusCode)
}
var result Result
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decoding query response: %w", err)
}
return &result, nil
}
+357
View File
@@ -0,0 +1,357 @@
// Package rulestore is pgx-backed CRUD for alert_rules/alert_state, plus
// the two operations that make the evaluator's concurrency and delivery
// guarantees hold (see /docs/phase-3-alerting-design.md's fixes 1 and 2):
// ClaimDueRules (atomic claim-before-evaluate) and ApplyTransition (the
// state transition and the delivery_log outbox insert in one DB
// transaction).
package rulestore
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
type ConditionType string
const (
ConditionThreshold ConditionType = "threshold"
ConditionAbsence ConditionType = "absence"
)
func ValidConditionType(c ConditionType) bool {
return c == ConditionThreshold || c == ConditionAbsence
}
type Comparator string
const (
Gt Comparator = "gt"
Gte Comparator = "gte"
Lt Comparator = "lt"
Lte Comparator = "lte"
Eq Comparator = "eq"
Ne Comparator = "ne"
)
func ValidComparator(c Comparator) bool {
switch c {
case Gt, Gte, Lt, Lte, Eq, Ne:
return true
default:
return false
}
}
type State string
const (
StateOK State = "ok"
StatePending State = "pending"
StateFiring State = "firing"
)
type Rule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
QueryLanguage string `json:"query_language"`
ConditionType ConditionType `json:"condition_type"`
Comparator *Comparator `json:"comparator,omitempty"`
ThresholdValue *float64 `json:"threshold_value,omitempty"`
EvalIntervalSeconds int `json:"eval_interval_seconds"`
ForMinutes int `json:"for_minutes"`
RenotifyIntervalMinutes *int `json:"renotify_interval_minutes,omitempty"`
NotificationTargetID string `json:"notification_target_id"`
Enabled bool `json:"enabled"`
CreatedBy string `json:"created_by"`
}
type AlertState struct {
RuleID string `json:"rule_id"`
State State `json:"state"`
ConditionTrueSince *time.Time `json:"condition_true_since,omitempty"`
FiredAt *time.Time `json:"fired_at,omitempty"`
LastNotifiedAt *time.Time `json:"last_notified_at,omitempty"`
LastEvaluatedAt *time.Time `json:"last_evaluated_at,omitempty"`
LastEvalStatus string `json:"last_eval_status"`
LastError *string `json:"last_error,omitempty"`
LastValue *float64 `json:"last_value,omitempty"`
ConsecutiveErrors int `json:"consecutive_errors"`
}
// RuleWithState is a rule joined with its current live state, exactly
// what the evaluator needs to run one evaluation and what the read API
// returns.
type RuleWithState struct {
Rule
State AlertState `json:"state"`
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create inserts the rule and its initial alert_state row in one
// transaction. A rule with no alert_state row is silently never picked
// up by ClaimDueRules -- see the design doc's explicit warning about
// this exact failure mode.
func (s *Store) Create(ctx context.Context, r *Rule) error {
r.ID = uuid.NewString()
if r.TenantID == "" {
r.TenantID = "default"
}
if r.CreatedBy == "" {
r.CreatedBy = "anonymous"
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO alert_rules (id, tenant_id, name, description, query, query_language, condition_type,
comparator, threshold_value, eval_interval_seconds, for_minutes,
renotify_interval_minutes, notification_target_id, enabled, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
r.ID, r.TenantID, r.Name, r.Description, r.Query, r.QueryLanguage, r.ConditionType,
r.Comparator, r.ThresholdValue, r.EvalIntervalSeconds, r.ForMinutes,
r.RenotifyIntervalMinutes, r.NotificationTargetID, r.Enabled, r.CreatedBy)
if err != nil {
return fmt.Errorf("inserting rule: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO alert_state (rule_id, state, last_eval_status, next_eval_at)
VALUES ($1, 'ok', 'ok', now())`, r.ID)
if err != nil {
return fmt.Errorf("inserting initial alert_state: %w", err)
}
return tx.Commit(ctx)
}
func (s *Store) List(ctx context.Context) ([]RuleWithState, error) {
rows, err := s.pool.Query(ctx, ruleWithStateSelect+" ORDER BY r.created_at DESC")
if err != nil {
return nil, err
}
defer rows.Close()
var out []RuleWithState
for rows.Next() {
rs, err := scanRuleWithState(rows)
if err != nil {
return nil, err
}
out = append(out, rs)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id string) (*RuleWithState, error) {
rows, err := s.pool.Query(ctx, ruleWithStateSelect+" WHERE r.id = $1", id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, ErrNotFound
}
rs, err := scanRuleWithState(rows)
if err != nil {
return nil, err
}
return &rs, nil
}
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM alert_rules WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
const ruleWithStateSelect = `
SELECT r.id, r.tenant_id, r.name, r.description, r.query, r.query_language, r.condition_type,
r.comparator, r.threshold_value, r.eval_interval_seconds, r.for_minutes,
r.renotify_interval_minutes, r.notification_target_id, r.enabled, r.created_by,
s.state, s.condition_true_since, s.fired_at, s.last_notified_at, s.last_evaluated_at,
s.last_eval_status, s.last_error, s.last_value, s.consecutive_errors
FROM alert_rules r JOIN alert_state s ON s.rule_id = r.id`
func scanRuleWithState(rows pgx.Rows) (RuleWithState, error) {
var rs RuleWithState
err := rows.Scan(
&rs.ID, &rs.TenantID, &rs.Name, &rs.Description, &rs.Query, &rs.QueryLanguage, &rs.ConditionType,
&rs.Comparator, &rs.ThresholdValue, &rs.EvalIntervalSeconds, &rs.ForMinutes,
&rs.RenotifyIntervalMinutes, &rs.NotificationTargetID, &rs.Enabled, &rs.CreatedBy,
&rs.State.State, &rs.State.ConditionTrueSince, &rs.State.FiredAt, &rs.State.LastNotifiedAt, &rs.State.LastEvaluatedAt,
&rs.State.LastEvalStatus, &rs.State.LastError, &rs.State.LastValue, &rs.State.ConsecutiveErrors)
rs.State.RuleID = rs.ID
return rs, err
}
// ClaimDueRules atomically claims up to batchSize due, enabled rules --
// see /docs/phase-3-alerting-design.md's fix 1. next_eval_at is bumped
// forward *before* returning, so a second scheduler tick (or a second
// evaluator replica, later) can't re-select the same rule while this
// one is still being evaluated. SKIP LOCKED means a concurrent claim
// query never blocks on rows another claim already has locked -- it
// just moves on to the next candidate.
func (s *Store) ClaimDueRules(ctx context.Context, batchSize int) ([]RuleWithState, error) {
rows, err := s.pool.Query(ctx, `
WITH due AS (
SELECT s.rule_id
FROM alert_state s
JOIN alert_rules r ON r.id = s.rule_id
WHERE s.next_eval_at <= now() AND r.enabled
ORDER BY s.next_eval_at
LIMIT $1
FOR UPDATE OF s SKIP LOCKED
)
UPDATE alert_state s
SET next_eval_at = now() + make_interval(secs => r.eval_interval_seconds),
claimed_at = now()
FROM due, alert_rules r
WHERE s.rule_id = due.rule_id AND r.id = s.rule_id
RETURNING r.id, r.tenant_id, r.name, r.description, r.query, r.query_language, r.condition_type,
r.comparator, r.threshold_value, r.eval_interval_seconds, r.for_minutes,
r.renotify_interval_minutes, r.notification_target_id, r.enabled, r.created_by,
s.state, s.condition_true_since, s.fired_at, s.last_notified_at, s.last_evaluated_at,
s.last_eval_status, s.last_error, s.last_value, s.consecutive_errors`,
batchSize)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RuleWithState
for rows.Next() {
rs, err := scanRuleWithState(rows)
if err != nil {
return nil, err
}
out = append(out, rs)
}
return out, rows.Err()
}
// NotifyEvent, if non-nil, is inserted into delivery_log in the same
// transaction as the state update -- the transactional outbox from fix
// 2. Payload is the already-rendered notification body (rulestore
// doesn't know how to format one; that's internal/delivery's job).
type NotifyEvent struct {
NotificationTargetID string
EventType string // "firing" | "resolved"
Payload []byte // JSON
}
// ApplyTransition writes a successful evaluation's outcome: the new
// alert_state fields, and -- atomically, in the same transaction -- a
// pending delivery_log row if notify is non-nil. See fix 2: "decided to
// notify" becomes durable exactly once, in lockstep with the state
// change, before any network call to a notification target is
// attempted.
func (s *Store) ApplyTransition(ctx context.Context, ruleID string, next AlertState, notify *NotifyEvent) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE alert_state SET
state = $1, condition_true_since = $2, fired_at = $3, last_notified_at = $4,
last_evaluated_at = now(), last_eval_status = 'ok', last_error = NULL,
last_value = $5, consecutive_errors = 0
WHERE rule_id = $6`,
next.State, next.ConditionTrueSince, next.FiredAt, next.LastNotifiedAt, next.LastValue, ruleID)
if err != nil {
return fmt.Errorf("updating alert_state: %w", err)
}
if notify != nil {
_, err = tx.Exec(ctx, `
INSERT INTO delivery_log (rule_id, notification_target_id, event_type, status, next_attempt_at, payload)
VALUES ($1, $2, $3, 'pending', now(), $4)`,
ruleID, notify.NotificationTargetID, notify.EventType, notify.Payload)
if err != nil {
return fmt.Errorf("inserting delivery_log outbox row: %w", err)
}
}
return tx.Commit(ctx)
}
// DeliveryLogEntry is the read shape the web UI's delivery log view and
// the httpapi read endpoint use -- deliberately excludes `payload`
// (the rendered notification body isn't needed for the list view, and
// keeping it out of the default read path avoids echoing
// target-specific formatting, e.g. a rendered webhook secret, back over
// HTTP by default).
type DeliveryLogEntry struct {
ID int64 `json:"id"`
RuleID string `json:"rule_id"`
NotificationTargetID string `json:"notification_target_id"`
EventType string `json:"event_type"`
Status string `json:"status"`
AttemptCount int `json:"attempt_count"`
LastError *string `json:"last_error,omitempty"`
ResponseStatus *int `json:"response_status,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ListForRule reads delivery_log's UI-visible role -- "why didn't I get
// paged" -- most recent first.
func (s *Store) ListForRule(ctx context.Context, ruleID string, limit int) ([]DeliveryLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, rule_id, notification_target_id, event_type, status, attempt_count, last_error, response_status, created_at
FROM delivery_log WHERE rule_id = $1 ORDER BY created_at DESC LIMIT $2`, ruleID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DeliveryLogEntry
for rows.Next() {
var e DeliveryLogEntry
if err := rows.Scan(&e.ID, &e.RuleID, &e.NotificationTargetID, &e.EventType, &e.Status, &e.AttemptCount, &e.LastError, &e.ResponseStatus, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// RecordError updates only the error-tracking fields on alert_state --
// state itself is left untouched, per fix 3: an evaluation error must
// never transition state (an error is not "condition false").
func (s *Store) RecordError(ctx context.Context, ruleID, errMsg string) error {
_, err := s.pool.Exec(ctx, `
UPDATE alert_state SET
last_evaluated_at = now(), last_eval_status = 'error', last_error = $1,
consecutive_errors = consecutive_errors + 1
WHERE rule_id = $2`, errMsg, ruleID)
return err
}
+61 -3
View File
@@ -7,18 +7,23 @@ package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/internal/querylang/executor"
"github.com/sentry/sentry/api/internal/dashboards"
"github.com/sentry/sentry/api/internal/httpserver"
"github.com/sentry/sentry/api/internal/queryapi"
"github.com/sentry/sentry/api/internal/querylang/executor"
"github.com/sentry/sentry/api/internal/searchclient"
)
@@ -31,6 +36,16 @@ func main() {
os.Exit(1)
}
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
// flag anyone runs by hand. The api image is distroless (no shell,
// no wget/curl -- see api/Dockerfile), so docker-compose's
// healthcheck execs this binary against itself instead of an
// external tool. Exits before any ClickHouse/Postgres/search dial,
// since those aren't what "is the HTTP server up" is asking.
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -60,12 +75,33 @@ func main() {
}
defer search.Close()
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
pgPool, err := pgxpool.New(ctx, pgDSN)
if err != nil {
logger.Error("opening postgres pool", "error", err)
os.Exit(1)
}
defer pgPool.Close()
if err := pgPool.Ping(ctx); err != nil {
logger.Error("pinging postgres", "error", err)
os.Exit(1)
}
sqlRunner := executor.NewChRunner(conn)
handler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout, cfg.CORSAllowedOrigin)
queryHandler := queryapi.NewHandler(logger, sqlRunner, search, cfg.QueryTimeout)
dashboardsHandler := dashboards.NewHandler(logger, dashboards.NewStore(pgPool))
// One shared mux, CORS applied once around the whole thing -- see
// internal/httpserver's doc comment for why this changed from each
// handler wrapping itself individually.
mux := http.NewServeMux()
queryHandler.RegisterRoutes(mux)
dashboardsHandler.RegisterRoutes(mux)
srv := &http.Server{
Addr: cfg.HTTPListenAddr,
Handler: handler.Routes(),
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
}
errCh := make(chan error, 1)
@@ -88,3 +124,25 @@ func main() {
}
}
}
// runHealthcheck GETs its own /healthz and returns an exit code, for
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
// "localhost" replaces a bare host part since that's this same
// container reaching itself, not another service.
func runHealthcheck(listenAddr string) int {
addr := listenAddr
if strings.HasPrefix(addr, ":") {
addr = "localhost" + addr
}
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + addr + "/healthz")
if err != nil {
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}
+5
View File
@@ -17,6 +17,10 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
@@ -25,6 +29,7 @@ require (
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+17
View File
@@ -6,6 +6,7 @@ github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtn
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
@@ -22,18 +23,30 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -52,6 +65,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
@@ -64,5 +79,7 @@ google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+17
View File
@@ -12,6 +12,7 @@ import (
type Config struct {
HTTPListenAddr string
ClickHouse ClickHouseConfig
Postgres PostgresConfig
SearchGRPCAddr string
QueryTimeout time.Duration
CORSAllowedOrigin string
@@ -24,6 +25,16 @@ type ClickHouseConfig struct {
Password string
}
// PostgresConfig is the control-plane metadata store (dashboards, panels
// -- see /docs/phase-3-dashboard-design.md), distinct from ClickHouse
// which remains log-data-only.
type PostgresConfig struct {
Addr string
Database string
Username string
Password string
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
@@ -33,6 +44,12 @@ func Load() (Config, error) {
Username: getenv("CLICKHOUSE_USERNAME", "default"),
Password: getenv("CLICKHOUSE_PASSWORD", ""),
},
Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"),
Password: getenv("POSTGRES_PASSWORD", ""),
},
// Search service's gRPC address (see /search) -- default matches
// /search's own default GRPC_LISTEN_ADDR.
SearchGRPCAddr: getenv("SEARCH_GRPC_ADDR", "localhost:50052"),
+212
View File
@@ -0,0 +1,212 @@
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})
}
+279
View File
@@ -0,0 +1,279 @@
package dashboards
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeStore struct {
dashboards map[string]*Dashboard
createErr error
importErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{dashboards: map[string]*Dashboard{}}
}
func (f *fakeStore) CreateDashboard(_ context.Context, d *Dashboard) error {
if f.createErr != nil {
return f.createErr
}
d.ID = "dash-1"
f.dashboards[d.ID] = d
return nil
}
func (f *fakeStore) ListDashboards(_ context.Context) ([]Dashboard, error) {
var out []Dashboard
for _, d := range f.dashboards {
out = append(out, *d)
}
return out, nil
}
func (f *fakeStore) GetDashboard(_ context.Context, id string) (*Dashboard, error) {
d, ok := f.dashboards[id]
if !ok {
return nil, ErrNotFound
}
return d, nil
}
func (f *fakeStore) UpdateDashboard(_ context.Context, d *Dashboard) error {
existing, ok := f.dashboards[d.ID]
if !ok {
return ErrNotFound
}
panels := existing.Panels
*existing = *d
existing.Panels = panels
return nil
}
func (f *fakeStore) DeleteDashboard(_ context.Context, id string) error {
if _, ok := f.dashboards[id]; !ok {
return ErrNotFound
}
delete(f.dashboards, id)
return nil
}
func (f *fakeStore) AddPanel(_ context.Context, dashboardID string, p *Panel) error {
d, ok := f.dashboards[dashboardID]
if !ok {
return ErrNotFound
}
p.ID = "panel-1"
p.DashboardID = dashboardID
d.Panels = append(d.Panels, *p)
return nil
}
func (f *fakeStore) UpdatePanel(_ context.Context, p *Panel) error {
d, ok := f.dashboards[p.DashboardID]
if !ok {
return ErrNotFound
}
for i := range d.Panels {
if d.Panels[i].ID == p.ID {
d.Panels[i] = *p
return nil
}
}
return ErrNotFound
}
func (f *fakeStore) DeletePanel(_ context.Context, dashboardID, panelID string) error {
d, ok := f.dashboards[dashboardID]
if !ok {
return ErrNotFound
}
for i := range d.Panels {
if d.Panels[i].ID == panelID {
d.Panels = append(d.Panels[:i], d.Panels[i+1:]...)
return nil
}
}
return ErrNotFound
}
func (f *fakeStore) ImportDashboard(_ context.Context, d *Dashboard) (*Dashboard, error) {
if f.importErr != nil {
return nil, f.importErr
}
imported := *d
imported.ID = "dash-imported"
f.dashboards[imported.ID] = &imported
return &imported, nil
}
func newTestMux(fs *fakeStore) *http.ServeMux {
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestCreateDashboard(t *testing.T) {
mux := newTestMux(newFakeStore())
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
var got Dashboard
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.ID == "" {
t.Fatalf("expected an assigned ID, got empty")
}
}
func TestCreateDashboardRejectsEmptyName(t *testing.T) {
mux := newTestMux(newFakeStore())
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": ""}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestGetDashboardNotFound(t *testing.T) {
mux := newTestMux(newFakeStore())
rec := doRequest(t, mux, http.MethodGet, "/dashboards/nope", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestAddPanelRejectsRawSQL(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
`{"query": "SELECT 1", "query_language": "sql", "viz_type": "table"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAddPanelRejectsInvalidVizType(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
`{"query": "service=api", "viz_type": "pie"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAddPanelSuccess(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodPost, "/dashboards/dash-1/panels",
`{"title": "Errors", "query": "service=api | stats count by host", "viz_type": "line", "width": 6, "height": 4}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if len(fs.dashboards["dash-1"].Panels) != 1 {
t.Fatalf("expected 1 panel stored, got %d", len(fs.dashboards["dash-1"].Panels))
}
}
func TestUpdateDashboardChangesTimeRange(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview", DefaultEarliest: "-1h", DefaultLatest: "now"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodPut, "/dashboards/dash-1",
`{"name": "Overview", "default_earliest": "-24h", "default_latest": "now"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if fs.dashboards["dash-1"].DefaultEarliest != "-24h" {
t.Fatalf("expected default_earliest to be updated, got %q", fs.dashboards["dash-1"].DefaultEarliest)
}
}
func TestUpdateDashboardNotFound(t *testing.T) {
mux := newTestMux(newFakeStore())
rec := doRequest(t, mux, http.MethodPut, "/dashboards/nope", `{"name": "Overview"}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestDeleteDashboard(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{ID: "dash-1", Name: "Overview"}
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodDelete, "/dashboards/dash-1", "")
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if _, ok := fs.dashboards["dash-1"]; ok {
t.Fatalf("expected dashboard to be deleted")
}
}
func TestExportThenImportRoundTrips(t *testing.T) {
fs := newFakeStore()
fs.dashboards["dash-1"] = &Dashboard{
ID: "dash-1", Name: "Overview",
Panels: []Panel{{ID: "panel-1", DashboardID: "dash-1", Query: "service=api", VizType: VizTable}},
}
mux := newTestMux(fs)
exportRec := doRequest(t, mux, http.MethodGet, "/dashboards/dash-1/export", "")
if exportRec.Code != http.StatusOK {
t.Fatalf("export status = %d, want 200", exportRec.Code)
}
importRec := doRequest(t, mux, http.MethodPost, "/dashboards/import", exportRec.Body.String())
if importRec.Code != http.StatusCreated {
t.Fatalf("import status = %d, want 201; body=%s", importRec.Code, importRec.Body.String())
}
var imported Dashboard
if err := json.Unmarshal(importRec.Body.Bytes(), &imported); err != nil {
t.Fatalf("decoding import response: %v", err)
}
if imported.ID == "dash-1" {
t.Fatalf("expected import to assign a fresh ID, got the source ID back")
}
}
func TestCreateDashboardStoreErrorReturns500(t *testing.T) {
fs := newFakeStore()
fs.createErr = errors.New("boom")
mux := newTestMux(fs)
rec := doRequest(t, mux, http.MethodPost, "/dashboards", `{"name": "Overview"}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
+256
View File
@@ -0,0 +1,256 @@
package dashboards
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned by Get/Delete when the id doesn't exist.
var ErrNotFound = errors.New("not found")
// Store is the pgx-backed CRUD implementation. IDs are assigned
// server-side (google/uuid), matching how /ingest assigns record_id --
// one place (Go) generates IDs, not split between the app and the
// database via a Postgres extension.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) CreateDashboard(ctx context.Context, d *Dashboard) error {
d.ID = uuid.NewString()
if d.TenantID == "" {
d.TenantID = "default"
}
if d.CreatedBy == "" {
d.CreatedBy = "anonymous"
}
if d.DefaultEarliest == "" {
d.DefaultEarliest = "-1h"
}
if d.DefaultLatest == "" {
d.DefaultLatest = "now"
}
row := s.pool.QueryRow(ctx, `
INSERT INTO dashboards (id, tenant_id, name, description, default_earliest, default_latest, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at`,
d.ID, d.TenantID, d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.CreatedBy)
return row.Scan(&d.CreatedAt, &d.UpdatedAt)
}
func (s *Store) ListDashboards(ctx context.Context) ([]Dashboard, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
FROM dashboards ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Dashboard
for rows.Next() {
var d Dashboard
if err := rows.Scan(&d.ID, &d.TenantID, &d.Name, &d.Description, &d.DefaultEarliest, &d.DefaultLatest, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
func (s *Store) GetDashboard(ctx context.Context, id string) (*Dashboard, error) {
var d Dashboard
row := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, description, default_earliest, default_latest, created_by, created_at, updated_at
FROM dashboards WHERE id = $1`, id)
if err := row.Scan(&d.ID, &d.TenantID, &d.Name, &d.Description, &d.DefaultEarliest, &d.DefaultLatest, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
panels, err := s.listPanels(ctx, id)
if err != nil {
return nil, err
}
d.Panels = panels
return &d, nil
}
func (s *Store) listPanels(ctx context.Context, dashboardID string) ([]Panel, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, dashboard_id, title, query, query_language, viz_type, viz_config,
position_x, position_y, width, height, earliest_override, latest_override,
sort_order, created_at, updated_at
FROM dashboard_panels WHERE dashboard_id = $1 ORDER BY sort_order, created_at`, dashboardID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Panel
for rows.Next() {
var p Panel
if err := rows.Scan(&p.ID, &p.DashboardID, &p.Title, &p.Query, &p.QueryLanguage, &p.VizType, &p.VizConfig,
&p.PositionX, &p.PositionY, &p.Width, &p.Height, &p.EarliestOverride, &p.LatestOverride,
&p.SortOrder, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) UpdateDashboard(ctx context.Context, d *Dashboard) error {
if d.DefaultEarliest == "" {
d.DefaultEarliest = "-1h"
}
if d.DefaultLatest == "" {
d.DefaultLatest = "now"
}
row := s.pool.QueryRow(ctx, `
UPDATE dashboards SET name = $1, description = $2, default_earliest = $3, default_latest = $4, updated_at = now()
WHERE id = $5
RETURNING tenant_id, created_by, created_at, updated_at`,
d.Name, d.Description, d.DefaultEarliest, d.DefaultLatest, d.ID)
if err := row.Scan(&d.TenantID, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
return nil
}
func (s *Store) DeleteDashboard(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboards WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) AddPanel(ctx context.Context, dashboardID string, p *Panel) error {
p.ID = uuid.NewString()
p.DashboardID = dashboardID
row := s.pool.QueryRow(ctx, `
INSERT INTO dashboard_panels (id, dashboard_id, title, query, query_language, viz_type, viz_config,
position_x, position_y, width, height, earliest_override, latest_override, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING created_at, updated_at`,
p.ID, p.DashboardID, p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
p.PositionX, p.PositionY, p.Width, p.Height, p.EarliestOverride, p.LatestOverride, p.SortOrder)
return row.Scan(&p.CreatedAt, &p.UpdatedAt)
}
func (s *Store) UpdatePanel(ctx context.Context, p *Panel) error {
tag, err := s.pool.Exec(ctx, `
UPDATE dashboard_panels SET
title = $1, query = $2, query_language = $3, viz_type = $4, viz_config = $5,
position_x = $6, position_y = $7, width = $8, height = $9,
earliest_override = $10, latest_override = $11, sort_order = $12, updated_at = now()
WHERE id = $13 AND dashboard_id = $14`,
p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
p.PositionX, p.PositionY, p.Width, p.Height,
p.EarliestOverride, p.LatestOverride, p.SortOrder, p.ID, p.DashboardID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeletePanel(ctx context.Context, dashboardID, panelID string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM dashboard_panels WHERE id = $1 AND dashboard_id = $2`, panelID, dashboardID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ImportDashboard creates a new dashboard and all its panels from an
// exported Dashboard document, assigning fresh IDs throughout -- so
// importing an exported dashboard into a different environment (or
// re-importing into the same one) never collides with the source IDs.
// Runs in one transaction: either the whole dashboard lands, or none of
// it does.
func (s *Store) ImportDashboard(ctx context.Context, d *Dashboard) (*Dashboard, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
id := uuid.NewString()
tenantID := d.TenantID
if tenantID == "" {
tenantID = "default"
}
createdBy := d.CreatedBy
if createdBy == "" {
createdBy = "anonymous"
}
earliest := d.DefaultEarliest
if earliest == "" {
earliest = "-1h"
}
latest := d.DefaultLatest
if latest == "" {
latest = "now"
}
var out Dashboard
out.ID, out.TenantID, out.Name, out.Description = id, tenantID, d.Name, d.Description
out.DefaultEarliest, out.DefaultLatest, out.CreatedBy = earliest, latest, createdBy
row := tx.QueryRow(ctx, `
INSERT INTO dashboards (id, tenant_id, name, description, default_earliest, default_latest, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at, updated_at`,
out.ID, out.TenantID, out.Name, out.Description, out.DefaultEarliest, out.DefaultLatest, out.CreatedBy)
if err := row.Scan(&out.CreatedAt, &out.UpdatedAt); err != nil {
return nil, err
}
for _, p := range d.Panels {
if err := validatePanel(&p); err != nil {
return nil, fmt.Errorf("panel %q: %w", p.Title, err)
}
p.ID = uuid.NewString()
p.DashboardID = out.ID
prow := tx.QueryRow(ctx, `
INSERT INTO dashboard_panels (id, dashboard_id, title, query, query_language, viz_type, viz_config,
position_x, position_y, width, height, earliest_override, latest_override, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING created_at, updated_at`,
p.ID, p.DashboardID, p.Title, p.Query, p.QueryLanguage, p.VizType, p.VizConfig,
p.PositionX, p.PositionY, p.Width, p.Height, p.EarliestOverride, p.LatestOverride, p.SortOrder)
if err := prow.Scan(&p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out.Panels = append(out.Panels, p)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &out, nil
}
+86
View File
@@ -0,0 +1,86 @@
// Package dashboards implements CRUD for saved, multi-panel dashboards
// -- see /docs/phase-3-dashboard-design.md. Deliberately pure CRUD: panel
// *query execution* happens client-side (the web UI calls the existing
// POST /query per panel), so this package never touches querylang.
package dashboards
import (
"encoding/json"
"fmt"
"time"
)
// VizType is one of the panel visualization kinds. "top_n" renders
// through the same path as "table" -- the query itself already did the
// sort/limit -- so there's no execution-side difference, only UI framing.
type VizType string
const (
VizTable VizType = "table"
VizLine VizType = "line"
VizBar VizType = "bar"
VizSingleStat VizType = "single_stat"
VizTopN VizType = "top_n"
)
func validVizType(v VizType) bool {
switch v {
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN:
return true
default:
return false
}
}
type Dashboard struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
DefaultEarliest string `json:"default_earliest"`
DefaultLatest string `json:"default_latest"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Panels []Panel `json:"panels,omitempty"`
}
type Panel struct {
ID string `json:"id"`
DashboardID string `json:"dashboard_id"`
Title string `json:"title"`
Query string `json:"query"`
QueryLanguage string `json:"query_language"`
VizType VizType `json:"viz_type"`
VizConfig json.RawMessage `json:"viz_config,omitempty"`
PositionX int `json:"position_x"`
PositionY int `json:"position_y"`
Width int `json:"width"`
Height int `json:"height"`
EarliestOverride *string `json:"earliest_override,omitempty"`
LatestOverride *string `json:"latest_override,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// validatePanel enforces the two rules /docs/phase-3-dashboard-design.md
// states as disclosed non-goals rather than silent gaps: raw-SQL panels
// aren't supported (time-range injection has no reliable splice point
// into arbitrary SQL), and viz_type must be one this API knows how to
// store/render.
func validatePanel(p *Panel) error {
if p.Query == "" {
return fmt.Errorf("query must not be empty")
}
if p.QueryLanguage == "sql" {
return fmt.Errorf("raw-SQL panels are not supported -- dashboards only support pipe-syntax queries, since the dashboard time-range picker is injected as leading query terms")
}
if !validVizType(p.VizType) {
return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, got %q", p.VizType)
}
if len(p.VizConfig) == 0 {
p.VizConfig = json.RawMessage(`{}`)
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
// Package httpserver holds cross-handler HTTP concerns for /api. Phase 3
// introduced a second handler package (internal/dashboards) alongside
// internal/queryapi, so CORS moved out of individual handlers into one
// wrap applied around the fully-assembled mux in cmd/api/main.go, rather
// than each handler package wrapping itself.
package httpserver
import "net/http"
// WithCORS is deliberately permissive by default (see CORSAllowedOrigin
// in internal/config) since there's no auth yet and the SvelteKit dev
// server runs on a different origin. Tighten alongside adding real auth.
func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+42
View File
@@ -0,0 +1,42 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestWithCORSPreflight(t *testing.T) {
inner := http.NewServeMux()
inner.HandleFunc("POST /query", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCORS(inner, "*")
req := httptest.NewRequest(http.MethodOptions, "/query", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
}
func TestWithCORSPassesThroughNonPreflight(t *testing.T) {
inner := http.NewServeMux()
inner.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCORS(inner, "*")
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
+12 -26
View File
@@ -24,38 +24,24 @@ import (
)
type Handler struct {
logger *slog.Logger
sqlRunner executor.SQLRunner
search executor.SearchClient
queryTimeout time.Duration
allowedOrigin string
logger *slog.Logger
sqlRunner executor.SQLRunner
search executor.SearchClient
queryTimeout time.Duration
}
func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration, allowedOrigin string) *Handler {
return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout, allowedOrigin: allowedOrigin}
func NewHandler(logger *slog.Logger, sqlRunner executor.SQLRunner, search executor.SearchClient, queryTimeout time.Duration) *Handler {
return &Handler{logger: logger, sqlRunner: sqlRunner, search: search, queryTimeout: queryTimeout}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
// RegisterRoutes adds this handler's routes onto a shared mux. Phase 3
// introduced a second handler package (internal/dashboards), so CORS is
// now applied once, by main.go, around the fully-assembled mux rather
// than by each handler wrapping itself individually -- see
// httpserver.WithCORS.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /query", h.handleQuery)
mux.HandleFunc("GET /healthz", h.handleHealthz)
return h.withCORS(mux)
}
// withCORS is deliberately permissive by default (see CORSAllowedOrigin in
// internal/config) since there's no auth yet and the SvelteKit dev server
// runs on a different origin. Tighten alongside adding real auth.
func (h *Handler) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", h.allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (h *Handler) handleHealthz(w http.ResponseWriter, _ *http.Request) {
+9 -18
View File
@@ -50,14 +50,20 @@ func newTestHandler(sqlRunner *fakeSQLRunner, search *fakeSearchClient) *Handler
if search == nil {
search = &fakeSearchClient{}
}
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second, "*")
return NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), sqlRunner, search, time.Second)
}
func newTestMux(h *Handler) *http.ServeMux {
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func postQuery(t *testing.T, h *Handler, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(body))
rec := httptest.NewRecorder()
h.Routes().ServeHTTP(rec, req)
newTestMux(h).ServeHTTP(rec, req)
return rec
}
@@ -195,24 +201,9 @@ func TestHandleHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
h.Routes().ServeHTTP(rec, req)
newTestMux(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestCORSPreflight(t *testing.T) {
h := newTestHandler(&fakeSQLRunner{}, nil)
req := httptest.NewRequest(http.MethodOptions, "/query", nil)
rec := httptest.NewRecorder()
h.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
}
@@ -300,10 +300,34 @@ func TestBuildSQLTimeRange(t *testing.T) {
to := mustParseTime(t, "2026-08-14T01:00:00Z")
plan := &ir.Plan{TimeRange: &ir.TimeRange{From: from, To: to}}
sql := buildSQL(plan, nil)
if !strings.Contains(sql, "`timestamp` >= '2026-08-14T00:00:00Z'") {
// Space-separated, no 'T'/'Z' -- ClickHouse's implicit string->DateTime64
// cast for a column-vs-literal comparison is strict and rejects
// RFC3339/ISO-8601 shaped literals ("code: 53, Cannot convert string...
// to type DateTime64(9, 'UTC')"), confirmed by actually running a
// dashboard panel with earliest= against live ClickHouse -- this test
// previously asserted the RFC3339 shape that ClickHouse rejects, which
// is exactly how the bug went unnoticed: nothing here ever executed the
// SQL against a real database.
if !strings.Contains(sql, "`timestamp` >= '2026-08-14 00:00:00'") {
t.Fatalf("missing From bound: %s", sql)
}
if !strings.Contains(sql, "`timestamp` <= '2026-08-14T01:00:00Z'") {
if !strings.Contains(sql, "`timestamp` <= '2026-08-14 01:00:00'") {
t.Fatalf("missing To bound: %s", sql)
}
}
func TestFormatClickHouseDateTime64OmitsTrailingZeroFraction(t *testing.T) {
// time.Time's default zero-value fractional seconds must not leave a
// stray "." with nothing after it -- Format's `.999999999` verb
// already handles this (trims to nothing when the fraction is zero),
// but it's worth pinning down given how easy the RFC3339Nano mistake
// was to miss in the first place.
got := formatClickHouseDateTime64(mustParseTime(t, "2026-08-14T00:00:00Z"))
if got != "2026-08-14 00:00:00" {
t.Fatalf("got %q, want no trailing fractional-seconds dot", got)
}
got = formatClickHouseDateTime64(mustParseTime(t, "2026-08-14T00:00:00.223505479Z"))
if got != "2026-08-14 00:00:00.223505479" {
t.Fatalf("got %q", got)
}
}
+23 -4
View File
@@ -164,22 +164,41 @@ func buildWhereClause(plan *ir.Plan, recordIDFilter []string) string {
if plan.TimeRange != nil {
if !plan.TimeRange.From.IsZero() {
conds = append(conds, "`timestamp` >= "+quoteLiteral(plan.TimeRange.From.UTC().Format(time.RFC3339Nano)))
conds = append(conds, "`timestamp` >= "+quoteLiteral(formatClickHouseDateTime64(plan.TimeRange.From)))
}
if !plan.TimeRange.To.IsZero() {
conds = append(conds, "`timestamp` <= "+quoteLiteral(plan.TimeRange.To.UTC().Format(time.RFC3339Nano)))
conds = append(conds, "`timestamp` <= "+quoteLiteral(formatClickHouseDateTime64(plan.TimeRange.To)))
}
}
return strings.Join(conds, " AND ")
}
// formatClickHouseDateTime64 formats t the way ClickHouse's implicit
// string->DateTime64 CAST expects for a WHERE-clause comparison:
// "YYYY-MM-DD HH:MM:SS[.fractional]", space-separated, no 'T'/'Z'. This
// is a real, measured requirement, not a guess: an ISO-8601/RFC3339Nano
// literal (e.g. "2026-08-12T20:17:40.223505479Z", what time.RFC3339Nano
// produces) fails at query time with "code: 53, Cannot convert string
// ... to type DateTime64(9, 'UTC')" -- ClickHouse's *implicit* cast used
// for column-vs-literal comparisons is strict, unlike the lenient
// parseDateTimeBestEffort used elsewhere in ClickHouse. Found by
// actually running a dashboard panel with a relative earliest= against
// live ClickHouse (Phase 2's own unit tests never caught this: they
// assert against a fake SQLRunner that checks the generated SQL string,
// not that ClickHouse accepts it, and none of Phase 2's own live-stack
// runbook queries happened to use earliest=/latest= at all).
func formatClickHouseDateTime64(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04:05.999999999")
}
// buildComparisonSQL numeric-casts a non-top-level field only when the
// compared value itself looks numeric -- `status>=500` casts (numeric
// comparison intent), `status="unknown"` doesn't (string comparison
// intent). Top-level fields are never cast; ClickHouse compares them
// against a string literal natively (DateTime64 columns parse an
// RFC3339-shaped literal, LowCardinality(String)/String compare as-is).
// against a string literal natively (LowCardinality(String)/String
// compare as-is; DateTime64 columns need formatClickHouseDateTime64's
// exact literal shape, handled in buildWhereClause above, not here).
func buildComparisonSQL(f ir.FilterPredicate) string {
if !topLevelFields[f.Field] && isNumericLiteral(f.Value) {
return "toFloat64OrZero(" + columnExpr(f.Field) + ") " + f.Op + " " + f.Value
+29 -3
View File
@@ -26,9 +26,35 @@ auto-detection, same optional override the HTTP API itself exposes.
Prints a table by default (stdlib `text/tabwriter`, no new dependency);
`--json` prints the raw `{columns, rows}` response instead.
No CLI framework (cobra/urfave-cli/etc.) — two commands don't need one,
and stdlib `os.Args` handling is boring enough not to need a dependency.
Revisit once there's a real command tree to justify one.
```sh
sentryctl dashboards list
sentryctl dashboards get <id>
sentryctl dashboards apply dashboard.json # imports a dashboard exported via the web UI's "Export JSON" button
sentryctl alerts list
sentryctl alerts get <id>
sentryctl alerts apply rule.json # creates a rule from a JSON file shaped like POST /rules's body
```
`dashboards` talks to `/api` (`--api`, same override as `query`/`ping`).
`alerts` talks to `/alerting`, a separate service with its own base URL
(`--alerting-api`, or `$SENTRYCTL_ALERTING_API_URL`, default
`http://localhost:8081`) — see `/docs/phase-3-alerting-design.md`'s
component boundary for why alerting isn't just another `/api` route.
`apply` in both cases sends the file's JSON as-is to the corresponding
create/import endpoint — no reshaping, since the file's shape already
matches what the endpoint expects (the same JSON the web UI's export
button downloads, or the same shape `GET /rules/{id}` returns). This is
deliberately the seed of a future Terraform provider: one JSON contract,
multiple callers (web export, CLI apply, eventually a provider), not
three different formats to keep in sync.
No CLI framework (cobra/urfave-cli/etc.) — six commands split across a
few files (`cmd_ping.go`, `cmd_query.go`, `cmd_dashboards.go`,
`cmd_alerts.go`) is still boring enough not to need one; stdlib
`os.Args` handling plus a hand-rolled `switch` in `main.go` covers it.
Revisit once there's a real command tree (nested subcommands, nontrivial
flag parsing) to justify a dependency.
## Building & testing
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"fmt"
"io"
"os"
)
func cmdAlerts(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts: expected a subcommand (list, get, apply)")
return 1
}
alertingURL, rest := extractAlertingAPIFlag(args[1:], os.Getenv)
switch args[0] {
case "list":
return httpGetJSON(alertingURL, "/rules", stdout, stderr)
case "get":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts get: missing rule id")
return 1
}
return httpGetJSON(alertingURL, "/rules/"+rest[0], stdout, stderr)
case "apply":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl alerts apply: missing file path")
return 1
}
// POST /rules accepts the same shape it returns -- a rule
// definition file (query, condition, interval, notification
// target ID) applies directly with no reshaping.
return httpPostFileJSON(alertingURL, "/rules", rest[0], stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl alerts: unknown subcommand %q (want list, get, apply)\n", args[0])
return 1
}
}
func extractAlertingAPIFlag(args []string, env func(string) string) (alertingURL string, rest []string) {
alertingURL = resolveAlertingURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--alerting-api" && i+1 < len(args) {
alertingURL = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return alertingURL, rest
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"bytes"
"reflect"
"testing"
)
func TestExtractAlertingAPIFlagDefault(t *testing.T) {
alertingURL, rest := extractAlertingAPIFlag([]string{"rule-1"}, func(string) string { return "" })
if alertingURL != defaultAlertingURL {
t.Fatalf("alertingURL = %q, want default %q", alertingURL, defaultAlertingURL)
}
if !reflect.DeepEqual(rest, []string{"rule-1"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAlertingAPIFlagOverride(t *testing.T) {
alertingURL, rest := extractAlertingAPIFlag([]string{"--alerting-api", "http://custom:9091", "rule-1"}, func(string) string { return "" })
if alertingURL != "http://custom:9091" {
t.Fatalf("alertingURL = %q", alertingURL)
}
if !reflect.DeepEqual(rest, []string{"rule-1"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAlertingAPIFlagFromEnv(t *testing.T) {
alertingURL, _ := extractAlertingAPIFlag(nil, func(k string) string {
if k == "SENTRYCTL_ALERTING_API_URL" {
return "http://env-alerting:8081"
}
return ""
})
if alertingURL != "http://env-alerting:8081" {
t.Fatalf("alertingURL = %q", alertingURL)
}
}
func TestCmdAlertsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAlertsApplyMissingFile(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts([]string{"apply"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdAlertsUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdAlerts([]string{"bogus"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"fmt"
"io"
"os"
)
func cmdDashboards(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards: expected a subcommand (list, get, apply)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
switch args[0] {
case "list":
return httpGetJSON(apiURL, "/dashboards", stdout, stderr)
case "get":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards get: missing dashboard id")
return 1
}
return httpGetJSON(apiURL, "/dashboards/"+rest[0], stdout, stderr)
case "apply":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl dashboards apply: missing file path")
return 1
}
// The import endpoint consumes exactly the shape GET
// /dashboards/{id}/export produces and the web UI's Export JSON
// button downloads -- one JSON contract, three call sites.
return httpPostFileJSON(apiURL, "/dashboards/import", rest[0], stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl dashboards: unknown subcommand %q (want list, get, apply)\n", args[0])
return 1
}
}
// extractAPIFlag pulls an optional --api <url> out of args, resolving
// the default the same way parsePingArgs/parseQueryArgs do, and returns
// the remaining positional args. Shared by dashboards and alerts since
// both take an optional --api/--alerting-api override the same way.
func extractAPIFlag(args []string, env func(string) string) (apiURL string, rest []string) {
apiURL = resolveAPIURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--api" && i+1 < len(args) {
apiURL = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return apiURL, rest
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"bytes"
"reflect"
"testing"
)
func TestExtractAPIFlagDefault(t *testing.T) {
apiURL, rest := extractAPIFlag([]string{"abc123"}, func(string) string { return "" })
if apiURL != defaultAPIURL {
t.Fatalf("apiURL = %q, want default %q", apiURL, defaultAPIURL)
}
if !reflect.DeepEqual(rest, []string{"abc123"}) {
t.Fatalf("rest = %v", rest)
}
}
func TestExtractAPIFlagOverride(t *testing.T) {
apiURL, rest := extractAPIFlag([]string{"--api", "http://custom:9090", "abc123"}, func(string) string { return "" })
if apiURL != "http://custom:9090" {
t.Fatalf("apiURL = %q", apiURL)
}
if !reflect.DeepEqual(rest, []string{"abc123"}) {
t.Fatalf("rest = %v, want [abc123] (flag pair stripped)", rest)
}
}
func TestCmdDashboardsMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards(nil, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdDashboardsGetMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdDashboards([]string{"get"}, &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
// parsePingArgs resolves the api base URL for ping: --api flag wins, then
// $SENTRYCTL_API_URL, then the hardcoded default. Kept pure (env passed in
// as a function) and separate from the HTTP call so it's unit-testable
// without a real environment or server.
func parsePingArgs(args []string, env func(string) string) string {
apiURL := resolveAPIURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--api" && i+1 < len(args) {
apiURL = args[i+1]
i++
}
}
return apiURL
}
func cmdPing(args []string, stdout, stderr io.Writer) int {
apiURL := parsePingArgs(args, os.Getenv)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL + "/healthz")
if err != nil {
fmt.Fprintf(stderr, "ping failed: %v\n", err)
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(stderr, "ping failed: api returned status %d\n", resp.StatusCode)
return 1
}
fmt.Fprintln(stdout, "ok")
return 0
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type queryArgs struct {
apiURL string
jsonOut bool
language string
query string
}
// parseQueryArgs is pure (env passed in, no I/O), same testability
// reasoning as parsePingArgs. Non-flag arguments are joined with spaces
// to form the query, so `sentryctl query service=api status=500` (no
// quotes, no shell-special characters) works without requiring users to
// quote every query -- though anything using "|" still needs shell
// quoting regardless, since that's a real shell pipe character otherwise.
func parseQueryArgs(args []string, env func(string) string) queryArgs {
qa := queryArgs{apiURL: resolveAPIURL(env)}
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--api":
if i+1 < len(args) {
qa.apiURL = args[i+1]
i++
}
case "--json":
qa.jsonOut = true
case "--language":
if i+1 < len(args) {
qa.language = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
qa.query = strings.Join(rest, " ")
return qa
}
type queryRequestBody struct {
Query string `json:"query"`
Language string `json:"language"`
}
type queryResponseBody struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
}
func cmdQuery(args []string, stdout, stderr io.Writer) int {
qa := parseQueryArgs(args, os.Getenv)
if strings.TrimSpace(qa.query) == "" {
fmt.Fprintln(stderr, "sentryctl query: missing query string")
return 1
}
reqBody, err := json.Marshal(queryRequestBody{Query: qa.query, Language: qa.language})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(qa.apiURL+"/query", "application/json", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "query failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "query failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "query failed: api returned status %d\n", resp.StatusCode)
}
return 1
}
if qa.jsonOut {
_, _ = stdout.Write(respBody)
fmt.Fprintln(stdout)
return 0
}
var result queryResponseBody
if err := json.Unmarshal(respBody, &result); err != nil {
fmt.Fprintf(stderr, "decoding response: %v\n", err)
return 1
}
printTable(stdout, result.Columns, result.Rows)
return 0
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
// httpGetJSON GETs path and prints the pretty-printed JSON response to
// stdout, or the error body/status to stderr. Shared by dashboards/alerts
// list and get, which otherwise differ only in path and resource name.
func httpGetJSON(baseURL, path string, stdout, stderr io.Writer) int {
resp, err := httpClient.Get(baseURL + path)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
return printJSONResponse(resp, stdout, stderr)
}
// httpPostFileJSON reads file (a JSON document, e.g. an exported
// dashboard or a rule definition) and POSTs it to path as-is -- no
// reshaping, since the file's shape already matches what the endpoint
// expects (the same JSON the web UI's export button and POST /rules
// produce/accept respectively). This is what makes "apply" the seed of a
// future Terraform provider: one JSON contract, multiple callers.
func httpPostFileJSON(baseURL, path, file string, stdout, stderr io.Writer) int {
body, err := os.ReadFile(file)
if err != nil {
fmt.Fprintf(stderr, "reading %s: %v\n", file, err)
return 1
}
resp, err := httpClient.Post(baseURL+path, "application/json", bytes.NewReader(body))
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
return printJSONResponse(resp, stdout, stderr)
}
func printJSONResponse(resp *http.Response, stdout, stderr io.Writer) int {
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errResp errorResponseBody
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "request failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "request failed: status %d\n", resp.StatusCode)
}
return 1
}
var pretty bytes.Buffer
if json.Indent(&pretty, body, "", " ") == nil {
stdout.Write(pretty.Bytes())
} else {
stdout.Write(body)
}
fmt.Fprintln(stdout)
return 0
}
+32 -145
View File
@@ -1,22 +1,23 @@
// Command sentryctl is Sentry's control CLI: "ping" (Phase 0) and
// "query" (Phase 2), which accepts either query syntax and hits the same
// POST /query endpoint the web UI does -- no separate query logic here,
// per the Phase 2 task list's explicit instruction.
// 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 (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"text/tabwriter"
"time"
)
const defaultAPIURL = "http://localhost:8080"
const (
defaultAPIURL = "http://localhost:8080"
defaultAlertingURL = "http://localhost:8081"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
@@ -33,6 +34,10 @@ func run(args []string, stdout, stderr io.Writer) int {
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
@@ -49,15 +54,25 @@ func usage(w io.Writer) {
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.
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.`)
}
@@ -68,145 +83,17 @@ func resolveAPIURL(env func(string) string) string {
return defaultAPIURL
}
// parsePingArgs resolves the api base URL for ping: --api flag wins, then
// $SENTRYCTL_API_URL, then the hardcoded default. Kept pure (env passed in
// as a function) and separate from the HTTP call so it's unit-testable
// without a real environment or server.
func parsePingArgs(args []string, env func(string) string) string {
apiURL := resolveAPIURL(env)
for i := 0; i < len(args); i++ {
if args[i] == "--api" && i+1 < len(args) {
apiURL = args[i+1]
i++
}
func resolveAlertingURL(env func(string) string) string {
if v := env("SENTRYCTL_ALERTING_API_URL"); v != "" {
return v
}
return apiURL
}
func cmdPing(args []string, stdout, stderr io.Writer) int {
apiURL := parsePingArgs(args, os.Getenv)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL + "/healthz")
if err != nil {
fmt.Fprintf(stderr, "ping failed: %v\n", err)
return 1
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(stderr, "ping failed: api returned status %d\n", resp.StatusCode)
return 1
}
fmt.Fprintln(stdout, "ok")
return 0
}
type queryArgs struct {
apiURL string
jsonOut bool
language string
query string
}
// parseQueryArgs is pure (env passed in, no I/O), same testability
// reasoning as parsePingArgs. Non-flag arguments are joined with spaces
// to form the query, so `sentryctl query service=api status=500` (no
// quotes, no shell-special characters) works without requiring users to
// quote every query -- though anything using "|" still needs shell
// quoting regardless, since that's a real shell pipe character otherwise.
func parseQueryArgs(args []string, env func(string) string) queryArgs {
qa := queryArgs{apiURL: resolveAPIURL(env)}
var rest []string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--api":
if i+1 < len(args) {
qa.apiURL = args[i+1]
i++
}
case "--json":
qa.jsonOut = true
case "--language":
if i+1 < len(args) {
qa.language = args[i+1]
i++
}
default:
rest = append(rest, args[i])
}
}
qa.query = strings.Join(rest, " ")
return qa
}
type queryRequestBody struct {
Query string `json:"query"`
Language string `json:"language"`
}
type queryResponseBody struct {
Columns []string `json:"columns"`
Rows [][]any `json:"rows"`
return defaultAlertingURL
}
type errorResponseBody struct {
Error string `json:"error"`
}
func cmdQuery(args []string, stdout, stderr io.Writer) int {
qa := parseQueryArgs(args, os.Getenv)
if strings.TrimSpace(qa.query) == "" {
fmt.Fprintln(stderr, "sentryctl query: missing query string")
return 1
}
reqBody, err := json.Marshal(queryRequestBody{Query: qa.query, Language: qa.language})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(qa.apiURL+"/query", "application/json", bytes.NewReader(reqBody))
if err != nil {
fmt.Fprintf(stderr, "query failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
if resp.StatusCode != http.StatusOK {
var errResp errorResponseBody
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
fmt.Fprintf(stderr, "query failed: %s\n", errResp.Error)
} else {
fmt.Fprintf(stderr, "query failed: api returned status %d\n", resp.StatusCode)
}
return 1
}
if qa.jsonOut {
_, _ = stdout.Write(respBody)
fmt.Fprintln(stdout)
return 0
}
var result queryResponseBody
if err := json.Unmarshal(respBody, &result); err != nil {
fmt.Fprintf(stderr, "decoding response: %v\n", err)
return 1
}
printTable(stdout, result.Columns, result.Rows)
return 0
}
func printTable(w io.Writer, columns []string, rows [][]any) {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(columns, "\t"))
+82 -3
View File
@@ -97,6 +97,41 @@ services:
CLICKHOUSE_HTTP: "http://clickhouse:8123"
CLICKHOUSE_PASSWORD: "sentry-dev-only"
# Control-plane metadata store (dashboards, alert rules -- see
# /docs/phase-3-dashboard-design.md for why this is Postgres rather
# than new ClickHouse tables). Log data stays on ClickHouse/Tantivy
# only, unaffected.
metadata-postgres:
image: postgres:16-alpine
container_name: sentry-metadata-postgres
environment:
POSTGRES_DB: sentry_metadata
POSTGRES_USER: sentry
POSTGRES_PASSWORD: "sentry-dev-only" # not a real secret, same framing as CLICKHOUSE_PASSWORD above
volumes:
- metadata-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sentry -d sentry_metadata"]
interval: 5s
timeout: 5s
retries: 30
# One-shot: applies /metadata/migrations/*.sql, then exits 0. api waits
# on this completing successfully, same shape as clickhouse-migrate.
metadata-migrate:
build:
context: ./metadata
container_name: sentry-metadata-migrate
depends_on:
metadata-postgres:
condition: service_healthy
environment:
POSTGRES_HOST: "metadata-postgres"
POSTGRES_PORT: "5432"
POSTGRES_USER: "sentry"
POSTGRES_PASSWORD: "sentry-dev-only"
POSTGRES_DATABASE: "sentry_metadata"
ingest:
build:
context: . # needs both ingest/ and proto/
@@ -148,25 +183,68 @@ services:
depends_on:
clickhouse-migrate:
condition: service_completed_successfully
metadata-migrate:
condition: service_completed_successfully
ports:
- "8080:8080"
environment:
CLICKHOUSE_ADDR: "clickhouse:9000"
CLICKHOUSE_PASSWORD: "sentry-dev-only"
SEARCH_GRPC_ADDR: "search:50052"
POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata"
POSTGRES_USERNAME: "sentry"
POSTGRES_PASSWORD: "sentry-dev-only"
healthcheck:
# alerting (Phase 3 task 5) depends_on api -- without this, that
# dependency can only mean "container started," not "actually
# listening," and would hammer a not-yet-ready api with errors on
# every evaluator tick during stack startup. api's image is
# distroless (no shell, no wget) so this execs the api binary's own
# -healthcheck self-check mode instead of an external tool.
test: ["CMD", "/api", "-healthcheck"]
interval: 5s
timeout: 5s
retries: 30
alerting:
build:
context: alerting # self-contained, no /proto needed -- see alerting/Dockerfile
dockerfile: Dockerfile
container_name: sentry-alerting
depends_on:
metadata-migrate:
condition: service_completed_successfully
api:
condition: service_healthy
ports:
- "8081:8081"
environment:
POSTGRES_ADDR: "metadata-postgres:5432"
POSTGRES_DATABASE: "sentry_metadata"
POSTGRES_USERNAME: "sentry"
POSTGRES_PASSWORD: "sentry-dev-only"
API_QUERY_URL: "http://api:8080"
healthcheck:
test: ["CMD", "/alerting", "-healthcheck"]
interval: 5s
timeout: 5s
retries: 30
web:
build:
context: web
args:
# Baked in at build time (static site, not a server) as
# localhost:8080 -- this is fetched from the *browser*, which
# resolves against the host's mapped port, not the compose
# network's service DNS name.
# localhost:8080/8081 -- fetched from the *browser*, which
# resolves against the host's mapped ports, not the compose
# network's service DNS names.
VITE_API_BASE_URL: "http://localhost:8080"
VITE_ALERTING_API_BASE_URL: "http://localhost:8081"
container_name: sentry-web
depends_on:
- api
- alerting
ports:
- "3000:3000"
@@ -174,3 +252,4 @@ volumes:
redpanda-data:
clickhouse-data:
search-index-data:
metadata-postgres-data:
+358
View File
@@ -0,0 +1,358 @@
# Alerting design
> **Status:** Design only — no code written against this yet. Task 4 of
> Phase 3. Per explicit instruction, execution stops here for review
> before task 5 (building `/alerting`) begins: the firing/resolved state
> machine and debounce behavior are easy to get subtly wrong, and this is
> the artifact to sign off on before anything is built against it. If
> implementation later reveals this design is wrong somewhere, fix this
> doc in the same change — same discipline as
> `/docs/query-language-design.md` and
> `/docs/phase-3-dashboard-design.md`.
## Why this design, in one paragraph
A rule is a saved Phase 2 query plus a condition (threshold or absence)
plus an evaluation interval plus a notification target. The genuinely
hard part isn't the data model — it's the firing/resolved state machine
under concurrent evaluation and its interaction with notification
delivery, where a naive implementation can plausibly double-fire, lose a
resolve, or silently misreport an infrastructure outage as "all clear."
This doc's state machine mirrors Prometheus Alertmanager's well-understood
`pending`/`firing` `for:` model, then adds four specific correctness
properties on top of it — not because the base model is wrong, but
because the *concurrent, at-least-once* environment a Go ticker-based
evaluator actually runs in exposes gaps a single-threaded description of
the model glosses over. Each fix below is stated as: the failure it
prevents, and the mechanism, so it's reviewable as a claim rather than
just an assertion.
## Data model
A rule is a saved query, evaluated on an interval, checked against a
condition, with a debounce before it's allowed to notify, and one
notification target it notifies. Two condition types:
- **`threshold`**: the query's result must resolve to exactly one row;
the value in that row's first column is compared against
`threshold_value` via `comparator`.
- **`absence`**: the query returned zero rows. The evaluation *window*
is not a separate rule field — it's whatever `earliest=`/`latest=` the
rule's own saved query already expresses (e.g. `service=payments
severity=ERROR earliest=-5m`), reusing Phase 2's time-range syntax
rather than inventing a second one.
```sql
CREATE TABLE notification_targets (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('webhook', 'slack', 'pagerduty')),
webhook_url TEXT NOT NULL,
payload_template TEXT, -- generic ("webhook") targets only; NULL for slack/pagerduty
headers JSONB NOT NULL DEFAULT '{}',
secret TEXT, -- PagerDuty routing key / generic-webhook HMAC secret -- plaintext, see "Known gaps" below
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE alert_rules (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
query_language TEXT NOT NULL DEFAULT '',
condition_type TEXT NOT NULL CHECK (condition_type IN ('threshold', 'absence')),
comparator TEXT CHECK (comparator IN ('gt', 'gte', 'lt', 'lte', 'eq', 'ne')), -- NULL for absence
threshold_value DOUBLE PRECISION, -- NULL for absence
eval_interval_seconds INT NOT NULL CHECK (eval_interval_seconds >= 30),
for_minutes INT NOT NULL DEFAULT 0, -- 0 = fire on first true evaluation
renotify_interval_minutes INT, -- NULL = notify once per firing episode
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
enabled BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE alert_state (
rule_id UUID PRIMARY KEY REFERENCES alert_rules(id) ON DELETE CASCADE,
state TEXT NOT NULL DEFAULT 'ok' CHECK (state IN ('ok', 'pending', 'firing')),
condition_true_since TIMESTAMPTZ,
fired_at TIMESTAMPTZ,
last_notified_at TIMESTAMPTZ,
last_evaluated_at TIMESTAMPTZ,
last_eval_status TEXT NOT NULL DEFAULT 'ok' CHECK (last_eval_status IN ('ok', 'error')),
last_error TEXT,
last_value DOUBLE PRECISION,
consecutive_errors INT NOT NULL DEFAULT 0,
next_eval_at TIMESTAMPTZ NOT NULL, -- the claim column, see "Concurrency" below
claimed_at TIMESTAMPTZ
);
CREATE TABLE delivery_log (
id BIGSERIAL PRIMARY KEY,
rule_id UUID NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
event_type TEXT NOT NULL CHECK (event_type IN ('firing', 'resolved')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'retrying')),
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ, -- the delivery worker's own claim key
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
response_status INT,
payload JSONB NOT NULL, -- the actual rendered payload -- needed to debug template issues
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON delivery_log (rule_id, created_at DESC); -- per-rule delivery log UI
CREATE INDEX ON delivery_log (status, next_attempt_at) WHERE status IN ('pending', 'retrying'); -- delivery worker's claim query
```
**`alert_state` must be inserted in the same transaction as its owning
`alert_rules` row**, `state = 'ok'`, `next_eval_at = now()` (evaluate
immediately on creation). A rule with no `alert_state` row is silently
never picked up by the claim query below — worth stating loudly since
it's the kind of thing that "just works" in every test that remembers to
seed both rows and then silently doesn't in production the one time it's
forgotten.
Single notification target per rule for MVP. Multi-target (e.g. page AND
Slack) is a disclosed, straightforward future extension — a join table,
not a redesign.
## Notification delivery: generic webhook as the base primitive
All three `notification_targets.kind` values ultimately do the same
thing — an HTTP POST to `webhook_url` — with kind-specific *payload
formatting only*, per the explicit requirement that this stays pluggable
rather than accumulating per-vendor delivery logic:
- **`webhook`**: `payload_template` is a Go `text/template` string,
rendered against the firing/resolved event (rule name, condition,
current value, timestamp). No template = a sane default JSON shape.
- **`slack`**: fixed formatter producing Slack's incoming-webhook shape
(`{"text": "..."}`), no user template. `payload_template` is ignored
for this kind (kept nullable in the schema rather than removed, so a
target's `kind` can be changed later without a payload column migration).
- **`pagerduty`**: fixed formatter producing PagerDuty's Events API v2
shape (`{"routing_key": secret, "event_action": "trigger"|"resolve",
"payload": {...}}`) — `secret` here is the PagerDuty integration
routing key, not a delivery credential in the auth sense.
All three go through the exact same HTTP POST + retry/backoff mechanism
in `internal/delivery/webhook.go`; `slack.go`/`pagerduty.go` are payload
builders only, never their own delivery path.
## The state machine
States per rule: `ok``pending``firing`, tracked in `alert_state`.
Every evaluation produces one of three outcomes — `condition_true`,
`condition_false`, or `error` — and error is handled entirely separately
from the other two (see fix 3).
**On `condition_true`:**
- `ok``pending`: set `condition_true_since = now()`. No notification.
- `pending``firing`, once `now() - condition_true_since >=
for_minutes`: send a **firing** notification, set `fired_at =
last_notified_at = now()`. `for_minutes = 0` means this transition
happens on the very first true evaluation.
- `pending`, not yet past `for_minutes`: no transition, no notification.
- `firing` → stays `firing`: silent, unless `renotify_interval_minutes`
is set and `now() - last_notified_at >= renotify_interval_minutes`, in
which case re-send **firing** and update `last_notified_at`. Default
(`NULL`) is "notify once per firing episode, stay silent until
resolved" — stated explicitly since it's exactly the kind of default an
implementer would otherwise have to guess.
**On `condition_false`:**
- `ok` → no-op.
- `pending` → `ok`: clear `condition_true_since`. **No notification** —
this was a blip inside the debounce window, not a real alert. This is
deliberate, not an oversight: it's the entire reason `for_minutes`
exists.
- `firing` → `ok`: clear `condition_true_since`/`fired_at`, send a
**resolved** notification.
`condition_true_since` is a **wall-clock timestamp**, not a
consecutive-evaluation counter. This is what makes the debounce survive
evaluator restarts/downtime correctly: if the evaluator is down for part
of a rule's `for_minutes` window and comes back, wall-clock math
correctly resumes toward firing where it left off, while a counter would
have silently lost that progress and restarted the count. Worth
defending explicitly here since a counter looks like the "simpler"
choice and a future contributor might "simplify" it into one.
**Disclosed non-goal: no debounce on the way down.** `firing` → `ok`
happens on a single false evaluation — there's no symmetric "stay firing
for N more minutes" hold (Grafana calls this "keep firing for"). A
condition that flickers right at the threshold produces a firing/resolved
notification pair per flicker. Future work, not solved in Phase 3.
### Four correctness properties, and the concrete failure each one prevents
**1. Concurrent evaluation of the same rule (claim-then-evaluate).**
A worker-pool evaluator is required at the scale task 8 targets (~500
rules @ 60s ≈ 8+ evaluations/sec sustained). If a single evaluation's
round-trip to `api`'s `/query` ever takes longer than the rule's own
interval, the next scheduler tick can pick the *same* rule again while
the first evaluation is still in flight — two goroutines then read-
modify-write the same `alert_state` row concurrently. Depending on
timing, that produces either a duplicated firing notification (both see
`pending`, both compute "elapsed ≥ for_minutes", both fire) or a lost
resolve (a slow evaluation's stale write clobbers a faster one).
Fix: atomically claim due rules **before** the slow network call starts:
```sql
UPDATE alert_state
SET next_eval_at = now() + (eval_interval_seconds || ' seconds')::interval,
claimed_at = now()
FROM alert_rules
WHERE alert_state.rule_id = alert_rules.id
AND alert_state.next_eval_at <= now()
AND alert_rules.enabled
LIMIT $batch_size
RETURNING alert_state.rule_id, alert_state.state, alert_state.condition_true_since, ...
```
`next_eval_at` is bumped *before* the `/query` HTTP call ever starts, so
a second scheduler tick can't re-select the same rule while the first is
still running. The `/query` call itself happens **outside** any database
transaction — never hold a Postgres connection open across a network
call to another service. A second, short transaction applies the
resulting state transition once the query result is known.
This same claim pattern is what makes horizontal evaluator replicas safe
to add later (a named Phase 4+ path, see task 8) without redesigning the
state model — each replica's claim query naturally excludes rows another
replica already claimed. Worth stating positively: this is the one place
in Phase 3 that's actively built *for* that future need, not just
avoiding a trap.
**2. Notification loss/duplication on crash (transactional outbox).**
If the state transition and the webhook POST happen as separate,
sequentially-ordered steps, a crash between them is unrecoverable in one
direction or the other: crash after a successful POST but before the DB
commit → next evaluation replays the transition and double-fires; crash
after commit but before the POST → the notification is silently owed
forever with no record that it was ever decided.
Fix: the state transition and `INSERT INTO delivery_log (..., status =
'pending')` happen in the **same database transaction** — "we decided to
notify" becomes durable exactly once, atomically with the state change
itself, before any network call to a notification target is attempted. A
**separate** delivery worker polls `delivery_log WHERE status IN
('pending', 'retrying') AND next_attempt_at <= now()` (same claim
pattern as fix 1), performs the actual HTTP POST, and updates
`status`/`attempt_count`/`last_error`. This decouples "did we decide to
notify" (transactionally certain) from "did the HTTP call succeed"
(best-effort with retries) — which is exactly what task 5's retry-with-
backoff requirement needs anyway, so this isn't extra machinery bolted
on for correctness's sake, it's the same piece of work.
**3. Query errors must never be treated as `condition_false`.**
The state machine above only describes `condition_true`/
`condition_false` — what happens when the `/query` call to `api` itself
fails (timeout, ClickHouse down, a 5xx)? Coercing an error to "false" is
the tempting default and the worst possible one: a `firing` alert would
silently auto-resolve and go quiet at precisely the moment something is
broken enough that the query can't even run. Coercing to "true" is
equally wrong the other way (spurious pages on transient infra hiccups).
Fix: evaluation outcome is modeled as a three-way result, and an `error`
outcome **never transitions `state`** at all. It only updates
`alert_state.last_evaluated_at`, `last_eval_status = 'error'`,
`last_error`, and increments `consecutive_errors`. This must be explicit
in the implementation, not left to the "obvious" path — the obvious
path (an error propagating into whatever boolean the rest of the
function expects) is also the wrong one.
**4. Zero rows on a `threshold` rule is an error, not a `0`.**
`stats count by host` can legitimately return zero rows for a threshold
rule (nothing matched in the window) — that's different in kind from an
`absence` rule, where zero rows *is* the signal being checked for. If a
threshold evaluation coerces "no rows" to a scalar `0` for comparison,
`count > 100` silently reports "definitely fine" in exactly the case
where the honest answer is "the query returned nothing, which might mean
nothing happened, or might mean something upstream is broken" — often
the more alarming possibility, not the safe one.
Fix, stated as an explicit rule rather than left implicit: `threshold`
evaluation requires **exactly one** result row (first row, first numeric
column, per the dashboard/query design's single-row precedent). Zero
rows — or more than one — is treated the same as fix 3's evaluation
error, not coerced to a value. Named non-goal alongside "no per-group
alerting": a threshold rule's query must resolve to a scalar.
## Evaluator architecture
A single Go process (`/alerting`), ticker-driven — **not** a workflow
engine, per the explicit instruction not to reach for one at this stage.
Loop shape:
1. Every few seconds, run the claim query (fix 1) for due, enabled rules
up to a bounded batch size.
2. Dispatch claimed rules to a bounded worker pool (goroutines).
3. Each worker: `POST /query` against `api` (reusing the existing
endpoint — the same precedent `sentryctl query` already set, never a
second query-execution path), evaluate the condition, run the state
transition (fix 3/4 aware) and, if applicable, the fix-2 transactional
outbox insert, in one short DB transaction.
4. A separate delivery-worker loop claims and sends `delivery_log` rows
independently of the evaluation loop.
`alerting` is therefore hard-dependent on `api` being reachable — if
`api`/ClickHouse is down, every due rule's evaluation records an error
(fix 3), not a false resolve. This is documented behavior, not an
accident: you can't trust a condition you can't evaluate. `alerting`'s
docker-compose entry depends on `api`'s healthcheck (added in task 3)
for this reason.
## Component boundary
`/alerting` (new top-level Go service, own `go.mod`) owns rule CRUD,
notification-target CRUD, the delivery-log read endpoint, the evaluator
loop, and the delivery worker — all four pieces of "alerting" in one
service, since they share the same Postgres tables and the same
claim-based concurrency pattern. It does **not** import `api`'s
`querylang` package or talk to ClickHouse/Tantivy directly; it only
calls `api`'s `POST /query` over HTTP, exactly like `sentryctl query`
and the web UI's dashboard panels already do. `web` gets a second
backend base URL (`alerting`'s) alongside the existing `api` one.
## Known gaps (named, not hidden)
- **`notification_targets.secret` is stored plaintext** in Postgres —
the same posture `dashboard-design.md` already named for that domain.
Becomes both an enterprise-tier (secrets/KMS) and a multi-tenancy
(per-tenant secret isolation) concern later; naming it now avoids it
being "discovered" as a surprise security-review finding during a
future push.
- **CORS on `alerting`'s HTTP API is wide open**, matching `api`'s
existing no-auth-system posture from Phases 02. Not a new problem
Phase 3 introduces, just a second surface that inherits the same one.
- **No per-group/multi-row threshold alerting** (fix 4's scope decision)
and **no resolved-side debounce** (state-machine section) are both
named future work.
- **Single shared Postgres role** for `api` and `alerting`, same as
`dashboard-design.md`'s note — fine for Phase 3, a named Phase 4 item
once real auth exists.
## Load-testing plan (task 8, not run yet)
Seed ~500 rules via `alerting`'s own create API (not a direct DB insert
— exercise the real code path) at 60-second intervals, against a real
ClickHouse dataset (reusing `hack/benchmark-fixture`'s generator).
Measure: drift between `alert_state.next_eval_at` and the actual claim
timestamp under sustained load, `consecutive_errors`/`last_eval_status`
distribution (did the evaluator start erroring under load, as opposed to
just running slow), and `delivery_log.attempt_count`/`status`
distribution. Document real numbers, not projections — matching every
prior phase's benchmark discipline — and name what would need to change
for materially larger rule counts (horizontal evaluator replicas
partitioning by rule-id hash, which fix 1's claim design already makes
safe to add without a state-model change; moving off a single-process
ticker) as explicit Phase 4+ scope, not solved here.
+288
View File
@@ -0,0 +1,288 @@
# Dashboard design
> **Status:** Approved, in progress. Task 2 of Phase 3 — see `/CLAUDE.md`'s
> "What done looks like for Phase 3" section for the exit criteria this is
> built against. Task 3 (dashboard CRUD API + web UI) implements this
> doc; if implementation reveals this design is wrong somewhere, fix this
> doc in the same change, don't let them drift apart — same discipline as
> `/docs/query-language-design.md`.
## Why this design, in one paragraph
A dashboard is a named collection of panels, each wrapping one saved
Phase 2 query plus a visualization type and a grid position. That's a
straightforward data model; the one real design decision here is *where
it's stored*. Dashboards and their panels need real create/update/delete
semantics with immediate read-your-writes consistency for a UI —
ClickHouse's MergeTree family isn't built for that access pattern (see
"Why not ClickHouse" below). This doc also covers the mechanism that
makes one query language work for both ad hoc search and reusable
dashboard panels: injecting the dashboard's time range into a stored
query at execution time, rather than baking a fixed time range into the
saved query itself.
## Why not ClickHouse: PostgreSQL for control-plane config
This phase adds PostgreSQL as a new pinned-stack component — flagged and
confirmed with the project owner before implementation, per CLAUDE.md's
"ask before... making an architectural decision not already specified in
`/docs/architecture.md`." Scope is strictly control-plane config
(dashboards, panels, and — per `/docs/phase-3-alerting-design.md`
notification targets, alert rules, alert state, delivery log). Log data
itself is untouched: ClickHouse and Tantivy remain the only stores for
`logs`.
Two concrete reasons ClickHouse doesn't fit this job, not just "it feels
risky":
1. **No row-level locking primitive.** The alerting evaluator (see the
alerting design doc) needs to atomically claim a due rule so a second
evaluation tick can't touch it concurrently — a `SELECT ... FOR UPDATE
SKIP LOCKED` operation. `ReplacingMergeTree` + `FINAL` gives
eventually-consistent "latest write wins," not concurrency control.
That's a missing primitive, not a tuning problem.
2. **Read-your-writes consistency for a UI.** A user editing a dashboard
expects to immediately see their own edit reflected back. ClickHouse's
MergeTree engines don't guarantee this the way a transactional
database does without extra machinery (`FINAL`, careful ordering) that
amounts to reimplementing what Postgres already provides natively.
Scope of the addition: new top-level `/metadata` component (naming
mirrors existing precedent — transport=Redpanda, storage=ClickHouse,
search=Tantivy → metadata=Postgres), `postgres:16-alpine` in
docker-compose, `jackc/pgx/v5` as the Go driver in `api` and the new
`alerting` service. No ORM, no `sqlc` — hand-written SQL per store
package, matching the project's existing avoidance of query-generation
machinery (no cobra, no golang-migrate, nothing code-generated from SQL
anywhere in the repo today).
### Migrations: mirror `/storage/migrate.sh`, not a framework
`storage/README.md`'s objection to `golang-migrate` ("premature machinery"
for what's being built) is general, not specific to ClickHouse's
HTTP-interface constraint — six new tables in one change doesn't meet the
bar that would justify revisiting that call. `/metadata/migrate.sh`
mirrors it: bash + `psql -v ON_ERROR_STOP=1 -f <file>` per migration, a
`schema_migrations` tracking table, one DDL object per file (kept for
repo-wide consistency of what a migration "version" means, even though
Postgres itself supports multi-statement transactions unlike ClickHouse's
HTTP interface), applied by a one-shot init container
(`metadata-migrate`) that other services `depends_on: condition:
service_completed_successfully` — same shape as `clickhouse-migrate`.
```
metadata/
README.md Dockerfile migrate.sh docker-compose.yml
migrations/
0001_create_dashboards.sql
0002_create_dashboard_panels.sql
0003_create_notification_targets.sql (alerting design doc)
0004_create_alert_rules.sql (alerting design doc)
0005_create_alert_state.sql (alerting design doc)
0006_create_delivery_log.sql (alerting design doc)
```
All six tables live in one migrations directory / one Postgres database,
even though `api` (dashboards) and `alerting` (rules/targets/state/log)
are the two services that own different tables within it — mirrors how
ClickHouse already hosts tables conceptually "owned" by different
components (`logs` via ingest's consumer, `schema_migrations` via the
migration runner itself) inside one physical database. No shared Go
store code between `api` and `alerting` — each owns hand-written SQL for
the tables it's responsible for; nothing is shared across service
`internal/` trees in this repo except `/proto`, and that precedent holds
here too.
## Schema
All tables carry `tenant_id TEXT NOT NULL DEFAULT 'default'` (populated,
unenforced — see "Not painting Phase 4 into a corner" below) and
`created_by TEXT NOT NULL DEFAULT 'anonymous'` for the same reason.
`updated_at` is app-managed on every UPDATE, no PL/pgSQL trigger —
consistent with the project's avoidance of database-side procedural code.
```sql
CREATE TABLE dashboards (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
default_earliest TEXT NOT NULL DEFAULT '-1h',
default_latest TEXT NOT NULL DEFAULT 'now',
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE dashboard_panels (
id UUID PRIMARY KEY,
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL, -- pipe syntax only, no earliest=/latest=
query_language TEXT NOT NULL DEFAULT '', -- mirrors queryRequest.Language: ''|sql|spl
viz_type TEXT NOT NULL CHECK (viz_type IN ('table','line','bar','single_stat','top_n')),
viz_config JSONB NOT NULL DEFAULT '{}', -- e.g. {"x_column":"timestamp","series_column":"host"}
position_x INT NOT NULL,
position_y INT NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
earliest_override TEXT NULL, -- NULL = inherit dashboard default
latest_override TEXT NULL,
sort_order INT NOT NULL DEFAULT 0, -- deterministic export ordering
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
`query_language` deliberately excludes `'sql'` from being usable in
practice for panels (enforced at the API layer, not the DB CHECK
constraint — see "Raw-SQL panels out of scope" below): the column exists
because it mirrors the existing `/query` request shape exactly, not
because raw SQL panels are supported yet.
`position_x/y/width/height` map 1:1 to the web UI's grid layout library's
own `x/y/w/h` units — no translation layer between stored position and
rendered position.
IDs are app-generated UUIDs (`google/uuid`, already an `api` dependency),
assigned server-side once, matching how `ingest` assigns `record_id`
not `gen_random_uuid()` via a Postgres extension, to keep ID generation
in one place (Go) rather than split between the app and the database.
## Time-range mechanics
A dashboard has a default time range (`default_earliest`/
`default_latest`, in the query language's own relative syntax, e.g.
`-1h`/`now`). Each panel can override it. Critically, **panel query
strings never contain `earliest=`/`latest=` themselves** — the query a
user writes for a panel is time-range-agnostic (e.g. `service=api |
where status>=500 | stats count by host`), and the effective time range
is resolved and injected at execution time:
```
effective_earliest = panel.earliest_override ?? dashboard.default_earliest
effective_latest = panel.latest_override ?? dashboard.default_latest
executed_query = "earliest={effective_earliest} latest={effective_latest} " + panel.query
```
This works because `earliest=`/`latest=` are ordinary `base_search` terms
in Phase 2's grammar (`term := ... | "earliest" "=" time_expr | "latest"
"=" time_expr`), implicitly AND'd with whatever else is in the query,
and — like every term in `bool_expr` — order-independent. Prepending them
is syntactically identical to a user having typed them first.
**`"now"` is a UI-only sentinel, never injected literally.** The default
`default_latest` value shown in the picker is the human-readable string
`"now"`, but `time_expr` only accepts a quoted absolute timestamp or a
`"-N unit"` relative offset — there is no `now` token in the grammar.
Injecting `latest=now` produces a real compile error (`expected a quoted
absolute timestamp or a relative offset`), found by actually running a
dashboard panel against the live stack, not a hypothetical. The fix:
when the effective latest value is `"now"` (or empty), the `latest=`
clause is omitted from the injected query entirely — which is exactly
what the query language already does to mean "no upper bound" (see
`planner.go`'s handling of an absent `latest` term). `earliest=` has no
equivalent sentinel; a dashboard's `default_earliest` is always a real
`time_expr` value.
**This injection only works for pipe-syntax queries.** A raw-SQL query
(Phase 2's `SELECT ...` escape hatch) has no equivalent injection point —
there's no reliable way to splice a time bound into arbitrary SQL without
parsing it, which is exactly the work the raw-SQL escape hatch was
designed to avoid (see `/docs/query-language-design.md`'s "not parsed,
wrapped as opaque IR"). **Raw-SQL dashboard panels are out of scope for
Phase 3** — a disclosed non-goal, not a silent gap. `dashboard_panels.
query_language` exists in the schema for symmetry with the `/query`
request shape, but the dashboard API layer (task 3) rejects `'sql'` on
create/update with a clear error rather than accepting it and having the
time-range picker silently do nothing.
## Panel execution: client-side, not server-side
`GET /dashboards/{id}` returns panel *definitions* only (query, viz_type,
position, overrides) — it does not execute any queries. The web UI
resolves each panel's effective time range and calls `POST /query`
per panel directly, exactly the same endpoint the root query page already
uses. This keeps `api/internal/dashboards` pure CRUD with zero
query-execution code of its own, consistent with the project's standing
rule that nothing duplicates `querylang`'s execution path (the same
reason `sentryctl query` calls `/query` over HTTP instead of importing
`querylang` internals). It also means panels load and error
independently in the UI — one broken panel query doesn't fail the whole
dashboard.
## Visualization types
- **`table`**: renders `{columns, rows}` directly via (an extended)
`ResultsTable.svelte`. No `viz_config` needed.
- **`line`** / **`bar`**: `viz_config` names which result column is the
x-axis/category (`x_column`, typically `timestamp` or a `stats ... by`
grouping column) and which is plotted (`series_column` for
multi-series, `value_column` for the plotted value). Rendered via
uPlot (see below).
- **`single_stat`**: takes the first row's first numeric column, no
`viz_config` needed for MVP (a labeled big number).
- **`top_n`**: a table sorted/limited view — for MVP this is really
"table, but the query already did `sort`/`head`," so it renders
through the same table path as `table` with different framing in the
UI; no separate execution logic.
### New frontend dependencies
`web/package.json` currently has zero runtime dependencies. Two are
added this phase, both confirmed with the project owner before landing:
- **gridstack.js** — panel grid layout with drag-and-drop/resize.
Vanilla JS, framework-agnostic (wrapped in a thin Svelte component),
well-established, used by real dashboard products. Pre-approved
("a lightweight grid library is fine, don't build drag-and-drop from
scratch") — listed here so the concrete choice is visible in the
design record, not just in a `package.json` diff.
- **uPlot** — line/bar chart rendering. ~45KB, canvas-based, fast, no
heavy transitive dependency tree. Chosen over Chart.js (heavier) and a
hand-rolled D3/SVG approach (correct axes/scales/tooltips is a lot of
code to own well for something a library already does correctly).
Table, single-stat, and top-N panels need neither dependency.
## Export / import
`GET /dashboards/{id}/export` returns the dashboard and its panels
marshaled as one JSON document (the same `Dashboard`/`Panel` Go structs
used internally, not a separate export-specific shape). `POST
/dashboards/import` accepts that same shape and creates a new dashboard
from it (new IDs assigned, not a literal replay of the source IDs, so
importing into a different environment doesn't collide). This is
deliberately the *same* JSON contract `sentryctl dashboards apply` (task
7) consumes — "exportable/importable, Terraform-friendly" isn't a
separate design, it's one JSON shape used from two call sites (web
export button, CLI apply).
## Not painting Phase 4 into a corner
- **`tenant_id`** on every table, populated but unenforced — Phase 4's
multi-tenancy retrofit needs a partitioning/enforcement layer, not a
schema migration + backfill on every table.
- **`created_by`** on every table, defaulted to `'anonymous'` since no
auth exists yet — same reasoning, cheaper to add the column now than
during Phase 4 when real identity is also landing.
- **"Shareable" currently means "shareable within the single trusted
deployment."** There is no access-control model at all yet — not
tenant isolation, not even within-tenant per-dashboard permissions.
Stating this plainly here so it doesn't read as more solved than it is
once multi-tenancy is on the table.
- CORS stays wide open (`Access-Control-Allow-Origin: *`) on `api`,
matching the existing Phase 02 posture. Adding dashboards doesn't
change this tradeoff, just widens the same already-accepted surface
area — noted so it's not mistaken for a new gap introduced this phase.
## Where this lives
`api/internal/dashboards/` (`handler.go`, `store.go`, `types.go`, plus
tests) — mirrors `api/internal/queryapi`'s existing `Handler`/`Store`
shape. Requires one small prerequisite refactor to existing code:
`queryapi.Handler.Routes()` currently builds its own `http.NewServeMux()`
and applies CORS in one shot; with a second handler package, both need to
register onto one shared mux in `main.go`, with CORS applied once at the
top. `queryapi.Handler` gains a `RegisterRoutes(mux *http.ServeMux)`
method in place of `Routes()`.
+260
View File
@@ -0,0 +1,260 @@
# Phase 3 runbook
Extends `/docs/phase-0-runbook.md` through `/docs/phase-2-runbook.md`
with dashboards and alerting. Read those first — this assumes the stack
already works. Phase 3 adds a new Postgres-backed control-plane
(`/metadata`), a new dashboard CRUD surface in `/api`, and a new
top-level service (`/alerting`) plus its web UI and CLI subcommands.
## What's actually been verified
Every claim below was checked against the live stack, not asserted —
same discipline as every prior phase's runbook. This phase's real
findings (five of them, not hypothetical) are called out inline where
they were caught, matching the project's standing "actually run it"
rule: a passing test suite and a working feature are not the same claim.
## 1. Bring up the stack
```sh
docker compose up -d --build
```
New services beyond Phase 02: `metadata-postgres`, `metadata-migrate`
(one-shot, applies `/metadata/migrations/*.sql`), `alerting` (port 8081).
`api` now depends on `metadata-migrate` and exposes a `-healthcheck`
self-check mode (distroless, no shell/wget — see `api/cmd/api/main.go`)
so `alerting`'s `depends_on: api: condition: service_healthy` means
something real. Confirm everything is healthy:
```sh
docker compose ps
# api, alerting, metadata-postgres, clickhouse, redpanda should all show (healthy)
```
## 2. Dashboards
Generate a fixture dataset (reuses Phase 2's `hack/benchmark-fixture`):
```sh
cd hack/benchmark-fixture
go run . --count 500000
```
Create a dashboard and a couple of panels, either through the web UI
(`http://localhost:3000/dashboards` → "+ Create" → "+ Add panel") or via
`sentryctl`:
```sh
sentryctl dashboards apply my-dashboard.json # shape = GET /dashboards/{id}/export
```
**Verified live**: a table panel (`severity=INFO | head 10`) and a bar
chart panel (`| stats count by host`, viz_type `bar`) both render
correctly against real data, including drag/resize via the gridstack
grid.
### Real bugs this caught
Building and actually loading the dashboard UI against a live stack
(not just unit tests) found four real bugs, all now fixed:
1. **A Phase 2 bug, only surfaced now**: `earliest=`/`latest=` time-range
queries never actually worked against live ClickHouse. The executor
formatted `TimeRange` bounds with `time.RFC3339Nano`
(`2026-08-12T20:17:40.223505479Z`), but ClickHouse's *implicit*
string→`DateTime64` cast for a column comparison is strict and wants
`'YYYY-MM-DD HH:MM:SS[.fractional]'` — no `T`/`Z`. Failed with `code:
53, Cannot convert string ... to type DateTime64(9, 'UTC')`. Nothing
in Phase 2's own runbook queries or unit tests happened to exercise a
relative time filter live; the unit test asserted the broken format
without ever asking real ClickHouse whether it was valid. Fixed in
`api/internal/querylang/executor/sql.go` (`formatClickHouseDateTime64`).
2. **`"now"` as a literal query token**: the dashboard time-range picker
injected `latest=now` verbatim; the query language has no `now`
keyword (only quoted absolute timestamps or `-N unit` relative
offsets). Fixed by treating `"now"` as a UI-only sentinel that omits
the `latest=` clause entirely (`web/src/lib/api.ts`'s
`injectTimeRange`).
3. **A layout-timing race**: GridStack/uPlot measured a panel's width
before the grid had finished laying out, producing a 74px-wide chart
canvas in a 540px container. Fixed with a `ResizeObserver` in
`PanelViz.svelte` that re-renders whenever the container's actual
size changes (also fixes chart sizing after a manual panel resize).
4. **`Date.parse` is too lenient to use as a "is this a timestamp"
check**: `Date.parse("host-06")` returns a real (bogus) timestamp
rather than `NaN`, which silently misrouted a categorical `stats
count by host` column onto a numeric time axis and rendered
unreadable giant tick labels instead of host names. Fixed by requiring
a strict ISO-8601 prefix match before attempting `Date.parse`
(`PanelViz.svelte`'s `isoTimestampPrefix`).
## 3. Alerting
Bring up a local webhook receiver for testing (no real Slack/PagerDuty
needed):
```sh
docker run -d --name sentry-webhook-sink --network sentry_default \
-p 9099:9099 -v $(pwd)/hack/webhook-sink:/src -w /src golang:1.25-alpine go run .
```
Create a notification target and a rule, either via the web UI
(`http://localhost:3000/alerts/new`) or directly:
```sh
curl -X POST http://localhost:8081/targets -H 'Content-Type: application/json' -d '{
"name": "local sink", "kind": "webhook", "webhook_url": "http://sentry-webhook-sink:9099/"
}'
curl -X POST http://localhost:8081/rules -H 'Content-Type: application/json' -d '{
"name": "always fires", "query": "SELECT 1", "query_language": "sql",
"condition_type": "threshold", "comparator": "gte", "threshold_value": 1,
"eval_interval_seconds": 30, "for_minutes": 0,
"notification_target_id": "<target id>"
}'
```
**Verified live, through both curl and the actual web UI** (created a
rule via the `/alerts/new` form, watched it transition in the browser):
the rule transitions `ok``firing` on its first evaluation
(`for_minutes: 0`), the delivery log shows `firing / sent / 200`, and
`docker logs sentry-webhook-sink` shows the real received payload.
Also verified live: a threshold rule whose query returns **zero rows**
records `last_eval_status: "error"` with the exact expected message
(`"threshold rule query returned 0 rows, want exactly 1"`) and leaves
`state` untouched at `"ok"` — confirming fix 3/4 from
`/docs/phase-3-alerting-design.md` hold in practice, not just in the unit
tests that were written against them.
### Real bugs this caught
5. **`enabled` silently defaulted to `false`.** `rulestore.Rule.Enabled`
is a plain `bool`; a create request that simply didn't mention
`"enabled"` decoded it to Go's zero value (`false`) rather than the
intended "enabled by default." A rule created this way was silently
dead on arrival — never picked up by the evaluator's claim query, no
error anywhere. Fixed in `alerting/internal/httpapi/handler.go` with a
`createRuleRequest` wrapper using `Enabled *bool`: `nil` (omitted)
means enabled; only an explicit `"enabled": false` creates a disabled
rule. Caught by actually creating a rule through the endpoint and
checking the response, not by inspection.
## 4. Load test (task 8)
```sh
cd hack/alert-load-test
go run . --rule-count 500 --eval-interval-seconds 60 --duration 3m30s
```
Seeds 500 rules via `alerting`'s real create API (not a direct DB
insert), each querying a different host's count over the last minute
against the 500,000-row fixture dataset from §2, with an unreachably
high threshold so rules stay `ok` — isolating evaluator/ClickHouse
scheduling throughput from delivery-worker load.
**First run, before a fix**: every one of 500 rules showed
`consecutive_errors > 0`. Root cause: the load-test tool's own query
(`host=host-01`, unquoted) hit a real, pre-existing Phase 2 lexer quirk —
an unquoted comparison value containing a literal `-` fails to parse
(`unexpected MINUS after query`). Fixed by quoting the value
(`host="host-01"`) in the load-test tool itself; not a bug worth chasing
in the query language for this runbook.
**Second run, after that fix — a real, significant finding**: zero
errors, but the observed inter-evaluation interval was a suspiciously
exact **125.0s** for every one of 320 observed intervals, against a
configured `eval_interval_seconds: 60`. Root cause: `evaluator.tick()`
passed `workerPoolSize` (default 20) as *both* the claim batch size and
the concurrency limit — two genuinely different concerns conflated into
one number. With 500 rules all due at nearly the same instant (created
within ~65ms of each other), each 5-second tick could claim only 20 of
them regardless of how many more were already due, so draining the full
backlog took 500 ÷ 20 = 25 ticks × 5s = **125s** — more than double the
configured interval. Fixed by separating `EVALUATOR_CLAIM_BATCH_SIZE`
(default 1000 — how many due rules one tick can pull off the queue) from
`EVALUATOR_WORKER_POOL_SIZE` (default 20 — bounded concurrent `/query`
calls within that batch); see `alerting/internal/config/config.go`.
**Third run, after the fix**:
| Metric | Value |
|---|---|
| Rules seeded | 500 |
| Rules with `consecutive_errors > 0` | 0 |
| Configured `eval_interval_seconds` | 60 |
| Observed interval — min | 59.9s |
| Observed interval — mean | 63.3s |
| Observed interval — p95 | 65.0s |
| Observed interval — max | 65.2s |
| Drift vs. configured (p95 configured) | 5.0s |
The remaining ~5s of "drift" is attributable to the load test's own
5-second poll granularity (an evaluation can only be observed to within
one poll interval), not to the evaluator falling behind — 500 rules at
60s intervals is comfortably within the evaluator's capacity once the
claim-batch/worker-pool conflation was fixed.
**Phase 4+ scaling paths, named but not solved here**: the claim query's
`SELECT ... FOR UPDATE SKIP LOCKED` design (see
`/docs/phase-3-alerting-design.md`'s fix 1) is specifically what makes
horizontal evaluator replicas safe to add later without a state-model
redesign — each replica's claim naturally excludes rows another replica
already claimed. Moving off a single-process ticker to a distributed
scheduler, and materially larger rule counts (10,000+), are both
explicitly out of scope for this phase.
## 5. Confirm `sentryctl`
```sh
sentryctl dashboards list
sentryctl dashboards apply exported-dashboard.json
sentryctl alerts list
sentryctl alerts apply rule.json
```
Both `dashboards` and `alerts` hit the exact same REST endpoints the web
UI does — `dashboards` against `/api` (`--api`), `alerts` against
`/alerting` (`--alerting-api`) — no separate CRUD logic to drift out of
sync.
## Tearing down
```sh
docker compose down -v # wipes the fixture dataset and all dashboards/rules
```
## Troubleshooting
**A relative `earliest=`/`latest=` query fails with `code: 53, Cannot
convert string ... to type DateTime64`.**
You've reverted or bypassed `formatClickHouseDateTime64` in
`api/internal/querylang/executor/sql.go` — ClickHouse's implicit
string→`DateTime64` cast needs `'YYYY-MM-DD HH:MM:SS[.fractional]'`, not
an ISO-8601/RFC3339 literal. See §2, bug 1 above.
**A dashboard chart panel renders with a tiny or zero-width canvas.**
Check that `PanelViz.svelte`'s `ResizeObserver` is still wired up — this
is the exact symptom of the gridstack/uPlot layout-timing race from §2,
bug 3.
**A rule created via the API never evaluates, but no error appears
anywhere.**
Check the response's `"enabled"` field — if the create request omitted
`"enabled"` entirely and the response shows `false`, the
`createRuleRequest` fix in `alerting/internal/httpapi/handler.go` has
regressed. See §3, bug 5.
**500+ rules at a short `eval_interval_seconds` fall behind the
configured interval.**
Check `EVALUATOR_CLAIM_BATCH_SIZE` hasn't been set equal to (or below)
`EVALUATOR_WORKER_POOL_SIZE` — that's the exact regression `hack/alert-
load-test` caught in §4. Claim batch size should stay well above the
worker pool size; the worker pool is what bounds concurrent `/query`
load, not the claim.
**`hack/alert-load-test` reports errors on every rule.**
Check the tool's generated query quotes any comparison value that might
contain a `-` (e.g. `host="host-01"`, not `host=host-01`) — an unquoted
value with a literal hyphen fails to parse. See §4's first-run finding.
+21
View File
@@ -209,6 +209,27 @@ ClickHouse-side text indexing, a different join strategy, or simply
raising `max_query_size` server-side with matching memory sizing) is
explicitly future work, out of scope for Phase 2.
### Post-Phase-2 fix: `earliest=`/`latest=` never actually worked against live ClickHouse
Found during Phase 3's dashboard time-range picker work (the first thing
to run a relative `earliest=`/`latest=` query against real ClickHouse
end-to-end — none of Phase 2's own runbook queries or unit tests
happened to exercise it): `executor/sql.go` formatted `TimeRange` bounds
with `time.RFC3339Nano` (e.g. `2026-08-12T20:17:40.223505479Z`), which
ClickHouse's *implicit* string→`DateTime64` cast for a column-vs-literal
comparison rejects outright — `code: 53, Cannot convert string ... to
type DateTime64(9, 'UTC')`. ClickHouse's implicit cast is strict and
wants `'YYYY-MM-DD HH:MM:SS[.fractional]'` (space-separated, no `T`/`Z`);
the lenient ISO-8601-accepting `parseDateTimeBestEffort` is a different,
explicitly-invoked function, not what a plain `WHERE timestamp >= '...'`
comparison uses. Fixed by `formatClickHouseDateTime64` in `sql.go`. The
Phase 2 unit test that covered this (`TestBuildSQLTimeRange`) only
asserted the generated SQL *string*, against a fake `SQLRunner` — it
never caught this because nothing in that test actually asked real
ClickHouse whether the SQL was valid. Left here as a pointed reminder of
why this project's "actually run it" discipline exists: a passing test
suite and a working feature are not the same claim.
## Where this lives: `api/internal/querylang/`
Not a new top-level component. This subsystem always executes in-process
+42
View File
@@ -0,0 +1,42 @@
# alert-load-test
Seeds a realistic number of concurrent alert rules via `/alerting`'s real
create API (not a direct DB insert) and measures whether the evaluator's
claim scheduling keeps up under load. See
`/docs/phase-3-alerting-design.md`'s "Load-testing plan" and
`/docs/phase-3-runbook.md` for the methodology and real measured results.
```sh
# 1. Push real data so rule queries have real work to do (reuses
# hack/benchmark-fixture):
cd ../benchmark-fixture
go run . --count 500000
# 2. Run a webhook-sink so the (never-firing, by design) rules have a
# valid notification target to point at:
docker run -d --name sentry-webhook-sink --network sentry_default \
-p 9099:9099 -v $(pwd)/../webhook-sink:/src -w /src golang:1.25-alpine go run .
# 3. Run the load test:
cd ../alert-load-test
go run . --rule-count 500 --eval-interval-seconds 60 --duration 3m30s
```
Each rule queries a different host's count over the last minute
(`earliest=-1m host="host-01" | stats count`) against real ClickHouse
data, with `threshold_value` set unreachably high so rules stay `ok` --
this isolates evaluator/ClickHouse scheduling throughput from
delivery-worker load (a query that never fires still exercises the exact
same claim → `/query` → evaluate → `ApplyTransition` path every tick).
The report shows, per rule, the observed intervals between consecutive
`last_evaluated_at` changes (polled at `--poll-interval`), compared
against the configured `eval_interval_seconds`. A real, significant
finding from actually running this: the evaluator's claim batch size and
worker-pool concurrency limit defaulted to the same number (20), so 500
rules all due at once took 125s to cycle through instead of the
configured 60s. Fixed by separating `EVALUATOR_CLAIM_BATCH_SIZE` from
`EVALUATOR_WORKER_POOL_SIZE` (see `alerting/internal/config/config.go`).
Cleans up the seeded rules and notification target on exit unless
`--no-cleanup` is passed.
+3
View File
@@ -0,0 +1,3 @@
module github.com/sentry/sentry/hack/alert-load-test
go 1.25.0
+286
View File
@@ -0,0 +1,286 @@
// Command alert-load-test seeds a realistic number of concurrent alert
// rules via alerting's real create API (not a direct DB insert -- the
// same discipline hack/benchmark-fixture uses, exercising the actual
// code path under test) and measures whether the evaluator's claim
// scheduling keeps up under load. See /docs/phase-3-alerting-design.md's
// "Load-testing plan" and /docs/phase-3-runbook.md for the methodology
// and real measured results from running this.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"sort"
"sync"
"time"
)
type target struct {
ID string `json:"id"`
}
type ruleState struct {
State string `json:"state"`
LastEvaluatedAt *string `json:"last_evaluated_at,omitempty"`
LastEvalStatus string `json:"last_eval_status"`
ConsecutiveErrors int `json:"consecutive_errors"`
}
type rule struct {
ID string `json:"id"`
State ruleState `json:"state"`
}
var (
alertingURL = flag.String("alerting-url", "http://localhost:8081", "alerting service base URL")
ruleCount = flag.Int("rule-count", 500, "number of rules to seed")
evalInterval = flag.Int("eval-interval-seconds", 60, "each rule's eval_interval_seconds")
duration = flag.Duration("duration", 5*time.Minute, "how long to observe after seeding")
pollInterval = flag.Duration("poll-interval", 5*time.Second, "how often to poll GET /rules while observing")
concurrency = flag.Int("concurrency", 20, "concurrent rule-creation requests")
skipCleanup = flag.Bool("no-cleanup", false, "leave the seeded rules/target in place after the run")
webhookURL = flag.String("webhook-url", "http://sentry-webhook-sink:9099/", "notification target URL -- default assumes a webhook-sink container reachable on the compose network")
)
func main() {
flag.Parse()
client := &http.Client{Timeout: 30 * time.Second}
fmt.Printf("creating notification target...\n")
targetID, err := createTarget(client)
if err != nil {
fmt.Fprintln(os.Stderr, "creating notification target:", err)
os.Exit(1)
}
fmt.Printf("target_id=%s\n", targetID)
fmt.Printf("seeding %d rules at %d concurrent requests...\n", *ruleCount, *concurrency)
start := time.Now()
ruleIDs, err := seedRules(client, targetID)
if err != nil {
fmt.Fprintln(os.Stderr, "seeding rules:", err)
os.Exit(1)
}
fmt.Printf("seeded %d rules in %s\n", len(ruleIDs), time.Since(start))
fmt.Printf("observing for %s (polling every %s)...\n", *duration, *pollInterval)
observations := observe(client)
report(observations, ruleIDs)
if !*skipCleanup {
fmt.Println("cleaning up...")
cleanup(client, ruleIDs, targetID)
}
}
func createTarget(client *http.Client) (string, error) {
body, _ := json.Marshal(map[string]string{
"name": "alert-load-test", "kind": "webhook", "webhook_url": *webhookURL,
})
resp, err := client.Post(*alertingURL+"/targets", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
var t target
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return "", err
}
return t.ID, nil
}
// seedRules creates *ruleCount rules, each querying a different host's
// count over the last minute against real ClickHouse data (reuse
// hack/benchmark-fixture to populate that data first). threshold_value
// is deliberately unreachably high (real fixture volumes stay well
// under it) so rules stay in "ok" and the measurement here isolates
// evaluator/ClickHouse scheduling throughput, not delivery-worker load
// -- a query that never fires still exercises the exact same claim ->
// /query -> evaluateCondition -> ApplyTransition path every tick.
func seedRules(client *http.Client, targetID string) ([]string, error) {
hosts := []string{"host-01", "host-02", "host-03", "host-04", "host-05", "host-06", "host-07", "host-08"}
type result struct {
id string
err error
}
work := make(chan int, *ruleCount)
for i := 0; i < *ruleCount; i++ {
work <- i
}
close(work)
results := make(chan result, *ruleCount)
var wg sync.WaitGroup
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range work {
host := hosts[i%len(hosts)]
body, _ := json.Marshal(map[string]any{
"name": fmt.Sprintf("load-test-rule-%d", i),
// host=host-01 (unquoted) fails to parse -- Phase 2's query
// language lexer treats the "-" in an unquoted comparison
// value as a token boundary (confirmed by actually running
// this query: "unexpected MINUS after query"). Quoting the
// value is the fix, not a language bug worth chasing here.
"query": fmt.Sprintf(`earliest=-1m host="%s" | stats count`, host),
"condition_type": "threshold",
"comparator": "gt",
"threshold_value": 1_000_000_000,
"eval_interval_seconds": *evalInterval,
"notification_target_id": targetID,
})
resp, err := client.Post(*alertingURL+"/rules", "application/json", bytes.NewReader(body))
if err != nil {
results <- result{err: err}
continue
}
var r rule
decodeErr := json.NewDecoder(resp.Body).Decode(&r)
resp.Body.Close()
if decodeErr != nil || r.ID == "" {
results <- result{err: fmt.Errorf("rule %d: unexpected response (status %d)", i, resp.StatusCode)}
continue
}
results <- result{id: r.ID}
}
}()
}
wg.Wait()
close(results)
var ids []string
var errs int
for r := range results {
if r.err != nil {
errs++
if errs <= 5 {
fmt.Fprintln(os.Stderr, "seed error:", r.err)
}
continue
}
ids = append(ids, r.id)
}
if errs > 0 {
fmt.Fprintf(os.Stderr, "%d rule-creation errors (showing up to 5 above)\n", errs)
}
return ids, nil
}
func observe(client *http.Client) [][]rule {
deadline := time.Now().Add(*duration)
var polls [][]rule
for time.Now().Before(deadline) {
resp, err := client.Get(*alertingURL + "/rules")
if err == nil {
var rules []rule
if json.NewDecoder(resp.Body).Decode(&rules) == nil {
polls = append(polls, rules)
}
resp.Body.Close()
}
time.Sleep(*pollInterval)
}
return polls
}
func report(observations [][]rule, seededIDs []string) {
if len(observations) < 2 {
fmt.Println("not enough observations to compute drift (need at least 2 polls)")
return
}
// Per rule, collect the distinct last_evaluated_at timestamps seen
// across polls, in order -- consecutive differences are the observed
// inter-evaluation intervals.
seen := map[string][]string{}
latestErrors := map[string]int{}
for _, poll := range observations {
for _, r := range poll {
if r.State.LastEvaluatedAt != nil {
ts := *r.State.LastEvaluatedAt
vals := seen[r.ID]
if len(vals) == 0 || vals[len(vals)-1] != ts {
seen[r.ID] = append(vals, ts)
}
}
latestErrors[r.ID] = r.State.ConsecutiveErrors
}
}
var intervals []float64
for _, timestamps := range seen {
var parsed []time.Time
for _, ts := range timestamps {
t, err := time.Parse(time.RFC3339Nano, ts)
if err == nil {
parsed = append(parsed, t)
}
}
sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) })
for i := 1; i < len(parsed); i++ {
intervals = append(intervals, parsed[i].Sub(parsed[i-1]).Seconds())
}
}
erroring := 0
for _, n := range latestErrors {
if n > 0 {
erroring++
}
}
fmt.Println()
fmt.Println("=== alert-load-test results ===")
fmt.Printf("rules seeded: %d\n", len(seededIDs))
fmt.Printf("rules observed with at least one evaluation: %d\n", len(seen))
fmt.Printf("rules with consecutive_errors > 0 at last poll: %d\n", erroring)
fmt.Printf("configured eval_interval_seconds: %d\n", *evalInterval)
if len(intervals) == 0 {
fmt.Println("no inter-evaluation intervals observed (duration may be too short relative to eval_interval_seconds)")
return
}
sort.Float64s(intervals)
fmt.Printf("observed evaluation intervals (n=%d): min=%.1fs mean=%.1fs p50=%.1fs p95=%.1fs max=%.1fs\n",
len(intervals), intervals[0], mean(intervals), percentile(intervals, 0.5), percentile(intervals, 0.95), intervals[len(intervals)-1])
fmt.Printf("drift vs configured interval (p95 - configured): %.1fs\n", percentile(intervals, 0.95)-float64(*evalInterval))
}
func mean(xs []float64) float64 {
var sum float64
for _, x := range xs {
sum += x
}
return sum / float64(len(xs))
}
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return 0
}
idx := int(p * float64(len(sorted)-1))
return sorted[idx]
}
func cleanup(client *http.Client, ruleIDs []string, targetID string) {
for _, id := range ruleIDs {
req, _ := http.NewRequest(http.MethodDelete, *alertingURL+"/rules/"+id, nil)
resp, err := client.Do(req)
if err == nil {
resp.Body.Close()
}
}
req, _ := http.NewRequest(http.MethodDelete, *alertingURL+"/targets/"+targetID, nil)
resp, err := client.Do(req)
if err == nil {
resp.Body.Close()
}
fmt.Printf("deleted %d rules and 1 target\n", len(ruleIDs))
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/sentry/sentry/hack/webhook-sink
go 1.25.0
+33
View File
@@ -0,0 +1,33 @@
// Command webhook-sink is a minimal HTTP receiver for manually verifying
// alert delivery against a live stack (see /docs/phase-3-runbook.md) when
// a real Slack workspace/PagerDuty account isn't available -- logs every
// received POST body to stdout and returns 200. Not part of any
// docker-compose service by default; run it standalone when you need it.
package main
import (
"flag"
"fmt"
"io"
"log"
"net/http"
"time"
)
func main() {
addr := flag.String("addr", ":9099", "listen address")
flag.Parse()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "reading body: "+err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("[%s] %s %s\n%s\n\n", time.Now().UTC().Format(time.RFC3339), r.Method, r.URL.Path, body)
w.WriteHeader(http.StatusOK)
})
log.Printf("webhook-sink listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}
+9
View File
@@ -0,0 +1,9 @@
# One-shot migration runner: bash + psql client baked in, migrations/*.sql
# copied in at build time. Mirrors /storage/Dockerfile's shape.
# docker build -f metadata/Dockerfile -t sentry-metadata-migrate metadata/
FROM alpine:3.20
RUN apk add --no-cache bash postgresql16-client
WORKDIR /metadata
COPY migrate.sh ./
COPY migrations ./migrations
ENTRYPOINT ["bash", "migrate.sh"]
+69
View File
@@ -0,0 +1,69 @@
# metadata
PostgreSQL schema and migration tooling for Sentry's control-plane
config: dashboards, alert rules, and everything else that isn't log data.
See `/docs/phase-3-dashboard-design.md` and
`/docs/phase-3-alerting-design.md` for why this is a separate database
from `/storage` (ClickHouse) rather than new ClickHouse tables — short
version: dashboards and alert state need real row-level locking and
transactional read-modify-write, which ClickHouse's MergeTree family
doesn't provide.
## Schema
Six tables across two features, one shared database (`sentry_metadata`):
- `dashboards`, `dashboard_panels` — owned by `/api` (`api/internal/dashboards`)
- `notification_targets`, `alert_rules`, `alert_state`, `delivery_log`
owned by `/alerting`
"Owned" here is a documentation convention, not a technical boundary —
both services connect to the same Postgres instance/database, each with
its own hand-written SQL for the tables it's responsible for. Nothing is
shared across service `internal/` trees for this, matching the existing
repo convention that only `/proto` is shared code (and even that isn't
shared logic, just generated bindings).
## Migration tooling: mirrors `/storage/migrate.sh`, not a framework
Same reasoning as `/storage/README.md`: pulling in `golang-migrate` for
what's currently six `CREATE TABLE` statements is premature machinery.
`migrate.sh` applies `migrations/*.sql` in filename order over `psql`,
tracking what's applied in a `schema_migrations` table, one DDL object
per file (kept for repo-wide consistency of what a migration "version"
means, even though Postgres itself supports multi-statement transactions
unlike ClickHouse's HTTP interface).
## Running
```sh
docker compose up -d # starts a standalone Postgres for local work
POSTGRES_PASSWORD=sentry-dev-only ./migrate.sh # applies migrations/*.sql
```
Environment variables `migrate.sh` reads (all optional except
`POSTGRES_PASSWORD`, matching the root docker-compose.yml's
`metadata-postgres` service):
| Var | Default |
|---|---|
| `POSTGRES_HOST` | `localhost` |
| `POSTGRES_PORT` | `5432` |
| `POSTGRES_USER` | `sentry` |
| `POSTGRES_PASSWORD` | (empty — must be set) |
| `POSTGRES_DATABASE` | `sentry_metadata` |
The database itself isn't created by `migrate.sh` — the `postgres:16-alpine`
image auto-creates `POSTGRES_DB` on first startup, unlike ClickHouse where
`migrate.sh` has to issue `CREATE DATABASE IF NOT EXISTS` itself.
There's also a `Dockerfile` (bash + the `postgresql16-client` package
baked in, `migrations/` copied in at build time) used by the root-level
`docker-compose.yml` as a one-shot init service (`metadata-migrate`) —
no runtime package install, no host volume mount needed.
## Adding a migration
Add `migrations/000N_description.sql` with the next sequential number and
a single DDL statement. `migrate.sh` picks it up automatically — no
registration step.
+19
View File
@@ -0,0 +1,19 @@
# Standalone Postgres for local development against /metadata in
# isolation (e.g. iterating on migrations). The root-level docker-compose.yml
# runs the full stack and defines its own metadata-postgres service
# separately — this file is not included by it.
services:
postgres:
image: postgres:16-alpine
container_name: sentry-metadata-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: sentry_metadata
POSTGRES_USER: sentry
POSTGRES_PASSWORD: "sentry-dev-only" # not a real secret, see root docker-compose.yml
volumes:
- metadata-postgres-data:/var/lib/postgresql/data
volumes:
metadata-postgres-data:
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Applies migrations/*.sql to Postgres in filename order, tracking what's
# already been applied in a schema_migrations table -- same shape as
# /storage/migrate.sh, adapted for psql instead of curl since Postgres
# supports real transactions per file (kept to one DDL object per file
# anyway, for repo-wide consistency of what a migration "version" means).
set -euo pipefail
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_USER="${POSTGRES_USER:-sentry}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
POSTGRES_DATABASE="${POSTGRES_DATABASE:-sentry_metadata}"
export PGPASSWORD="$POSTGRES_PASSWORD"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MIGRATIONS_DIR="${SCRIPT_DIR}/migrations"
psql_exec() {
psql -v ON_ERROR_STOP=1 -X -q -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DATABASE" "$@"
}
echo "Ensuring schema_migrations table exists..."
psql_exec -c "CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
applied="$(psql_exec -t -A -c "SELECT version FROM schema_migrations")"
shopt -s nullglob
for file in "${MIGRATIONS_DIR}"/*.sql; do
version="$(basename "$file")"
if grep -qx "$version" <<< "$applied"; then
echo "skip ${version} (already applied)"
continue
fi
echo "apply ${version}"
psql_exec -f "$file"
psql_exec -c "INSERT INTO schema_migrations (version) VALUES ('${version}')"
done
echo "Migrations complete."
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS dashboards
(
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
default_earliest TEXT NOT NULL DEFAULT '-1h',
default_latest TEXT NOT NULL DEFAULT 'now',
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS dashboard_panels
(
id UUID PRIMARY KEY,
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
query_language TEXT NOT NULL DEFAULT '',
viz_type TEXT NOT NULL CHECK (viz_type IN ('table', 'line', 'bar', 'single_stat', 'top_n')),
viz_config JSONB NOT NULL DEFAULT '{}',
position_x INT NOT NULL,
position_y INT NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
earliest_override TEXT,
latest_override TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS notification_targets
(
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('webhook', 'slack', 'pagerduty')),
webhook_url TEXT NOT NULL,
payload_template TEXT,
headers JSONB NOT NULL DEFAULT '{}',
secret TEXT,
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS alert_rules
(
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
query_language TEXT NOT NULL DEFAULT '',
condition_type TEXT NOT NULL CHECK (condition_type IN ('threshold', 'absence')),
comparator TEXT CHECK (comparator IN ('gt', 'gte', 'lt', 'lte', 'eq', 'ne')),
threshold_value DOUBLE PRECISION,
eval_interval_seconds INT NOT NULL CHECK (eval_interval_seconds >= 30),
for_minutes INT NOT NULL DEFAULT 0,
renotify_interval_minutes INT,
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
enabled BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL DEFAULT 'anonymous',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS alert_state
(
rule_id UUID PRIMARY KEY REFERENCES alert_rules(id) ON DELETE CASCADE,
state TEXT NOT NULL DEFAULT 'ok' CHECK (state IN ('ok', 'pending', 'firing')),
condition_true_since TIMESTAMPTZ,
fired_at TIMESTAMPTZ,
last_notified_at TIMESTAMPTZ,
last_evaluated_at TIMESTAMPTZ,
last_eval_status TEXT NOT NULL DEFAULT 'ok' CHECK (last_eval_status IN ('ok', 'error')),
last_error TEXT,
last_value DOUBLE PRECISION,
consecutive_errors INT NOT NULL DEFAULT 0,
next_eval_at TIMESTAMPTZ NOT NULL,
claimed_at TIMESTAMPTZ
)
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS alert_state_next_eval_at_idx ON alert_state (next_eval_at)
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS delivery_log
(
id BIGSERIAL PRIMARY KEY,
rule_id UUID NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
notification_target_id UUID NOT NULL REFERENCES notification_targets(id),
event_type TEXT NOT NULL CHECK (event_type IN ('firing', 'resolved')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed', 'retrying')),
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ,
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
response_status INT,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
@@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS delivery_log_rule_id_created_at_idx ON delivery_log (rule_id, created_at DESC)
@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS delivery_log_claim_idx ON delivery_log (status, next_attempt_at)
WHERE status IN ('pending', 'retrying')
+10 -1
View File
@@ -4,6 +4,15 @@ server {
index index.html;
location / {
try_files $uri $uri/ /index.html;
# adapter-static writes prerendered routes as flat <route>.html
# files (e.g. /dashboards -> dashboards.html, confirmed by
# actually inspecting the build output), not <route>/index.html --
# $uri.html has to be in this chain or a request for exactly
# "/dashboards" falls straight through to the SPA fallback and
# skips the prerendered page it should be serving. 200.html (not
# index.html) is the fallback shell for genuinely dynamic routes
# (dashboards/[id] etc.) -- named differently so it doesn't
# collide with "/" -> index.html, which really is prerendered.
try_files $uri $uri.html $uri/ /200.html;
}
}
+26
View File
@@ -7,6 +7,10 @@
"": {
"name": "web",
"version": "0.0.1",
"dependencies": {
"gridstack": "^11.5.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
@@ -631,6 +635,22 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gridstack": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/gridstack/-/gridstack-11.5.1.tgz",
"integrity": "sha512-qgbH65F6TtyKyi9t6fCkrxLhiobgYR3RBjnK0AzZl+YO7hreMVlsZ1MFbkPV0+7ZhjXdvcaRSZp3UA1yAJqYBQ==",
"funding": [
{
"type": "paypal",
"url": "https://www.paypal.me/alaind831"
},
{
"type": "venmo",
"url": "https://www.venmo.com/adumesny"
}
],
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1229,6 +1249,12 @@
"node": ">=14.17"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+4
View File
@@ -19,5 +19,9 @@
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"gridstack": "^11.5.0",
"uplot": "^1.6.32"
}
}
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
// Dispatches a query result to the right rendering for a panel's
// viz_type. table/top_n reuse ResultsTable.svelte (top_n is "table,
// but the query already did sort/head" -- same execution path per
// /docs/phase-3-dashboard-design.md). line/bar use uPlot.
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import ResultsTable from '$lib/ResultsTable.svelte';
import type { QueryResult, VizType } from '$lib/api';
let {
result,
vizType,
vizConfig = {}
}: { result: QueryResult; vizType: VizType; vizConfig?: Record<string, string> } = $props();
let chartEl: HTMLDivElement | undefined = $state();
let chart: uPlot | undefined;
// ISO-8601-ish prefix ("2026-08-13T20:20:24...") -- the shape Sentry's
// own timestamp column actually comes back as. Deliberately narrow:
// found by actually rendering a `stats count by host` bar chart that
// JS's built-in Date.parse() is far too lenient to use as a "does
// this look like a timestamp" check -- Date.parse("host-06") returns
// a real (bogus) timestamp rather than NaN, which silently misrouted
// a categorical `host` column onto a numeric time axis and rendered
// unreadable giant tick labels instead of host names.
const isoTimestampPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
// Prefers a real numeric/time x-axis when the column parses cleanly
// (e.g. a timestamp column); falls back to row index with the raw
// value as a tick label otherwise (e.g. a `stats ... by host` grouping
// column, which is categorical text).
function resolveXAxis(columns: string[], rows: unknown[][], columnName: string | undefined) {
const idx = columnName ? Math.max(columns.indexOf(columnName), 0) : 0;
const labels = rows.map((r) => String(r[idx] ?? ''));
const asSeconds = rows.map((r) => {
const v = r[idx];
if (typeof v === 'number') return v;
if (typeof v !== 'string' || !isoTimestampPrefix.test(v)) return NaN;
const parsed = Date.parse(v);
return Number.isNaN(parsed) ? NaN : parsed / 1000;
});
const allNumeric = asSeconds.every((n) => !Number.isNaN(n));
return allNumeric
? { values: asSeconds, labels, categorical: false }
: { values: rows.map((_, i) => i), labels, categorical: true };
}
function resolveValueColumn(columns: string[], columnName: string | undefined): number {
if (columnName) {
const i = columns.indexOf(columnName);
if (i >= 0) return i;
}
// default: second column if there is one (first is usually the
// grouping/x column), otherwise the only column there is.
return columns.length > 1 ? 1 : 0;
}
function renderChart() {
if (!chartEl) return;
chart?.destroy();
chart = undefined;
if (vizType !== 'line' && vizType !== 'bar') return;
const { columns, rows } = result;
if (columns.length === 0 || rows.length === 0) return;
const x = resolveXAxis(columns, rows, vizConfig.x_column);
const valueIdx = resolveValueColumn(columns, vizConfig.value_column);
const values = rows.map((r) => {
const v = r[valueIdx];
return typeof v === 'number' ? v : Number(v) || 0;
});
chart = new uPlot(
{
width: chartEl.clientWidth || 400,
height: 220,
legend: { show: false },
series: [
{},
{
label: columns[valueIdx],
stroke: '#06c',
fill: vizType === 'bar' ? '#06c33' : undefined,
width: vizType === 'bar' ? 0 : 2,
paths: vizType === 'bar' ? uPlot.paths.bars!({ size: [0.6] }) : undefined
}
],
axes: [
{
values: (_u, ticks) =>
ticks.map((t) => (x.categorical ? (x.labels[t] ?? '') : String(t)))
},
{}
]
},
[x.values, values],
chartEl
);
}
$effect(() => {
// Re-render whenever the result or viz settings change.
result;
vizType;
vizConfig;
renderChart();
return () => chart?.destroy();
});
// A dashboard panel's real width isn't known at first render --
// gridstack.js sizes the parent .grid-stack-item via its own layout
// pass, which can land after this component's own effect runs. Found
// by actually adding a bar-chart panel and inspecting the rendered
// canvas: it came out 74px wide (chartEl.clientWidth measured before
// gridstack finished sizing the container), not the container's real
// ~540px. A ResizeObserver re-renders whenever chartEl's actual size
// changes, which fixes both that initial race and, as a side benefit,
// keeps the chart correctly sized when a panel is drag-resized later.
$effect(() => {
if (!chartEl) return;
const observer = new ResizeObserver(() => renderChart());
observer.observe(chartEl);
return () => observer.disconnect();
});
</script>
{#if vizType === 'table' || vizType === 'top_n'}
<ResultsTable columns={result.columns} rows={result.rows} hasRun={true} />
{:else if vizType === 'single_stat'}
<div class="single-stat">{result.rows[0]?.[0] ?? '—'}</div>
{:else}
<div bind:this={chartEl} class="chart"></div>
{/if}
<style>
.single-stat {
font-size: 2.5rem;
font-weight: 600;
padding: 1rem 0;
}
.chart {
width: 100%;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
// Extracted from the root query page in Phase 3 so the dashboard panel
// editor and the alert rule editor can reuse the same input --
// deliberately just the input+run affordance, not results/history,
// which differ per consumer.
import type { Language } from '$lib/api';
let {
query = $bindable(''),
language = $bindable<Language>(''),
onRun,
loading = false,
placeholder = 'service=api | where status>=500 | stats count by host | sort -count'
}: {
query: string;
language: Language;
onRun: () => void;
loading?: boolean;
placeholder?: string;
} = $props();
// Client-side mirror of the backend's auto-detect heuristic
// (api/internal/querylang/planner.looksLikeSQL) -- purely a UI hint,
// the server does its own detection independently and is the
// authority on what actually runs.
function detectedLanguage(q: string): 'sql' | 'spl' {
return /^\s*select\b/i.test(q) ? 'sql' : 'spl';
}
let detected = $derived(detectedLanguage(query));
let effectiveLanguage = $derived(language === '' ? detected : language);
function onKeydown(e: KeyboardEvent) {
// Cmd/Ctrl+Enter runs the query -- textarea's own Enter key needs
// to stay newline-for-pipe-stage-formatting, so this isn't a bare
// Enter binding.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onRun();
}
}
</script>
<div class="query-bar">
<textarea bind:value={query} onkeydown={onKeydown} rows="4" spellcheck="false" {placeholder}></textarea>
<div class="controls">
<label>
Language:
<select bind:value={language}>
<option value="">Auto ({detected})</option>
<option value="spl">Pipe syntax</option>
<option value="sql">SQL</option>
</select>
</label>
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
</span>
<button onclick={onRun} disabled={loading || query.trim() === ''}>
{loading ? 'Running…' : 'Run query'}
</button>
<span class="hint">⌘/Ctrl+Enter to run</span>
</div>
</div>
<style>
.query-bar textarea {
width: 100%;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.controls {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.detected-badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: #eef;
color: #224;
}
.detected-badge.sql {
background: #fee;
color: #422;
}
.hint {
font-size: 0.8rem;
color: #777;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
// First real API-client module -- previously each route did its own
// inline fetch(). Introduced in Phase 3 because the surface triples
// (query + dashboards + panels + export/import); still zero-dependency,
// a thin fetch wrapper, not a generated client.
export const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http://localhost:8081';
export type Language = '' | 'sql' | 'spl';
export type QueryResult = { columns: string[]; rows: unknown[][] };
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n';
export type Panel = {
id: string;
dashboard_id: string;
title: string;
query: string;
query_language: Language;
viz_type: VizType;
viz_config: Record<string, string>;
position_x: number;
position_y: number;
width: number;
height: number;
earliest_override: string | null;
latest_override: string | null;
sort_order: number;
};
export type Dashboard = {
id: string;
name: string;
description: string;
default_earliest: string;
default_latest: string;
created_at: string;
updated_at: string;
panels: Panel[] | null;
};
class ApiError extends Error {}
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { 'Content-Type': 'application/json' },
...init
});
if (!res.ok) {
let message = `request failed with status ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
// non-JSON error body -- keep the generic message
}
throw new ApiError(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
function request<T>(path: string, init?: RequestInit): Promise<T> {
return requestFrom(apiBase, path, init);
}
// alerting is a separate service (its own base URL) -- see
// /docs/phase-3-alerting-design.md's component boundary.
function alertingRequest<T>(path: string, init?: RequestInit): Promise<T> {
return requestFrom(alertingBase, path, init);
}
export function runQuery(query: string, language: Language): Promise<QueryResult> {
return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) });
}
export function listDashboards(): Promise<Dashboard[]> {
return request('/dashboards').then((d) => (d as Dashboard[]) ?? []);
}
export function getDashboard(id: string): Promise<Dashboard> {
return request(`/dashboards/${id}`);
}
export function createDashboard(input: {
name: string;
description?: string;
default_earliest?: string;
default_latest?: string;
}): Promise<Dashboard> {
return request('/dashboards', { method: 'POST', body: JSON.stringify(input) });
}
export function updateDashboard(
id: string,
input: { name: string; description?: string; default_earliest?: string; default_latest?: string }
): Promise<Dashboard> {
return request(`/dashboards/${id}`, { method: 'PUT', body: JSON.stringify(input) });
}
export function deleteDashboard(id: string): Promise<void> {
return request(`/dashboards/${id}`, { method: 'DELETE' });
}
export function addPanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
return request(`/dashboards/${dashboardId}/panels`, { method: 'POST', body: JSON.stringify(panel) });
}
export function updatePanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
return request(`/dashboards/${dashboardId}/panels/${panel.id}`, {
method: 'PUT',
body: JSON.stringify(panel)
});
}
export function deletePanel(dashboardId: string, panelId: string): Promise<void> {
return request(`/dashboards/${dashboardId}/panels/${panelId}`, { method: 'DELETE' });
}
export function exportDashboard(id: string): Promise<Dashboard> {
return request(`/dashboards/${id}/export`);
}
export function importDashboard(dashboard: Dashboard): Promise<Dashboard> {
return request('/dashboards/import', { method: 'POST', body: JSON.stringify(dashboard) });
}
// resolveTimeRange applies the override-or-default rule from
// /docs/phase-3-dashboard-design.md's "Time-range mechanics": a panel's
// own earliest/latest override wins if set, otherwise the dashboard's
// default applies.
export function resolveTimeRange(
dashboard: Pick<Dashboard, 'default_earliest' | 'default_latest'>,
panel: Pick<Panel, 'earliest_override' | 'latest_override'>
): { earliest: string; latest: string } {
return {
earliest: panel.earliest_override ?? dashboard.default_earliest,
latest: panel.latest_override ?? dashboard.default_latest
};
}
// injectTimeRange prepends earliest=/latest= as leading base_search
// terms -- works because they're ordinary implicit-AND terms in Phase
// 2's grammar, order-independent. Never used for raw-SQL panels (the
// dashboards API rejects query_language: "sql" on panels entirely, so
// this never has to handle that case).
//
// "now" is a UI-only sentinel (the default_latest value shown in the
// time-range picker), not a token the query language understands --
// time_expr only accepts a quoted absolute timestamp or a "-N unit"
// relative offset (see /docs/query-language-design.md). Emitting a
// literal `latest=now` produces a real compile error ("expected a
// quoted absolute timestamp or a relative offset"), caught by actually
// running this against the live stack. Omitting the latest= clause
// entirely is the query language's own way of saying "no upper bound",
// which is exactly what "now" means here.
export function injectTimeRange(query: string, earliest: string, latest: string): string {
const clauses = [`earliest=${earliest}`];
if (latest && latest !== 'now') clauses.push(`latest=${latest}`);
return `${clauses.join(' ')} ${query}`;
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
export type Comparator = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne';
export type NotificationKind = 'webhook' | 'slack' | 'pagerduty';
export type AlertRuleState = 'ok' | 'pending' | 'firing';
export type NotificationTarget = {
id: string;
name: string;
kind: NotificationKind;
webhook_url: string;
};
export type AlertRule = {
id: string;
name: string;
description: string;
query: string;
query_language: Language;
condition_type: ConditionType;
comparator?: Comparator;
threshold_value?: number;
eval_interval_seconds: number;
for_minutes: number;
renotify_interval_minutes?: number;
notification_target_id: string;
enabled: boolean;
state: {
state: AlertRuleState;
last_evaluated_at?: string;
last_eval_status: 'ok' | 'error';
last_error?: string;
last_value?: number;
consecutive_errors: number;
};
};
export type DeliveryLogEntry = {
id: number;
event_type: 'firing' | 'resolved';
status: 'pending' | 'sent' | 'failed' | 'retrying';
attempt_count: number;
last_error?: string;
response_status?: number;
created_at: string;
};
export function listRules(): Promise<AlertRule[]> {
return alertingRequest<AlertRule[]>('/rules').then((r) => r ?? []);
}
export function getRule(id: string): Promise<AlertRule> {
return alertingRequest(`/rules/${id}`);
}
export function createRule(input: Partial<AlertRule>): Promise<AlertRule> {
return alertingRequest('/rules', { method: 'POST', body: JSON.stringify(input) });
}
export function deleteRule(id: string): Promise<void> {
return alertingRequest(`/rules/${id}`, { method: 'DELETE' });
}
export function listDeliveries(ruleId: string): Promise<DeliveryLogEntry[]> {
return alertingRequest<DeliveryLogEntry[]>(`/rules/${ruleId}/deliveries`).then((d) => d ?? []);
}
export function listNotificationTargets(): Promise<NotificationTarget[]> {
return alertingRequest<NotificationTarget[]>('/targets').then((t) => t ?? []);
}
export function createNotificationTarget(input: {
name: string;
kind: NotificationKind;
webhook_url: string;
}): Promise<NotificationTarget> {
return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) });
}
+28
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
// Phase 3: dashboards + alerts routes added, so there's now more than
// one page -- a minimal nav replaces the previous "no nav, one page"
// layout.
let { children } = $props();
</script>
@@ -9,4 +12,29 @@
<link rel="icon" href={favicon} />
</svelte:head>
<nav>
<a href="/">Query</a>
<a href="/dashboards">Dashboards</a>
<a href="/alerts">Alerts</a>
</nav>
{@render children()}
<style>
nav {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 1rem auto 0;
padding: 0 1rem;
display: flex;
gap: 1.25rem;
}
nav a {
color: #06c;
text-decoration: none;
font-size: 0.9rem;
}
nav a:hover {
text-decoration: underline;
}
</style>
+10 -92
View File
@@ -2,12 +2,14 @@
// Phase 2: single unified query page. Replaces Phase 0/1's two
// separate pages (raw-SQL-only /query, free-text-only /search) --
// see /docs/query-language-design.md and /docs/query-language-reference.md.
// Phase 3: query bar extracted into $lib/QueryBar.svelte (reused by
// the dashboard panel editor and alert rule editor), fetch calls
// moved into $lib/api.ts.
import ResultsTable from '$lib/ResultsTable.svelte';
import QueryBar from '$lib/QueryBar.svelte';
import { runQuery as apiRunQuery, type Language } from '$lib/api';
const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
type Language = '' | 'sql' | 'spl';
type HistoryEntry = { query: string; language: Language; at: number };
const HISTORY_KEY = 'sentry.queryHistory';
@@ -22,16 +24,6 @@
let hasRun = $state(false);
let history = $state<HistoryEntry[]>(loadHistory());
// Client-side mirror of the backend's auto-detect heuristic
// (api/internal/querylang/planner.looksLikeSQL) -- purely a UI hint,
// the server does its own detection independently and is the
// authority on what actually runs.
function detectedLanguage(q: string): 'sql' | 'spl' {
return /^\s*select\b/i.test(q) ? 'sql' : 'spl';
}
let detected = $derived(detectedLanguage(query));
let effectiveLanguage = $derived(language === '' ? detected : language);
function loadHistory(): HistoryEntry[] {
if (typeof sessionStorage === 'undefined') return [];
try {
@@ -61,20 +53,9 @@
loading = true;
error = '';
try {
const res = await fetch(`${apiBase}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, language })
});
const body = await res.json();
if (!res.ok) {
error = body?.error ?? `request failed with status ${res.status}`;
columns = [];
rows = [];
return;
}
columns = body.columns ?? [];
rows = body.rows ?? [];
const result = await apiRunQuery(query, language);
columns = result.columns ?? [];
rows = result.rows ?? [];
saveHistory({ query, language, at: Date.now() });
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -85,16 +66,6 @@
hasRun = true;
}
}
function onKeydown(e: KeyboardEvent) {
// Cmd/Ctrl+Enter runs the query -- textarea's own Enter key needs
// to stay newline-for-pipe-stage-formatting, so this isn't a bare
// Enter binding.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
}
</script>
<main>
@@ -102,35 +73,10 @@
<p>
One query bar for both filter/stats queries and free-text search — see
<code>/docs/query-language-reference.md</code> in the repo for the full syntax, or the cheat
sheet below.
sheet below. Build reusable queries into a <a href="/dashboards">dashboard</a>.
</p>
<textarea
bind:value={query}
onkeydown={onKeydown}
rows="4"
cols="100"
spellcheck="false"
placeholder={'service=api | where status>=500 | stats count by host | sort -count'}
></textarea>
<div class="controls">
<label>
Language:
<select bind:value={language}>
<option value="">Auto ({detected})</option>
<option value="spl">Pipe syntax</option>
<option value="sql">SQL</option>
</select>
</label>
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
</span>
<button onclick={runQuery} disabled={loading || query.trim() === ''}>
{loading ? 'Running…' : 'Run query'}
</button>
<span class="hint">⌘/Ctrl+Enter to run</span>
</div>
<QueryBar bind:query bind:language onRun={runQuery} {loading} />
{#if error}
<p class="error">Error: {error}</p>
@@ -179,34 +125,6 @@
margin: 2rem auto;
padding: 0 1rem;
}
textarea {
width: 100%;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.controls {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.detected-badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: #eef;
color: #224;
}
.detected-badge.sql {
background: #fee;
color: #422;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.error {
color: #b00020;
}
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { listRules, deleteRule, type AlertRule } from '$lib/api';
let rules = $state<AlertRule[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
try {
rules = await listRules();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function remove(id: string) {
try {
await deleteRule(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
function conditionSummary(r: AlertRule): string {
if (r.condition_type === 'absence') return 'absence (query returns zero rows)';
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
return `${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`;
}
</script>
<main>
<h1>Alerts</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<a class="new-rule" href="/alerts/new">+ New rule</a>
{#if loading}
<p>Loading…</p>
{:else if rules.length === 0}
<p>No alert rules yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Condition</th>
<th>State</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rules as r (r.id)}
<tr>
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
<td><code>{conditionSummary(r)}</code></td>
<td>
<span class="state" class:firing={r.state.state === 'firing'} class:pending={r.state.state === 'pending'}>
{r.state.state}
</span>
{#if r.state.last_eval_status === 'error'}
<span class="eval-error" title={r.state.last_error}>eval error</span>
{/if}
</td>
<td>{r.enabled ? 'yes' : 'no'}</td>
<td><button class="delete" onclick={() => remove(r.id)}>Delete</button></td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.new-rule {
display: inline-block;
margin-bottom: 1rem;
color: #06c;
text-decoration: none;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.4rem 0.6rem;
text-align: left;
font-size: 0.9rem;
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
}
.eval-error {
margin-left: 0.4rem;
font-size: 0.75rem;
color: #b00020;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the dashboards list page's +page.ts.
export const prerender = true;
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { page } from '$app/state';
import { getRule, deleteRule, listDeliveries, type AlertRule, type DeliveryLogEntry } from '$lib/api';
const ruleId = page.params.id!;
let rule = $state<AlertRule | null>(null);
let deliveries = $state<DeliveryLogEntry[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
rule = await getRule(ruleId);
deliveries = await listDeliveries(ruleId);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function remove() {
await deleteRule(ruleId);
window.location.href = '/alerts';
}
function conditionSummary(r: AlertRule): string {
if (r.condition_type === 'absence') return 'query returns zero rows in its own time window';
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
return `first row's value ${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`;
}
</script>
<main>
{#if loading}
<p>Loading…</p>
{:else if !rule}
<p class="error">Error: {error}</p>
{:else}
<div class="header">
<h1>{rule.name}</h1>
<button class="delete" onclick={remove}>Delete rule</button>
</div>
{#if rule.description}<p class="desc">{rule.description}</p>{/if}
{#if error}<p class="error">Error: {error}</p>{/if}
<section class="summary">
<div>
<span class="label">State</span>
<span
class="state"
class:firing={rule.state.state === 'firing'}
class:pending={rule.state.state === 'pending'}
>
{rule.state.state}
</span>
</div>
<div><span class="label">Condition</span> {rule.condition_type}{conditionSummary(rule)}</div>
<div><span class="label">Query</span> <code>{rule.query}</code></div>
<div><span class="label">Evaluation interval</span> {rule.eval_interval_seconds}s</div>
<div><span class="label">Debounce (for)</span> {rule.for_minutes}m</div>
<div><span class="label">Enabled</span> {rule.enabled ? 'yes' : 'no'}</div>
{#if rule.state.last_eval_status === 'error'}
<div class="eval-error">
<span class="label">Last evaluation error</span> {rule.state.last_error}
({rule.state.consecutive_errors} consecutive)
</div>
{:else if rule.state.last_value !== undefined}
<div><span class="label">Last observed value</span> {rule.state.last_value}</div>
{/if}
</section>
<h2>Delivery log</h2>
<p class="hint">Most recent first — this is "why didn't I get paged."</p>
{#if deliveries.length === 0}
<p>No deliveries yet.</p>
{:else}
<table>
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Status</th>
<th>Attempts</th>
<th>Response</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{#each deliveries as d (d.id)}
<tr>
<td>{new Date(d.created_at).toLocaleString()}</td>
<td>{d.event_type}</td>
<td>{d.status}</td>
<td>{d.attempt_count}</td>
<td>{d.response_status ?? '—'}</td>
<td class="error-cell">{d.last_error ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.desc {
color: #555;
}
.error {
color: #b00020;
}
.summary {
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.9rem;
}
.label {
font-weight: 600;
margin-right: 0.4rem;
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
}
.eval-error {
color: #b00020;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.3rem 0.5rem;
text-align: left;
font-size: 0.85rem;
}
.error-cell {
color: #b00020;
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// The rule ID param doesn't exist at build time -- same dynamic-route
// shape as dashboards/[id], served from adapter-static's fallback shell.
export const prerender = false;
export const ssr = false;
+241
View File
@@ -0,0 +1,241 @@
<script lang="ts">
import QueryBar from '$lib/QueryBar.svelte';
import {
listNotificationTargets,
createNotificationTarget,
createRule,
type NotificationTarget,
type NotificationKind,
type ConditionType,
type Comparator,
type Language
} from '$lib/api';
let name = $state('');
let description = $state('');
let query = $state('');
let language = $state<Language>('');
let conditionType = $state<ConditionType>('threshold');
let comparator = $state<Comparator>('gt');
let thresholdValue = $state('100');
let evalIntervalSeconds = $state('60');
let forMinutes = $state('0');
let renotifyIntervalMinutes = $state('');
let targets = $state<NotificationTarget[]>([]);
let targetId = $state('');
let showNewTarget = $state(false);
let newTargetName = $state('');
let newTargetKind = $state<NotificationKind>('webhook');
let newTargetURL = $state('');
let error = $state('');
let submitting = $state(false);
async function loadTargets() {
try {
targets = await listNotificationTargets();
if (targets.length > 0 && !targetId) targetId = targets[0].id;
} catch (e) {
// Every other data-loading call on this page's siblings
// (dashboards, alerts list, rule detail) wraps its fetch in
// try/catch -- this one didn't, and an unhandled rejection here
// (e.g. alerting unreachable) crashes the prerendering build
// entirely rather than just showing an error, found by actually
// running `docker build` for web.
error = e instanceof Error ? e.message : String(e);
}
}
loadTargets();
async function submitNewTarget() {
if (!newTargetName.trim() || !newTargetURL.trim()) return;
try {
const t = await createNotificationTarget({ name: newTargetName, kind: newTargetKind, webhook_url: newTargetURL });
await loadTargets();
targetId = t.id;
showNewTarget = false;
newTargetName = '';
newTargetURL = '';
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function submit() {
error = '';
if (!name.trim() || !query.trim() || !targetId) {
error = 'name, query, and a notification target are all required';
return;
}
submitting = true;
try {
const payload: Record<string, unknown> = {
name,
description,
query,
query_language: language,
condition_type: conditionType,
eval_interval_seconds: Number(evalIntervalSeconds),
for_minutes: Number(forMinutes),
notification_target_id: targetId
};
if (conditionType === 'threshold') {
payload.comparator = comparator;
payload.threshold_value = Number(thresholdValue);
}
if (renotifyIntervalMinutes.trim() !== '') {
payload.renotify_interval_minutes = Number(renotifyIntervalMinutes);
}
const rule = await createRule(payload);
window.location.href = `/alerts/${rule.id}`;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<main>
<h1>New alert rule</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<label class="field">
Name
<input bind:value={name} placeholder="High error rate" />
</label>
<label class="field">
Description
<input bind:value={description} placeholder="optional" />
</label>
<QueryBar bind:query bind:language onRun={() => {}} placeholder="service=api | where status>=500 | stats count" />
<p class="hint">
For <code>threshold</code> rules, the query must resolve to exactly one row (e.g.
<code>| stats count</code>). For <code>absence</code> rules, the query's own <code>earliest=</code>
defines the window being checked for zero results.
</p>
<div class="row">
<label>
Condition
<select bind:value={conditionType}>
<option value="threshold">Threshold</option>
<option value="absence">Absence</option>
</select>
</label>
{#if conditionType === 'threshold'}
<label>
Comparator
<select bind:value={comparator}>
<option value="gt">&gt;</option>
<option value="gte">&gt;=</option>
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">==</option>
<option value="ne">!=</option>
</select>
</label>
<label>
Threshold value
<input type="number" bind:value={thresholdValue} />
</label>
{/if}
</div>
<div class="row">
<label>
Evaluation interval (seconds)
<input type="number" min="30" bind:value={evalIntervalSeconds} />
</label>
<label>
Debounce, "for" minutes
<input type="number" min="0" bind:value={forMinutes} />
</label>
<label>
Renotify interval (minutes, optional)
<input type="number" min="1" bind:value={renotifyIntervalMinutes} placeholder="never" />
</label>
</div>
<div class="row">
<label>
Notification target
<select bind:value={targetId}>
{#each targets as t (t.id)}
<option value={t.id}>{t.name} ({t.kind})</option>
{/each}
</select>
</label>
<button type="button" onclick={() => (showNewTarget = !showNewTarget)}>
{showNewTarget ? 'Cancel' : '+ New target'}
</button>
</div>
{#if showNewTarget}
<div class="new-target">
<input placeholder="Target name" bind:value={newTargetName} />
<select bind:value={newTargetKind}>
<option value="webhook">Generic webhook</option>
<option value="slack">Slack</option>
<option value="pagerduty">PagerDuty</option>
</select>
<input placeholder="https://..." bind:value={newTargetURL} />
<button type="button" onclick={submitNewTarget}>Add target</button>
</div>
{/if}
<button class="submit" onclick={submit} disabled={submitting}>
{submitting ? 'Creating…' : 'Create rule'}
</button>
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.field {
display: block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.field input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.2rem;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.row {
display: flex;
gap: 1rem;
align-items: flex-end;
margin: 1rem 0;
flex-wrap: wrap;
}
.row label {
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.new-target {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.submit {
margin-top: 1rem;
padding: 0.4rem 1rem;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// A static path segment ("new"), not a dynamic route -- SvelteKit
// resolves this before matching /alerts/[id], so "new" never collides
// with a rule ID lookup. No route params, prerenderable like the list page.
export const prerender = true;
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import {
listDashboards,
createDashboard,
deleteDashboard,
importDashboard,
type Dashboard
} from '$lib/api';
let dashboards = $state<Dashboard[]>([]);
let loading = $state(true);
let error = $state('');
let newName = $state('');
async function load() {
loading = true;
try {
dashboards = await listDashboards();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function create() {
if (!newName.trim()) return;
try {
await createDashboard({ name: newName.trim() });
newName = '';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function remove(id: string) {
try {
await deleteDashboard(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function onImportFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
await importDashboard(JSON.parse(text));
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
input.value = '';
}
}
</script>
<main>
<h1>Dashboards</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<div class="create-row">
<input
placeholder="New dashboard name"
bind:value={newName}
onkeydown={(e) => e.key === 'Enter' && create()}
/>
<button onclick={create} disabled={!newName.trim()}>Create</button>
<label class="import-label">
Import JSON
<input type="file" accept="application/json" onchange={onImportFile} hidden />
</label>
</div>
{#if loading}
<p>Loading…</p>
{:else if dashboards.length === 0}
<p>No dashboards yet.</p>
{:else}
<ul class="dashboard-list">
{#each dashboards as d (d.id)}
<li>
<a href={`/dashboards/${d.id}`}>{d.name}</a>
{#if d.description}<span class="desc">{d.description}</span>{/if}
<button class="delete" onclick={() => remove(d.id)}>Delete</button>
</li>
{/each}
</ul>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.create-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.import-label {
cursor: pointer;
color: #06c;
font-size: 0.85rem;
}
.dashboard-list {
list-style: none;
padding: 0;
}
.dashboard-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.dashboard-list a {
font-weight: 600;
color: #06c;
text-decoration: none;
}
.desc {
color: #777;
font-size: 0.85rem;
}
.delete {
margin-left: auto;
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the root query page's +page.ts.
export const prerender = true;
+362
View File
@@ -0,0 +1,362 @@
<script lang="ts">
import { page } from '$app/state';
import { GridStack, type GridStackNode } from 'gridstack';
import 'gridstack/dist/gridstack.min.css';
import QueryBar from '$lib/QueryBar.svelte';
import PanelViz from '$lib/PanelViz.svelte';
import {
getDashboard,
updateDashboard,
deleteDashboard as apiDeleteDashboard,
addPanel,
deletePanel as apiDeletePanel,
updatePanel as apiUpdatePanel,
exportDashboard,
runQuery,
resolveTimeRange,
injectTimeRange,
type Dashboard,
type Panel,
type VizType,
type Language,
type QueryResult
} from '$lib/api';
const dashboardId = page.params.id!;
let dashboard = $state<Dashboard | null>(null);
let loading = $state(true);
let error = $state('');
let earliestInput = $state('-1h');
let latestInput = $state('now');
let panelResults = $state<Record<string, QueryResult>>({});
let panelErrors = $state<Record<string, string>>({});
let gridEl: HTMLDivElement | undefined = $state();
let grid: GridStack | undefined;
let showAddPanel = $state(false);
let newTitle = $state('');
let newQuery = $state('');
let newLanguage = $state<Language>('');
let newVizType = $state<VizType>('table');
async function load() {
loading = true;
error = '';
try {
dashboard = await getDashboard(dashboardId);
earliestInput = dashboard.default_earliest;
latestInput = dashboard.default_latest;
await runAllPanels();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function runPanel(panel: Panel) {
if (!dashboard) return;
const { earliest, latest } = resolveTimeRange(dashboard, panel);
try {
const result = await runQuery(injectTimeRange(panel.query, earliest, latest), panel.query_language);
panelResults = { ...panelResults, [panel.id]: result };
if (panelErrors[panel.id]) {
const rest = { ...panelErrors };
delete rest[panel.id];
panelErrors = rest;
}
} catch (e) {
// A broken panel query shouldn't take down the rest of the
// dashboard -- per /docs/phase-3-dashboard-design.md's "panel
// execution" section, panels load and error independently.
panelErrors = { ...panelErrors, [panel.id]: e instanceof Error ? e.message : String(e) };
}
}
async function runAllPanels() {
if (!dashboard?.panels) return;
await Promise.all(dashboard.panels.map(runPanel));
}
async function applyTimeRange() {
if (!dashboard) return;
dashboard = await updateDashboard(dashboardId, {
name: dashboard.name,
description: dashboard.description,
default_earliest: earliestInput,
default_latest: latestInput
});
await runAllPanels();
}
function nextY(): number {
if (!dashboard?.panels || dashboard.panels.length === 0) return 0;
return Math.max(...dashboard.panels.map((p) => p.position_y + p.height));
}
async function submitAddPanel() {
if (!newQuery.trim()) return;
try {
await addPanel(dashboardId, {
title: newTitle,
query: newQuery,
query_language: newLanguage,
viz_type: newVizType,
position_x: 0,
position_y: nextY(),
width: 6,
height: 4
});
showAddPanel = false;
newTitle = '';
newQuery = '';
newLanguage = '';
newVizType = 'table';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function removePanel(panelId: string) {
try {
await apiDeletePanel(dashboardId, panelId);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function removeDashboard() {
await apiDeleteDashboard(dashboardId);
window.location.href = '/dashboards';
}
async function doExport() {
const doc = await exportDashboard(dashboardId);
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.name.replace(/\s+/g, '-').toLowerCase() || 'dashboard'}.json`;
a.click();
URL.revokeObjectURL(url);
}
// Persists drag/resize moves back to the API. Re-initialized whenever
// the *set* of panels changes (add/remove/reload) -- not on every
// result update, which would disrupt an in-progress drag.
function setupGrid() {
if (!gridEl || !dashboard?.panels) return;
grid?.destroy(false);
grid = GridStack.init({ float: true, cellHeight: 60, column: 12 }, gridEl);
grid.on('change', (_event: Event, items: GridStackNode[]) => {
for (const item of items) {
const panel = dashboard?.panels?.find((p) => p.id === item.id);
if (!panel) continue;
apiUpdatePanel(dashboardId, {
...panel,
position_x: item.x ?? panel.position_x,
position_y: item.y ?? panel.position_y,
width: item.w ?? panel.width,
height: item.h ?? panel.height
}).catch(() => {
// Best-effort persistence -- a failed position save isn't
// worth surfacing as a page-level error; the layout is
// still usable for the current session either way.
});
}
});
}
let panelIds = $derived(dashboard?.panels?.map((p) => p.id).join(',') ?? '');
$effect(() => {
// Deliberately no queueMicrotask here: $effect in Svelte 5 already
// runs after the DOM has committed the render that triggered it
// (unlike $effect.pre), so the {#each} block's grid-stack-item
// elements already exist by the time this runs. An earlier version
// wrapped this in queueMicrotask "to be safe" and that extra hop
// raced against Svelte's own DOM-update scheduling -- GridStack.init
// sometimes ran before or after the elements existed depending on
// scheduling order, silently initializing against zero items.
panelIds;
if (dashboard?.panels && dashboard.panels.length > 0) {
setupGrid();
}
});
</script>
<main>
{#if loading}
<p>Loading…</p>
{:else if !dashboard}
<p class="error">Error: {error}</p>
{:else}
<div class="header">
<h1>{dashboard.name}</h1>
<div class="header-actions">
<button onclick={doExport}>Export JSON</button>
<button class="delete" onclick={removeDashboard}>Delete dashboard</button>
</div>
</div>
{#if dashboard.description}<p class="desc">{dashboard.description}</p>{/if}
{#if error}<p class="error">Error: {error}</p>{/if}
<div class="time-range">
<label>Earliest <input bind:value={earliestInput} placeholder="-1h" /></label>
<label>Latest <input bind:value={latestInput} placeholder="now" /></label>
<button onclick={applyTimeRange}>Apply to all panels</button>
<span class="hint">Per-panel overrides win over this default -- see the panel editor.</span>
</div>
{#if dashboard.panels && dashboard.panels.length > 0}
<div class="grid-stack" bind:this={gridEl}>
{#each dashboard.panels as panel (panel.id)}
<div
class="grid-stack-item"
{...{
'gs-id': panel.id,
'gs-x': panel.position_x,
'gs-y': panel.position_y,
'gs-w': panel.width,
'gs-h': panel.height
}}
>
<div class="grid-stack-item-content panel">
<div class="panel-header">
<span class="panel-title">{panel.title || panel.query}</span>
<button class="panel-delete" onclick={() => removePanel(panel.id)}>×</button>
</div>
{#if panelErrors[panel.id]}
<p class="error">Error: {panelErrors[panel.id]}</p>
{:else if panelResults[panel.id]}
<PanelViz
result={panelResults[panel.id]}
vizType={panel.viz_type}
vizConfig={panel.viz_config}
/>
{:else}
<p>Loading…</p>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<p>No panels yet. Add one below.</p>
{/if}
<div class="add-panel">
<button onclick={() => (showAddPanel = !showAddPanel)}>
{showAddPanel ? 'Cancel' : '+ Add panel'}
</button>
{#if showAddPanel}
<div class="add-panel-form">
<input placeholder="Panel title" bind:value={newTitle} />
<QueryBar bind:query={newQuery} bind:language={newLanguage} onRun={submitAddPanel} />
<label>
Visualization:
<select bind:value={newVizType}>
<option value="table">Table</option>
<option value="line">Line chart</option>
<option value="bar">Bar chart</option>
<option value="single_stat">Single stat</option>
<option value="top_n">Top-N</option>
</select>
</label>
<button onclick={submitAddPanel} disabled={!newQuery.trim()}>Add panel</button>
</div>
{/if}
</div>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.desc {
color: #555;
}
.error {
color: #b00020;
}
.time-range {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0;
flex-wrap: wrap;
}
.time-range input {
width: 6rem;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.panel {
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.5rem 0.75rem;
height: 100%;
box-sizing: border-box;
overflow: auto;
background: white;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 0.5rem;
}
.panel-delete {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #999;
}
.add-panel {
margin-top: 1.5rem;
}
.add-panel-form {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 640px;
}
.add-panel-form input {
box-sizing: border-box;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
// The dashboard ID param doesn't exist at build time, so this route can't
// be prerendered like the list page -- served from adapter-static's
// fallback shell (see vite.config.ts) and rendered fully client-side.
export const prerender = false;
export const ssr = false;
+10 -1
View File
@@ -9,7 +9,16 @@ export default defineConfig({
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
// fallback: dashboards/[id] and alerts/[id] are dynamic routes
// whose params (dashboard/rule IDs) don't exist at build time, so
// they can't be prerendered like the root/list pages. Named
// 200.html, not index.html -- the root route ("/") is itself
// prerendered to index.html, and a same-named fallback silently
// overwrites it (confirmed by actually running the build: "Using
// index.html" produced the warning "Overwriting build/index.html
// with fallback page"). nginx.conf's try_files points at
// /200.html to match.
adapter: adapter({ fallback: '200.html' })
})
]
});