Documentation
¶
Overview ¶
Package metrics aggregates what a DNS engine did, so that an operator can answer "is it healthy?" without reading a log.
It has no dependencies outside the Go standard library and this module. There is deliberately no Prometheus client here, no OpenTelemetry, and no pluggable exporter framework: an embeddable engine must not force a metrics stack on the program embedding it. What the package exposes instead is Metrics.Snapshot, a plain, immutable, JSON-taggable value. Writing a Prometheus collector, an OTel meter or a /stats handler on top of a Snapshot is a few dozen lines, and those lines belong in whichever optional module wants that dependency.
Metrics are not events ¶
This package answers "how many, how fast, how often". The events package answers "what just happened, to whom, and when". A metric is an aggregate with bounded memory and bounded cost; an event is an individual occurrence with a subscriber, a timestamp and detail. Blocking one advert is an event; the fact that this device has been blocked 4,102 times today is a metric. Recording an event per query is a design choice a deployment makes; recording a metric per query is not optional, so everything here is built for the per-query path and the events package is not.
One consequence is that this package never retains a caller's error, name or identifier beyond what it must. An UpstreamRecord carries an error so that the recorder can classify it; the error itself is never stored, because a counter that pins a heap object per failure is a leak.
Domain-specific, but struct-shaped ¶
Recorder has three methods — Recorder.Query, Recorder.Upstream, Recorder.Cache — one per thing the engine actually does. There is no generic Counter(name string, labels map[string]string).
That generic shape is the industry default and it is the wrong trade at ten thousand queries a second. It costs a map allocation and several string hashes per query; it turns a typo into a silent metric that never appears; and it tells a reader nothing about which metrics exist. The domain-specific form costs no allocation, cannot be misspelled, and is self-documenting.
The price is that the interface names the dimensions, so a new dimension would be a breaking change to every implementation. That is why the methods take structs rather than parameter lists: adding a field to QueryRecord is additive, existing callers keep compiling, and existing recorders keep working — they simply ignore what they do not read. Any future dimension goes in a struct field, never in a new method or a new parameter.
Cardinality is a production hazard ¶
Device identifiers and provider names are attacker-influenced in the general case: a device identifier is usually derived from a client address, and a resolver is reachable by whoever can send it a packet. An unbounded map[device]counters is therefore not a feature, it is a memory-exhaustion bug with a dashboard.
Metrics caps the tracked sets at Options.MaxDevices and Options.MaxProviders. Once a cap is reached, every further distinct key is folded into a single bucket named OverflowKey, and the key itself is discarded rather than stored — so no amount of hostile traffic, and no length of hostile identifier, grows the map further. Snapshot.Cardinality reports how many records were folded and roughly how many distinct keys they came from, so that an operator can tell "we track everything" from "we track the first thousand and lump the rest together". Read those numbers before trusting a per-device breakdown.
Query types are bounded the same way, but by construction rather than by policy: assigned QTYPEs occupy a small numeric range, so they are counted in a fixed array and everything else lands in a single "other" bucket. A flood of QTYPE 51966 cannot grow anything.
Cost ¶
Metrics is built for the query path. Counters are atomics, and everything map-based is sharded across at least runtime.NumCPU shards, rounded up to a power of two, so that concurrent recorders rarely contend on the same cache line. Recorder.Query performs no allocation.
Measured on an eight-core Apple M1: Metrics.Query costs about 55ns and zero allocations with a device and a provider attributed, 23ns with neither, and 57ns once the device is past the cardinality cap — folding must not be the expensive path, or the cap becomes its own denial of service. Under b.RunParallel across all eight cores it costs about 111ns per operation wall-clock, which is roughly nine million queries a second recorded, against a design target of ten thousand. Metrics.Snapshot is the only expensive operation, at roughly 110µs and 280KiB for a thousand tracked devices; it is a scrape-path cost, not a query-path one. Re-run the package benchmarks on the target hardware rather than trusting these figures.
Nop is for callers who want no metrics at all. It is an empty struct with empty methods: no allocation, no atomic, nothing to turn off.
Approximation ¶
Two numbers here are estimates and are labelled as such wherever they appear. HistogramSnapshot.Quantile interpolates within a fixed bucket, so its error is bounded by that bucket's width and never smaller. The folded-key estimate in LimitStat comes from a fixed-size probabilistic sketch. Everything else — every counter, every sum, every bucket — is exact.
Index ¶
- Constants
- func DefaultBuckets() []float64
- type Bucket
- type CacheOp
- type CacheRecord
- type CacheStat
- type CardinalityStat
- type DeviceStat
- type Histogram
- type HistogramSnapshot
- type LimitStat
- type Metrics
- type Options
- type Outcome
- type ProviderStat
- type QueryRecord
- type Recorder
- type Snapshot
- type Source
- type TransportStat
- type UpstreamRecord
- type UpstreamStat
Constants ¶
const OverflowKey = "<overflow>"
OverflowKey is the name under which every device, provider or transport beyond its cardinality cap is aggregated.
It is deliberately not a legal DNS name, a legal IP address or a plausible provider name, so that it can never collide with a real key and can be recognised on sight in a dashboard. See Options for what causes a key to be folded into it.
Variables ¶
This section is empty.
Functions ¶
func DefaultBuckets ¶
func DefaultBuckets() []float64
DefaultBuckets returns the default latency bucket boundaries in seconds, ascending, as a fresh slice the caller may modify.
Use it as the starting point for a custom set — appending a boundary is far more often right than replacing the lot — and remember that changing boundaries makes the new histogram incomparable with previously exported data.
Types ¶
type Bucket ¶
type Bucket struct {
// LE is the inclusive upper bound in seconds, following the Prometheus
// convention so that a bridge can emit it unchanged.
LE float64 `json:"le"`
// Inf marks the final, unbounded bucket. It exists because JSON cannot
// represent an infinity: encoding/json refuses to marshal one, and a
// snapshot that cannot be serialised would defeat the point of the package.
Inf bool `json:"inf,omitempty"`
// Count is the number of observations at or below LE, including every
// lower bucket.
Count uint64 `json:"count"`
}
Bucket is one cumulative histogram bucket, counting every observation less than or equal to LE.
type CacheOp ¶
type CacheOp uint8
CacheOp names a cache operation. The set is closed on purpose: these are the transitions a cache entry can make, and a cache that needs a sixth one has changed shape enough to warrant a new field here.
const ( // CacheOpHit is a lookup that found a usable entry, reported for lookups no // single query is responsible for, such as a prefetch check. CacheOpHit CacheOp = iota // CacheOpMiss is a lookup that found nothing usable. CacheOpMiss // CacheOpInsert is an entry being stored. CacheOpInsert // CacheOpEvict is an entry removed to stay within a size limit. Eviction // rising while the hit rate falls is the signal that the cache is too small. CacheOpEvict // CacheOpExpire is an entry removed because its TTL ran out, which is the // cache working correctly rather than a pressure signal. CacheOpExpire // CacheOpStaleServe is an expired entry served under RFC 8767. CacheOpStaleServe )
Cache operations.
type CacheRecord ¶
type CacheRecord struct {
// Op is what happened.
Op CacheOp
// Entries is the number of entries the cache holds after the operation, or
// zero if unknown.
Entries int
// Bytes is the cache's approximate memory footprint after the operation, or
// zero if unknown.
Bytes int
}
CacheRecord describes one cache operation.
Entries and Bytes are gauges, not deltas: they describe the cache as a whole immediately after the operation. Reporting them here rather than through a separate registration means the cache never has to be reachable from the metrics package, which keeps the dependency arrow pointing one way.
type CacheStat ¶
type CacheStat struct {
// Ops is every operation counter indexed by [CacheOp], for a caller that
// would rather range than name fields.
Ops [numCacheOps]uint64 `json:"ops"`
Hits uint64 `json:"hits"`
Misses uint64 `json:"misses"`
Inserts uint64 `json:"inserts"`
Evictions uint64 `json:"evictions"`
Expirations uint64 `json:"expirations"`
StaleServes uint64 `json:"stale_serves"`
// HitRatio is Hits/(Hits+Misses), or zero when neither has happened.
HitRatio float64 `json:"hit_ratio"`
// Entries and Bytes are gauges describing the cache after the most recent
// operation that reported them, not totals.
Entries int64 `json:"entries"`
Bytes int64 `json:"bytes"`
// Invalid counts records with an undefined [CacheOp].
Invalid uint64 `json:"invalid"`
}
CacheStat aggregates cache operations reported through Recorder.Cache.
Queries answered from cache are NOT counted here; they appear as Sources["cache"] and Sources["stale"] in the Snapshot, because counting them in both places would double-count them. HitRatio therefore describes only the operations the cache reported directly, which is the prefetch and maintenance path.
type CardinalityStat ¶
type CardinalityStat struct {
Devices LimitStat `json:"devices"`
Providers LimitStat `json:"providers"`
Transports LimitStat `json:"transports"`
// OverflowKey repeats [OverflowKey] so that a consumer parsing the JSON
// does not have to hard-code it.
OverflowKey string `json:"overflow_key"`
}
CardinalityStat says how much of the per-key detail survived the caps. An operator should read it before drawing conclusions from a per-device or per-provider breakdown.
type DeviceStat ¶
type DeviceStat struct {
Queries uint64 `json:"queries"`
Blocked uint64 `json:"blocked"`
Cached uint64 `json:"cached"`
Errors uint64 `json:"errors"`
ResponseBytes uint64 `json:"response_bytes"`
}
DeviceStat is per-device accounting. Blocked, Cached and Errors are subsets of Queries and overlap only where an outcome and a source coincide.
type Histogram ¶
type Histogram struct {
// contains filtered or unexported fields
}
Histogram is a fixed-bucket latency histogram with cumulative, Prometheus-shaped buckets.
Fixed buckets are the right structure for the query path because they make Histogram.Observe a bounded, allocation-free, lock-free operation: find the bucket by a short linear scan, then add. There is no reservoir to sample into and no sorted window to maintain, so cost does not depend on how much has already been observed. The price is paid at read time, in precision: see HistogramSnapshot.Quantile.
A Histogram is safe for concurrent Observe from any number of goroutines. It must be created by NewHistogram; the zero value has no buckets.
func NewHistogram ¶
NewHistogram returns a Histogram with the given boundaries, in seconds, interpreted as inclusive upper bounds.
Boundaries are copied, sorted and de-duplicated, and non-finite or negative values are dropped, so a caller cannot corrupt a histogram by passing a hand-written slice or by mutating it afterwards. Passing nil or an empty slice yields DefaultBuckets.
func (*Histogram) Observe ¶
Observe records one duration.
Negative durations are clamped to zero: a negative latency means the caller subtracted two clocks in the wrong order, and recording it would poison Min forever. Observe allocates nothing and takes no lock.
func (*Histogram) Quantile ¶
Quantile is shorthand for taking a snapshot and asking it. Prefer Histogram.Snapshot when asking for more than one quantile, so that every answer comes from the same set of observations.
func (*Histogram) Reset ¶
func (h *Histogram) Reset()
Reset zeroes the histogram. It exists for tests and for an operator zeroing counters; it is not a way to implement a sliding window, because observations concurrent with the reset may land on either side of it.
func (*Histogram) Snapshot ¶
func (h *Histogram) Snapshot() HistogramSnapshot
Snapshot returns an immutable view of the histogram.
The buckets it returns are cumulative and internally consistent: Count is the final cumulative value, so the buckets always add up to it. Under concurrent observation the individual fields are read at slightly different instants, so Sum, Min and Max may correspond to an observation not yet visible in the buckets. The skew is bounded by one in-flight Observe and never goes backwards.
type HistogramSnapshot ¶
type HistogramSnapshot struct {
// Buckets are cumulative and ascending; the last is the +Inf bucket.
Buckets []Bucket `json:"buckets"`
// Count is the total number of observations.
Count uint64 `json:"count"`
// Sum is the total of every observation. Sum/Count is the mean, and unlike
// a quantile it is exact.
Sum time.Duration `json:"sum_ns"`
// Min and Max are exact, which is precisely what a bucketed quantile is
// not. They are zero when Count is zero.
Min time.Duration `json:"min_ns"`
Max time.Duration `json:"max_ns"`
}
HistogramSnapshot is an immutable view of a Histogram, safe to serialise, hand to another goroutine, or keep for comparison against a later one.
func (HistogramSnapshot) Quantile ¶
func (s HistogramSnapshot) Quantile(q float64) time.Duration
Quantile returns an approximation of the q-quantile, q in [0,1].
The result is an ESTIMATE and cannot be otherwise: the histogram knows only how many observations fell in each bucket, not where in the bucket they fell. The value is interpolated linearly between the bucket's bounds, so the error is bounded by the width of the bucket the answer lands in and by nothing smaller. With DefaultBuckets that means a p50 near 300µs is accurate to roughly ±250µs, while a p99 near 3s is accurate to roughly ±2.5s. Do not quote it as a precise figure, do not alert on small changes in it, and use [Min], [Max] and Sum/Count when an exact number is needed.
Two edges are handled specially. An empty histogram returns 0. A quantile landing in the +Inf bucket has no upper bound to interpolate towards and returns Max, which is exact rather than approximate. Every result is clamped to [Min, Max], so a histogram with a single observation reports that observation exactly at every quantile.
type LimitStat ¶
type LimitStat struct {
// Limit is the cap. Zero means the dimension is not tracked per key at all.
Limit int `json:"limit"`
// Tracked is how many keys have their own entry.
Tracked int `json:"tracked"`
// FoldedRecords is how many records were attributed to [OverflowKey]
// because their key did not fit. It is exact.
FoldedRecords uint64 `json:"folded_records"`
// FoldedKeys ESTIMATES how many distinct keys those records came from.
// Folded keys are not stored — that is the point of folding them — so the
// count comes from a fixed-size probabilistic sketch: accurate to a few per
// cent in the thousands, and saturating around thirty thousand per shard,
// beyond which it reads "a great many" rather than a number. Treat it as an
// order of magnitude, which is all an operator needs to distinguish "a few
// extra devices" from "someone is spraying identifiers at us".
FoldedKeys uint64 `json:"folded_keys_estimate"`
}
LimitStat describes one capped dimension.
type Metrics ¶
type Metrics struct {
// contains filtered or unexported fields
}
Metrics is the in-memory Recorder the engine uses in production.
Cost and contention ¶
At ten thousand queries a second a single mutex around a counter map is the bottleneck, and a single atomic counter is not much better once several cores share its cache line. So there is no central map and no central counter: state is split across a power-of-two number of shards, at least runtime.NumCPU of them, and every record picks its shard by hashing the key it is about. Counters within a shard are atomics, per-key state lives behind that shard's own RWMutex, and Metrics.Snapshot does the summing. Recording a query allocates nothing.
A key always hashes to the same shard, so per-key state exists in exactly one place and needs no merging; only the counters are summed across shards.
Cardinality, and what the numbers mean ¶
An operator reading a per-device breakdown must know whether it is complete. Once Options.MaxDevices distinct devices are tracked, every further distinct device is folded into OverflowKey and its identifier is discarded rather than stored — so hostile traffic, however varied and however long its identifiers, cannot grow this structure. The same applies to providers and, with a fixed internal cap, to transports.
Snapshot.Cardinality reports the cap, how many keys are tracked, how many records were folded, and an estimate of how many distinct keys those records came from. When the folded counts are zero the breakdown is complete; when they are not, OverflowKey is everything else and the individual entries are merely the first keys seen, not the busiest.
A Metrics is safe for concurrent use by any number of goroutines. It must be created by New.
func (*Metrics) Cache ¶
func (m *Metrics) Cache(r CacheRecord)
Cache implements Recorder. Entries and Bytes are gauges: the most recently reported non-negative value wins, and a zero is taken to mean "not reported" rather than "the cache is empty", because most callers will not bother to fill them in on every operation.
func (*Metrics) Query ¶
func (m *Metrics) Query(r QueryRecord)
Query implements Recorder.
It allocates nothing, takes no lock in the common case — a known device and a known provider are read under a shared lock — and reads no clock.
func (*Metrics) Reset ¶
func (m *Metrics) Reset()
Reset zeroes every counter and forgets every tracked key, and restarts the uptime clock.
It is for tests and for an operator deliberately zeroing counters. It is not a way to build a sliding window: records concurrent with a Reset may land on either side of it, and no ordering between them is defined.
func (*Metrics) Snapshot ¶
Snapshot returns an immutable view of every counter.
This is what an engine's GetStats and a REST handler return, so every field carries a JSON tag and no field holds a pointer into live state. Maps omit zero-valued entries, so a snapshot of an idle server is small rather than a wall of zeroes.
The snapshot is consistent in the sense that matters operationally: it is composed of atomic reads, no counter is read twice, and no value can move backwards between successive snapshots. It is not a stop-the-world instant. A query recorded while the snapshot is being taken may be visible in the totals but not in the per-device breakdown, so the sub-totals can trail the totals by the number of records in flight, and never by more.
func (*Metrics) Upstream ¶
func (m *Metrics) Upstream(r UpstreamRecord)
Upstream implements Recorder. It classifies r.Err and does not retain it.
type Options ¶
type Options struct {
// Clock supplies the time for uptime and for [Snapshot.Time]. Nil means the
// system clock. Note that nothing on the query path reads it: the query
// path never calls Now at all, because the caller already measured the
// duration it passes in.
Clock clock.Clock
// Buckets are the latency histogram boundaries in seconds. Nil means
// [DefaultBuckets]. See [NewHistogram] for how the slice is sanitised.
Buckets []float64
// MaxDevices caps how many distinct [QueryRecord.Device] values are tracked
// individually. Zero means the default; a negative value disables
// per-device tracking entirely, folding every device into [OverflowKey],
// which is the right setting for a privacy-sensitive deployment.
//
// The cap exists because device identifiers are attacker-influenced: they
// are usually derived from a client address, and anyone who can send a
// packet can invent a new one. Without a cap, the per-device map is a
// memory-exhaustion vector that looks like a feature.
MaxDevices int
// MaxProviders caps distinct provider names the same way. Providers are
// normally configuration-derived and few, so the cap is a backstop against
// a caller that derives the name from a response instead.
MaxProviders int
}
Options configures New. The zero value is usable and gives a recorder with the default buckets, the default caps and the system clock.
type Outcome ¶
type Outcome uint8
Outcome is what the client got, in the terms an operator thinks in.
It is deliberately not the RCODE. A blocked name and a genuinely non-existent name both leave as NXDOMAIN on the wire (RFC 8020 makes that the honest answer for a name a filtering resolver refuses to resolve), and a dashboard that cannot tell the two apart is useless for the one question a filtering resolver exists to answer. Both are recorded: QueryRecord carries the RCODE too.
const ( // OutcomeAnswered means the client received real data. OutcomeAnswered Outcome = iota // OutcomeBlocked means policy suppressed the answer, whatever RCODE or // synthetic address was used to express that. OutcomeBlocked // OutcomeNXDomain means the name genuinely does not exist. OutcomeNXDomain // OutcomeServFail means resolution failed in a way the protocol has a name // for: no reachable upstream, a broken delegation, a DNSSEC failure. OutcomeServFail // OutcomeRefused means the server declined to answer at all, typically // because the client is not permitted to ask. OutcomeRefused // OutcomeTimeout means nothing answered in time. It is separated from // OutcomeServFail because it points at the network rather than at the data. OutcomeTimeout // OutcomeError means the engine itself failed: a malformed message, an // internal error, a bug. It should be rare, and an operator should page on // it rising. OutcomeError )
Query outcomes.
The zero value is OutcomeAnswered because it is overwhelmingly the common case; a QueryRecord assembled without setting Outcome therefore counts as answered, so set it explicitly on every path that is not one.
func Outcomes ¶
func Outcomes() []Outcome
Outcomes returns every defined Outcome, ascending, as a fresh slice.
It exists for the case this package's interface is designed around: an exporter bridging a Snapshot to Prometheus or a REST payload has to enumerate the dimensions to emit a zero for the ones that have not occurred, and without this it would have to hardcode the list or parse String output. Both go stale the moment an outcome is added.
type ProviderStat ¶
type ProviderStat struct {
Queries uint64 `json:"queries"`
Attempts uint64 `json:"attempts"`
Errors uint64 `json:"errors"`
Timeouts uint64 `json:"timeouts"`
Latency HistogramSnapshot `json:"latency"`
}
ProviderStat is per-provider accounting. Queries counts answers attributed to the provider, Attempts counts individual tries, and Attempts exceeding Queries is normal — it is what retries look like.
type QueryRecord ¶
type QueryRecord struct {
// Device identifies the client for per-device accounting. Empty means "not
// attributed", and skips per-device work entirely, which is the right thing
// for a deployment that does not care or must not know. Bear the cardinality
// cap in mind when choosing what to put here; see [Options].
Device string
// Provider names the upstream that ultimately supplied the answer, empty
// when nothing upstream was asked.
Provider string
// QType is the question's type. It is counted in a fixed array, so a flood
// of unassigned types costs nothing.
QType dnsmsg.Type
// RCode is the response code actually sent to the client, recorded
// alongside Outcome because the two answer different questions.
RCode dnsmsg.RCode
// Outcome is what the client effectively got.
Outcome Outcome
// Source is what produced the answer.
Source Source
// Duration is the whole client-visible latency, not just the upstream part.
Duration time.Duration
// ResponseBytes is the size of the message written to the client, which is
// what a bandwidth graph and an amplification-ratio alert are built from.
ResponseBytes int
}
QueryRecord describes one query from arrival to answer.
It is a struct rather than a parameter list so that a later dimension — a protocol, a client subnet, a policy rule identifier — can be added without breaking every Recorder in existence. Pass it by value: it is a handful of words and copying it costs less than the pointer indirection would.
type Recorder ¶
type Recorder interface {
// Query records one resolved query, start to finish, whatever answered it.
Query(QueryRecord)
// Upstream records one attempt against one upstream provider. A query that
// retries or fans out produces several of these and one Query.
Upstream(UpstreamRecord)
// Cache records a cache operation that no single query is responsible for,
// such as an eviction, an expiry or a background refresh. A query answered
// from cache is reported by [Recorder.Query] with [SourceCache]; reporting
// it here as well would double-count it.
Cache(CacheRecord)
}
Recorder is what the engine holds a reference to. Every component that measures something takes one, and a deployment that measures nothing takes Nop.
The three methods are the three things worth counting separately, because they fail separately: a query is one client-visible answer, an upstream is one attempt against one provider (a single query may make several), and a cache operation is bookkeeping that no query asked for. Rolling them into one method would make it impossible to say "the cache is fine and the upstream is not", which is the first question an operator asks.
Implementations must be safe for concurrent use and must not block: they are called from the query path, and a recorder that takes a lock a resolver waits on has turned observability into an outage.
func Nop ¶
func Nop() Recorder
Nop returns a Recorder that discards everything.
It exists so that "metrics are optional" never becomes a nil check on the query path: a component takes a Recorder and always has one. The returned value is an empty struct with empty method bodies, so a call compiles down to nothing measurable and allocates nothing — proven by BenchmarkNop.
type Snapshot ¶
type Snapshot struct {
// Time is when the snapshot was taken, from the configured clock.
Time time.Time `json:"time"`
// Uptime is how long the recorder has been counting, measured from
// construction or from the last [Metrics.Reset].
Uptime time.Duration `json:"uptime_ns"`
// Queries is every query recorded, whatever answered it.
Queries uint64 `json:"queries"`
// ResponseBytes is the total size of responses written to clients.
ResponseBytes uint64 `json:"response_bytes"`
// InvalidRecords counts records whose Outcome or Source was outside the
// defined set. Anything above zero is a caller bug.
InvalidRecords uint64 `json:"invalid_records"`
// Outcomes, Sources, QTypes and RCodes are keyed by mnemonic and omit
// zeroes. QTypes and RCodes use "<other>" for values outside the range
// counted individually.
Outcomes map[string]uint64 `json:"outcomes"`
Sources map[string]uint64 `json:"sources"`
QTypes map[string]uint64 `json:"qtypes"`
RCodes map[string]uint64 `json:"rcodes"`
// Devices and Providers are the capped dimensions. Read
// [Snapshot.Cardinality] before trusting them to be complete; an
// [OverflowKey] entry means they are not.
Devices map[string]DeviceStat `json:"devices"`
Providers map[string]ProviderStat `json:"providers"`
// Transports is keyed by "udp", "tcp", "dot", "doh", "doq" or whatever the
// caller passed.
Transports map[string]TransportStat `json:"transports"`
Cache CacheStat `json:"cache"`
Upstream UpstreamStat `json:"upstream"`
// QueryLatency is client-visible latency: what a user experiences.
QueryLatency HistogramSnapshot `json:"query_latency"`
// UpstreamLatency covers single attempts against providers, so a query that
// retried contributes several observations here and one there.
UpstreamLatency HistogramSnapshot `json:"upstream_latency"`
Cardinality CardinalityStat `json:"cardinality"`
}
Snapshot is a complete, immutable, serialisable view of a Metrics.
Nothing in it aliases live state, so it can be held, compared against a later snapshot, or marshalled at leisure. Durations marshal as nanoseconds, which is what their _ns suffixes say; a bridge to a system that wants seconds converts once at the boundary.
type Source ¶
type Source uint8
Source is where the answer came from, which is the other half of the story Outcome tells. "Answered" is good news at ten microseconds from cache and bad news at two seconds from upstream.
const ( // SourceCache means a fresh cache entry answered it. SourceCache Source = iota // SourceUpstream means a provider was asked. SourceUpstream // SourcePolicy means the answer was synthesised by a rule: a block, a // rewrite, a redirect. SourcePolicy // SourceLocal means local data answered it: a hosts entry, a local zone, // or the engine answering for itself. SourceLocal // SourceStale means an expired entry was served because nothing better was // available, per RFC 8767. Counting it separately matters: a rising stale // rate means upstream is failing while clients still see answers. SourceStale )
Answer sources.
type TransportStat ¶
type TransportStat struct {
Attempts uint64 `json:"attempts"`
Errors uint64 `json:"errors"`
Timeouts uint64 `json:"timeouts"`
}
TransportStat is per-transport accounting.
type UpstreamRecord ¶
type UpstreamRecord struct {
// Provider names the upstream, subject to the same cardinality cap as
// [QueryRecord.Device].
Provider string
// Transport is how it was reached: "udp", "tcp", "dot", "doh", "doq". It is
// configuration-derived rather than client-derived, so it is tracked with a
// small fixed cap rather than the configurable one.
Transport string
// Duration is the time this attempt took, successful or not. A timed-out
// attempt records the time spent waiting, which is what makes a timeout
// visible in the latency histogram rather than invisible.
Duration time.Duration
// Err is the failure, or nil. The recorder classifies it and does not
// retain it: a counter must not keep a heap object alive per failure. Send
// the detail to the events package or a log.
Err error
// Timeout distinguishes "no answer arrived" from "an answer arrived and it
// was bad", because they have different causes and different fixes. Set it
// alongside Err rather than instead of it.
Timeout bool
}
UpstreamRecord describes one attempt against one provider.
One query may produce several of these — a retry, a fan-out to two resolvers, a fallback from DoH to UDP — which is exactly why upstream attempts are counted separately from queries. Attempts per query is itself a health signal.
type UpstreamStat ¶
type UpstreamStat struct {
Attempts uint64 `json:"attempts"`
// Errors counts attempts that failed for any reason, timeouts included, so
// Timeouts is a subset of it.
Errors uint64 `json:"errors"`
Timeouts uint64 `json:"timeouts"`
// ErrorRatio is Errors/Attempts, or zero when nothing has been attempted.
ErrorRatio float64 `json:"error_ratio"`
}
UpstreamStat aggregates every attempt against every provider.