Serve Prometheus metrics behind SWWAF_METRICS_TOKEN (closes #23)
check / check (push) Successful in 4m12s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user