Files
adamandClaude Opus 5.5 33060f86ce Don't count a per-view counter going down as a restart of named
After a reconfig that changed named's views, the cache and resolver
counters (summed over views) went down, which was taken for a restart:
every counter then counted its total since named started for one minute.
Only a new boot time means a restart now; a counter that goes down
counts 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:24 +01:00

152 lines
4.4 KiB
Go

package stats
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Snapshot is named's cumulative counters at one moment, flattened to the
// keys Jarl stores (see Counters in docs/stats.md).
type Snapshot struct {
BootTime string
Values map[string]int64
}
// serverJSON is the part of /json/v1/server Jarl uses.
type serverJSON struct {
BootTime string `json:"boot-time"`
Opcodes map[string]int64 `json:"opcodes"`
RCodes map[string]int64 `json:"rcodes"`
QTypes map[string]int64 `json:"qtypes"`
NSStats map[string]int64 `json:"nsstats"`
Views map[string]struct {
Resolver struct {
Stats map[string]int64 `json:"stats"`
CacheStats map[string]int64 `json:"cachestats"`
} `json:"resolver"`
} `json:"views"`
}
// nsstatsKeys maps named's server counters to Jarl's counter names.
var nsstatsKeys = map[string]string{
"QryAuthAns": "answer:authoritative",
"QryNoauthAns": "answer:recursive",
"QryRecursion": "recursion",
"QryDropped": "dropped",
"RateDropped": "ratelimit:dropped",
"RateSlipped": "ratelimit:slipped",
"QryTCP": "transport:tcp",
"QryUDP": "transport:udp",
"QryDuplicate": "duplicate",
}
// rttKeys maps named's upstream round-trip buckets to readable names.
var rttKeys = map[string]string{
"QryRTT10": "rtt:<10ms",
"QryRTT100": "rtt:10-100ms",
"QryRTT500": "rtt:100-500ms",
"QryRTT800": "rtt:500-800ms",
"QryRTT1600": "rtt:800-1600ms",
"QryRTT1600+": "rtt:>1600ms",
}
var dnssecKeys = map[string]string{"ValOk": "dnssec:valid", "ValNegOk": "dnssec:valid-negative", "ValFail": "dnssec:failed"}
// Flatten turns the statistics channel's JSON into a snapshot.
func Flatten(body []byte) (*Snapshot, error) {
var j serverJSON
if err := json.Unmarshal(body, &j); err != nil {
return nil, fmt.Errorf("unexpected statistics data: %w", err)
}
v := map[string]int64{"queries": j.Opcodes["QUERY"]}
for k, n := range j.RCodes {
if n != 0 && !strings.HasPrefix(k, "RESERVED") && (k[0] < '0' || k[0] > '9') {
v["rcode:"+k] = n
}
}
for k, n := range j.QTypes {
v["qtype:"+k] = n
}
for k, name := range nsstatsKeys {
if n := j.NSStats[k]; n != 0 {
v[name] = n
}
}
for _, view := range j.Views {
for k, name := range rttKeys {
v[name] += view.Resolver.Stats[k]
}
for k, name := range dnssecKeys {
v[name] += view.Resolver.Stats[k]
}
v["cache:hits"] += view.Resolver.CacheStats["QueryHits"]
v["cache:misses"] += view.Resolver.CacheStats["QueryMisses"]
}
for k, n := range v {
if n == 0 && k != "queries" {
delete(v, k)
}
}
return &Snapshot{BootTime: j.BootTime, Values: v}, nil
}
// Delta returns the change from prev to cur. When named restarted (its
// boot time changed), the counters started again from zero, so the
// current values are the change. Without a restart, a counter can still
// go down: the per-view ones (cache, upstream times, validation) are
// summed over named's views, and a reconfig that adds, removes or
// recreates views changes that sum. Such a counter counts 0 for the
// minute; it must never make every counter count from zero, which would
// add everything since named started (millions of "queries" in a minute).
func Delta(prev, cur *Snapshot) map[string]int64 {
out := map[string]int64{}
restarted := prev.BootTime != cur.BootTime
for k, n := range cur.Values {
d := n
if !restarted {
d = max(n-prev.Values[k], 0)
}
if d != 0 || k == "queries" {
out[k] = d
}
}
return out
}
// Poller reads named's statistics channel.
type Poller struct {
URL string // e.g. http://127.0.0.1:8058/json/v1/server
Client *http.Client
}
// NewPoller returns a poller for a statistics channel address.
func NewPoller(addr string) *Poller {
return &Poller{URL: "http://" + addr + "/json/v1/server", Client: &http.Client{Timeout: 10 * time.Second}}
}
// Poll fetches a snapshot.
func (p *Poller) Poll(ctx context.Context) (*Snapshot, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return nil, err
}
resp, err := p.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("named's statistics channel is not reachable (%v); is the configuration with statistics applied and named running?", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("named's statistics channel answered %s", resp.Status)
}
return Flatten(body)
}