//! `GET /metrics` — Prometheus text format 0.0.4 (ruling 21). //! //! Two halves, so that neither needs the other to be testable: `collect` walks //! the live collaborators and copies every number into a `Sample`, and `render` //! turns a `Sample` into text. Nothing is computed during rendering, with one //! exception: an upstream url is redacted where its label is written rather //! than where it is copied. `writeUrlLabel` carries the reasoning. //! //! Three rules the collection half obeys: //! //! - The cache and the DNS rate limiter are the query path's, so their numbers //! are read under the handler's own mutexes. Both sections are a struct copy //! long. `lockUncancelable` because a handler carries no `Canceled`. //! - Every borrowed string is copied on the spot. `Pool.Snapshot.last_error` //! and `url` point into entries a concurrent failure may rewrite. //! - A collaborator that is not wired omits its whole metric family rather than //! reporting zeros. An absent series is a gap a dashboard can see; a zero is //! a lie that looks like health. //! //! No timestamps: Prometheus stamps a scrape with its own clock, and the //! optional per-sample timestamp is for federation, which nxdns does not do. const std = @import("std"); const Allocator = std.mem.Allocator; const cert_store = @import("../server/cert_store.zig"); const client_names = @import("../server/client_names.zig"); const clients = @import("../server/clients.zig"); const dns_cache = @import("../cache/dns_cache.zig"); const dns_handler = @import("../server/handler.zig"); const disk_monitor = @import("../storage/disk_monitor.zig"); const dot_server = @import("../server/dot_server.zig"); const http_util = @import("http_util.zig"); const logging = @import("../platform/logging.zig"); const pool_mod = @import("../upstream/pool.zig"); const rate_limiter = @import("../server/rate_limiter.zig"); const retention_mod = @import("../storage/retention.zig"); const safe_url = @import("../safe_url.zig"); const server = @import("server.zig"); const tcp_server = @import("../server/tcp_server.zig"); const udp_server = @import("../server/udp_server.zig"); /// The exposition format version, as the 0.0.4 specification writes it. pub const content_type = "text/plain; version=0.0.4; charset=utf-8"; /// Upstreams copied per scrape. `validate.zig` bounds a configuration far below /// this; a pool larger than the buffer is truncated rather than allocated for, /// because a scrape must not depend on the heap. pub const max_upstreams = 64; const dns_stat_fields = @typeInfo(dns_handler.Handler.Stats).@"struct".fields; /// The DNS pipeline counters, in `Handler.Stats` field order. Held as an array /// so that a new counter in the handler appears here, and in the exposition, /// without an edit. pub const DnsCounters = [dns_stat_fields.len]u64; pub const LoggerCounters = struct { queries_dropped: u64 = 0, rows_written: u64 = 0, batches_gated: u64 = 0, }; pub const CacheSample = struct { stats: dns_cache.Stats, entries: u64, memory_bytes: u64, }; pub const LimiterSample = struct { stats: rate_limiter.Stats, tracked_clients: u64, }; pub const TrackerSample = struct { stats: clients.Tracker.Stats, pending_clients: u64, }; pub const BlocklistSample = struct { refreshes_gated: u64, /// Null before the first snapshot is published. generation: ?u64, }; pub const DiskSample = struct { gauges: disk_monitor.Gauges, sample_failures: u64, }; /// The DoH listener counters this exposition exports (milestone-10 ruling 10): /// the four every TLS listener keeps, plus DoH's `bad_requests`. A subset of /// `doh_server.Snapshot` on purpose — the accept-side refusal counters stay /// internal, exactly as they do for the DoT listener and TCP/53. pub const DohListenerSample = struct { connections: u64, tls_handshake_failures: u64, idle_timeouts: u64, connection_errors: u64, bad_requests: u64, }; /// One upstream, with every string owned by the caller's arena. pub const UpstreamSample = struct { /// The configured url, whole. It reaches the exposition only through /// `writeUrlLabel`, which redacts it; a reader of this field is reading a /// credential. url: []const u8, enabled: bool, available: bool, consecutive_failures: u64, total_successes: u64, total_failures: u64, success_rate: f32, }; /// Everything one scrape reports. A null section is a collaborator the state /// does not have. pub const Sample = struct { dns: DnsCounters = @splat(0), logger: LoggerCounters = .{}, log_sink: logging.Stats = .{}, cache: ?CacheSample = null, limiter: ?LimiterSample = null, tracker: ?TrackerSample = null, client_names: ?client_names.Resolver.Stats = null, retention: ?retention_mod.Stats = null, blocklist: ?BlocklistSample = null, disk: ?DiskSample = null, /// One entry per enabled TLS endpoint (milestone-10 ruling 10). Rendered /// under an `endpoint` label so both share the two `nxdns_cert_*` /// families. `last_reload_unix` is deliberately not exported: the reload /// endpoint reports cert state on demand. doh_certs: ?cert_store.CertStore.Stats = null, dot_certs: ?cert_store.CertStore.Stats = null, /// The listener families (ruling 10): absent while an endpoint is /// disabled or its bind failed, like every other unwired collaborator. doh_listener: ?DohListenerSample = null, dot_listener: ?dot_server.StatsSnapshot = null, /// The plain-DNS listener families (ruling 13). The app binds one listener /// per address family, and both answer the same port for the same reason, /// so their counters are summed into one family rather than labelled: an /// operator asks how much UDP/53 dropped, not how much of it arrived over /// IPv6. Absent when no listener is wired, like every other collaborator. udp_listener: ?udp_server.Snapshot = null, tcp_listener: ?tcp_server.Snapshot = null, upstreams: []const UpstreamSample = &.{}, }; pub fn handle( state: *server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { const sample = try collect(state, io, request.arena); var allocating: std.Io.Writer.Allocating = .init(request.arena); defer allocating.deinit(); render(&allocating.writer, sample) catch return error.OutOfMemory; return http_util.respondBytes(request, .ok, allocating.written(), content_type, &.{}); } pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.Error!Sample { var sample: Sample = .{ .log_sink = logging.stats() }; if (state.handler) |handler| { sample.dns = dnsCounters(&handler.stats); if (handler.cache) |cache| { handler.cache_mutex.lockUncancelable(io); defer handler.cache_mutex.unlock(io); sample.cache = .{ .stats = cache.stats, .entries = cache.len(), .memory_bytes = cache.memoryBytes(), }; } if (handler.limiter) |limiter| { handler.limiter_mutex.lockUncancelable(io); defer handler.limiter_mutex.unlock(io); sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() }; } } if (state.logger) |logger| sample.logger = .{ .queries_dropped = logger.queries_dropped.load(.monotonic), .rows_written = logger.rows_written.load(.monotonic), .batches_gated = logger.batches_gated.load(.monotonic), }; if (state.tracker) |tracker| sample.tracker = .{ .stats = tracker.snapshotStats(io), .pending_clients = tracker.pendingClients(io), }; if (state.client_names) |names| sample.client_names = names.snapshotStats(io); if (state.retention) |retention| sample.retention = retention.snapshotStats(); if (state.manager) |manager| { const generation: ?u64 = if (manager.acquire(io)) |acquired| gen: { defer acquired.release(io); break :gen acquired.snapshot.generation; } else null; sample.blocklist = .{ .refreshes_gated = manager.refreshesGated(), .generation = generation }; } if (state.monitor) |monitor| sample.disk = .{ .gauges = monitor.gauges(), .sample_failures = monitor.sample_failures.load(.monotonic), }; if (state.doh_certs) |store| sample.doh_certs = store.snapshotStats(); if (state.dot_certs) |store| sample.dot_certs = store.snapshotStats(); if (state.doh_listener) |listener| { const snapshot = listener.snapshotStats(); sample.doh_listener = .{ .connections = snapshot.connections, .tls_handshake_failures = snapshot.tls_handshake_failures, .idle_timeouts = snapshot.idle_timeouts, .connection_errors = snapshot.connection_errors, .bad_requests = snapshot.bad_requests, }; } if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats(); sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners); sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners); if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena); return sample; } /// Adds one snapshot per listener field by field. Null for an empty slice, so /// an unbound listener omits its family rather than reporting zeros. /// /// A `u64` counter cannot realistically overflow the sum of four of them, and /// wrapping addition would be a worse answer than a wrong-looking large one, so /// the addition is the ordinary checked one. fn sumListeners(comptime Snapshot: type, comptime Server: type, listeners: []const *Server) ?Snapshot { if (listeners.len == 0) return null; var total: Snapshot = undefined; inline for (@typeInfo(Snapshot).@"struct".fields) |field| { @field(total, field.name) = 0; } for (listeners) |listener| { const one = listener.snapshotStats(); inline for (@typeInfo(Snapshot).@"struct".fields) |field| { @field(total, field.name) += @field(one, field.name); } } return total; } fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters { var out: DnsCounters = undefined; inline for (dns_stat_fields, 0..) |field, i| { out[i] = @field(stats, field.name).load(.monotonic); } return out; } /// Copies pool health into `out` and returns the count. /// /// `Pool.snapshot` takes the pool's mutex and copies structs, so it blocks only /// on other snapshots. Cancellation is held off for the length of that copy: /// the alternative is a report of no upstreams at all because the connection /// happened to be closing, which reads as an outage. Shared with the health /// rollup, which needs the same copy under the same reasoning. pub fn poolSnapshot(pool: *pool_mod.Pool, io: std.Io, out: []pool_mod.Snapshot) usize { const prev = io.swapCancelProtection(.blocked); defer _ = io.swapCancelProtection(prev); return pool.snapshot(io, out) catch |err| switch (err) { error.Canceled => unreachable, }; } fn upstreams(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error![]const UpstreamSample { var raw: [max_upstreams]pool_mod.Snapshot = undefined; const count = poolSnapshot(pool, io, &raw); const out = try arena.alloc(UpstreamSample, count); for (raw[0..count], out) |entry, *slot| { slot.* = .{ .url = try arena.dupe(u8, entry.url), .enabled = entry.enabled, .available = entry.available, .consecutive_failures = entry.consecutive_failures, .total_successes = entry.total_successes, .total_failures = entry.total_failures, .success_rate = entry.success_rate, }; } return out; } // --------------------------------------------------------------------------- // rendering // --------------------------------------------------------------------------- pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void { try gauge(w, "nxdns_up", "1 while the nxdns process is answering scrapes.", 1); inline for (dns_stat_fields, sample.dns) |field, value| { try counter( w, "nxdns_dns_" ++ field.name ++ "_total", "DNS pipeline counter: " ++ field.name ++ ".", value, ); } try counterGroup(w, "nxdns_querylog_", "Query log writer counter", sample.logger); try counterGroup(w, "nxdns_log_", "Diagnostic log sink counter", sample.log_sink); if (sample.cache) |cache| { try counterGroup(w, "nxdns_cache_", "DNS cache counter", cache.stats); try gauge(w, "nxdns_cache_entries", "Responses currently held in the DNS cache.", cache.entries); try gauge(w, "nxdns_cache_memory_bytes", "Bytes held by the DNS cache.", cache.memory_bytes); } if (sample.limiter) |limiter| { try counterGroup(w, "nxdns_dns_rate_limit_", "DNS rate limiter counter", limiter.stats); try gauge( w, "nxdns_dns_rate_limit_tracked_clients", "Client addresses the DNS rate limiter is tracking.", limiter.tracked_clients, ); } if (sample.tracker) |tracker| { try counterGroup(w, "nxdns_clients_", "Client tracker counter", tracker.stats); try gauge( w, "nxdns_clients_pending", "Clients seen but not yet written to the database.", tracker.pending_clients, ); } if (sample.client_names) |names| { try counterGroup(w, "nxdns_client_names_", "Learned client name counter", names); } if (sample.retention) |retention| { try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention); } if (sample.blocklist) |blocklist| { try counter( w, "nxdns_blocklist_refreshes_gated_total", "Scheduled blocklist refreshes skipped because the disk was low.", blocklist.refreshes_gated, ); if (blocklist.generation) |generation| { try gauge( w, "nxdns_blocklist_generation", "Generation of the filter snapshot currently answering queries.", generation, ); } } if (sample.disk) |disk| { try gauge(w, "nxdns_disk_free_bytes", "Free bytes on the data filesystem.", disk.gauges.free_bytes); try gauge(w, "nxdns_disk_db_bytes", "Bytes held by the databases.", disk.gauges.db_bytes); try gauge(w, "nxdns_disk_log_bytes", "Bytes held by the log files.", disk.gauges.log_bytes); try counter( w, "nxdns_disk_sample_failures_total", "Disk measurements that failed.", disk.sample_failures, ); } if (sample.udp_listener) |listener| { try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener); } if (sample.tcp_listener) |listener| { try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener); } if (sample.doh_listener) |listener| { try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener); } if (sample.dot_listener) |listener| { try counterGroup(w, "nxdns_dot_server_", "DoT listener counter", listener); } if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample); if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams); } fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void { try labeledHead(w, "nxdns_cert_reloads_total", "Certificate reloads that published a new context.", "counter"); if (sample.doh_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "doh", stats.reloads); if (sample.dot_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "dot", stats.reloads); try labeledHead( w, "nxdns_cert_reload_failures_total", "Certificate reloads that failed; the old certificate keeps serving.", "counter", ); if (sample.doh_certs) |stats| { try endpointValue(w, "nxdns_cert_reload_failures_total", "doh", stats.reload_failures); } if (sample.dot_certs) |stats| { try endpointValue(w, "nxdns_cert_reload_failures_total", "dot", stats.reload_failures); } } /// The endpoint names are ours ("doh"/"dot"), so unlike a url label there is /// nothing to escape. fn endpointValue( w: *std.Io.Writer, name: []const u8, endpoint: []const u8, value: u64, ) std.Io.Writer.Error!void { try w.print("{s}{{endpoint=\"{s}\"}} {d}\n", .{ name, endpoint, value }); } fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void { try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge"); for (list, 0..) |entry, i| { try labeledValue(w, "nxdns_upstream_up", i, entry.url, @intFromBool(entry.available)); } try labeledHead(w, "nxdns_upstream_enabled", "1 while an upstream is enabled by configuration.", "gauge"); for (list, 0..) |entry, i| { try labeledValue(w, "nxdns_upstream_enabled", i, entry.url, @intFromBool(entry.enabled)); } try labeledHead(w, "nxdns_upstream_success_rate", "Share of recent exchanges that succeeded.", "gauge"); for (list, 0..) |entry, i| { try writeUpstreamLabels(w, "nxdns_upstream_success_rate", i, entry.url); try w.print(" {d:.4}\n", .{entry.success_rate}); } try labeledHead( w, "nxdns_upstream_consecutive_failures", "Failures since an upstream last answered.", "gauge", ); for (list, 0..) |entry, i| { try labeledValue(w, "nxdns_upstream_consecutive_failures", i, entry.url, entry.consecutive_failures); } try labeledHead(w, "nxdns_upstream_successes_total", "Exchanges an upstream answered.", "counter"); for (list, 0..) |entry, i| { try labeledValue(w, "nxdns_upstream_successes_total", i, entry.url, entry.total_successes); } try labeledHead(w, "nxdns_upstream_failures_total", "Exchanges an upstream failed.", "counter"); for (list, 0..) |entry, i| { try labeledValue(w, "nxdns_upstream_failures_total", i, entry.url, entry.total_failures); } } /// Every field of a plain counter struct, under one prefix. fn counterGroup( w: *std.Io.Writer, comptime prefix: []const u8, comptime help: []const u8, value: anytype, ) std.Io.Writer.Error!void { inline for (@typeInfo(@TypeOf(value)).@"struct".fields) |field| { try counter(w, prefix ++ field.name ++ "_total", help ++ ": " ++ field.name ++ ".", @field(value, field.name)); } } fn counter(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void { try w.print("# HELP {s} {s}\n# TYPE {s} counter\n{s} {d}\n", .{ name, help, name, name, value }); } fn gauge(w: *std.Io.Writer, name: []const u8, help: []const u8, value: u64) std.Io.Writer.Error!void { try w.print("# HELP {s} {s}\n# TYPE {s} gauge\n{s} {d}\n", .{ name, help, name, name, value }); } fn labeledHead( w: *std.Io.Writer, name: []const u8, help: []const u8, kind: []const u8, ) std.Io.Writer.Error!void { try w.print("# HELP {s} {s}\n# TYPE {s} {s}\n", .{ name, help, name, kind }); } fn labeledValue( w: *std.Io.Writer, name: []const u8, index: usize, url: []const u8, value: u64, ) std.Io.Writer.Error!void { try writeUpstreamLabels(w, name, index, url); try w.print(" {d}\n", .{value}); } /// The label set every upstream family shares, up to and including the closing /// brace. One definition, because six families have to agree on it exactly: /// Prometheus identifies a series by its name and its whole label set, so a /// family that labelled its samples differently would be a different series. /// /// `index` is the upstream's position in the pool, in the priority order `Pool` /// sorts on. It is here because the url alone stopped identifying a series once /// it was redacted: two upstreams on one host — the shape a NextDNS account with /// two profiles takes — both print `https://dns.nextdns.io`, and two samples of /// one name with one label set is a duplicate series a scrape must not contain. /// The position is read from the rendered slice rather than carried in /// `UpstreamSample`, so no caller can build two samples that claim one index. /// /// What the index is not: a durable key, and the difference is an operator's to /// know. `Pool.Snapshot` carries no row id — threading one out of the repository /// through the pool to reach here is a larger change than the defect warrants — /// so the position is all there is. Removing `upstreams[0]` renumbers every /// upstream after it, and one upstream's history then continues under the label /// its neighbour used to carry. /// /// What bounds that: the index is only load-bearing when two upstreams share an /// origin, which is the case it was added for. Where origins differ, `url` /// carries the identity on its own and a reorder moves nothing that a query /// grouping on `url` can see. So group on `url`, and read `index` as the /// disambiguator between upstreams that group would otherwise merge. fn writeUpstreamLabels( w: *std.Io.Writer, name: []const u8, index: usize, url: []const u8, ) std.Io.Writer.Error!void { try w.print("{s}{{index=\"{d}\",url=\"", .{ name, index }); try writeUrlLabel(w, url); try w.writeAll("\"}"); } /// The one place an upstream url becomes exposition text. /// /// `/metrics` is `.auth = .open` in `web/routes.zig` and `web.bind` defaults to /// `0.0.0.0`, so a url in a label is readable by anything on the LAN without a /// session, and a Prometheus that scrapes it keeps that string for as long as it /// keeps the series. A NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`, /// where the path segment is the whole account identifier, so the label prints /// what `safe_url.redact` leaves: the scheme, the host and the port. /// /// The redaction sits here rather than in `collect` because this is where the /// open endpoint writes the value. A `Sample` built anywhere else renders /// through this function too, so the guarantee cannot be one caller away. /// `UpstreamSample.url` stays whole for the same reason it is safe to: nothing /// but this function reads it, and the session-authenticated /// `GET /api/upstream/health` reports the same pool with the same urls whole. /// /// **`redact` output is not safe to interpolate into a label value, and this /// function is the reason it never has to be.** Do not delete the second layer /// on the grounds that the first one escapes. /// /// What `redact` gives: it escapes every control character and bounds its /// output, so it emits neither a raw newline nor a lone backslash. What it does /// not give: it leaves `"` alone. A `"` is legal in the text `redact` keeps, and /// it is the one character that ends a label value — so a host holding one /// closes the label and lets the rest of the string write label pairs of its /// own. That is a defect of this call site, not of `redact`: a `"` needs no /// escape in a log line, which is what `redact` was written for. /// /// So `writeLabelValue` runs over the redacted text rather than instead of it, /// and the order is the whole point. It also doubles a backslash `redact` wrote, /// which is what keeps `\n` in a host from reading as a newline to a parser: the /// four bytes `\x1b` arrive at a scrape as `\\x1b`. /// /// A label value's escapes are not a shell's. `quoteText`, which the log lines /// use, answers a different question — its delimiter is `'` and it writes its /// own quotes — and it is not the tool here. fn writeUrlLabel(w: *std.Io.Writer, url: []const u8) std.Io.Writer.Error!void { // `SafeUrl.format` prints at most `max_len` characters, plus the `...` that // marks a truncation. The buffer is that bound, so the write cannot fail. var buf: [safe_url.max_len + 3]u8 = undefined; var redacted: std.Io.Writer = .fixed(&buf); try redacted.print("{f}", .{safe_url.redact(url)}); try writeLabelValue(w, redacted.buffered()); } /// The three characters the exposition format reserves inside a label value. fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!void { for (value) |byte| switch (byte) { '\\' => try w.writeAll("\\\\"), '"' => try w.writeAll("\\\""), '\n' => try w.writeAll("\\n"), else => try w.writeByte(byte), }; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const local_tables = @import("../server/local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const testing = std.testing; /// A handler with no upstream reachable: every test here reads counters and /// never runs a query. fn testHandler() dns_handler.Handler { return .{ .upstream = .{ .ptr = undefined, .exchangeFn = undefined }, .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake }, }; } fn renderToString(gpa: Allocator, sample: Sample) ![]u8 { var allocating: std.Io.Writer.Allocating = .init(gpa); errdefer allocating.deinit(); try render(&allocating.writer, sample); return allocating.toOwnedSlice(); } test "a full sample renders the whole exposition, byte for byte" { var dns: DnsCounters = @splat(0); dns[0] = 12; dns[1] = 3; const upstream_list = [_]UpstreamSample{ .{ .url = "https://dns.example/dns-query", .enabled = true, .available = true, .consecutive_failures = 0, .total_successes = 9, .total_failures = 1, .success_rate = 0.9, }, }; const sample: Sample = .{ .dns = dns, .logger = .{ .queries_dropped = 1, .rows_written = 40, .batches_gated = 2 }, .log_sink = .{ .lines_written = 5, .lines_deduped = 1, .rotations = 0, .sink_errors = 0 }, .cache = .{ .stats = .{ .hits = 7, .misses = 8, .inserts = 6, .evictions = 1, .expirations = 2, .invalid_hits = 0 }, .entries = 5, .memory_bytes = 4096, }, .limiter = .{ .stats = .{ .allowed = 20, .refused = 2, .untracked = 1 }, .tracked_clients = 3 }, .tracker = .{ .stats = .{ .tracked = 4, .flushed = 3, .dropped_full = 0, .pruned = 1, .flush_failures = 0 }, .pending_clients = 2, }, .client_names = .{ .attempted = 6, .answered = 3, .nxdomain = 1, .no_zone = 2, .invalid = 0, .failed = 0, .read_failures = 0, .write_failures = 1 }, .retention = .{ .passes = 7, .rows_pruned = 100, .checkpoints = 7, .vacuums = 1 }, .blocklist = .{ .refreshes_gated = 2, .generation = 4 }, .disk = .{ .gauges = .{ .free_bytes = 1000, .db_bytes = 200, .log_bytes = 30 }, .sample_failures = 1, }, .upstreams = &upstream_list, }; const text = try renderToString(testing.allocator, sample); defer testing.allocator.free(text); // Every family, in the order `render` writes them. The golden text is the // contract a scrape reads; a counter that changes name changes this test. try testing.expectEqualStrings( \\# HELP nxdns_up 1 while the nxdns process is answering scrapes. \\# TYPE nxdns_up gauge \\nxdns_up 1 \\# HELP nxdns_dns_queries_total DNS pipeline counter: queries. \\# TYPE nxdns_dns_queries_total counter \\nxdns_dns_queries_total 12 \\# HELP nxdns_dns_dropped_malformed_total DNS pipeline counter: dropped_malformed. \\# TYPE nxdns_dns_dropped_malformed_total counter \\nxdns_dns_dropped_malformed_total 3 \\ , text[0..std.mem.indexOf(u8, text, "# HELP nxdns_dns_formerr_total").?]); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_querylog_queries_dropped_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_log_lines_written_total 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_hits_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_entries 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_memory_bytes 4096\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_refused_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_pending 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_no_zone_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_answered_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_write_failures_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_retention_rows_pruned_total 100\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_refreshes_gated_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_free_bytes 1000\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_sample_failures_total 1\n")); // The label set carries the redaction, so the path of the configured url is // already gone from the golden text. try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_up{index=\"0\",url=\"https://dns.example\"} 1\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.example\"} 0.9000\n", )); try testing.expect(std.mem.endsWith( u8, text, "nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.example\"} 1\n", )); } test "every HELP line has a TYPE line and a sample, and every sample a name" { const text = try renderToString(testing.allocator, .{}); defer testing.allocator.free(text); var helps: usize = 0; var types: usize = 0; var samples: usize = 0; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; if (std.mem.startsWith(u8, line, "# HELP ")) { helps += 1; } else if (std.mem.startsWith(u8, line, "# TYPE ")) { types += 1; } else { samples += 1; try testing.expect(std.mem.startsWith(u8, line, "nxdns_")); } } try testing.expectEqual(helps, types); try testing.expectEqual(helps, samples); // The families a bare state still has: `nxdns_up`, every DNS counter, the // query log writer's, and the diagnostic log sink's. Derived rather than // counted, so a new counter in any of those structs extends the exposition // and this assertion together. try testing.expectEqual( 1 + dns_stat_fields.len + @typeInfo(LoggerCounters).@"struct".fields.len + @typeInfo(logging.Stats).@"struct".fields.len, samples, ); // The reflective walk names the counters, so a renamed `Handler.Stats` // field silently renames a scraped series. Pin the ones an operator alerts // on by name. for ([_][]const u8{ "nxdns_dns_queries_total", "nxdns_dns_formerr_total", "nxdns_dns_notimp_total", "nxdns_dns_badvers_total", "nxdns_dns_servfail_total", "nxdns_dns_refused_total", "nxdns_dns_blocked_total", }) |metric| { var line_buf: [128]u8 = undefined; const line = try std.fmt.bufPrint(&line_buf, "# TYPE {s} counter\n", .{metric}); try testing.expect(std.mem.containsAtLeast(u8, text, 1, line)); } } test "an unwired collaborator omits its family rather than reporting zeros" { const text = try renderToString(testing.allocator, .{}); defer testing.allocator.free(text); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_up 1\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_queries_total 0\n")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cache_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_")); } test "the plain-DNS listener families carry every counter of both listeners" { const text = try renderToString(testing.allocator, .{ .udp_listener = .{ .received = 90, .dropped_oversize = 1, .dropped_no_slot = 2, .dropped_handler = 3, .receive_errors = 4, .send_errors = 5, }, .tcp_listener = .{ .connections = 12, .rejected_at_capacity = 6, .rejected_at_shutdown = 7, .accept_errors = 8, .connection_errors = 9, .idle_timeouts = 10, }, }); defer testing.allocator.free(text); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connections_total 12\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n")); // One family per listener kind, whatever the number of listeners behind it: // the counters are summed, not labelled. try testing.expectEqual( @as(usize, 1), std.mem.count(u8, text, "# TYPE nxdns_udp_server_received_total counter\n"), ); } test "one family covers all four listeners, summed" { // Only `stats` is read, so the listeners need no socket: `snapshotStats` // loads counters and touches nothing else. var udp6: udp_server.UdpServer = undefined; udp6.stats = .{}; udp6.stats.received.store(10, .monotonic); udp6.stats.dropped_no_slot.store(1, .monotonic); var udp4: udp_server.UdpServer = undefined; udp4.stats = .{}; udp4.stats.received.store(7, .monotonic); udp4.stats.dropped_no_slot.store(2, .monotonic); var tcp6: tcp_server.TcpServer = undefined; tcp6.core.stats = .{}; tcp6.core.stats.connections.store(4, .monotonic); var tcp4: tcp_server.TcpServer = undefined; tcp4.core.stats = .{}; tcp4.core.stats.connections.store(5, .monotonic); tcp4.core.stats.idle_timeouts.store(3, .monotonic); const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?; try testing.expectEqual(@as(u64, 17), udp.received); try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot); try testing.expectEqual(@as(u64, 0), udp.send_errors); const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?; try testing.expectEqual(@as(u64, 9), tcp.connections); try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts); // No listener at all is a missing family, not a family of zeros. try testing.expectEqual( @as(?udp_server.Snapshot, null), sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{}), ); } test "the three forward-client counters reach the DNS families" { var dns: DnsCounters = @splat(0); dns[fieldIndex("forward_udp_truncated")] = 2; dns[fieldIndex("forward_foreign_datagrams")] = 3; dns[fieldIndex("forward_failures")] = 4; const text = try renderToString(testing.allocator, .{ .dns = dns }); defer testing.allocator.free(text); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_udp_truncated_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_foreign_datagrams_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_failures_total 4\n")); } test "listener counters render only for the wired servers" { const doh_only = try renderToString(testing.allocator, .{ .doh_listener = .{ .connections = 9, .tls_handshake_failures = 2, .idle_timeouts = 1, .connection_errors = 0, .bad_requests = 4, }, }); defer testing.allocator.free(doh_only); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connections_total 9\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_tls_handshake_failures_total 2\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_idle_timeouts_total 1\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n")); try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n")); try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_")); const dot_only = try renderToString(testing.allocator, .{ .dot_listener = .{ .connections = 5, .tls_handshake_failures = 0, .idle_timeouts = 3, .connection_errors = 1, }, }); defer testing.allocator.free(dot_only); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connections_total 5\n")); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n")); try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n")); // The DoT listener has no HTTP layer, so no bad_requests family. try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total")); try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_")); } test "cert reload counters render per endpoint, only for the wired stores" { const one = try renderToString(testing.allocator, .{ .doh_certs = .{ .reloads = 2, .reload_failures = 1, .last_reload_unix = 1_700_000_000 }, }); defer testing.allocator.free(one); try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 2\n")); try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reload_failures_total{endpoint=\"doh\"} 1\n")); try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "endpoint=\"dot\"")); // The wall-clock second stays off the exposition. try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "last_reload")); const both = try renderToString(testing.allocator, .{ .doh_certs = .{ .reloads = 0, .reload_failures = 0, .last_reload_unix = 0 }, .dot_certs = .{ .reloads = 3, .reload_failures = 0, .last_reload_unix = 0 }, }); defer testing.allocator.free(both); try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 0\n")); try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"dot\"} 3\n")); try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reload_failures_total{endpoint=\"dot\"} 0\n")); } /// Reads a sample line's label set the way a scrape does and returns the number /// of label pairs, or null if the line is not one a parser accepts. /// /// A checker rather than a golden string, because the input that exercises it is /// a url no parser accepts, and `safe_url.redact` is entitled to change what it /// prints for one of those. What may not change is the property: an /// operator-supplied byte must not close a label value early, end the line, or /// leave an escape sequence behind that means something else to a parser. Only /// the format's three escapes are accepted for that reason — a `\x1b` `redact` /// wrote reaches here as `\\x1b`, whose backslash is escaped and whose `x1b` is /// three ordinary characters. fn labelPairs(line: []const u8) ?usize { var i = (std.mem.indexOfScalar(u8, line, '{') orelse return null) + 1; var pairs: usize = 0; while (true) { const eq = std.mem.indexOfScalarPos(u8, line, i, '=') orelse return null; if (eq == i) return null; for (line[i..eq]) |c| if (!std.ascii.isAlphanumeric(c) and c != '_') return null; if (eq + 1 >= line.len or line[eq + 1] != '"') return null; i = eq + 2; while (true) { if (i >= line.len) return null; if (line[i] == '"') break; if (line[i] != '\\') { i += 1; continue; } if (i + 1 >= line.len) return null; switch (line[i + 1]) { '\\', '"', 'n' => i += 2, else => return null, } } pairs += 1; i += 1; if (i >= line.len) return null; if (line[i] == '}') return pairs; if (line[i] != ',') return null; i += 1; } } /// `name{labels}` — what Prometheus identifies a series by. Null for a sample /// that carries no label set. fn seriesKey(line: []const u8) ?[]const u8 { const close = std.mem.lastIndexOfScalar(u8, line, '}') orelse return null; return line[0 .. close + 1]; } test "a label value escapes the characters the format reserves" { // Every shape an operator-supplied url can take that reaches the label with // a character the format reserves. The assertion is the property, not the // text: these are urls no parser accepts, and `safe_url.redact` may change // what it prints for one of them without changing what this test protects. const hostile = [_][]const u8{ "https://a\"b/dns-query", "https://a\nb/dns-query", "https://a\\b/dns-query", "https://a\x1bb/dns-query", "https://user:pa55@h\"ost/dns-query", "https://\"}{=,\"/dns-query", // The shape `safe_url.redact` is being hardened against in this same // wave: a `?` before the last `@`. What it prints is that fix's to // decide; that the label holds it safely is this one's. "https://lists.example?token=prefix@hunter2", }; for (hostile) |url| { const upstream_list = [_]UpstreamSample{.{ .url = url, .enabled = true, .available = false, .consecutive_failures = 2, .total_successes = 0, .total_failures = 2, .success_rate = 0, }}; const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list }); defer testing.allocator.free(text); // The url wrote no line of its own, and lost none: every line is a // comment or a sample, and the six families contribute six samples. var samples: usize = 0; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue; try testing.expect(std.mem.startsWith(u8, line, "nxdns_")); if (!std.mem.startsWith(u8, line, "nxdns_upstream_")) continue; samples += 1; // Both labels are there, and both values close where they opened. try testing.expectEqual(@as(?usize, 2), labelPairs(line)); } try testing.expectEqual(@as(usize, 6), samples); } } test "two upstreams on one host stay two series" { // Redaction costs the url the job of telling two upstreams apart: a NextDNS // account with two profiles is two urls on one host, and both print // `https://dns.nextdns.io`. Two samples of one name with one label set is a // duplicate series, which is a broken scrape rather than a hidden one. const upstream_list = [_]UpstreamSample{ .{ .url = "https://dns.nextdns.io/abcd12", .enabled = true, .available = true, .consecutive_failures = 0, .total_successes = 5, .total_failures = 0, .success_rate = 1, }, .{ .url = "https://dns.nextdns.io/efgh34", .enabled = true, .available = false, .consecutive_failures = 3, .total_successes = 9, .total_failures = 3, .success_rate = 0.75, }, }; const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list }); defer testing.allocator.free(text); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_up{index=\"1\",url=\"https://dns.nextdns.io\"} 0\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_successes_total{index=\"1\",url=\"https://dns.nextdns.io\"} 9\n", )); // Distinguishable, not merely present. `nxdns_upstream_up` is the family // this matters most in: one of these two upstreams is down and the other is // up, and a reader has to be able to see which. Under one shared label set // the two samples say 1 and 0 of the same series, so a scrape either reports // whichever it read last or rejects the pair — and the down upstream is // invisible either way, on the endpoint an operator watches to find out. var up_keys: [2][]const u8 = undefined; var up_values: [2][]const u8 = undefined; var found: usize = 0; var up_lines = std.mem.splitScalar(u8, text, '\n'); while (up_lines.next()) |line| { if (!std.mem.startsWith(u8, line, "nxdns_upstream_up{")) continue; try testing.expect(found < up_keys.len); const key = seriesKey(line).?; up_keys[found] = key; up_values[found] = line[key.len + 1 ..]; found += 1; } try testing.expectEqual(@as(usize, 2), found); try testing.expect(!std.mem.eql(u8, up_keys[0], up_keys[1])); try testing.expectEqualStrings("1", up_values[0]); try testing.expectEqualStrings("0", up_values[1]); // No two samples in the scrape share a series key, whatever the urls were. var keys: [32][]const u8 = undefined; var count: usize = 0; var lines = std.mem.splitScalar(u8, text, '\n'); while (lines.next()) |line| { if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue; const key = seriesKey(line) orelse continue; for (keys[0..count]) |seen| try testing.expect(!std.mem.eql(u8, seen, key)); keys[count] = key; count += 1; } try testing.expectEqual(@as(usize, 12), count); } test "an upstream url is redacted before it reaches an open endpoint's label" { // `/metrics` is `.auth = .open`, so every label here is readable without a // session by anything that can reach the bind address. A NextDNS DoH // upstream carries the whole account identifier in its path, and a scraper // keeps a label for as long as it keeps the series. const upstream_list = [_]UpstreamSample{ .{ .url = "https://dns.nextdns.io/abcd12", .enabled = true, .available = true, .consecutive_failures = 0, .total_successes = 3, .total_failures = 0, .success_rate = 1, }, .{ .url = "https://user:hunter2@dns.example:8443/dns-query?apikey=s3cr3t#frag", .enabled = false, .available = false, .consecutive_failures = 4, .total_successes = 0, .total_failures = 4, .success_rate = 0, }, }; const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list }); defer testing.allocator.free(text); // The four components a credential can live in, none of them exposed. try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "abcd12")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "hunter2")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "s3cr3t")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "frag")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "dns-query")); // Every family carries the label, so none of the six may keep the whole url. try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_enabled{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.nextdns.io\"} 1.0000\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_successes_total{index=\"0\",url=\"https://dns.nextdns.io\"} 3\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.nextdns.io\"} 0\n", )); // The scheme, the host and the port stay: an operator reading a scrape has // to know which upstream a series is about. try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_enabled{index=\"1\",url=\"https://dns.example:8443\"} 0\n", )); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_consecutive_failures{index=\"1\",url=\"https://dns.example:8443\"} 4\n", )); } test "a url longer than the redaction bound cannot run past it" { const long_host = "h" ** (4 * safe_url.max_len); const upstream_list = [_]UpstreamSample{.{ .url = "https://" ++ long_host ++ "/dns-query", .enabled = true, .available = true, .consecutive_failures = 0, .total_successes = 0, .total_failures = 0, .success_rate = 1, }}; const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list }); defer testing.allocator.free(text); try testing.expect(std.mem.containsAtLeast( u8, text, 1, "nxdns_upstream_up{index=\"0\",url=\"" ++ ("https://" ++ long_host)[0..safe_url.max_len] ++ "...\"} 1\n", )); } test "collect reads the live counters of the components it is given" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var cache = try dns_cache.DnsCache.init(testing.allocator, .{ .size = 4 }); defer cache.deinit(); cache.stats.hits = 11; cache.stats.misses = 5; var limiter = try rate_limiter.RateLimiter.init(testing.allocator, .{ .limit = 10, .window_seconds = 60 }); defer limiter.deinit(); limiter.stats.refused = 3; var handler = testHandler(); handler.cache = &cache; handler.limiter = &limiter; handler.stats.queries.store(42, .monotonic); handler.stats.blocked.store(7, .monotonic); var queue_buf: [4]logger_mod.Entry = undefined; var query_logger: logger_mod.Logger = .init(.{}, &queue_buf); query_logger.rows_written.store(90, .monotonic); var tracker: clients.Tracker = .init(30); var retention: retention_mod.Retention = .init(.{}); var tables: local_tables.LocalTables = .empty; var names: client_names.Resolver = .init(&tables); names.stats.no_zone = 4; names.stats.attempted = 4; var state: server.WebState = .{ .gpa = testing.allocator, .handler = &handler, .logger = &query_logger, .tracker = &tracker, .client_names = &names, .retention = &retention, }; var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const sample = try collect(&state, io, arena.allocator()); try testing.expectEqual(@as(u64, 42), sample.dns[fieldIndex("queries")]); try testing.expectEqual(@as(u64, 7), sample.dns[fieldIndex("blocked")]); try testing.expectEqual(@as(u64, 11), sample.cache.?.stats.hits); try testing.expectEqual(@as(u64, 5), sample.cache.?.stats.misses); try testing.expectEqual(@as(u64, 0), sample.cache.?.entries); try testing.expectEqual(@as(u64, 3), sample.limiter.?.stats.refused); try testing.expectEqual(@as(u64, 90), sample.logger.rows_written); try testing.expectEqual(@as(u64, 0), sample.tracker.?.pending_clients); try testing.expectEqual(@as(u64, 4), sample.client_names.?.no_zone); try testing.expectEqual(@as(u64, 0), sample.retention.?.passes); try testing.expectEqual(@as(?BlocklistSample, null), sample.blocklist); try testing.expectEqual(@as(usize, 0), sample.upstreams.len); } fn fieldIndex(comptime name: []const u8) usize { inline for (dns_stat_fields, 0..) |field, i| { if (comptime std.mem.eql(u8, field.name, name)) return i; } @compileError("no such counter: " ++ name); }