Serve Prometheus metrics behind SWWAF_METRICS_TOKEN (closes #23)
check / check (push) Successful in 4m12s

GET /_smallwebwaf/metrics answers in the Prometheus text format for a
request carrying SWWAF_METRICS_TOKEN, 401 without it and 404 while it is
unset. Every request under /_smallwebwaf/ but the health check now goes
through the checks and is answered where it would be forwarded, 404 for
any path but the metrics, so none reaches the app. SWWAF_METRICS_TOP_N
bounds the series by country, the rest counted as other.

Judgement call: a request answered at smallwebwaf's own endpoints is
neither forwarded nor refused in the client's history.
Deviation: go.mod and go.sum written by hand from the module proxy and
sum.golang.org, as go runs only through make.
Deviation: no metrics yet for state files read again after an edit or
edits set aside; that work is not merged.

Model: opus-5-5
This commit is contained in:
2026-10-06 08:27:11 +00:00
parent 68f687cb0c
commit 2776bb4b09
23 changed files with 1459 additions and 66 deletions
+114 -2
View File
@@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"testing/synctest"
@@ -15,6 +18,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/state"
)
@@ -402,6 +406,59 @@ func TestFailedWriteLeavesTheFileAsItWas(t *testing.T) {
}
}
func TestWritesAreCountedInTheMetrics(t *testing.T) {
t.Parallel()
dir := t.TempDir()
params := newParams(dir)
params.Ledger.Load([]bans.Ban{permanentBan()})
files := load(t, params)
err := files.WriteAll()
if err != nil {
t.Fatalf("write: %v", err)
}
const (
ofBans = `{file="bans.json"}`
ofClients = `{file="clients.json"}`
)
got := scrape(t, params)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 1)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofClients, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 0)
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans,
float64(len(permanentBansJSON)))
written := metric(t, got, "smallwebwaf_state_file_last_write_timestamp_seconds"+ofBans)
if written < float64(time.Now().Add(-time.Hour).Unix()) {
t.Errorf("bans.json was last written at %v, not by that write", written)
}
// A directory in the way of bans.json's temporary file fails its next
// write, which leaves its size as it was, although it has a ban more.
err = os.Mkdir(filepath.Join(dir, bansJSON+".tmp"), 0o700)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
params.Ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
bans.Notes{})
err = files.WriteAll()
if err == nil {
t.Fatal("the write did not fail")
}
got = scrape(t, params)
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 2)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofClients, 0)
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans,
float64(len(permanentBansJSON)))
}
func TestFailedRenameLeavesNoTemporaryFile(t *testing.T) {
t.Parallel()
@@ -431,6 +488,7 @@ func midnight() time.Time {
// hold nothing yet. GeoJS is never asked.
func newParams(dir string) state.Params {
discard := slog.New(slog.DiscardHandler)
m := metrics.New(1)
return state.Params{
Dir: dir,
@@ -442,10 +500,13 @@ func newParams(dir string) state.Params {
MaxBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
}),
Limiter: ratelimit.New(ratelimit.Limits{}),
GeoJS: lookup.New(lookup.Params{Now: midnight, ProcessLog: discard}),
Limiter: ratelimit.New(ratelimit.Limits{}),
GeoJS: lookup.New(lookup.Params{
Now: midnight, ProcessLog: discard, Metrics: m,
}),
Now: midnight,
ProcessLog: discard,
Metrics: m,
}
}
@@ -633,3 +694,54 @@ func wantEntries(t *testing.T, path, key string, want ...string) {
}
}
}
// scrape returns the metrics of params, in the Prometheus text format.
func scrape(t *testing.T, params state.Params) string {
t.Helper()
recorder := httptest.NewRecorder()
params.Metrics.ServeHTTP(recorder,
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody))
if recorder.Code != http.StatusOK {
t.Fatalf("the metrics were answered %d", recorder.Code)
}
return recorder.Body.String()
}
// metric returns the value of series in text, the metrics, such as
// smallwebwaf_state_file_writes_total{file="bans.json"}, or fails the test
// if there is no such series.
func metric(t *testing.T, text, series string) float64 {
t.Helper()
for line := range strings.Lines(text) {
value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ")
if !found {
continue
}
number, err := strconv.ParseFloat(value, 64)
if err != nil {
t.Fatalf("%s has the value %q", series, value)
}
return number
}
t.Fatalf("no series %s in the metrics:\n%s", series, text)
return 0
}
// wantMetric checks the value of series in text, the metrics, as metric
// reads it.
func wantMetric(t *testing.T, text, series string, want float64) {
t.Helper()
got := metric(t, text, series)
if got != want {
t.Errorf("%s is %v, want %v", series, got, want)
}
}