Metrics
Every metric sdns exports, what it means, and the queries worth building a dashboard from.
sdns exports 64 metrics in Prometheus format on the API listener, alongside the Go runtime and process collectors.
api = "127.0.0.1:8080"
curl http://127.0.0.1:8080/metrics
Names and help strings on this page were read from a running instance, not from the source, so they are what your scrape will actually see.
A note on what appears when
Counters with labels materialise on first use. A metric that has never been
incremented — rpz_action_total before any policy match, dns_queries_total
before the first query — is absent from the scrape rather than present at zero.
Alerts should therefore use absent() deliberately or tolerate the gap, and a
dashboard panel that is empty on a fresh process is not necessarily broken.
Registration is not uniform: some metrics appear only once their feature is configured — the RPZ zone gauges need a policy zone loaded — while others, DNS64 among them, are registered by their package whatever the configuration says and simply stay at zero.
Traffic
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_queries_total |
counter | qtype, rcode |
Queries processed |
dns_domain_queries_total |
counter | domain |
Queries per domain — only with domainmetrics = true |
dns_recovery_panics_total |
counter | Panics caught by the recovery middleware |
dns_queries_total split by rcode is the health of the resolver in one line.
dns_recovery_panics_total should be zero forever; anything else is a bug worth
reporting.
dns_domain_queries_total has unbounded cardinality on a public resolver, which
is what domainmetricslimit exists for.
Cache
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_cache_hits_total |
counter | Cache hits | |
dns_cache_misses_total |
counter | Cache misses | |
dns_cache_hit_rate |
gauge | Hit rate as a percentage, 0–100 — not a 0–1 ratio | |
dns_cache_size |
gauge | type |
Entries currently held |
dns_cache_evictions_total |
counter | Entries dropped under pressure | |
dns_cache_prefetches_total |
counter | Background refreshes of popular entries | |
dns_cache_stale_answers_total |
counter | Expired positive answers served after a resolution failure | |
dns_cache_wire_fastpath_total |
counter | outcome |
Hits attempted on the byte serving path, by outcome |
dns_cache_ecs_lookups_total |
counter | outcome |
ECS-aware lookups, by outcome |
failure_cache_hits_total |
counter | RFC 9520 cached resolution failures served | |
nxdomain_cut_hits_total |
counter | Descendant NXDOMAINs served from validated RFC 8020 cuts | |
aggressive_negative_hits_total |
counter | proof, rcode |
RFC 8198 answers synthesised from validated denial proofs |
The last three are answers that cost no upstream query and would otherwise have been a full resolution each. They are the cheapest traffic the resolver serves, and worth a panel of their own.
dns_cache_wire_fastpath_total{outcome="..."} shows how much of the hit traffic
is served straight from stored bytes rather than being re-encoded.
Resolution and DNSSEC
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_resolver_failures_total |
counter | reason |
Recursive resolution failures |
dns_resolver_dnssec_failures_total |
counter | reason |
DNSSEC validation failures |
dns_circuit_breaker_trips_total |
counter | Resolver circuit breaker opened (5 consecutive failures) | |
dns_circuit_breaker_resets_total |
counter | An open breaker closed again | |
dns_trust_anchor_refresh_total |
counter | result |
RFC 5011 refresh attempts, by terminal result |
dns_trust_anchor_lifecycle_total |
counter | transition |
RFC 5011 anchor lifecycle transitions |
dns_resolution_shed_total |
counter | scope |
Lookups shed at an in-flight ceiling before any upstream work |
dns_edns_errors_total |
counter | reason |
EDNS protocol errors that rejected a query |
dns_resolver_dnssec_failures_total deserves an alert. Clients see only
SERVFAIL, so a zone that has broken its signing — or something interfering with
your traffic — is otherwise invisible.
dns_trust_anchor_lifecycle_total is quiet for years and then matters
enormously. Watch it around a root KSK rollover.
Recursion work
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_recursion_fanout_ratio |
histogram | Outbound attempts per resolution tree | |
dns_recursion_firewall_exhaustions_total |
counter | mode, reason |
Request trees that exhausted a work budget |
dnssec_work_per_request |
histogram | operation, mode |
DNSSEC operations per completed request tree |
dnssec_work_total |
counter | operation, mode |
Accepted or observed DNSSEC work across request trees |
Buckets: dns_recursion_fanout_ratio is 1–128, dnssec_work_per_request is
0–512.
These are the numbers to read before switching the
recursion firewall
from shadow to enforce. In shadow mode,
dns_recursion_firewall_exhaustions_total is exactly the set of requests
enforce would have failed — if it is nonzero for ordinary traffic, the limit is
too low for your workload.
The mode label distinguishes enforced from observed, so a shadow soak and the
enforcing run afterwards are directly comparable.
Ingress and transports
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_ingress_plan |
gauge | bound |
Serving bounds this process derived at startup |
dns_udp_ingress_drops_total |
counter | reason |
UDP packets dropped before the handler |
dns_udp_ingress_overflow_total |
counter | kind |
UDP queries served outside the fixed worker pool |
dns_udp_inline_total |
counter | outcome |
UDP queries attempted on the reader’s inline fast path |
dns_tcp_ingress_drops_total |
counter | reason |
TCP events dropped before the handler |
dns_listener_errors_total |
counter | proto |
Listener loops that exited with an error |
dns_doh_http_errors_total |
counter | code |
DoH responses with a 4xx or 5xx status |
dns_ingress_plan is the most useful metric nobody looks at: it publishes what
the process actually decided about worker count, in-flight cap and connection
cap, given the machine’s memory, CPUs and file-descriptor limit. If a container
is performing worse than the host it replaced, compare this gauge before
anything else.
Overflow is a capacity signal, not a bug — it means queries arrived faster than the fixed pool accepted them and were served on their own goroutines.
Policy and access
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_accesslist_denied_total |
counter | Queries denied by the access list | |
dns_ratelimit_exceeded_total |
counter | Queries rejected by rate limiting | |
dns_blocklist_hits_total |
counter | Queries blocked by the blocklist | |
dns_blocklist_entries |
gauge | Blocklist size (exact names plus wildcard suffixes) | |
reflex_blocked_total |
counter | Queries blocked as amplification-attack suspects | |
reflex_detections_total |
counter | qtype |
Queries scored as amplification suspects, whether or not blocking is on |
reflex_tracked_ips |
gauge | IPs currently tracked by reflex |
Response Policy Zones
| Metric | Type | Labels | Meaning |
|---|---|---|---|
rpz_action_total |
counter | zone, trigger, action, outcome |
Matches under winner-bounded counting |
rpz_zone_rules |
gauge | zone, trigger |
Compiled rules per zone and trigger, set on load |
rpz_zone_rules_skipped |
gauge | zone, reason |
Records a zone load stepped over |
rpz_reload_errors_total |
counter | zone |
Loads or reloads that failed; the previous store keeps serving |
rpz_zone_serial |
gauge | zone |
Serial of an AXFR-fed zone; -1 for file zones and withdrawn feeds |
The outcome label is the whole point of shadow mode: enforced counts matches
that acted, observed counts matches that only would have. Summing over
outcome gives a zone’s match rate in either mode, which is what makes a
shadow soak comparable to the enforcing run that follows it.
rpz_zone_rules_skipped climbing after a feed update means the publisher
started emitting something the loader will not accept — the zone still works,
with fewer rules than intended.
Local root
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_localroot_answers_total |
counter | kind |
Walk consultations answered from the local copy |
dns_localroot_transfers_total |
counter | outcome |
Transfer attempts, by outcome |
dns_localroot_serial |
gauge | Serial of the active copy; -1 when none is active |
|
dns_localroot_copy_age_seconds |
gauge | Age since last successful refresh; -1 when none is active |
dns_localroot_copy_age_seconds is the one to alert on. Climbing steadily means
refreshes are failing and the copy is walking toward its SOA expire, after which
you silently go back to querying the root servers.
Forwarding and failover
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_forwarder_failures_total |
counter | Upstream forwarder exchange failures | |
dns_forwarder_response_mismatch_total |
counter | Responses dropped for question-section mismatch | |
dns_failover_attempts_total |
counter | Failover engaged after a SERVFAIL | |
dns_failover_success_total |
counter | Queries answered by a fallback server |
dns_forwarder_response_mismatch_total is a poisoning signal, not a
performance one. It should be zero.
Other namespaces
| Metric | Type | Labels | Meaning |
|---|---|---|---|
dns_hostsfile_lookups_total |
counter | Lookups attempted against the hosts file | |
dns_hostsfile_hits_total |
counter | Lookups that matched | |
dns_kubernetes_queries_total |
counter | Queries entering the Kubernetes middleware | |
dns_kubernetes_answered_total |
counter | Queries it answered authoritatively | |
dns_kubernetes_errors_total |
counter | Lookup or response-build errors | |
dns_kubernetes_write_errors_total |
counter | Failed writes to the client (subset of the above) | |
dns64_synthesised_total |
counter | AAAA queries answered with synthesised records | |
dns64_passthrough_total |
counter | reason |
AAAA queries DNS64 left untouched |
dns64_a_lookup_failures_total |
counter | reason |
Failures of the secondary A lookup |
dns64_ptr_translated_total |
counter | ip6.arpa PTRs answered with a CNAME to in-addr.arpa |
dns64_passthrough_total{reason="dnssec_fail"} is worth watching: it counts
the cases where synthesis was declined because it would have masked a
validation failure.
Runtime
The standard Go collectors are exported too — go_goroutines,
go_memstats_*, go_gc_duration_seconds, process_resident_memory_bytes,
process_open_fds and the rest.
process_open_fds against the unit’s LimitNOFILE is worth a panel: the
TCP/DoT connection cap is derived from that limit at startup, so a low limit
quietly gives you a smaller cap than the machine could carry.
Queries worth starting from
Hit rate over five minutes
sum(rate(dns_cache_hits_total[5m]))
/ (sum(rate(dns_cache_hits_total[5m])) + sum(rate(dns_cache_misses_total[5m])))
Denials answered from a proof already held
sum(rate(nxdomain_cut_hits_total[5m]))
sum(rate(aggressive_negative_hits_total[5m]))
Local-root consultations, by kind
sum(rate(dns_localroot_answers_total[5m])) by (kind)
One expression per block, because each of these is a query in its own right — pasting the three together is not valid PromQL.
Rates, not a share — and that is not a presentation choice. There is no correct way to express any of them as a fraction of client traffic with the series that exist today.
dns_queries_total is client-only by construction: the metrics middleware
declares itself a client-traffic observer and the sub-pipeline leaves it out,
so a resolver-private lookup never reaches it. The cache counters have no such
gate. A DS or DNSKEY fetch, a DNS64 secondary lookup, an alias chase — each can
be answered from a validated cut or a denial proof and increment the numerator
without the denominator moving. Divide one by the other and the result can
exceed 1, which is the tell that the two are counted at different levels.
The local-root counter has the same problem twice over: fallback counts
consultations the copy declined, which went upstream; referral and ds are
steps within a walk that usually continues; and apex includes the
resolver’s own background trust-anchor lookups. With hyperlocal_root = false
the series does not exist at all, so a term referencing it would empty an
expression it was part of.
Read these as absolute rates and watch their shape. A real share would need either a client/internal dimension on the cache counters or a counter incremented at the point the client’s answer is written.
SERVFAIL rate
sum(rate(dns_queries_total{rcode="SERVFAIL"}[5m]))
/ sum(rate(dns_queries_total[5m]))
Fan-out p99 — how much work the heaviest one percent of queries causes
histogram_quantile(0.99, sum(rate(dns_recursion_fanout_ratio_bucket[5m])) by (le))
NSEC3 work p99, the number to calibrate the firewall against
histogram_quantile(0.99,
sum(rate(dnssec_work_per_request_bucket{operation="nsec3_hash"}[5m])) by (le))
What enforcement would have failed, while still in shadow
sum(rate(dns_recursion_firewall_exhaustions_total{mode="shadow"}[5m])) by (reason)
RPZ match rate by zone, in either mode
sum(rate(rpz_action_total[5m])) by (zone, action, outcome)
Local root copy going stale
The gauge is the age of the copy, so it is above zero almost all the time on a healthy resolver — alert on a threshold, not on the value being positive. The root zone refreshes on its own SOA schedule, so a day without a successful transfer is the signal:
dns_localroot_copy_age_seconds > 86400
Alerts worth having
| Condition | Why |
|---|---|
rate(dns_resolver_dnssec_failures_total[10m]) > 0 |
Invisible to clients; they only see SERVFAIL |
rate(dns_recovery_panics_total[10m]) > 0 |
Should never fire |
rate(dns_forwarder_response_mismatch_total[10m]) > 0 |
Poisoning signal |
dns_localroot_copy_age_seconds > 86400 |
Refreshes failing; falls back silently |
rate(dns_cache_evictions_total[10m]) > 0 and dns_cache_hit_rate < 80 |
cachesize below the working set |
rate(dns_udp_ingress_overflow_total[5m]) sustained |
Capacity, not correctness |