Statistics per view: toggle views on the Statistics page
Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ead749dd3d
commit
12820b17bb
13 files changed
+526
-61
No files matched your search
+76
-19
@@ -33,6 +33,13 @@ const (
|
||||
KindRateLimitedName = "ratelimited_name"
|
||||
)
|
||||
|
||||
// ViewKey is the key of a counter for one view: per-view copies of the
|
||||
// counters that come from named's response log (docs/stats.md).
|
||||
func ViewKey(view, key string) string { return "@" + view + "/" + key }
|
||||
|
||||
// ViewKind is the kind of a top list for one view.
|
||||
func ViewKind(kind, view string) string { return kind + "@" + view }
|
||||
|
||||
// Kinds lists the top-list kinds.
|
||||
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
|
||||
|
||||
@@ -115,6 +122,9 @@ type Collector struct {
|
||||
// rewritten holds queries named blocked (client#port|name) until their
|
||||
// response line arrives.
|
||||
rewritten map[string]bool
|
||||
// viewsSince is set once per-view counters exist (meta views_since).
|
||||
viewsSince bool
|
||||
viewsSinceAt int64
|
||||
// proc is the last sample of named's process, server the latest server
|
||||
// statistics (server.go).
|
||||
proc *namedProc
|
||||
@@ -212,6 +222,9 @@ func (c *Collector) start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, _ := c.store.Meta(ctx, "views_since"); v != "" {
|
||||
c.viewsSince = true
|
||||
}
|
||||
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
t := time.Unix(n, 0)
|
||||
@@ -227,7 +240,8 @@ func (c *Collector) start(ctx context.Context) {
|
||||
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
|
||||
from := max(last+3600, cur-48*3600)
|
||||
for h := from; h < cur; h += 3600 {
|
||||
for _, k := range Kinds {
|
||||
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
|
||||
for _, k := range kinds {
|
||||
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
|
||||
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
|
||||
}
|
||||
@@ -235,7 +249,8 @@ func (c *Collector) start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
|
||||
for _, k := range Kinds {
|
||||
curKinds, _ := c.store.KindsAt(ctx, ResHour, cur)
|
||||
for _, k := range curKinds {
|
||||
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
|
||||
if err != nil || len(es) == 0 {
|
||||
continue
|
||||
@@ -294,7 +309,7 @@ func (c *Collector) count(minute int64, key string, n int64) {
|
||||
c.minutes[minute] = m
|
||||
}
|
||||
m[key] += n
|
||||
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") {
|
||||
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") && !strings.HasPrefix(key, "@") {
|
||||
c.totals[key] += n
|
||||
}
|
||||
}
|
||||
@@ -365,33 +380,55 @@ func (c *Collector) handleLine(line string) {
|
||||
if view == "" {
|
||||
view = "_default"
|
||||
}
|
||||
zone := c.zoneOf(ev.Name, ev.View)
|
||||
c.count(minute, "responses", 1)
|
||||
c.count(minute, "view:"+view, 1)
|
||||
c.count(minute, "zone:"+c.zoneOf(ev.Name, ev.View), 1)
|
||||
c.count(minute, "zone:"+zone, 1)
|
||||
// The same, per view, for the statistics of chosen views.
|
||||
c.countView(minute, view, "responses", 1)
|
||||
c.countView(minute, view, "zone:"+zone, 1)
|
||||
c.countView(minute, view, "rcode:"+ev.RCode, 1)
|
||||
c.countView(minute, view, "qtype:"+ev.Type, 1)
|
||||
client := clientKey(ev.Client, c.opts.Anonymize)
|
||||
hours := c.hours
|
||||
if hour < c.liveHour {
|
||||
hours = c.late
|
||||
}
|
||||
hours[hour] = c.hourMaps(hours, hour)
|
||||
tops := hours[hour]
|
||||
tops[KindName][ev.Name]++
|
||||
tops[KindClient][client]++
|
||||
switch {
|
||||
case blocked:
|
||||
// Counted by handleRewrite; not a name that doesn't exist.
|
||||
case ev.RCode == "NXDOMAIN":
|
||||
tops[KindNXDomain][ev.Name]++
|
||||
case ev.RCode == "SERVFAIL":
|
||||
tops[KindServFail][ev.Name]++
|
||||
case ev.RCode == "REFUSED":
|
||||
tops[KindRefused][client]++
|
||||
}
|
||||
for _, m := range tops {
|
||||
add := func(kind, key string) {
|
||||
hours[hour][kind][key]++
|
||||
m := c.topsMap(hours, hour, ViewKind(kind, view))
|
||||
m[key]++
|
||||
if len(m) > maxKeys {
|
||||
shrink(m)
|
||||
}
|
||||
}
|
||||
add(KindName, ev.Name)
|
||||
add(KindClient, client)
|
||||
switch {
|
||||
case blocked:
|
||||
// Counted by handleRewrite; not a name that doesn't exist.
|
||||
case ev.RCode == "NXDOMAIN":
|
||||
add(KindNXDomain, ev.Name)
|
||||
case ev.RCode == "SERVFAIL":
|
||||
add(KindServFail, ev.Name)
|
||||
case ev.RCode == "REFUSED":
|
||||
add(KindRefused, client)
|
||||
}
|
||||
for _, k := range Kinds {
|
||||
if m := hours[hour][k]; len(m) > maxKeys {
|
||||
shrink(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countView counts a per-view copy of a counter.
|
||||
func (c *Collector) countView(minute int64, view, key string, n int64) {
|
||||
if !c.viewsSince {
|
||||
c.viewsSince = true
|
||||
c.viewsSinceAt = minute
|
||||
}
|
||||
c.count(minute, ViewKey(view, key), n)
|
||||
}
|
||||
|
||||
// handleRewrite counts a query that blocking answered: "blocked", and
|
||||
@@ -412,15 +449,24 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
||||
}
|
||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||
view := rw.View
|
||||
if view == "" {
|
||||
view = "_default"
|
||||
}
|
||||
c.count(minute, "blocked", 1)
|
||||
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
|
||||
c.countView(minute, view, "blocked", 1)
|
||||
c.countView(minute, view, "blocklist:"+policySource(rw.Via), 1)
|
||||
hours := c.hours
|
||||
if hour < c.liveHour {
|
||||
hours = c.late
|
||||
}
|
||||
hours[hour] = c.hourMaps(hours, hour)
|
||||
client := clientKey(rw.Client, c.opts.Anonymize)
|
||||
hours[hour][KindBlocked][rw.Name]++
|
||||
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
|
||||
hours[hour][KindBlockedClient][client]++
|
||||
c.topsMap(hours, hour, ViewKind(KindBlocked, view))[rw.Name]++
|
||||
c.topsMap(hours, hour, ViewKind(KindBlockedClient, view))[client]++
|
||||
}
|
||||
|
||||
// handleRateLimit counts a response rate limiting limited, or would have:
|
||||
@@ -437,7 +483,12 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
|
||||
key = "rrl:would-" + rl.Action
|
||||
}
|
||||
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
|
||||
view := rl.View
|
||||
if view == "" {
|
||||
view = "_default"
|
||||
}
|
||||
c.count(minute, key, 1)
|
||||
c.countView(minute, view, key, 1)
|
||||
hours := c.hours
|
||||
if hour < c.liveHour {
|
||||
hours = c.late
|
||||
@@ -445,6 +496,8 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
|
||||
hours[hour] = c.hourMaps(hours, hour)
|
||||
hours[hour][KindRateLimited][rl.Prefix]++
|
||||
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
|
||||
c.topsMap(hours, hour, ViewKind(KindRateLimited, view))[rl.Prefix]++
|
||||
c.topsMap(hours, hour, ViewKind(KindRateLimitedName, view))[rl.Name+" "+rl.Type]++
|
||||
}
|
||||
|
||||
// policySource names what a rewrite came from: a blocklist's name, "rules"
|
||||
@@ -547,6 +600,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
||||
b.AddCounters(ResHour, c.hourOf(t), vals)
|
||||
b.AddCounters(ResDay, c.dayOf(t), vals)
|
||||
delete(c.minutes, m)
|
||||
if c.viewsSinceAt != 0 {
|
||||
b.SetMeta("views_since", strconv.FormatInt(c.viewsSinceAt, 10))
|
||||
c.viewsSinceAt = 0
|
||||
}
|
||||
if first && len(vals) > 0 {
|
||||
ft := t
|
||||
c.status.FirstData = &ft
|
||||
|
||||
+158
-10
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -14,6 +15,110 @@ type Querier struct {
|
||||
Store *Store
|
||||
Opts Options
|
||||
Now func() time.Time
|
||||
// Views limits the figures to these views (none: every view). Only
|
||||
// what named's response log says can be split by view; named's own
|
||||
// counters (cache, transport, round trips, ...) can't.
|
||||
Views []string
|
||||
}
|
||||
|
||||
// ForViews returns a querier limited to views (all views when empty).
|
||||
func (q *Querier) ForViews(views []string) *Querier {
|
||||
c := *q
|
||||
c.Views = views
|
||||
return &c
|
||||
}
|
||||
|
||||
func (q *Querier) filtered() bool { return len(q.Views) > 0 }
|
||||
|
||||
// perView reports whether a counter key exists per view, and its name
|
||||
// there ("queries" is counted as responses).
|
||||
func perView(key string) (string, bool) {
|
||||
if key == "queries" || key == "responses" {
|
||||
return "responses", true
|
||||
}
|
||||
if key == "blocked" {
|
||||
return key, true
|
||||
}
|
||||
for _, p := range []string{"rcode:", "qtype:", "zone:", "blocklist:", "rrl:"} {
|
||||
if strings.HasPrefix(key, p) || key+":" == p {
|
||||
return key, true
|
||||
}
|
||||
}
|
||||
return key, false
|
||||
}
|
||||
|
||||
// PerViewGroups are the Breakdown groups that exist per view.
|
||||
var PerViewGroups = []string{"qtype", "rcode", "view", "zone", "blocklist", "rrl"}
|
||||
|
||||
// ErrNotPerView is returned for figures named only keeps for the whole server.
|
||||
var ErrNotPerView = fmt.Errorf("named keeps this for the whole server only, not per view")
|
||||
|
||||
// sums is Store.sums, limited to the querier's views.
|
||||
func (q *Querier) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
|
||||
if !q.filtered() {
|
||||
return q.Store.sums(ctx, res, from, to, prefix)
|
||||
}
|
||||
if prefix == "view:" {
|
||||
all, err := q.Store.sums(ctx, res, from, to, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, v := range q.Views {
|
||||
if n := all["view:"+v]; n != 0 {
|
||||
out["view:"+v] = n
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if _, ok := perView(prefix); !ok {
|
||||
// Not kept per view (named's own counters): the whole server's.
|
||||
return q.Store.sums(ctx, res, from, to, prefix)
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, v := range q.Views {
|
||||
m, err := q.Store.sums(ctx, res, from, to, ViewKey(v, prefix))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, n := range m {
|
||||
out[strings.TrimPrefix(k, ViewKey(v, ""))] += n
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// series is Store.series, limited to the querier's views.
|
||||
func (q *Querier) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
|
||||
pv, ok := perView(key)
|
||||
if !q.filtered() || !ok {
|
||||
return q.Store.series(ctx, res, from, to, key)
|
||||
}
|
||||
out := map[int64]int64{}
|
||||
for _, v := range q.Views {
|
||||
m, err := q.Store.series(ctx, res, from, to, ViewKey(v, pv))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for ts, n := range m {
|
||||
out[ts] += n
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ViewsSince is when per-view figures start (nil before any).
|
||||
func (q *Querier) ViewsSince(ctx context.Context) *time.Time {
|
||||
v, err := q.Store.Meta(ctx, "views_since")
|
||||
if err != nil || v == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(n, 0)
|
||||
return &t
|
||||
}
|
||||
|
||||
// NewQuerier returns a querier for a collector's data.
|
||||
@@ -105,10 +210,15 @@ func (s *Store) series(ctx context.Context, res string, from, to int64, key stri
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// tops sums top lists in [from, to).
|
||||
func (s *Store) tops(ctx context.Context, res, kind string, from, to int64, limit int) ([]Entry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind = ? AND ts >= ? AND ts < ?
|
||||
GROUP BY key ORDER BY n DESC, key LIMIT ?`, res, kind, from, to, limit)
|
||||
// tops sums top lists (of one or more kinds) in [from, to).
|
||||
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
|
||||
args := []any{res}
|
||||
for _, k := range kinds {
|
||||
args = append(args, k)
|
||||
}
|
||||
args = append(args, from, to, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
|
||||
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,6 +277,11 @@ type Summary struct {
|
||||
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
|
||||
Blocklists []Entry `json:"blocklists,omitempty"`
|
||||
Tops map[string][]Entry `json:"tops,omitempty"`
|
||||
// Views and ViewsSince are set when the figures are for some views
|
||||
// only: queries then counts responses, and named's own counters
|
||||
// (answers, transport, cache) are left out.
|
||||
FilterViews []string `json:"filter_views,omitempty"`
|
||||
ViewsSince *time.Time `json:"views_since,omitempty"`
|
||||
}
|
||||
|
||||
// Summary summarizes the last since.
|
||||
@@ -179,9 +294,32 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if q.filtered() {
|
||||
// The chosen views' counters, under their usual names.
|
||||
views := map[string]int64{}
|
||||
for _, v := range q.Views {
|
||||
views["view:"+v] = all["view:"+v]
|
||||
}
|
||||
picked := map[string]int64{}
|
||||
for k, n := range all {
|
||||
for _, v := range q.Views {
|
||||
if rest, ok := strings.CutPrefix(k, ViewKey(v, "")); ok {
|
||||
picked[rest] += n
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, n := range views {
|
||||
picked[k] = n
|
||||
}
|
||||
picked["queries"] = picked["responses"]
|
||||
all = picked
|
||||
}
|
||||
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
|
||||
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
|
||||
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
|
||||
if q.filtered() {
|
||||
s.FilterViews, s.ViewsSince = q.Views, q.ViewsSince(ctx)
|
||||
}
|
||||
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
|
||||
r := float64(h) / float64(h+m)
|
||||
s.CacheHitRatio = &r
|
||||
@@ -195,7 +333,7 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
|
||||
s.BlockedRatio = &b
|
||||
}
|
||||
}
|
||||
per, err := q.Store.series(ctx, res, from, to, "queries")
|
||||
per, err := q.series(ctx, res, from, to, "queries")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,7 +375,14 @@ func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, lim
|
||||
}
|
||||
now := q.Now()
|
||||
res := q.topsResolution(since)
|
||||
return q.Store.tops(ctx, res, kind, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
||||
kinds := []string{kind}
|
||||
if q.filtered() {
|
||||
kinds = nil
|
||||
for _, v := range q.Views {
|
||||
kinds = append(kinds, ViewKind(kind, v))
|
||||
}
|
||||
}
|
||||
return q.Store.tops(ctx, res, kinds, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
|
||||
}
|
||||
|
||||
// Breakdown returns the totals of a counter group (qtype, rcode, view,
|
||||
@@ -246,9 +391,12 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
|
||||
if !slices.Contains(Groups, group) {
|
||||
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
|
||||
}
|
||||
if q.filtered() && !slices.Contains(PerViewGroups, group) {
|
||||
return nil, ErrNotPerView
|
||||
}
|
||||
now := q.Now()
|
||||
res := q.Resolution(since)
|
||||
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
||||
m, err := q.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -329,7 +477,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
||||
var expanded []string
|
||||
for _, k := range keys {
|
||||
if g, ok := strings.CutSuffix(k, ":*"); ok {
|
||||
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
|
||||
m, err := q.sums(ctx, st.res, from, to, g+":")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -342,7 +490,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
|
||||
}
|
||||
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
|
||||
bucketed := func(key string) ([]int64, error) {
|
||||
raw, err := q.Store.series(ctx, st.res, from, to, key)
|
||||
raw, err := q.series(ctx, st.res, from, to, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -404,7 +552,7 @@ func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration)
|
||||
since = min(since, q.Opts.RetainHour)
|
||||
now := q.Now()
|
||||
from := q.bucketStart(ResHour, now.Add(-since))
|
||||
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
|
||||
raw, err := q.series(ctx, ResHour, from, now.Unix()+1, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -264,3 +264,22 @@ func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) (
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// KindsAt returns the top-list kinds stored for one bucket (per-view
|
||||
// kinds included).
|
||||
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Figures for chosen views: from the response log, per view, and added
|
||||
// up for several.
|
||||
func TestViewFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
loc := time.FixedZone("test", 0)
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "responses.log")
|
||||
store, err := OpenStore(filepath.Join(dir, "stats.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
named := newFakeNamed(t)
|
||||
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)}
|
||||
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 10,
|
||||
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc}
|
||||
zones := func() map[string][]string {
|
||||
return map[string][]string{"internal": {"adbraun.lol"}, "external": {"adbraun.lol"}}
|
||||
}
|
||||
c := NewCollector(store, opts, zones, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
c.poller.URL = named.srv.URL
|
||||
c.now = clk.now
|
||||
c.start(ctx)
|
||||
at := time.Date(2026, 10, 5, 10, 0, 40, 0, loc)
|
||||
lines := []string{
|
||||
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
|
||||
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"),
|
||||
logLine(at, "10.10.0.42", "internal", "nope.adbraun.lol", "A", "NXDOMAIN"),
|
||||
logLine(at, "203.0.113.9", "external", "www.adbraun.lol", "A", "NOERROR"),
|
||||
logLine(at, "203.0.113.9", "external", "x.example", "A", "REFUSED"),
|
||||
logLine(at, "192.0.2.5", "vps", "debian.org", "AAAA", "NOERROR"),
|
||||
}
|
||||
f, _ := os.Create(logFile)
|
||||
for _, l := range lines {
|
||||
f.WriteString(l)
|
||||
}
|
||||
f.Close()
|
||||
c.readLog()
|
||||
named.add(6, 1)
|
||||
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
|
||||
c.flush(ctx, false)
|
||||
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
|
||||
c.flush(ctx, false)
|
||||
|
||||
all := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||
s, err := all.Summary(ctx, 24*time.Hour, 5)
|
||||
if err != nil || s.Queries != 6 || s.FilterViews != nil {
|
||||
t.Fatalf("all views: %+v %v", s, err)
|
||||
}
|
||||
in := all.ForViews([]string{"internal"})
|
||||
s, err = in.Summary(ctx, 24*time.Hour, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Queries != 3 || len(s.Views) != 1 || s.Views[0].Key != "internal" || s.ViewsSince == nil {
|
||||
t.Errorf("internal: queries %d views %v since %v", s.Queries, s.Views, s.ViewsSince)
|
||||
}
|
||||
if len(s.RCodes) != 2 || s.RCodes[0] != (Entry{Key: "NOERROR", Count: 2}) || len(s.QTypes) != 2 {
|
||||
t.Errorf("internal rcodes %v qtypes %v", s.RCodes, s.QTypes)
|
||||
}
|
||||
if s.Tops[KindName][0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) || len(s.Tops[KindNXDomain]) != 1 || len(s.Answers) != 0 {
|
||||
t.Errorf("internal tops %v answers %v", s.Tops, s.Answers)
|
||||
}
|
||||
// Linked views, say: two together.
|
||||
two := all.ForViews([]string{"internal", "external"})
|
||||
s, _ = two.Summary(ctx, 24*time.Hour, 5)
|
||||
if s.Queries != 5 || len(s.Views) != 2 {
|
||||
t.Errorf("internal+external: %d %v", s.Queries, s.Views)
|
||||
}
|
||||
if es, _ := two.Top(ctx, KindClient, 24*time.Hour, 5); len(es) != 3 || es[2].Key != "10.10.0.42" {
|
||||
t.Errorf("clients of two views: %v", es)
|
||||
}
|
||||
if es, _ := two.Breakdown(ctx, "rcode", 24*time.Hour); len(es) != 3 {
|
||||
t.Errorf("rcodes of two views: %v", es)
|
||||
}
|
||||
if _, err := two.Breakdown(ctx, "answer", 24*time.Hour); !errors.Is(err, ErrNotPerView) {
|
||||
t.Errorf("answers per view: %v", err)
|
||||
}
|
||||
tl, err := all.ForViews([]string{"vps"}).Timeline(ctx, 2*time.Hour, "1m", []string{"queries", "rcode:*"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var total int64
|
||||
for _, v := range tl.Series[0].Values {
|
||||
total += v
|
||||
}
|
||||
if total != 1 || len(tl.Series) != 2 || tl.Series[1].Key != "rcode:NOERROR" {
|
||||
t.Errorf("vps timeline: %d %+v", total, tl.Series)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user