Files
jarl/internal/stats/explore_test.go
T
adamandClaude Opus 5.5 01ec37b9b2 Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard
jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore.

The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:15:36 +01:00

99 lines
3.6 KiB
Go

package stats
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestExplore(t *testing.T) {
ctx := context.Background()
loc := time.UTC
store, err := OpenStore(filepath.Join(t.TempDir(), "stats.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
clk := &clock{time.Date(2026, 10, 13, 12, 0, 30, 0, loc)}
opts := Options{QueryLog: true, LogFile: "unused", StatisticsChannel: "unused", TopN: 10, RetainMinute: time.Hour, RetainHour: time.Hour, RetainDay: time.Hour,
RetainQueries: 7 * 24 * time.Hour, Location: loc}
c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = clk.now
at := clk.t.Add(-10 * time.Minute)
// The phone: lookups, an NXDOMAIN, a blocked tracker.
c.HandleLine(logLine(at, "10.0.0.5", "internal", "www.adbraun.lol", "A", "NOERROR"))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "typo.adbraun.lol", "A", "NXDOMAIN"))
c.HandleLine(fmt.Sprintf("%s client @0x7f0000000000 10.0.0.5#5353 (t.tracker.example): view internal: rpz QNAME NXDOMAIN rewrite t.tracker.example/A/IN via t.tracker.example.hagezi.blocklist.jarl.invalid\n", at.Format(logTimeLayout)))
c.HandleLine(logLine(at, "10.0.0.5", "internal", "t.tracker.example", "A", "NXDOMAIN"))
// A scanner in the external view, NXDOMAIN twice.
for i := 0; i < 2; i++ {
c.HandleLine(logLine(at, "198.51.100.7", "external", "nope.adbraun.lol", "A", "NXDOMAIN"))
}
c.flush(ctx, true)
q := &Querier{Store: store, Opts: opts, Now: clk.now}
ex, err := q.Explore(ctx, ExploreFilter{View: "external", RCode: "NXDOMAIN"}, 10, 50)
if err != nil {
t.Fatal(err)
}
if ex.Total != 2 || len(ex.By["client"]) != 1 || ex.By["client"][0].Key != "198.51.100.7" || ex.By["view"] != nil {
t.Errorf("external NXDOMAIN: %+v", ex)
}
// What else does the phone do?
ex, err = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5"}, 10, 50)
if err != nil {
t.Fatal(err)
}
if ex.Total != 4 || ex.Blocked != 1 || len(ex.Recent) != 4 {
t.Errorf("phone: total %d blocked %d recent %d", ex.Total, ex.Blocked, len(ex.Recent))
}
if b := ex.By["blocked"]; len(b) != 1 || b[0].Key != "hagezi" || b[0].Count != 1 {
t.Errorf("blocked by %+v", b)
}
if r := ex.By["rcode"]; len(r) != 2 || r[0].Key != "NOERROR" || r[0].Count != 2 {
t.Errorf("rcode %+v", r)
}
var sum int64
for _, p := range ex.Series {
sum += p.Count
}
if sum != 4 || ex.Step != 60*int64(24*60/100) {
t.Errorf("series sum %d step %d", sum, ex.Step)
}
// A domain and what's below it.
ex, _ = q.Explore(ctx, ExploreFilter{Domain: "adbraun.lol"}, 10, 50)
if ex.Total != 5 {
t.Errorf("domain adbraun.lol: %d", ex.Total)
}
ex, _ = q.Explore(ctx, ExploreFilter{Name: "nas.adbraun.lol", Type: "aaaa"}, 10, 50)
if ex.Total != 1 || ex.Recent[0].Client != "10.0.0.5" {
t.Errorf("nas AAAA: %+v", ex)
}
ex, _ = q.Explore(ctx, ExploreFilter{Blocked: "no", Client: "10.0.0.5"}, 10, 50)
if ex.Total != 3 {
t.Errorf("not blocked: %d", ex.Total)
}
// Limited to a view (the Statistics page's view switches).
ex, _ = q.ForViews([]string{"internal"}).Explore(ctx, ExploreFilter{RCode: "NXDOMAIN"}, 10, 50)
if ex.Total != 2 {
t.Errorf("internal NXDOMAIN: %d", ex.Total)
}
// Pruning: past the retention they go.
clk.t = clk.t.Add(8 * 24 * time.Hour)
c.prune(ctx)
if n, _ := store.QueryLogSize(ctx); n != 0 {
t.Errorf("after pruning: %d rows", n)
}
// Off: an error that says how to turn it on.
q.Opts.RetainQueries = 0
if _, err := q.Explore(ctx, ExploreFilter{}, 10, 50); err != ErrNoQueryLog {
t.Errorf("off: %v", err)
}
}