milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,611 @@
|
||||
//! `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.
|
||||
//!
|
||||
//! 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 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 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 server = @import("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,
|
||||
};
|
||||
|
||||
/// One upstream, with every string owned by the caller's arena.
|
||||
pub const UpstreamSample = struct {
|
||||
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,
|
||||
retention: ?retention_mod.Stats = null,
|
||||
blocklist: ?BlocklistSample = null,
|
||||
disk: ?DiskSample = 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.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.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
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.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.upstreams.len != 0) try renderUpstreams(w, sample.upstreams);
|
||||
}
|
||||
|
||||
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) |entry| try labeledValue(w, "nxdns_upstream_up", entry.url, @intFromBool(entry.available));
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_enabled", "1 while an upstream is enabled by configuration.", "gauge");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_enabled", entry.url, @intFromBool(entry.enabled));
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_success_rate", "Share of recent exchanges that succeeded.", "gauge");
|
||||
for (list) |entry| {
|
||||
try w.writeAll("nxdns_upstream_success_rate{url=\"");
|
||||
try writeLabelValue(w, 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) |entry| {
|
||||
try labeledValue(w, "nxdns_upstream_consecutive_failures", entry.url, entry.consecutive_failures);
|
||||
}
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_successes_total", "Exchanges an upstream answered.", "counter");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_successes_total", entry.url, entry.total_successes);
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_failures_total", "Exchanges an upstream failed.", "counter");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_failures_total", 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,
|
||||
url: []const u8,
|
||||
value: u64,
|
||||
) std.Io.Writer.Error!void {
|
||||
try w.print("{s}{{url=\"", .{name});
|
||||
try writeLabelValue(w, url);
|
||||
try w.print("\"}} {d}\n", .{value});
|
||||
}
|
||||
|
||||
/// 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 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,
|
||||
},
|
||||
.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_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"));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{url=\"https://dns.example/dns-query\"} 1\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_success_rate{url=\"https://dns.example/dns-query\"} 0.9000\n",
|
||||
));
|
||||
try testing.expect(std.mem.endsWith(
|
||||
u8,
|
||||
text,
|
||||
"nxdns_upstream_failures_total{url=\"https://dns.example/dns-query\"} 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);
|
||||
// `nxdns_up` plus every DNS counter: the families a bare state still has.
|
||||
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples);
|
||||
}
|
||||
|
||||
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_"));
|
||||
}
|
||||
|
||||
test "a label value escapes the characters the format reserves" {
|
||||
const upstream_list = [_]UpstreamSample{.{
|
||||
.url = "https://dns.example/a\"b\\c",
|
||||
.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);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{url=\"https://dns.example/a\\\"b\\\\c\"} 0\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 state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.handler = &handler,
|
||||
.logger = &query_logger,
|
||||
.tracker = &tracker,
|
||||
.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, 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);
|
||||
}
|
||||
Reference in New Issue
Block a user