Files
fancy-dns/internal/metrics/metrics_test.go
T
developer 4f49033f1b Cover the engine, config.Size and metrics; fix the five bugs they found
internal/engine, internal/config and internal/metrics had no test file at all,
and writing them turned up five real bugs.

internal/engine (23 tests, new harness_test.go): a wired engine over a fake
upstream on loopback, a local zone, a blocklist, a cache, a query log and an
injectable clock. It pins the pipeline order end to end -- ACL deny before the
local zone, local zone before blocklist, blocklist before cache, drop sends
nothing, the concurrency and rate-limit refusals, serve-stale served with TTL 1
and refreshed once per key, truncation against a 512-byte client, and exactly
one metrics record and one query-log entry per query.

Five fixes:

- config.ParseSize ignored the unit multiplier, so 512MiB parsed as 512 bytes
  and the whole memory envelope was inert for anyone who wrote a unit.

- finalize (EDNS0 and the UDP size) ran only on the cache-hit path, so a large
  uncached answer left oversized for a 512-byte client. It now runs on the
  upstream, local-zone and blocklist paths too.

- The upstream UDP client read buffer was hard-coded to 1232 while we advertise
  dns.max_udp_size, so with a larger buffer configured every answer above 1232
  bytes was read truncated and became SERVFAIL. The pool takes the size now.

- LRU.prefetchGate was a 64-slot global cap, not the single flight its comment
  promised: a burst of stale hits launched up to 64 duplicate refreshes of one
  name. It is a per-key latch now; distinct names still refresh in parallel.

- metrics.RecordMiss and RecordStaleHit moved only the Prometheus collector, so
  the Cache page's misses and stale hits read 0 for the life of the process.

AGENTS.md gains the trap that cost the most time: this version of miekg/dns
reports a character-string over 255 bytes as zero length, so such a message
silently fails to pack, and the write failure looks like a client timeout.
2026-09-27 16:03:21 +00:00

251 lines
8.2 KiB
Go

