milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+611
View File
@@ -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);
}