New metrics: section (enabled, listen, allow, token). /metrics is served on the main listeners, the local socket (root and jarld's user) and optional metrics-only addresses; a scrape needs an allowed address or the bearer token. Exports health (named up, problems, drift, backups, blocklists, DNSSEC phases, token expiry) and, with stats on, named's and Jarl's query counters since jarld started plus named's process and cache gauges. docs/metrics.md has example queries and alerts checked with promtool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.2 KiB
Go
207 lines
8.2 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/stats"
|
|
)
|
|
|
|
// scrape GETs /metrics with an optional bearer token.
|
|
func (e *apiEnv) scrape(token string) (string, int) {
|
|
e.t.Helper()
|
|
r, _ := http.NewRequest("GET", e.ts.URL+"/metrics", nil)
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return string(b), resp.StatusCode
|
|
}
|
|
|
|
var (
|
|
promSampleRe = regexp.MustCompile(`^[a-zA-Z_:][a-zA-Z0-9_:]*(\{[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*"(,[a-zA-Z_][a-zA-Z0-9_]*="(?:[^"\\]|\\.)*")*\})? -?[0-9.eE+-]+$`)
|
|
promTypeRe = regexp.MustCompile(`^# TYPE ([a-zA-Z_:][a-zA-Z0-9_:]*) (gauge|counter)$`)
|
|
)
|
|
|
|
// checkExposition checks Prometheus's text format: valid lines, and each
|
|
// metric's samples together under a single TYPE.
|
|
func checkExposition(t *testing.T, body string) {
|
|
t.Helper()
|
|
typed := map[string]bool{}
|
|
cur := ""
|
|
for _, l := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
|
switch {
|
|
case strings.HasPrefix(l, "# HELP "):
|
|
case strings.HasPrefix(l, "# TYPE "):
|
|
m := promTypeRe.FindStringSubmatch(l)
|
|
if m == nil {
|
|
t.Errorf("bad TYPE line %q", l)
|
|
continue
|
|
}
|
|
if typed[m[1]] {
|
|
t.Errorf("metric %s appears twice", m[1])
|
|
}
|
|
typed[m[1]], cur = true, m[1]
|
|
default:
|
|
if !promSampleRe.MatchString(l) {
|
|
t.Errorf("bad sample line %q", l)
|
|
}
|
|
if name, _, _ := strings.Cut(strings.SplitN(l, " ", 2)[0], "{"); name != cur {
|
|
t.Errorf("sample %q is not under its TYPE (%s)", l, cur)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMetricsAccess(t *testing.T) {
|
|
loopback := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
|
e := newAPIEnv(t)
|
|
if _, status := e.scrape(""); status != 404 {
|
|
t.Errorf("disabled = %d, want 404", status)
|
|
}
|
|
|
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: loopback}})
|
|
body, status := e.scrape("")
|
|
if status != 200 {
|
|
t.Fatalf("allowed scrape = %d: %s", status, body)
|
|
}
|
|
checkExposition(t, body)
|
|
for _, want := range []string{`jarl_build_info{version="test"} 1`, `jarl_problems{severity="error"} 0`, `jarl_objects{kind="zones"} 0`, "jarl_open_changesets 0", "jarl_metrics_state_ok 1"} {
|
|
if !strings.Contains(body, want+"\n") {
|
|
t.Errorf("missing %q in:\n%s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "jarl_queries_total") {
|
|
t.Error("query counters without statistics")
|
|
}
|
|
// No token is configured: one that is sent is refused.
|
|
if _, status := e.scrape("whatever-token-123"); status != 401 {
|
|
t.Errorf("token without metrics.token = %d, want 401", status)
|
|
}
|
|
|
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}}})
|
|
if body, status := e.scrape(""); status != 403 || !strings.Contains(body, "metrics.allow") {
|
|
t.Errorf("not allowed = %d %s", status, body)
|
|
}
|
|
|
|
const token = "0123456789abcdef-metrics"
|
|
e = newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Token: token}})
|
|
if _, status := e.scrape(""); status != 401 {
|
|
t.Errorf("no token = %d, want 401", status)
|
|
}
|
|
if _, status := e.scrape("wrong-token-0123456789"); status != 401 {
|
|
t.Errorf("wrong token = %d, want 401", status)
|
|
}
|
|
if _, status := e.scrape(token); status != 200 {
|
|
t.Errorf("right token = %d, want 200", status)
|
|
}
|
|
// The API's tokens don't count for /metrics.
|
|
if _, status := e.scrape(e.token); status != 401 {
|
|
t.Errorf("API token = %d, want 401", status)
|
|
}
|
|
}
|
|
|
|
func TestMetricsState(t *testing.T) {
|
|
e := newAPIEnvConfig(t, Config{Version: "test", Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
|
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "ads", "url": "https://lists.example/ads.txt"}, 201)
|
|
e.srv.metricsCache.body = nil // skip the cache
|
|
body, _ := e.scrape("")
|
|
checkExposition(t, body)
|
|
for _, want := range []string{`jarl_objects{kind="zones"} 1`, `jarl_blocklist_enabled{list="ads"} 1`, "jarl_blocking_paused 0"} {
|
|
if !strings.Contains(body, want+"\n") {
|
|
t.Errorf("missing %q in:\n%s", want, body)
|
|
}
|
|
}
|
|
// Cached: a change shows after metricsStateTTL.
|
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.org", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 1`+"\n") {
|
|
t.Error("state was not cached")
|
|
}
|
|
e.srv.metricsCache.at = time.Now().Add(-metricsStateTTL)
|
|
if body, _ := e.scrape(""); !strings.Contains(body, `jarl_objects{kind="zones"} 2`+"\n") {
|
|
t.Errorf("state not refreshed:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestMetricsStats(t *testing.T) {
|
|
ss, err := stats.OpenStore(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ss.Close()
|
|
opts := stats.Options{QueryLog: true, LogFile: "/nonexistent", StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour}
|
|
c := stats.NewCollector(ss, opts, func() map[string][]string { return map[string][]string{"internal": {"example.com"}} }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
ts := time.Now().Format("02-Jan-2006 15:04:05.000")
|
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5353 (www.example.com): view internal: response: www.example.com IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)")
|
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid")
|
|
c.HandleLine(ts + " client @0x7f0000000000 10.0.0.9#5354 (ads.example): view internal: response: ads.example IN A NXDOMAIN 1 0 1 +E(0)K (10.10.0.2)")
|
|
|
|
e := newAPIEnvConfig(t, Config{Version: "test", Stats: c, Metrics: Metrics{Enabled: true, Allow: []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}}})
|
|
body, status := e.scrape("")
|
|
if status != 200 {
|
|
t.Fatalf("scrape = %d", status)
|
|
}
|
|
checkExposition(t, body)
|
|
for _, want := range []string{
|
|
"jarl_queries_total 0", "jarl_logged_responses_total 2", `jarl_responses_by_view_total{view="internal"} 2`,
|
|
`jarl_responses_by_zone_total{zone="example.com"} 1`, `jarl_responses_by_zone_total{zone="-"} 1`,
|
|
"jarl_blocked_queries_total 1", `jarl_blocked_by_list_total{list="hagezi"} 1`, "jarl_stats_log_lines_total 2",
|
|
} {
|
|
if !strings.Contains(body, want+"\n") {
|
|
t.Errorf("missing %q in:\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatsMetricsMapping(t *testing.T) {
|
|
pw := &promWriter{}
|
|
totals := map[string]int64{"queries": 10, "rcode:NOERROR": 8, "rcode:NXDOMAIN": 2, "qtype:A": 10, "ratelimit:slipped": 3,
|
|
"cache:hits": 4, "rtt:<10ms": 5, "unknown:x": 1, "rcode": 7}
|
|
srv := &stats.Server{BootTime: "2026-10-07T20:00:00.000Z", Version: "9.20.4", PID: 42, RSS: 1 << 20, CPUSeconds: 1.5, SocketsUDP: 4}
|
|
statsMetrics(pw, totals, srv, stats.Status{})
|
|
var buf bytes.Buffer
|
|
pw.writeTo(&buf)
|
|
out := buf.String()
|
|
checkExposition(t, out)
|
|
for _, want := range []string{
|
|
"jarl_queries_total 10", `jarl_responses_by_rcode_total{rcode="NXDOMAIN"} 2`, `jarl_ratelimit_responses_total{action="slipped"} 3`,
|
|
`jarl_upstream_responses_by_rtt_total{rtt="<10ms"} 5`, `jarl_cache_lookups_total{result="hits"} 4`,
|
|
"jarl_named_start_time_seconds 1791403200", "jarl_named_cpu_seconds_total 1.5", `jarl_named_sockets{proto="udp"} 4`,
|
|
"jarl_named_resident_memory_bytes 1048576",
|
|
} {
|
|
if !strings.Contains(out, want+"\n") {
|
|
t.Errorf("missing %q in:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "unknown") || strings.Contains(out, "jarl_responses_by_rcode_total 7") {
|
|
t.Errorf("unknown keys exported:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestPromWriterEscaping(t *testing.T) {
|
|
pw := &promWriter{}
|
|
pw.gauge("m", "a \\ help\nline", 1, "l", "a\"b\\c\nd")
|
|
var buf bytes.Buffer
|
|
pw.writeTo(&buf)
|
|
want := "# HELP m a \\\\ help\\nline\n# TYPE m gauge\nm{l=\"a\\\"b\\\\c\\nd\"} 1\n"
|
|
if buf.String() != want {
|
|
t.Errorf("got %q, want %q", buf.String(), want)
|
|
}
|
|
if got := formatFloat(1759867200.123); got != "1759867200.123" {
|
|
t.Errorf("formatFloat = %s", got)
|
|
}
|
|
}
|