Files
fancy-dns/internal/metrics/metrics.go
T
matsandClaude Sonnet 5.5 dd1117fd91 Coalesce identical misses, refuse non-QUERY opcodes, refresh hot entries early, check the answered question
Concurrent cache misses for one name each went upstream. After a restart, a
cache flush or a cluster-object change that is a burst of identical queries
exactly when the cache is empty. Identical exchanges (same name, type, class,
DO bit, pinned upstream set and forwarded client subnet) now share one: the
first query runs it on its own goroutine and context, the rest wait and get
private copies, and the leader alone writes the cache. If the leader's client
leaves, waiters take over instead of failing with its cancellation. Shared
answers are counted in fancydns_upstream_coalesced_total.

validate() accepted OpcodeUpdate, so a dynamic update was forwarded upstream as
an ordinary lookup, and every other opcode got FORMERR. A recursive resolver
implements QUERY only: anything else is now NOTIMP (RFC 1035 4.1.1), checked
before the question count since STATUS carries no question.

Prefetch only fired when a stale entry was served, so the first client after a
hot name expired got a TTL-1 stale answer. A fresh entry is now refreshed in
the background when it has been served at least twice and under a tenth of its
lifetime remains; the hit counter restarts with each stored entry, so a name
nobody comes back for stops being refreshed. Counted in
fancydns_cache_prefetch_total.

miekg/dns matches only the message ID of an upstream reply, so a forged reply
with the right ID for a different question was accepted and cached under the
name that was asked. exchangeOne now requires the reply to carry the question
that was sent (an empty section only for FORMERR, SERVFAIL, NOTIMP and
REFUSED). A mismatch fails the attempt without counting against the upstream,
so a spoofer cannot trip the circuit breaker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 08:58:56 +02:00

497 lines
15 KiB
Go

