Files
fancy-dns/internal/metrics/metrics.go
T
matsandClaude Sonnet 5.5 c6c5ca4ff7 Make the resolver pure DNS: JSON access log on stdout instead of a log store
The resolver kept its own query-log store (a bbolt ring, a live ring with SSE
fanout, rolling per-domain counters and dashboard time-series) for the console.
In a multi-node deployment each pod held a different slice of that, so every
view disagreed with itself, and the cost sat on the query path. Charts belong
in Grafana and log search in the log collector, so the store is gone.

internal/accesslog writes one JSON line per notable query to stdout. The default
is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped,
refused); log_cached and sample_rate add ordinary answers for a debugging
session. Lines go through a bounded buffer to a single writer goroutine and are
dropped, and counted in fancydns_access_log_dropped_total, when the stream
cannot keep up, so a stalled log pipe can never stall a query. The config key is
access_log (the old qlog block is ignored, like any unknown key).

The metrics gain what the dashboard used to compute: fancydns_group_queries_total
by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the
store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear,
/api/cache/top, the series rings, and the cache's per-name hit table, which took
a mutex and built a string on every cache hit.

Tests that read the old log now read the access-log lines; the contract test
drops the removed endpoints. The web console still calls them and is changed in
the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:53:04 +02:00

381 lines
13 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 (
"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
logDropped prometheus.Counter
groupQueries *prometheus.CounterVec
rcodes *prometheus.CounterVec
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.",
})
logDropped = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: Namespace, Name: "access_log_dropped_total",
Help: "Access-log lines discarded because the log stream could not keep up.",
})
groupQueries = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace, Name: "group_queries_total",
Help: "Queries by ACL group and outcome. The group is empty-named \"default\" when no group matched.",
}, []string{"group", "outcome"})
rcodes = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace, Name: "responses_total",
Help: "Responses by RCODE.",
}, []string{"rcode"})
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, logDropped, groupQueries, rcodes,
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() }
// RecordAccessLogDropped counts an access-log line that was discarded.
func RecordAccessLogDropped() { initCollectors(); logDropped.Inc() }
// RecordGroupQuery counts a finished query against its ACL group.
func RecordGroupQuery(group, outcome string) {
initCollectors()
if group == "" {
group = "default"
}
groupQueries.WithLabelValues(group, outcome).Inc()
}
// RecordRcode counts a response by RCODE name.
func RecordRcode(rcode string) { initCollectors(); rcodes.WithLabelValues(rcode).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) }