337 lines
12 KiB
Go
337 lines
12 KiB
Go
// Package metrics collects counters for the dashboard and renders them in the
|
|
// Prometheus text exposition format.
|
|
//
|
|
// The exposition format is small and stable, so it is written directly rather
|
|
// than pulling in the Prometheus client library and its dependency tree. The
|
|
// counters are plain atomics on the query path.
|
|
package metrics
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// labelCounter is a set of counters keyed by a single label value.
|
|
type labelCounter struct {
|
|
mu sync.RWMutex
|
|
values map[string]*atomic.Int64
|
|
}
|
|
|
|
func newLabelCounter() *labelCounter {
|
|
return &labelCounter{values: map[string]*atomic.Int64{}}
|
|
}
|
|
|
|
func (c *labelCounter) Inc(label string) {
|
|
c.mu.RLock()
|
|
v, ok := c.values[label]
|
|
c.mu.RUnlock()
|
|
if ok {
|
|
v.Add(1)
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
if v, ok = c.values[label]; !ok {
|
|
v = &atomic.Int64{}
|
|
c.values[label] = v
|
|
}
|
|
c.mu.Unlock()
|
|
v.Add(1)
|
|
}
|
|
|
|
// Snapshot returns the counters sorted by label.
|
|
func (c *labelCounter) Snapshot() []LabelValue {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
out := make([]LabelValue, 0, len(c.values))
|
|
for k, v := range c.values {
|
|
out = append(out, LabelValue{Label: k, Value: v.Load()})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Value != out[j].Value {
|
|
return out[i].Value > out[j].Value
|
|
}
|
|
return out[i].Label < out[j].Label
|
|
})
|
|
return out
|
|
}
|
|
|
|
// LabelValue is one labelled counter reading.
|
|
type LabelValue struct {
|
|
Label string `json:"label"`
|
|
Value int64 `json:"value"`
|
|
}
|
|
|
|
// histogram is a fixed-bucket latency histogram in seconds.
|
|
type histogram struct {
|
|
bounds []float64
|
|
counts []atomic.Int64
|
|
sum atomic.Uint64 // float64 bits
|
|
total atomic.Int64
|
|
}
|
|
|
|
func newHistogram(bounds []float64) *histogram {
|
|
return &histogram{bounds: bounds, counts: make([]atomic.Int64, len(bounds)+1)}
|
|
}
|
|
|
|
func (h *histogram) Observe(d time.Duration) {
|
|
v := d.Seconds()
|
|
i := sort.SearchFloat64s(h.bounds, v)
|
|
h.counts[i].Add(1)
|
|
h.total.Add(1)
|
|
for {
|
|
old := h.sum.Load()
|
|
nv := float64FromBits(old) + v
|
|
if h.sum.CompareAndSwap(old, float64ToBits(nv)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *histogram) write(w io.Writer, name, help string) {
|
|
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s histogram\n", name, help, name)
|
|
var cumulative int64
|
|
for i, b := range h.bounds {
|
|
cumulative += h.counts[i].Load()
|
|
fmt.Fprintf(w, "%s_bucket{le=\"%s\"} %d\n", name, strconv.FormatFloat(b, 'g', -1, 64), cumulative)
|
|
}
|
|
cumulative += h.counts[len(h.bounds)].Load()
|
|
fmt.Fprintf(w, "%s_bucket{le=\"+Inf\"} %d\n", name, cumulative)
|
|
fmt.Fprintf(w, "%s_sum %s\n", name, strconv.FormatFloat(float64FromBits(h.sum.Load()), 'g', -1, 64))
|
|
fmt.Fprintf(w, "%s_count %d\n", name, h.total.Load())
|
|
}
|
|
|
|
// Mean returns the average observation in milliseconds.
|
|
func (h *histogram) Mean() float64 {
|
|
n := h.total.Load()
|
|
if n == 0 {
|
|
return 0
|
|
}
|
|
return float64FromBits(h.sum.Load()) / float64(n) * 1000
|
|
}
|
|
|
|
// The sum is kept as float64 bits inside an atomic so that Observe stays
|
|
// lock-free on the query path.
|
|
func float64ToBits(f float64) uint64 { return math.Float64bits(f) }
|
|
func float64FromBits(u uint64) float64 { return math.Float64frombits(u) }
|
|
|
|
// Gauges are values sampled at scrape time rather than counted incrementally.
|
|
type Gauges struct {
|
|
CacheEntries int64
|
|
CacheBytes int64
|
|
Zones int64
|
|
Records int64
|
|
BlacklistDomains int64
|
|
AllowlistDomains int64
|
|
Networks int64
|
|
UpstreamsHealthy int64
|
|
UpstreamsTotal int64
|
|
QueryLogRows int64
|
|
}
|
|
|
|
// Metrics holds every counter the application exports.
|
|
type Metrics struct {
|
|
start time.Time
|
|
|
|
QueriesTotal atomic.Int64
|
|
Authoritative atomic.Int64
|
|
Recursive atomic.Int64
|
|
CacheHits atomic.Int64
|
|
CacheMisses atomic.Int64
|
|
StaleServed atomic.Int64
|
|
Blocked atomic.Int64
|
|
Refused atomic.Int64
|
|
RateLimited atomic.Int64
|
|
Errors atomic.Int64
|
|
TruncatedResp atomic.Int64
|
|
UDPQueries atomic.Int64
|
|
TCPQueries atomic.Int64
|
|
ResolverQueries atomic.Int64
|
|
ResolverErrors atomic.Int64
|
|
|
|
byType *labelCounter
|
|
byRcode *labelCounter
|
|
bySource *labelCounter
|
|
|
|
queryDuration *histogram
|
|
resolverLatency *histogram
|
|
|
|
gaugeFn atomic.Value // func() Gauges
|
|
|
|
buildVersion string
|
|
}
|
|
|
|
// New creates a metrics registry.
|
|
func New(version string) *Metrics {
|
|
return &Metrics{
|
|
start: time.Now(),
|
|
byType: newLabelCounter(),
|
|
byRcode: newLabelCounter(),
|
|
bySource: newLabelCounter(),
|
|
buildVersion: version,
|
|
// Buckets chosen around the latencies a DNS server actually sees:
|
|
// sub-millisecond for cache and authoritative hits, tens of
|
|
// milliseconds for upstream queries.
|
|
queryDuration: newHistogram([]float64{
|
|
0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5,
|
|
}),
|
|
resolverLatency: newHistogram([]float64{
|
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// SetGaugeSource registers the callback used to sample gauges at scrape time.
|
|
func (m *Metrics) SetGaugeSource(fn func() Gauges) { m.gaugeFn.Store(fn) }
|
|
|
|
// Uptime returns how long the process has been serving.
|
|
func (m *Metrics) Uptime() time.Duration { return time.Since(m.start) }
|
|
|
|
// StartedAt returns the process start time.
|
|
func (m *Metrics) StartedAt() time.Time { return m.start }
|
|
|
|
// ObserveQuery records one completed query.
|
|
func (m *Metrics) ObserveQuery(qtype, rcode, source, protocol string, d time.Duration) {
|
|
m.QueriesTotal.Add(1)
|
|
m.byType.Inc(qtype)
|
|
m.byRcode.Inc(rcode)
|
|
m.bySource.Inc(source)
|
|
m.queryDuration.Observe(d)
|
|
if protocol == "tcp" {
|
|
m.TCPQueries.Add(1)
|
|
} else {
|
|
m.UDPQueries.Add(1)
|
|
}
|
|
}
|
|
|
|
// ObserveResolver records one upstream exchange.
|
|
func (m *Metrics) ObserveResolver(d time.Duration, err bool) {
|
|
m.ResolverQueries.Add(1)
|
|
if err {
|
|
m.ResolverErrors.Add(1)
|
|
return
|
|
}
|
|
m.resolverLatency.Observe(d)
|
|
}
|
|
|
|
// QueriesPerSecond returns the average query rate since start.
|
|
func (m *Metrics) QueriesPerSecond() float64 {
|
|
secs := time.Since(m.start).Seconds()
|
|
if secs < 1 {
|
|
secs = 1
|
|
}
|
|
return float64(m.QueriesTotal.Load()) / secs
|
|
}
|
|
|
|
// CacheHitRate returns the cache hit percentage.
|
|
func (m *Metrics) CacheHitRate() float64 {
|
|
h, ms := m.CacheHits.Load(), m.CacheMisses.Load()
|
|
if h+ms == 0 {
|
|
return 0
|
|
}
|
|
return float64(h) / float64(h+ms) * 100
|
|
}
|
|
|
|
// AvgQueryMS returns the mean query duration in milliseconds.
|
|
func (m *Metrics) AvgQueryMS() float64 { return m.queryDuration.Mean() }
|
|
|
|
// AvgResolverMS returns the mean upstream latency in milliseconds.
|
|
func (m *Metrics) AvgResolverMS() float64 { return m.resolverLatency.Mean() }
|
|
|
|
// ByType returns query counts per record type.
|
|
func (m *Metrics) ByType() []LabelValue { return m.byType.Snapshot() }
|
|
|
|
// ByRcode returns response counts per rcode.
|
|
func (m *Metrics) ByRcode() []LabelValue { return m.byRcode.Snapshot() }
|
|
|
|
// BySource returns response counts per answer source.
|
|
func (m *Metrics) BySource() []LabelValue { return m.bySource.Snapshot() }
|
|
|
|
// Reset zeroes every counter. Used by the "reset statistics" action.
|
|
func (m *Metrics) Reset() {
|
|
m.start = time.Now()
|
|
for _, c := range []*atomic.Int64{
|
|
&m.QueriesTotal, &m.Authoritative, &m.Recursive, &m.CacheHits, &m.CacheMisses,
|
|
&m.StaleServed, &m.Blocked, &m.Refused, &m.RateLimited, &m.Errors,
|
|
&m.TruncatedResp, &m.UDPQueries, &m.TCPQueries, &m.ResolverQueries, &m.ResolverErrors,
|
|
} {
|
|
c.Store(0)
|
|
}
|
|
m.byType = newLabelCounter()
|
|
m.byRcode = newLabelCounter()
|
|
m.bySource = newLabelCounter()
|
|
}
|
|
|
|
// WritePrometheus renders every metric in the Prometheus text format.
|
|
func (m *Metrics) WritePrometheus(w io.Writer) {
|
|
counter := func(name, help string, v int64) {
|
|
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", name, help, name, name, v)
|
|
}
|
|
gauge := func(name, help string, v any) {
|
|
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s gauge\n%s %v\n", name, help, name, name, v)
|
|
}
|
|
|
|
fmt.Fprintf(w, "# HELP vibedns_build_info Build information.\n# TYPE vibedns_build_info gauge\n")
|
|
fmt.Fprintf(w, "vibedns_build_info{version=%q} 1\n", escapeLabel(m.buildVersion))
|
|
|
|
gauge("vibedns_uptime_seconds", "Seconds since the DNS server started.",
|
|
strconv.FormatFloat(time.Since(m.start).Seconds(), 'f', 3, 64))
|
|
|
|
counter("vibedns_dns_queries_total", "Total DNS queries received.", m.QueriesTotal.Load())
|
|
counter("vibedns_dns_queries_authoritative_total", "Queries answered from a local authoritative zone.", m.Authoritative.Load())
|
|
counter("vibedns_dns_queries_recursive_total", "Queries answered by forwarding upstream.", m.Recursive.Load())
|
|
counter("vibedns_dns_queries_blocked_total", "Queries blocked by policy.", m.Blocked.Load())
|
|
counter("vibedns_dns_queries_refused_total", "Queries refused, mostly recursion denied by ACL.", m.Refused.Load())
|
|
counter("vibedns_dns_queries_ratelimited_total", "Queries dropped by the per-client rate limiter.", m.RateLimited.Load())
|
|
counter("vibedns_dns_errors_total", "Queries that ended in a server error.", m.Errors.Load())
|
|
counter("vibedns_dns_responses_truncated_total", "Responses truncated, prompting TCP retry.", m.TruncatedResp.Load())
|
|
counter("vibedns_dns_queries_udp_total", "Queries received over UDP.", m.UDPQueries.Load())
|
|
counter("vibedns_dns_queries_tcp_total", "Queries received over TCP.", m.TCPQueries.Load())
|
|
counter("vibedns_cache_hits_total", "Cache lookups that were served from cache.", m.CacheHits.Load())
|
|
counter("vibedns_cache_misses_total", "Cache lookups that missed.", m.CacheMisses.Load())
|
|
counter("vibedns_cache_stale_served_total", "Responses served from the stale cache window.", m.StaleServed.Load())
|
|
counter("vibedns_resolver_queries_total", "Queries forwarded to an upstream resolver.", m.ResolverQueries.Load())
|
|
counter("vibedns_resolver_errors_total", "Upstream resolver failures.", m.ResolverErrors.Load())
|
|
|
|
writeLabelled(w, "vibedns_dns_queries_by_type_total", "Queries by record type.", "type", m.byType.Snapshot())
|
|
writeLabelled(w, "vibedns_dns_responses_by_rcode_total", "Responses by rcode.", "rcode", m.byRcode.Snapshot())
|
|
writeLabelled(w, "vibedns_dns_responses_by_source_total", "Responses by answer source.", "source", m.bySource.Snapshot())
|
|
|
|
m.queryDuration.write(w, "vibedns_dns_query_duration_seconds", "End-to-end query handling time.")
|
|
m.resolverLatency.write(w, "vibedns_resolver_latency_seconds", "Upstream resolver round-trip time.")
|
|
|
|
if fn, ok := m.gaugeFn.Load().(func() Gauges); ok && fn != nil {
|
|
g := fn()
|
|
gauge("vibedns_cache_entries", "Entries currently held in the resolver cache.", g.CacheEntries)
|
|
gauge("vibedns_cache_bytes", "Estimated memory used by the resolver cache.", g.CacheBytes)
|
|
gauge("vibedns_zones", "Configured authoritative zones.", g.Zones)
|
|
gauge("vibedns_records", "Configured resource records.", g.Records)
|
|
gauge("vibedns_blacklist_domains", "Domains across all blacklists.", g.BlacklistDomains)
|
|
gauge("vibedns_allowlist_domains", "Domains across all allowlists.", g.AllowlistDomains)
|
|
gauge("vibedns_client_networks", "Configured client networks.", g.Networks)
|
|
gauge("vibedns_resolver_upstreams", "Configured upstream resolvers.", g.UpstreamsTotal)
|
|
gauge("vibedns_resolver_upstreams_healthy", "Upstream resolvers currently considered healthy.", g.UpstreamsHealthy)
|
|
gauge("vibedns_query_log_rows", "Rows currently stored in the query log.", g.QueryLogRows)
|
|
}
|
|
}
|
|
|
|
func writeLabelled(w io.Writer, name, help, label string, values []LabelValue) {
|
|
fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s counter\n", name, help, name)
|
|
for _, v := range values {
|
|
fmt.Fprintf(w, "%s{%s=%q} %d\n", name, label, escapeLabel(v.Label), v.Value)
|
|
}
|
|
}
|
|
|
|
func escapeLabel(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
s = strings.ReplaceAll(s, "\n", `\n`)
|
|
return s
|
|
}
|