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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 09:53:04 +02:00
1 parent dd1117fd91
commit c6c5ca4ff7
31 files changed
+818 -3389

No files matched your search

+8 -335
View File
@@ -8,7 +8,6 @@ import (
"reflect"
"sort"
"strings"
"sync"
"time"
"github.com/miekg/dns"
@@ -17,8 +16,6 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
@@ -31,7 +28,6 @@ func (a *App) hooks() api.Hooks {
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Dashboard: a.dashboard,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
@@ -61,12 +57,7 @@ func (a *App) hooks() api.Hooks {
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
SearchLog: a.searchLog,
LogStats: a.logStats,
SubscribeLog: a.subscribeLog,
ClearLog: a.clearLog,
CacheStats: a.cacheStats,
CacheTop: a.cacheTop,
FlushCache: a.flushCache,
Flush: a.flush,
}
@@ -269,9 +260,11 @@ func (a *App) health() any {
if a.dns == nil || len(a.dns.Listeners()) == 0 {
status = "down"
}
if a.logger != nil && a.logger.ErrorCount() > 0 && status == "ok" {
if a.logger.Dropped() > 0 && status == "ok" {
// Lines are dropped when the log stream cannot keep up. DNS is fine, but
// whoever reads the log is missing events, which is worth a mention.
status = "degraded"
problems = append(problems, "query log write errors")
problems = append(problems, "access log is dropping lines")
}
return map[string]any{
"status": status,
@@ -316,8 +309,9 @@ func (a *App) status() any {
if a.cache != nil {
out["cache"] = a.cacheStats()
}
if a.logger != nil {
out["qlog"] = a.logger.Stats()
out["access_log"] = map[string]any{
"enabled": a.logger != nil,
"dropped": a.logger.Dropped(),
}
if a.pool != nil {
out["upstream"] = a.getUpstreams()
@@ -866,106 +860,6 @@ func (a *App) putUpstream(_ context.Context, up config.Upstream) (int64, error)
return a.cfg.Get().Version, nil
}
// searchLog pages the query log.
func (a *App) searchLog(_ context.Context, q api.LogQuery) (any, error) {
if a.logger == nil {
return map[string]any{"items": []any{}, "total": 0, "offset": 0}, nil
}
page, err := a.logger.Search(qlog.Query{
Limit: q.Limit, Offset: q.Offset, Text: q.Text, Action: q.Action,
Client: q.Client, RCode: q.RCode, BlockedOnly: q.BlockedOnly, Since: q.Since,
})
if err != nil {
return nil, err
}
out := make([]api.LogEntry, 0, len(page.Items))
for _, e := range page.Items {
out = append(out, toLogEntry(e))
}
return map[string]any{
"items": out,
"total": page.Total,
"offset": page.Offset,
"limit": page.Limit,
"has_more": len(out) == page.Limit,
}, nil
}
func toLogEntry(e qlog.Entry) api.LogEntry {
return api.LogEntry{
ID: e.ID, TS: e.TS, Client: e.Client, Group: e.Group, Name: e.Name,
QType: e.QType, QTypeStr: e.QTypeStr, RCode: e.RCode,
RCodeStr: e.RCodeName(), Action: string(e.Action), Response: e.Response,
Duration: e.Duration, Upstream: e.Upstream, List: e.List, Protocol: e.Protocol,
Blocked: e.Blocked,
}
}
// logStats is the live stats frame pushed over SSE.
func (a *App) logStats() any {
t := metrics.TotalsNow()
var hitRate float64
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
var blockedPct float64
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries)
}
return map[string]any{
"qps": a.series.qps(),
"blocked_pct": blockedPct,
"cache_hit_rate": hitRate,
"queries_total": t.Queries,
"blocked_total": t.Blocked,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"uptime_s": int64(time.Since(a.startedAt).Seconds()),
}
}
// subscribeLog streams new entries to one SSE client.
func (a *App) subscribeLog() (<-chan api.LogEntry, func()) {
if a.logger == nil {
ch := make(chan api.LogEntry)
close(ch)
return ch, func() {}
}
src, cancel := a.logger.Subscribe()
out := make(chan api.LogEntry, 256)
done := make(chan struct{})
go func() {
defer close(out)
for {
select {
case <-done:
return
case e, ok := <-src:
if !ok {
return
}
select {
case out <- toLogEntry(e):
case <-done:
return
}
}
}
}()
return out, func() {
close(done)
cancel()
}
}
// clearLog empties the query log.
func (a *App) clearLog() error {
if a.logger == nil {
return nil
}
return a.logger.Clear()
}
// cacheStats returns the cache snapshot.
func (a *App) cacheStats() any {
if a.cache == nil {
@@ -992,25 +886,6 @@ func (a *App) cacheStats() any {
}
}
// cacheTop returns the hottest cached names.
func (a *App) cacheTop(limit int) any {
if a.cache == nil {
return []any{}
}
entries := a.cache.TopEntries(limit)
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
out = append(out, map[string]any{
"name": e.Name,
"qtype": e.Type,
"qtype_name": dns.Type(e.Type).String(),
"hits": e.Hits,
"bytes": e.Bytes,
})
}
return out
}
// flushCache empties the cache.
func (a *App) flushCache() int64 {
if a.cache == nil {
@@ -1023,208 +898,6 @@ func (a *App) flushCache() int64 {
// flush persists buffered state.
func (a *App) flush() {
if a.logger != nil {
a.logger.Flush()
}
a.logger.Flush()
a.snapshotCache()
}
// seriesStore keeps the rolling counters the dashboard charts read.
type seriesStore struct {
mu sync.Mutex
queries *metrics.Ring
blocked *metrics.Ring
errors *metrics.Ring
cacheHit *metrics.Ring
// The previous totals, used to derive per-bucket deltas.
lastQueries uint64
lastBlocked uint64
lastErrors uint64
lastHits uint64
lastAt time.Time
qpsValue float64
}
func newSeriesStore() *seriesStore {
return &seriesStore{
queries: metrics.NewRing(300, 5*time.Second),
blocked: metrics.NewRing(300, 5*time.Second),
errors: metrics.NewRing(300, 5*time.Second),
cacheHit: metrics.NewRing(300, 5*time.Second),
lastAt: time.Now(),
}
}
// sample folds the delta since the last sample into the rings. Sampling on a
// timer rather than per query keeps the query path free of any extra work and
// is accurate enough for a chart.
func (s *seriesStore) sample() {
t := metrics.TotalsNow()
s.mu.Lock()
now := time.Now()
dt := now.Sub(s.lastAt).Seconds()
if dt > 0 {
s.qpsValue = float64(t.Queries-s.lastQueries) / dt
}
// Ring buckets are 5s wide; scale the interval delta accordingly.
scale := dt / 5
if scale < 0 {
scale = 0
}
dq := int64(float64(t.Queries-s.lastQueries) * scale)
db := int64(float64(t.Blocked-s.lastBlocked) * scale)
de := int64(float64(t.Errors-s.lastErrors) * scale)
dc := int64(float64(t.Hits-s.lastHits) * scale)
if dq > 0 {
s.queries.Add(dq)
}
if db > 0 {
s.blocked.Add(db)
}
if de > 0 {
s.errors.Add(de)
}
if dc > 0 {
s.cacheHit.Add(dc)
}
s.lastQueries, s.lastBlocked, s.lastErrors, s.lastHits = t.Queries, t.Blocked, t.Errors, t.Hits
s.lastAt = now
s.mu.Unlock()
}
func (s *seriesStore) qps() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.qpsValue
}
// dashboard builds the aggregated payload.
func (a *App) dashboard(_ context.Context, rangeKey string) (any, error) {
spec, ok := ranges[rangeKey]
if !ok {
spec = ranges["1h"]
}
t := metrics.TotalsNow()
hitRate := 0.0
if t.Hits+t.Misses > 0 {
hitRate = float64(t.Hits) / float64(t.Hits+t.Misses)
}
blockedPct := 0.0
if t.Queries > 0 {
blockedPct = float64(t.Blocked) / float64(t.Queries) * 100
}
// Build the series by walking the rings oldest-first and dropping anything
// older than the requested range.
var series []map[string]any
now := time.Now()
qts, qv := a.series.queries.Snapshot()
_, bv := a.series.blocked.Snapshot()
_, ev := a.series.errors.Snapshot()
_, cv := a.series.cacheHit.Snapshot()
series = make([]map[string]any, 0, len(qts))
for i := range qts {
age := now.Sub(time.Unix(qts[i], 0))
if age > spec.duration {
break
}
series = append(series, map[string]any{
"t": qts[i],
"queries": qv[i],
"blocked": bv[i],
"errors": ev[i],
"cache_hits": cv[i],
})
}
topBlocked := []any{}
topQueried := []any{}
if a.logger != nil {
for _, d := range a.logger.TopBlocked(15) {
topBlocked = append(topBlocked, map[string]any{
"domain": d.Name, "count": d.Count, "list": "", "qtype": d.QType,
})
}
for _, d := range a.logger.TopQueried(15) {
topQueried = append(topQueried, map[string]any{
"domain": d.Name, "count": d.Count, "qtype": d.QType, "blocked": d.Blocked > 0,
})
}
}
problems := a.Degraded()
status := "ok"
if len(problems) > 0 {
status = "degraded"
}
out := map[string]any{
"health": status,
"degraded_reason": strings.Join(problems, "; "),
"version": a.ver,
"node_id": a.clusterNodeID(),
"uptime_seconds": int64(time.Since(a.startedAt).Seconds()),
"queries_total": t.Queries,
"queries_per_sec": a.series.qps(),
"cache_hit_rate": hitRate,
"blocked_total": t.Blocked,
"blocked_pct": blockedPct,
"errors_total": t.Errors,
"inflight": t.Concurrent,
"series": series,
"top_blocked": topBlocked,
"top_queried": topQueried,
"upstreams": a.pool.Stats(),
"listeners": a.dns.Listeners(),
"transports": a.dns.ByTransport(),
"blocklist_rules": a.blocklistRuleCount(),
"cache_entries": a.cacheEntries(),
"config_version": a.cfg.Get().Version,
"generation": a.cfg.Generation(),
}
if a.logger != nil {
recent := a.logger.Recent(20)
items := make([]api.LogEntry, 0, len(recent))
for _, e := range recent {
items = append(items, toLogEntry(e))
}
out["recent"] = items
}
return out, nil
}
func (a *App) clusterNodeID() string {
if a.cl != nil {
return a.cl.NodeID()
}
return ""
}
func (a *App) blocklistRuleCount() int {
if a.blocks == nil {
return 0
}
if st := a.blocks.State(); st != nil && st.Set != nil {
return st.Set.Len()
}
return 0
}
func (a *App) cacheEntries() int {
if a.cache == nil {
return 0
}
return a.cache.Stats().Entries
}
// rangeSpec is one dashboard time range.
type rangeSpec struct {
duration time.Duration
buckets int
}
var ranges = map[string]rangeSpec{
"15m": {duration: 15 * time.Minute, buckets: 45},
"1h": {duration: time.Hour, buckets: 60},
"6h": {duration: 6 * time.Hour, buckets: 72},
"24h": {duration: 24 * time.Hour, buckets: 96},
}