package metrics
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// The counters are package singletons, so every assertion here is a delta
// around the call rather than an absolute value. Nothing in this package runs
// in parallel, so a delta is exact.
func TestRecordMovesTheOutcomeCounter(t *testing.T) {
cases := []struct {
outcome string
check func(Totals) uint64
name string
wantMove bool
}{
{OutcomeCache, func(x Totals) uint64 { return x.Hits }, "hits", true},
{OutcomeBlocked, func(x Totals) uint64 { return x.Blocked }, "blocked", true},
{OutcomeError, func(x Totals) uint64 { return x.Errors }, "errors", true},
{OutcomeRefused, func(x Totals) uint64 { return x.Refused }, "refused", true},
{OutcomeDropped, func(x Totals) uint64 { return x.Drops }, "dropped", true},
// local and upstream are outcomes for the query log and the
// duration histogram, but they have no counter of their own: a
// resolution that succeeded is counted as a query and nothing
// more.
{OutcomeLocal, func(x Totals) uint64 { return x.Queries }, "queries only", true},
{OutcomeUpstream, func(x Totals) uint64 { return x.Queries }, "queries only", true},
}
for _, tc := range cases {
before := TotalsNow()
Record("udp", tc.outcome, 2*time.Millisecond)
after := TotalsNow()
if got := after.Queries - before.Queries; got != 1 {
t.Errorf("%s: queries moved by %d, want 1", tc.name, got)
}
if tc.outcome != OutcomeLocal && tc.outcome != OutcomeUpstream {
if got := tc.check(after) - tc.check(before); got != 1 {
t.Errorf("%s: outcome counter moved by %d, want 1", tc.name, got)
}
}
// No other counter may move: a blocked query is not also a miss, and
// a refused one is not an error, or the operator cannot trust the
// sum on the dashboard.
if tc.outcome == OutcomeLocal || tc.outcome == OutcomeUpstream {
continue
}
for _, other := range cases {
if other.outcome == tc.outcome {
continue
}
if other.outcome == OutcomeLocal || other.outcome == OutcomeUpstream {
continue
}
// A drop is the one deliberate overlap: it is also an error.
// See TestRecordDropIsAlsoAnError.
if tc.outcome == OutcomeDropped && other.outcome == OutcomeError {
continue
}
if got := other.check(after) - other.check(before); got != 0 {
t.Errorf("%s: %s also moved by %d, want 0", tc.name, other.name, got)
}
}
}
}
// A drop is a refusal to resolve, so an operator counts it as both. Pin that,
// because the two series are what the "why are queries failing" dashboard
// panel sums.
func TestRecordDropIsAlsoAnError(t *testing.T) {
before := TotalsNow()
Record("udp", OutcomeDropped, time.Millisecond)
after := TotalsNow()
if after.Drops != before.Drops+1 {
t.Errorf("drops moved by %d, want 1", after.Drops-before.Drops)
}
if after.Errors != before.Errors+1 {
t.Errorf("errors moved by %d, want 1: a drop is an error for the operator", after.Errors-before.Errors)
}
}
// Record with no duration still counts the query: the duration is for the
// histogram, the counter is for the total.
func TestRecordWithoutDurationStillCounts(t *testing.T) {
before := TotalsNow()
Record("udp", OutcomeCache, 0)
after := TotalsNow()
if after.Queries != before.Queries+1 {
t.Errorf("queries moved by %d, want 1", after.Queries-before.Queries)
}
}
func TestRecordMissAndStaleAreNotOutcomes(t *testing.T) {
// A cache miss is not an error and a stale hit is not a cache hit: each
// has its own counter precisely so the two can be told apart.
before := TotalsNow()
RecordMiss()
RecordStaleHit()
RecordUpstreamError()
after := TotalsNow()
if after.Misses != before.Misses+1 {
t.Errorf("misses moved by %d, want 1", after.Misses-before.Misses)
}
if after.StaleHits != before.StaleHits+1 {
t.Errorf("stale hits moved by %d, want 1", after.StaleHits-before.StaleHits)
}
if after.Queries != before.Queries {
t.Errorf("queries moved by %d: these three do not go through Record", after.Queries-before.Queries)
}
}
func TestCountHelpersDoNotCountQueries(t *testing.T) {
// These exist for the few places that report an outcome without a full
// Response. They must not inflate the query total, or the queries counter
// stops meaning "queries".
before := TotalsNow()
CountBlocked()
CountError()
CountDrop()
CountRefused()
after := TotalsNow()
if after.Queries != before.Queries {
t.Errorf("queries moved by %d, want 0", after.Queries-before.Queries)
}
if after.Blocked != before.Blocked+1 || after.Errors != before.Errors+1 ||
after.Drops != before.Drops+1 || after.Refused != before.Refused+1 {
t.Errorf("a helper missed its own counter: %+v", after)
}
}
// Totals is a value, not a view: a caller holding a snapshot must not see it
// change under them.
func TestTotalsIsASnapshot(t *testing.T) {
before := TotalsNow()
queries := before.Queries
Record("udp", OutcomeCache, time.Millisecond)
if before.Queries != queries {
t.Errorf("the snapshot moved: %d -> %d", queries, before.Queries)
}
}
func TestGaugesArePublished(t *testing.T) {
SetConcurrent(7)
SetUp(true)
SetUpstreamHealth("10.0.0.1:53", "udp", true)
SetUpstreamHealth("10.0.0.2:53", "udp", false)
SetBlocklistStats(1234, 4096)
SetACLGroups(3)
SetLocalRecords(11)
RecordBlocklistCompile(15 * time.Millisecond)
RecordUpstreamLatency("10.0.0.1:53", 4*time.Millisecond)
body := scrape(t)
for _, want := range []string{
// The name and the value: a dashboard or a ServiceMonitor that
// reads the wrong series fails silently otherwise.
"fancydns_concurrent_queries 7",
"fancydns_up 1",
`fancydns_upstream_health{addr="10.0.0.1:53",proto="udp"} 1`,
`fancydns_upstream_health{addr="10.0.0.2:53",proto="udp"} 0`,
"fancydns_blocklist_rules 1234",
"fancydns_blocklist_source_bytes 4096",
"fancydns_acl_groups 3",
"fancydns_local_records 11",
} {
if !strings.Contains(body, want) {
t.Errorf("scrape is missing %q\nbody (%d bytes):\n%s", want, len(body), truncate(body, 1200))
}
}
// The histogram's observation count is cumulative across runs of the
// suite, so only its presence is pinned here, never its value.
if !strings.Contains(body, "fancydns_blocklist_compile_seconds_count") {
t.Errorf("scrape is missing the blocklist compile histogram\nbody (%d bytes):\n%s", len(body), truncate(body, 1200))
}
}
// The counter names are the contract with the Grafana dashboards and the
// ServiceMonitor, so they are pinned here rather than assumed.
func TestHandlerExposesTheCounterSeries(t *testing.T) {
Record("udp", OutcomeBlocked, time.Millisecond)
RecordMiss()
body := scrape(t)
for _, want := range []string{
"fancydns_queries_total",
"fancydns_blocked_total",
"fancydns_cache_hits_total",
"fancydns_cache_misses_total",
"fancydns_cache_stale_hits_total",
"fancydns_upstream_errors_total",
"fancydns_errors_total",
"fancydns_dropped_total",
"fancydns_refused_total",
"fancydns_query_duration_seconds_bucket",
"fancydns_query_duration_seconds_count",
} {
if !strings.Contains(body, want) {
t.Errorf("scrape is missing the series %q\nbody (%d bytes):\n%s", want, len(body), truncate(body, 1200))
}
}
}
func TestSetUpFalseClearsLiveness(t *testing.T) {
SetUp(true)
if !strings.Contains(scrape(t), "fancydns_up 1") {
t.Fatal("up was not published as 1")
}
SetUp(false)
if !strings.Contains(scrape(t), "fancydns_up 0") {
t.Error("up was not published as 0 after SetUp(false)")
}
}
func scrape(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(Handler())
t.Cleanup(srv.Close)
res, err := srv.Client().Get(srv.URL)
if err != nil {
t.Fatalf("scrape: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("scrape status = %d", res.StatusCode)
}
b, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read scrape: %v", err)
}
return string(b)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// The namespace is part of the contract with the dashboards, the ServiceMonitor
// and every saved query: changing it orphans all of them silently.
func TestNamespaceIsStable(t *testing.T) {
if Namespace != "fancydns" {
t.Fatalf("Namespace = %q, want %q: every dashboard and alert is keyed on it", Namespace, "fancydns")
}
}