Instance name on process log lines and every metric (closes #91)
check / check (push) Waiting to run

Process log lines carry instance, as request lines do; the instance name
is read before the other settings, so the line saying a setting is
invalid carries it too. Every metric, Go's and the process's included,
carries the label instance, set once on the registry. README.md says so,
and that Prometheus keeps it as exported_instance unless the scrape sets
honor_labels. An instance name that is not valid UTF-8 stops the start,
as the metrics library panics on such a label.

Tests that read metrics expect the label; one helper replaces the alert
tests' loops that wait for them.

Judgement call: the label is named instance, as in the log lines and
alerts, although Prometheus gives each target a label of that name.

Model: opus-5-5
This commit was merged in pull request #96.
This commit is contained in:
2026-10-07 06:48:09 +02:00
parent 70a8ea1b92
commit f35cbd01cf
15 changed files with 267 additions and 121 deletions
+12 -3
View File
@@ -210,9 +210,11 @@ effective settings are logged at start.
`https`, a host and an optional port, and nothing more. `https`, a host and an optional port, and nothing more.
- `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the - `SWWAF_INSTANCE_NAME` (default: the host's name, which docker sets to the
first 12 characters of the container's id unless the deployment names one): first 12 characters of the container's id unless the deployment names one):
the name each request log line gives as `instance`. Set it, for example to the name every log line and alert gives as `instance`, and every metric
carries as its label `instance` (see "Metrics" below). Set it, for example to
`fsn1app1/gitea`, for a name that stays the same when a deploy replaces the `fsn1app1/gitea`, for a name that stays the same when a deploy replaces the
container, and that tells instances apart when several log to one place. container, and that tells instances apart when several log to one place. A
name that is not valid UTF-8, such as one saved in Latin-1, stops the start.
- `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the - `SWWAF_MODE` (default `enforce`): `enforce`, or `observe` to pass on the
requests `smallwebwaf` would refuse and log what it would have done (see "What requests `smallwebwaf` would refuse and log what it would have done (see "What
it does so far" above). it does so far" above).
@@ -513,7 +515,7 @@ A field that does not apply to a request is left out of its line, apart from
No body is logged, and no header but those above. `smallwebwaf`'s own messages No body is logged, and no header but those above. `smallwebwaf`'s own messages
(start, the settings, stop, errors) share the stream as JSON lines marked (start, the settings, stop, errors) share the stream as JSON lines marked
`"type":"process"`. `"type":"process"`, each with `instance` as a request's line has it.
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
headers before `smallwebwaf` sees the request, and some requests end there, headers before `smallwebwaf` sees the request, and some requests end there,
@@ -897,6 +899,13 @@ empty directory or set `SWWAF_RULES_ENABLED=false`.
format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any format, for a scraper that sends `SWWAF_METRICS_TOKEN`, through traefik like any
other request. No metric carries a client's address. other request. No metric carries a client's address.
Every metric below, Go's and the process's included, carries the label
`instance`, `SWWAF_INSTANCE_NAME`, as a constant label set once on the registry
the metrics are kept in, rather than as a label each metric declares. Prometheus
gives each series it scrapes an `instance` label of its own, the address it
scraped, and keeps this one as `exported_instance` unless the scrape sets
`honor_labels: true`.
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and - `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
`smallwebwaf_response_bytes_total`: requests, and their body bytes each way, `smallwebwaf_response_bytes_total`: requests, and their body bytes each way,
by `status_class`, such as `2xx`, or `none` when nothing was sent, and by by `status_class`, such as `2xx`, or `none` when nothing was sent, and by
+29 -5
View File
@@ -34,9 +34,10 @@ type Config struct {
ListenAddr string ListenAddr string
// UpstreamURL is the app (SWWAF_UPSTREAM_URL). // UpstreamURL is the app (SWWAF_UPSTREAM_URL).
UpstreamURL *url.URL UpstreamURL *url.URL
// InstanceName is the name each request log line gives as instance // InstanceName is the name every log line and alert gives as instance,
// (SWWAF_INSTANCE_NAME), by default the host's name, which docker sets // and every metric carries as its label instance (SWWAF_INSTANCE_NAME),
// to the first 12 characters of the container's id. // by default the host's name, which docker sets to the first 12
// characters of the container's id.
InstanceName string InstanceName string
// Observe is true in observe mode, when SWWAF_MODE is observe rather // Observe is true in observe mode, when SWWAF_MODE is observe rather
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country // than enforce: a request that SWWAF_DENY_NETS, a ban, the country
@@ -267,6 +268,7 @@ var (
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
"source_failure or file_error") "source_failure or file_error")
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off") errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
errNotUTF8 = errors.New("is not valid UTF-8")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
@@ -276,11 +278,10 @@ var (
// setting that is set but invalid is an error that names it. // setting that is set but invalid is an error that names it.
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env := &environment{lookupEnv: lookupEnv} env := &environment{lookupEnv: lookupEnv}
hostname, _ := os.Hostname() // "" when the host has no name to give
cfg := &Config{ cfg := &Config{
ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr), ListenAddr: env.address("SWWAF_LISTEN_ADDR", defaultListenAddr),
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL), UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", defaultUpstreamURL),
InstanceName: env.value("SWWAF_INSTANCE_NAME", hostname), InstanceName: env.instanceName(),
Observe: env.observe("SWWAF_MODE", "enforce"), Observe: env.observe("SWWAF_MODE", "enforce"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"), ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
@@ -373,6 +374,16 @@ func ListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil return listenAddr, upstreamURL, nil
} }
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
// file, as FromEnvironment does, so that the line saying a setting is
// invalid carries it too. A file that cannot be read gives the default
// here, and FromEnvironment then stops the start over it.
func InstanceName(lookupEnv func(string) (string, bool)) string {
env := &environment{lookupEnv: lookupEnv}
return env.instanceName()
}
// privateRanges are the private address ranges, the default trusted // privateRanges are the private address ranges, the default trusted
// proxies. // proxies.
const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" const privateRanges = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
@@ -660,6 +671,19 @@ func (e *environment) facility(name, defaultValue string) int {
return number return number
} }
// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It
// must be valid UTF-8: the metrics library panics on a label that is not.
func (e *environment) instanceName() string {
hostname, _ := os.Hostname() // "" when the host has no name to give
value := e.value("SWWAF_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) {
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
}
return value
}
// appName reads the setting that is the APP-NAME of the records the log // appName reads the setting that is the APP-NAME of the records the log
// lines are sent in, by default the instance name. Its value is checked // lines are sent in, by default the instance name. Its value is checked
// when it is set, and, while lines are sent, when it is the instance name. // when it is set, and, while lines are sent, when it is the instance name.
+19
View File
@@ -737,6 +737,25 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
} }
} }
func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
t.Parallel()
// café saved in Latin-1.
const latin1 = "caf\xe9"
for name, env := range map[string]environment{
"set": {instanceName: latin1},
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
} {
_, err := config.FromEnvironment(env.lookupEnv)
want := instanceName + `: "caf\xe9" is not valid UTF-8`
if err == nil || err.Error() != want {
t.Errorf("%s: error %v, want %s", name, err, want)
}
}
}
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) { func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
+3 -3
View File
@@ -199,7 +199,7 @@ func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
URL: lookup.URL, URL: lookup.URL,
Now: time.Now, Now: time.Now,
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)), ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
Metrics: metrics.New(1), Metrics: metrics.New(1, "app"),
Alerts: alerts.New(alerts.Params{}), Alerts: alerts.New(alerts.Params{}),
}) })
g.SetTransport(geojs) g.SetTransport(geojs)
@@ -398,7 +398,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
m := metrics.New(1) m := metrics.New(1, "app")
g := lookup.New(lookup.Params{ g := lookup.New(lookup.Params{
URL: lookup.URL, URL: lookup.URL,
Now: time.Now, Now: time.Now,
@@ -589,7 +589,7 @@ func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
URL: lookup.URL, URL: lookup.URL,
Now: clock.Now, Now: clock.Now,
ProcessLog: slog.New(slog.DiscardHandler), ProcessLog: slog.New(slog.DiscardHandler),
Metrics: metrics.New(1), Metrics: metrics.New(1, "app"),
Alerts: queue, Alerts: queue,
}) })
g.SetTransport(geojs) g.SetTransport(geojs)
+9 -6
View File
@@ -21,7 +21,8 @@ import (
// Metrics are smallwebwaf's metrics. They are safe for concurrent use. // Metrics are smallwebwaf's metrics. They are safe for concurrent use.
type Metrics struct { type Metrics struct {
registry *prometheus.Registry // registry gives every metric registered with it the label instance.
registry prometheus.Registerer
handler http.Handler handler http.Handler
inFlight prometheus.Gauge inFlight prometheus.Gauge
@@ -55,13 +56,17 @@ type Metrics struct {
// New returns the metrics, with the Go runtime's and the process's own. // New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries get series of their own // topN is how many countries get series of their own
// (SWWAF_METRICS_TOP_N). // (SWWAF_METRICS_TOP_N). Every metric carries instanceName
func New(topN int) *Metrics { // (SWWAF_INSTANCE_NAME) as its label instance.
func New(topN int, instanceName string) *Metrics {
byStatus := []string{"status_class", "action"} byStatus := []string{"status_class", "action"}
byFile := []string{"file"} byFile := []string{"file"}
registry := prometheus.NewRegistry()
m := &Metrics{ m := &Metrics{
registry: prometheus.NewRegistry(), registry: prometheus.WrapRegistererWith(
prometheus.Labels{"instance": instanceName}, registry),
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{ inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "smallwebwaf_requests_in_flight", Name: "smallwebwaf_requests_in_flight",
Help: "Requests under way.", Help: "Requests under way.",
@@ -119,8 +124,6 @@ func New(topN int) *Metrics {
byFile), byFile),
} }
m.handler = promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
m.registry.MustRegister( m.registry.MustRegister(
collectors.NewGoCollector(), collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
+64 -44
View File
@@ -148,8 +148,8 @@ func TestMetricsCountTheTraffic(t *testing.T) {
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
out.requestLines(t, 2) out.requestLines(t, 2)
forward := `{action="forward",status_class="2xx"}` forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="admin",status_class="4xx"}` notFound := `{action="admin",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way. // The request for the metrics is itself under way.
metrics := scrape(t, addr) metrics := scrape(t, addr)
@@ -159,11 +159,13 @@ func TestMetricsCountTheTraffic(t *testing.T) {
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5) wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound, wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
float64(len("Not Found\n"))) float64(len("Not Found\n")))
wantMetric(t, metrics, "smallwebwaf_request_duration_seconds_count", 2) wantMetric(t, metrics,
wantMetric(t, metrics, "smallwebwaf_upstream_duration_seconds_count", 1) `smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
wantMetric(t, metrics, "smallwebwaf_requests_in_flight", 1) wantMetric(t, metrics,
metric(t, metrics, "go_goroutines") `smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
metric(t, metrics, "process_start_time_seconds") wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
metric(t, metrics, `go_goroutines{instance="app"}`)
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
// A request the app holds is under way until it ends. // A request the app holds is under way until it ends.
httpClient := newClient(t) httpClient := newClient(t)
@@ -180,7 +182,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
}() }()
<-arrived <-arrived
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 2) wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
releaseApp() releaseApp()
err := <-ended err := <-ended
@@ -189,7 +191,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
} }
out.requestLines(t, 5) out.requestLines(t, 5)
wantMetric(t, scrape(t, addr), "smallwebwaf_requests_in_flight", 1) wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
} }
func TestMetricsCountLimitsAndBans(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) {
@@ -219,15 +221,16 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
metrics := s.scrape(scraper) metrics := s.scrape(scraper)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_requests_total{action="denied",status_class="none"}`, 1) `smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 1) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 1) `smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 1) wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, "smallwebwaf_active_bans", 1) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 0) wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
clk.advance(time.Hour) clk.advance(time.Hour)
wantMetric(t, s.scrape(scraper), "smallwebwaf_active_bans", 0) wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent. // than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
@@ -235,13 +238,14 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionRateLimited)
metrics = s.scrape(scraper) metrics = s.scrape(scraper)
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{window="minute"}`, 2) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_offences_total{kind="limit"}`, 2) `smallwebwaf_rate_limit_hits_total{instance="app",window="minute"}`, 2)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 2) wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
wantMetric(t, metrics, "smallwebwaf_active_bans", 1) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1) wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
// denied, client, and the scraper as of its earlier requests. // denied, client, and the scraper as of its earlier requests.
wantMetric(t, metrics, "smallwebwaf_tracked_clients", 3) wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 3)
} }
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
@@ -255,7 +259,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
rateLimitExemptNets: scraper, rateLimitExemptNets: scraper,
}) })
const admins = `smallwebwaf_bans_made_total{cause="admin"}` const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
wantMetric(t, s.scrape(scraper), admins, 0) wantMetric(t, s.scrape(scraper), admins, 0)
@@ -319,28 +323,42 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
metrics := scrape(t, addr) metrics := scrape(t, addr)
lines++ lines++
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`, 2) `smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 1) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_country_list_refusals_total{country="KP"}`, 3) `smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="KP"}`, 0) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`, 6) `smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="KP"}`, wantMetric(t, metrics,
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics,
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
wantMetric(t, metrics,
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
wantMetric(t, metrics,
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
float64(3*len("Forbidden\n"))) float64(3*len("Forbidden\n")))
wantMetric(t, metrics, `smallwebwaf_country_response_bytes_total{country="other"}`, wantMetric(t, metrics,
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
float64(len("hello"))) float64(len("hello")))
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`) wantNoSeries(t, metrics,
`smallwebwaf_country_requests_total{country="FR",instance="app"}`)
// Once FR is busier than DE, it takes DE's place: its series counts // Once FR is busier than DE, it takes DE's place: its series counts
// from then on, and DE's is gone. // from then on, and DE's is gone.
send(fromFR, 3, http.StatusOK) send(fromFR, 3, http.StatusOK)
metrics = scrape(t, addr) metrics = scrape(t, addr)
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="KP"}`, 3) wantMetric(t, metrics,
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="FR"}`, 2) `smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, `smallwebwaf_country_requests_total{country="other"}`, 2) wantMetric(t, metrics,
wantNoSeries(t, metrics, `smallwebwaf_country_requests_total{country="DE"}`) `smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
wantNoSeries(t, metrics, `smallwebwaf_country_request_bytes_total{country="DE"}`) wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
wantNoSeries(t, metrics,
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
wantNoSeries(t, metrics,
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
} }
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
@@ -370,16 +388,16 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, addr) metrics := scrape(t, addr)
for metric(t, metrics, "smallwebwaf_geojs_failures_total") == 0 && for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
time.Now().Before(deadline) { time.Now().Before(deadline) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
metrics = scrape(t, addr) metrics = scrape(t, addr)
} }
wantMetric(t, metrics, "smallwebwaf_geojs_requests_total", 1) wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, "smallwebwaf_geojs_failures_total", 1) wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, "smallwebwaf_geojs_unanswered_total", 1) wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
} }
// keptAnswer returns GeoJS's answer that the client at addr is in // keptAnswer returns GeoJS's answer that the client at addr is in
@@ -422,8 +440,9 @@ func (s *sender) scrape(from string) string {
// metric returns the value of series in metrics, which are in the // metric returns the value of series in metrics, which are in the
// Prometheus text format. series is a name and its labels in the order of // Prometheus text format. series is a name and its labels in the order of
// their names, such as smallwebwaf_offences_total{kind="limit"}. It fails // their names, such as
// the test if there is no such series. // smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the
// test if there is no such series.
func metric(t *testing.T, metrics, series string) float64 { func metric(t *testing.T, metrics, series string) float64 {
t.Helper() t.Helper()
@@ -471,7 +490,8 @@ func wantNoSeries(t *testing.T, metrics, series string) {
func wantLimitHits(t *testing.T, addr, limit string, hits int) { func wantLimitHits(t *testing.T, addr, limit string, hits int) {
t.Helper() t.Helper()
series := `smallwebwaf_size_and_time_limit_hits_total{limit="` + limit + `"}` series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
limit + `"}`
metrics := scrape(t, addr) metrics := scrape(t, addr)
if hits == 0 { if hits == 0 {
+2 -5
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"os"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
@@ -123,10 +122,8 @@ func wantAnswer(t *testing.T, got answer, body []byte) {
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) { func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
t.Helper() t.Helper()
hostname, _ := os.Hostname()
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, Time: line.Time, Instance: hostname, Type: requestType, Time: line.Time, Instance: "app",
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host, ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
Path: rawPath, Query: rawQuery, Protocol: protocol, Path: rawPath, Query: rawQuery, Protocol: protocol,
Status: http.StatusTeapot, RequestBytes: int64(sent), Status: http.StatusTeapot, RequestBytes: int64(sent),
@@ -318,7 +315,7 @@ func TestServerHasTheDefaultLimits(t *testing.T) {
server := proxy.New(proxy.Params{ server := proxy.New(proxy.Params{
Config: cfg, Config: cfg,
RequestLog: io.Discard, RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard), ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName),
}) })
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 || if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
+1 -1
View File
@@ -88,7 +88,7 @@ type Server struct {
// applies the timeouts and size limits from then on. // applies the timeouts and size limits from then on.
func New(params Params) *Server { func New(params Params) *Server {
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn) errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
m := metrics.New(params.Config.MetricsTopN) m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
h := &handler{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
+7 -3
View File
@@ -217,7 +217,9 @@ func startProxyWithGeoJS(
// startProxyWithClock is startProxyWithGeoJS with requests counted and // startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns the server as well. Unless // bans made by the time now tells, and returns the server as well. Unless
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules. // env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of
// every metric.
func startProxyWithClock( func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
@@ -238,7 +240,9 @@ func startProxyWithAlerts(
) (string, *output, *proxy.Server, *alerts.Queue) { ) (string, *output, *proxy.Server, *alerts.Queue) {
t.Helper() t.Helper()
settings := map[string]string{"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir()} settings := map[string]string{
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
}
maps.Copy(settings, env) maps.Copy(settings, env)
cfg, err := config.FromEnvironment(func(name string) (string, bool) { cfg, err := config.FromEnvironment(func(name string) (string, bool) {
@@ -251,7 +255,7 @@ func startProxyWithAlerts(
} }
out := &output{} out := &output{}
processLog := requestlog.NewProcessLogger(out) processLog := requestlog.NewProcessLogger(out, cfg.InstanceName)
ruleFiles, err := rules.Load(rules.Params{ ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog, Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
+8 -8
View File
@@ -197,15 +197,15 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metrics := s.scrape(scraper) metrics := s.scrape(scraper)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="block",rule_id="blocked"}`, 1) `smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="ban",rule_id="probe"}`, 1) `smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(t, metrics, "smallwebwaf_rules_loaded", 2) wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
`smallwebwaf_requests_total{action="rule_blocked",status_class="4xx"}`, 1) `instance="app",status_class="4xx"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack"}`, 1) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit"}`, 0) wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
wantMetric(t, metrics, "smallwebwaf_permanent_bans", 1) wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
} }
// writeRules writes content as a rule file into a new directory, and // writeRules writes content as a rule file into a new directory, and
+3 -3
View File
@@ -171,8 +171,8 @@ func Milliseconds(d time.Duration) float64 {
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
// JSON lines on w, marked "type":"process", with the time in the same form // JSON lines on w, marked "type":"process", with the time in the same form
// as a request line's. // as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
func NewProcessLogger(w io.Writer) *slog.Logger { func NewProcessLogger(w io.Writer, instanceName string) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey && len(groups) == 0 { if attr.Key == slog.TimeKey && len(groups) == 0 {
@@ -183,5 +183,5 @@ func NewProcessLogger(w io.Writer) *slog.Logger {
}, },
}) })
return slog.New(handler).With("type", "process") return slog.New(handler).With("type", "process", "instance", instanceName)
} }
+5 -4
View File
@@ -65,12 +65,12 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
} }
} }
func TestProcessLinesAreMarkedProcess(t *testing.T) { func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Parallel() t.Parallel()
var out bytes.Buffer var out bytes.Buffer
requestlog.NewProcessLogger(&out).Info("starting", "version", "v1") requestlog.NewProcessLogger(&out, "fsn1app1/gitea").Info("starting", "version", "v1")
var fields map[string]any var fields map[string]any
@@ -79,8 +79,9 @@ func TestProcessLinesAreMarkedProcess(t *testing.T) {
t.Fatalf("decode %q: %v", out.String(), err) t.Fatalf("decode %q: %v", out.String(), err)
} }
if fields["type"] != "process" || fields["msg"] != "starting" || if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
fields["level"] != "INFO" || fields["version"] != "v1" { fields["msg"] != "starting" || fields["level"] != "INFO" ||
fields["version"] != "v1" {
t.Errorf("process line %v", fields) t.Errorf("process line %v", fields)
} }
+3 -2
View File
@@ -68,7 +68,8 @@ func Main(version string) int {
// requests until ctx is done. It returns the process's exit status, 1 // requests until ctx is done. It returns the process's exit status, 1
// when smallwebwaf cannot start. // when smallwebwaf cannot start.
func Run(ctx context.Context, params Params) int { func Run(ctx context.Context, params Params) int {
processLog := requestlog.NewProcessLogger(params.Stdout) processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.LookupEnv))
cfg, err := config.FromEnvironment(params.LookupEnv) cfg, err := config.FromEnvironment(params.LookupEnv)
if err != nil { if err != nil {
@@ -86,7 +87,7 @@ func Run(ctx context.Context, params Params) int {
if cfg.LogRemoteURL != nil { if cfg.LogRemoteURL != nil {
remote = newRemoteLogSender(cfg) remote = newRemoteLogSender(cfg)
stdout = io.MultiWriter(params.Stdout, remote) stdout = io.MultiWriter(params.Stdout, remote)
processLog = requestlog.NewProcessLogger(stdout) processLog = requestlog.NewProcessLogger(stdout, cfg.InstanceName)
stopSending := startSending(ctx, remote, processLog) stopSending := startSending(ctx, remote, processLog)
defer stopSending() defer stopSending()
+94 -26
View File
@@ -38,10 +38,14 @@ const (
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
rulesDir = "SWWAF_RULES_DIR" rulesDir = "SWWAF_RULES_DIR"
instanceName = "SWWAF_INSTANCE_NAME"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set. // adminSecret is the SWWAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at
// it.
instance = "fsn1app1/gitea"
// greeting is what the tests' app answers. // greeting is what the tests' app answers.
greeting = "hello from the app" greeting = "hello from the app"
) )
@@ -118,7 +122,10 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{} out := &output{}
status := run(t.Context(), map[string]string{"SWWAF_REQUEST_MAX_BYTES": "lots"}, out) status := run(t.Context(), map[string]string{
"SWWAF_REQUEST_MAX_BYTES": "lots",
instanceName: instance,
}, out)
if status != 1 { if status != 1 {
t.Errorf("exit status %d, want 1", status) t.Errorf("exit status %d, want 1", status)
} }
@@ -126,7 +133,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
line := out.line(t, "msg", "invalid setting") line := out.line(t, "msg", "invalid setting")
message, _ := line["error"].(string) message, _ := line["error"].(string)
if line["type"] != "process" || line["level"] != "ERROR" || if line["type"] != "process" || line["instance"] != instance ||
line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") { !strings.HasPrefix(message, "SWWAF_REQUEST_MAX_BYTES: ") {
t.Errorf("start refused with %v", line) t.Errorf("start refused with %v", line)
} }
@@ -231,6 +239,46 @@ func TestServesUntilToldToStop(t *testing.T) {
out.line(t, "msg", "stopped") out.line(t, "msg", "stopped")
} }
func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
t.Parallel()
const token = "0123456789abcdef0123456789abcdef"
env := map[string]string{
listenAddr: localhost + ":0",
upstreamURL: startApp(t),
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
}
var metrics string
out := runUntilStopped(t, env, func(url string) {
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
})
// The process's lines from its start to its stop, and the request's.
for line := range strings.Lines(out.text()) {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil || fields["instance"] != instance {
t.Errorf("line %q (%v), want instance %s", line, err, instance)
}
}
// Each series, Go's and the process's included; the other lines are
// the comments.
for line := range strings.Lines(metrics) {
if !strings.HasPrefix(line, "#") &&
!strings.Contains(line, `instance="fsn1app1/gitea"`) {
t.Errorf("series %q, without instance=\"fsn1app1/gitea\"", line)
}
}
}
func TestStateKeptAcrossRestarts(t *testing.T) { func TestStateKeptAcrossRestarts(t *testing.T) {
t.Parallel() t.Parallel()
@@ -519,6 +567,7 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(), "SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWWAF_LOG_REMOTE_BUFFER": "1", "SWWAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token, metricsToken: token,
instanceName: instance,
} }
out := runUntilStopped(t, env, func(url string) { out := runUntilStopped(t, env, func(url string) {
@@ -528,16 +577,17 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
// last. // last.
metrics := metricsText(t, url+"_smallwebwaf/metrics", token) metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
for _, series := range []string{ for _, series := range []string{
"smallwebwaf_remote_log_lines_sent_total 0", `smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/gitea"} 0`,
"smallwebwaf_remote_log_buffer_depth 1", `smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/gitea"} 1`,
} { } {
if !strings.Contains(metrics, "\n"+series+"\n") { if !strings.Contains(metrics, "\n"+series+"\n") {
t.Errorf("no %q in the metrics:\n%s", series, metrics) t.Errorf("no %q in the metrics:\n%s", series, metrics)
} }
} }
if strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total 0\n") || const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" +
!strings.Contains(metrics, "\nsmallwebwaf_remote_log_lines_dropped_total ") { `{instance="fsn1app1/gitea"} `
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
t.Errorf("no line dropped in the metrics:\n%s", metrics) t.Errorf("no line dropped in the metrics:\n%s", metrics)
} }
@@ -547,6 +597,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}) })
out.line(t, "type", "request") out.line(t, "type", "request")
// While the lines are sent, the process's lines give the instance name
// too.
line := out.line(t, "msg", "starting")
if line["instance"] != instance {
t.Errorf("start logged with instance %v, want %s", line["instance"], instance)
}
} }
func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) { func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
@@ -569,6 +626,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
rulesDir: rules, rulesDir: rules,
"SWWAF_ALERT_WEBHOOK_URL": webhook.url, "SWWAF_ALERT_WEBHOOK_URL": webhook.url,
"SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret, "SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
} }
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
@@ -622,19 +680,13 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
runUntilStopped(t, env, func(url string) { runUntilStopped(t, env, func(url string) {
webhook.waitFor(t, "permanent_ban", true) webhook.waitFor(t, "permanent_ban", true)
// As long as that takes, so that a slow test process cannot fail const ofWebhook = `{destination="webhook",instance="fsn1app1/gitea"}`
// the test.
const sent = "\nsmallwebwaf_alerts_sent_total{destination=\"webhook\"} 1\n"
metrics := metricsText(t, url+"_smallwebwaf/metrics", token) metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
for !strings.Contains(metrics, sent) { "\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n")
time.Sleep(pollInterval)
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
}
for _, series := range []string{"failed", "suppressed", "dropped"} { for _, series := range []string{"failed", "suppressed", "dropped"} {
zero := "\nsmallwebwaf_alerts_" + series + "_total{destination=\"webhook\"} 0\n" zero := "\nsmallwebwaf_alerts_" + series + "_total" + ofWebhook + " 0\n"
if !strings.Contains(metrics, zero) { if !strings.Contains(metrics, zero) {
t.Errorf("no %q in the metrics:\n%s", zero, metrics) t.Errorf("no %q in the metrics:\n%s", zero, metrics)
} }
@@ -662,7 +714,7 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
// The metrics are read from 127.0.0.1, which no limit counts. // The metrics are read from 127.0.0.1, which no limit counts.
"SWWAF_ALLOW_NETS": localhost + "/32", "SWWAF_ALLOW_NETS": localhost + "/32",
metricsToken: token, metricsToken: token,
"SWWAF_INSTANCE_NAME": "fsn1app1/gitea", instanceName: instance,
"SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url, "SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWWAF_ALERT_NTFY_URL": ntfy.url, "SWWAF_ALERT_NTFY_URL": ntfy.url,
"SWWAF_ALERT_NTFY_TOKEN": ntfyToken, "SWWAF_ALERT_NTFY_TOKEN": ntfyToken,
@@ -698,17 +750,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
// webhook, which is not set. As long as that takes, so that a slow // webhook, which is not set. As long as that takes, so that a slow
// test process cannot fail the test. // test process cannot fail the test.
sent := []string{ sent := []string{
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"} 1\n", "\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," +
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"} 1\n", "instance=\"fsn1app1/gitea\"} 1\n",
} "\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," +
"instance=\"fsn1app1/gitea\"} 1\n",
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
for !strings.Contains(metrics, sent[0]) || !strings.Contains(metrics, sent[1]) {
time.Sleep(pollInterval)
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
} }
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...)
if strings.Contains(metrics, `destination="webhook"`) { if strings.Contains(metrics, `destination="webhook"`) {
t.Errorf("the metrics give the webhook:\n%s", metrics) t.Errorf("the metrics give the webhook:\n%s", metrics)
} }
@@ -974,6 +1022,26 @@ func metricsText(t *testing.T, url, token string) string {
return string(body) return string(body)
} }
// metricsWith asks for the metrics at url with token until they hold each
// of series, as long as that takes, so that a slow test process cannot
// fail the test, and returns them.
func metricsWith(t *testing.T, url, token string, series ...string) string {
t.Helper()
for {
metrics := metricsText(t, url, token)
missing := slices.ContainsFunc(series, func(one string) bool {
return !strings.Contains(metrics, one)
})
if !missing {
return metrics
}
time.Sleep(pollInterval)
}
}
// askAsAdmin sends a request with method to url, with body and // askAsAdmin sends a request with method to url, with body and
// adminSecret, and checks that it is answered 200. // adminSecret, and checks that it is answered 200.
func askAsAdmin(t *testing.T, method, url, body string) { func askAsAdmin(t *testing.T, method, url, body string) {
+8 -8
View File
@@ -736,8 +736,8 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
} }
const ( const (
ofBans = `{file="bans.json"}` ofBans = `{file="bans.json",instance="app"}`
ofClients = `{file="clients.json"}` ofClients = `{file="clients.json",instance="app"}`
) )
got := scrape(t, params) got := scrape(t, params)
@@ -1162,7 +1162,7 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
wantTakenIn(t, lines, dir, bansJSON) wantTakenIn(t, lines, dir, bansJSON)
wantMetric(t, scrape(t, params), wantMetric(t, scrape(t, params),
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json"}`, 2) `smallwebwaf_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2)
} }
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) { func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
@@ -1227,7 +1227,7 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
} }
wantMetric(t, scrape(t, params), wantMetric(t, scrape(t, params),
`smallwebwaf_state_file_edits_set_aside_total{file="bans.json"}`, 1) `smallwebwaf_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1)
} }
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) { func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
@@ -1262,7 +1262,7 @@ func midnight() time.Time {
// hour, are never sent. // hour, are never sent.
func newParams(dir string) state.Params { func newParams(dir string) state.Params {
discard := slog.New(slog.DiscardHandler) discard := slog.New(slog.DiscardHandler)
m := metrics.New(1) m := metrics.New(1, "app")
return state.Params{ return state.Params{
Dir: dir, Dir: dir,
@@ -1647,8 +1647,8 @@ func scrape(t *testing.T, params state.Params) string {
} }
// metric returns the value of series in text, the metrics, such as // metric returns the value of series in text, the metrics, such as
// smallwebwaf_state_file_writes_total{file="bans.json"}, or fails the test // smallwebwaf_state_file_writes_total{file="bans.json",instance="app"}, or
// if there is no such series. // fails the test if there is no such series.
func metric(t *testing.T, text, series string) float64 { func metric(t *testing.T, text, series string) float64 {
t.Helper() t.Helper()
@@ -1677,7 +1677,7 @@ func wantWriteFailed(t *testing.T, params state.Params, name string) {
t.Helper() t.Helper()
got := scrape(t, params) got := scrape(t, params)
file := `{file="` + name + `"}` file := `{file="` + name + `",instance="app"}`
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1) wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1)
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1) wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1)