// Package metrics holds the Prometheus collectors and the in-process
// time-series buffers that back the admin UI's charts.
//
// The UI needs finer-grained series than Prometheus's default histogram gives,
// so a small fixed-bucket ring per metric is kept in memory: cheap, bounded,
// and enough to draw a dashboard without shipping raw samples to a browser.
package metrics
import (
"math"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
// Namespace prefixes every metric name.
const Namespace = "fancydns"
// Counters for the request pipeline.
var (
queriesTotal prometheus.Counter
blockedTotal prometheus.Counter
cacheHits prometheus.Counter
cacheMisses prometheus.Counter
cacheStaleHits prometheus.Counter
coalesced prometheus.Counter
prefetches prometheus.Counter
upstreamErrors prometheus.Counter
dnsErrors prometheus.Counter
dropsTotal prometheus.Counter
refusedTotal prometheus.Counter
)
// Gauges for live state.
var (
upstreamHealth *prometheus.GaugeVec
concurrentNow prometheus.Gauge
listCount prometheus.Gauge
listBytes prometheus.Gauge
aclGroups prometheus.Gauge
localRecords prometheus.Gauge
up prometheus.Gauge
)
// Histograms for latency, with buckets that matter for DNS: sub-millisecond
// cache hits, a few milliseconds for a local answer, tens for an upstream.
var (
queryDuration *prometheus.HistogramVec
upstreamDur *prometheus.HistogramVec
parseDuration prometheus.Histogram
)
var registerOnce sync.Once
// Registry is the registry served on /metrics.
var Registry = prometheus.NewRegistry()
// initCollectors registers everything exactly once. It is safe to call from
// several components.
func initCollectors() {
registerOnce.Do(func() {
queriesTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "queries_total",
Help: "Total DNS queries received across all transports.",
})
blockedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "blocked_total",
Help: "Queries answered with a blocklist response.",
})
cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_hits_total",
Help: "Cache hits (fresh).",
})
cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_misses_total",
Help: "Cache misses that required an upstream lookup.",
})
cacheStaleHits = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_stale_hits_total",
Help: "Answers served from the serve-stale window.",
})
coalesced = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "upstream_coalesced_total",
Help: "Cache misses answered by sharing another query's in-flight upstream exchange.",
})
prefetches = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "cache_prefetch_total",
Help: "Background refreshes started for an entry that was stale or close to expiry.",
})
upstreamErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "upstream_errors_total",
Help: "Upstream exchange failures.",
})
dnsErrors = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "errors_total",
Help: "Responses with a non-success RCODE.",
})
dropsTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "dropped_total",
Help: "Queries dropped without a response (blocklist drop or overload).",
})
refusedTotal = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "refused_total",
Help: "Queries answered REFUSED by ACL policy.",
})
upstreamHealth = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace, Name: "upstream_health",
Help: "1 when an upstream is up, 0 when down.",
}, []string{"addr", "proto"})
concurrentNow = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "concurrent_queries",
Help: "Queries currently in flight.",
})
listCount = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_rules",
Help: "Total compiled blocklist rules.",
})
listBytes = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "blocklist_source_bytes",
Help: "Bytes of source data compiled into the blocklist.",
})
aclGroups = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "acl_groups",
Help: "Number of configured ACL groups.",
})
localRecords = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "local_records",
Help: "Records in the local zone store.",
})
up = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace, Name: "up",
Help: "1 if the process is serving, 0 otherwise.",
})
queryDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "query_duration_seconds",
Help: "End-to-end query handling time.",
Buckets: []float64{0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
}, []string{"transport", "outcome"})
upstreamDur = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace, Name: "upstream_duration_seconds",
Help: "Time spent talking to a specific upstream.",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2},
}, []string{"addr"})
parseDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace, Name: "blocklist_compile_seconds",
Help: "Time to fetch and compile all blocklist sources.",
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30},
})
Registry.MustRegister(
queriesTotal, blockedTotal, cacheHits, cacheMisses, cacheStaleHits, coalesced, prefetches,
upstreamErrors, dnsErrors, dropsTotal, refusedTotal, upstreamHealth,
concurrentNow, listCount, listBytes, aclGroups, localRecords, up,
queryDuration, upstreamDur, parseDuration,
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
})
}
// Outcome labels for queryDuration.
const (
OutcomeCache = "cache"
OutcomeBlocked = "blocked"
OutcomeLocal = "local"
OutcomeUpstream = "upstream"
OutcomeError = "error"
OutcomeRefused = "refused"
OutcomeDropped = "dropped"
)
// Record is the single entry point for a pipeline outcome. Both the Prometheus
// counters and the in-process atomics the dashboard reads are bumped here, so
// the two views can never disagree.
//
// Callers must not also call CountBlocked/CountError/etc: that would double
// count. Those helpers exist for the few places that report an outcome without
// going through Record.
func Record(transport, outcome string, d time.Duration) {
initCollectors()
queriesTotal.Inc()
queries.Add(1)
if d > 0 {
queryDuration.WithLabelValues(transport, outcome).Observe(d.Seconds())
}
switch outcome {
case OutcomeCache:
cacheHits.Inc()
hits.Add(1)
case OutcomeBlocked:
blockedTotal.Inc()
blocked.Add(1)
case OutcomeError:
dnsErrors.Inc()
errs.Add(1)
case OutcomeDropped:
dropsTotal.Inc()
drops.Add(1)
// A drop is both a drop and an error for the operator's purposes.
errs.Add(1)
case OutcomeRefused:
refusedTotal.Inc()
refused.Add(1)
}
}
// RecordMiss counts a cache miss. A miss is not an error: it just means the
// cache could not answer, so the error counter does not move. The in-process
// counter does, like every other outcome: the dashboard reads it and must
// never disagree with the Prometheus series.
func RecordMiss() { initCollectors(); cacheMisses.Inc(); misses.Add(1) }
// RecordStaleHit counts a serve-stale hit.
func RecordStaleHit() { initCollectors(); cacheStaleHits.Inc(); staleHits.Add(1) }
// RecordCoalesced counts a query that shared an in-flight upstream exchange.
func RecordCoalesced() { initCollectors(); coalesced.Inc() }
// RecordPrefetch counts a background refresh.
func RecordPrefetch() { initCollectors(); prefetches.Inc() }
// RecordUpstreamError counts an upstream failure.
func RecordUpstreamError() { initCollectors(); upstreamErrors.Inc() }
// RecordUpstreamLatency observes a single upstream exchange.
func RecordUpstreamLatency(addr string, d time.Duration) {
initCollectors()
upstreamDur.WithLabelValues(addr).Observe(d.Seconds())
}
// SetConcurrent publishes the in-flight gauge.
func SetConcurrent(n int) { initCollectors(); concurrentNow.Set(float64(n)) }
// SetUp publishes the liveness gauge.
func SetUp(v bool) {
initCollectors()
if v {
up.Set(1)
return
}
up.Set(0)
}
// SetUpstreamHealth publishes per-upstream health.
func SetUpstreamHealth(addr, proto string, healthy bool) {
initCollectors()
v := 0.0
if healthy {
v = 1
}
upstreamHealth.WithLabelValues(addr, proto).Set(v)
}
// SetBlocklistStats publishes the compiled rule count and source bytes.
func SetBlocklistStats(rules int, sourceBytes int64) {
initCollectors()
listCount.Set(float64(rules))
listBytes.Set(float64(sourceBytes))
}
// SetACLGroups publishes the group count.
func SetACLGroups(n int) { initCollectors(); aclGroups.Set(float64(n)) }
// SetLocalRecords publishes the local zone record count.
func SetLocalRecords(n int) { initCollectors(); localRecords.Set(float64(n)) }
// RecordBlocklistCompile observes a full compile pass.
func RecordBlocklistCompile(d time.Duration) {
initCollectors()
parseDuration.Observe(d.Seconds())
}
// Handler returns the HTTP handler for /metrics.
func Handler() http.Handler {
initCollectors()
return promhttp.HandlerFor(Registry, promhttp.HandlerOpts{
Registry: Registry,
EnableOpenMetrics: true,
MaxRequestsInFlight: 4,
})
}
// Totals is a point-in-time snapshot of the counters, used by the dashboard.
type Totals struct {
Queries uint64
Blocked uint64
Hits uint64
Misses uint64
StaleHits uint64
Errors uint64
Drops uint64
Refused uint64
Concurrent int64
}
var (
queries atomic.Uint64
blocked atomic.Uint64
hits atomic.Uint64
misses atomic.Uint64
staleHits atomic.Uint64
errs atomic.Uint64
drops atomic.Uint64
refused atomic.Uint64
inflight atomic.Int64
)
// TotalsNow returns the current totals. These mirror the Prometheus counters
// (both are incremented together) so the dashboard does not have to scrape.
func TotalsNow() Totals {
return Totals{
Queries: queries.Load(),
Blocked: blocked.Load(),
Hits: hits.Load(),
Misses: misses.Load(),
StaleHits: staleHits.Load(),
Errors: errs.Load(),
Drops: drops.Load(),
Refused: refused.Load(),
Concurrent: inflight.Load(),
}
}
func bumpCounter(c *atomic.Uint64) func() {
c.Add(1)
return func() { c.Add(^uint64(0)) }
}
// BeginQuery marks a query as in flight and returns its completion func.
func BeginQuery() func() {
inflight.Add(1)
queries.Add(1)
return func() { inflight.Add(-1) }
}
// CountBlocked increments the blocked counter.
func CountBlocked() { blocked.Add(1) }
// CountError increments the error counter.
func CountError() { errs.Add(1) }
// CountDrop increments the drop counter.
func CountDrop() { drops.Add(1) }
// CountRefused increments the refused counter.
func CountRefused() { refused.Add(1) }
// Series is a fixed-window time series for the UI charts.
type Series struct {
// Buckets are the sample timestamps (epoch seconds), oldest first.
Buckets []int64 `json:"t"`
Values []int64 `json:"v"`
// ResolutionMS is the bucket width.
ResolutionMS int64 `json:"resolution_ms"`
}
// Ring is a fixed-bucket counter ring: a circular array of counts indexed by
// bucket number. Lock-free on the write path, which matters because it is hit
// once per DNS query.
type Ring struct {
buckets []atomic.Int64
res time.Duration
start atomic.Int64 // first bucket timestamp, epoch seconds
pos atomic.Uint32
sealed atomic.Bool
count int
}
// NewRing builds a ring of n buckets at the given resolution.
func NewRing(n int, res time.Duration) *Ring {
if n <= 0 {
n = 120
}
if res <= 0 {
res = 5 * time.Second
}
r := &Ring{buckets: make([]atomic.Int64, n), res: res, count: n}
r.start.Store(time.Now().Unix())
return r
}
// Inc bumps the current bucket by 1, rotating when the window advances.
func (r *Ring) Inc() { r.Add(1) }
// Add bumps the current bucket.
func (r *Ring) Add(delta int64) {
if r == nil {
return
}
now := time.Now().Unix()
bucket := now / int64(r.res.Seconds())
start := r.start.Load()
// If more than count buckets elapsed, we skipped a full window: reset
// everything so a long idle period does not report phantom zeros followed
// by a sudden spike.
if bucket-start >= int64(r.count) {
if r.start.CompareAndSwap(start, bucket-int64(r.count)+1) {
for i := range r.buckets {
r.buckets[i].Store(0)
}
}
start = r.start.Load()
}
if bucket < start {
return
}
idx := int(bucket - start)
if idx >= r.count {
idx = r.count - 1
}
// Store at the position implied by the bucket so readers can map index to
// time without extra bookkeeping.
r.buckets[idx%r.count].Add(delta)
}
// AddAt bumps an explicit bucket (used by tests).
func (r *Ring) AddAt(bucket int64, delta int64) {
start := r.start.Load()
idx := int(bucket - start)
if idx < 0 || idx >= r.count {
return
}
r.buckets[idx%r.count].Add(delta)
}
// Series snapshots the ring into aligned arrays.
func (r *Ring) Snapshot() ([]int64, []int64) {
if r == nil {
return nil, nil
}
start := r.start.Load()
now := time.Now().Unix()
step := int64(r.res.Seconds())
ts := make([]int64, r.count)
vs := make([]int64, r.count)
for i := 0; i < r.count; i++ {
b := start + int64(i)
ts[i] = b * step
vs[i] = r.buckets[i%r.count].Load()
}
// Trim leading buckets that are in the future (ring not yet filled) so the
// chart starts at "now" rather than at zero time.
if now > ts[len(ts)-1] {
shift := int((now-ts[len(ts)-1])/step) + 1
if shift >= len(ts) {
return []int64{now}, []int64{0}
}
ts = append(ts[:0], ts[shift:]...)
vs = append(vs[:0], vs[shift:]...)
}
return ts, vs
}
// Series is the snapshot form.
func (r *Ring) Series() Series {
ts, vs := r.Snapshot()
return Series{Buckets: ts, Values: vs, ResolutionMS: r.res.Milliseconds()}
}
// LCG is a tiny deterministic PRNG for jitter, so the test suite does not
// depend on the global rand.
type LCG struct{ s uint64 }
func NewLCG(seed uint64) *LCG { return &LCG{s: seed | 1} }
func (l *LCG) Next() uint64 {
l.s = l.s*6364136223846793005 + 1442695040888963407
return l.s >> 11
}
func (l *LCG) Float() float64 { return float64(l.Next()) / float64(1<<53) }
// SafeFloat converts an int count to a float for charting, guarding NaN.
func SafeFloat(n int64) float64 {
if n <= 0 {
return 0
}
return float64(n)
}
// Percent computes a safe percentage.
func Percent(part, total int64) float64 {
if total <= 0 {
return 0
}
p := float64(part) / float64(total) * 100
if math.IsNaN(p) || math.IsInf(p, 0) {
return 0
}
return p
}