milestone 27: diagnostics — operational failures land in one curated log, resolved history purgeable
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
Gates / frontend (push) Successful in 1m33s
Gates / test (push) Successful in 1m48s
Gates / test-aarch64 (push) Successful in 7m10s
Gates / package (push) Successful in 5m31s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 14m51s
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
//! `GET /api/diagnostics` and `GET /api/diagnostics/{id}` — the operational
|
||||
//! event log, newest first.
|
||||
//!
|
||||
//! Keyset pagination and the same page envelope as `/api/queries`, for the same
|
||||
//! reason: the table is append-only at the head, so `id < before` is one index
|
||||
//! seek however deep a client has scrolled, and rows arriving between two pages
|
||||
//! cannot shift the window and duplicate one.
|
||||
//!
|
||||
//! Filter parsing is separated from fetching, because parsing is where PLAN
|
||||
//! §19's input validation lives and it is worth testing on its own. Every value
|
||||
//! is length-capped here and bound as a SQL parameter by the repository;
|
||||
//! nothing this file reads is ever concatenated into a statement.
|
||||
//!
|
||||
//! No SQL and no connection of its own: `events.Store` owns the one diagnostics
|
||||
//! connection and locks its mutex around every read, so this file cannot race
|
||||
//! the emitters writing through it.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const events = @import("../../storage/events.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_diagnostics);
|
||||
|
||||
pub const default_limit: u32 = 100;
|
||||
pub const max_limit: u32 = events.max_limit;
|
||||
|
||||
/// Wide enough for every component `events.component` can produce, and for a
|
||||
/// mistyped one to still be reported as a bad filter rather than a truncated
|
||||
/// match.
|
||||
pub const max_component_len = 64;
|
||||
|
||||
/// Where the string filters are copied to. The parsed filter borrows them, so
|
||||
/// it must not outlive the buffers — in the handler both live in the same stack
|
||||
/// frame.
|
||||
pub const Buffers = struct {
|
||||
state: [16]u8 = undefined,
|
||||
severity: [16]u8 = undefined,
|
||||
component: [max_component_len]u8 = undefined,
|
||||
};
|
||||
|
||||
pub const FilterError = error{
|
||||
BadState,
|
||||
BadSeverity,
|
||||
BadComponent,
|
||||
BadSince,
|
||||
BadUntil,
|
||||
BadLimit,
|
||||
BadBefore,
|
||||
};
|
||||
|
||||
/// An absent parameter drops the filter; a malformed one is a 400 rather than a
|
||||
/// filter silently left off, which would answer a question the client did not
|
||||
/// ask.
|
||||
pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!events.Filter {
|
||||
var filter: events.Filter = .{ .limit = default_limit };
|
||||
|
||||
if (http_util.queryValue(query, "state", &buffers.state) catch return error.BadState) |text| {
|
||||
filter.state = std.meta.stringToEnum(events.State, text) orelse return error.BadState;
|
||||
}
|
||||
|
||||
if (http_util.queryValue(query, "severity", &buffers.severity) catch return error.BadSeverity) |text| {
|
||||
// Bound as text by the repository, so it is normalised to one of the
|
||||
// two stored spellings here rather than passed through.
|
||||
const severity = std.meta.stringToEnum(events.Severity, text) orelse return error.BadSeverity;
|
||||
filter.severity = severity.text();
|
||||
}
|
||||
|
||||
if (http_util.queryValue(query, "component", &buffers.component) catch return error.BadComponent) |text| {
|
||||
if (text.len != 0) filter.component = text;
|
||||
}
|
||||
|
||||
filter.since = http_util.queryInt(i64, query, "since") catch return error.BadSince;
|
||||
filter.until = http_util.queryInt(i64, query, "until") catch return error.BadUntil;
|
||||
|
||||
if (http_util.queryInt(u32, query, "limit") catch return error.BadLimit) |limit| {
|
||||
if (limit == 0 or limit > max_limit) return error.BadLimit;
|
||||
filter.limit = limit;
|
||||
}
|
||||
|
||||
if (http_util.queryInt(i64, query, "before") catch return error.BadBefore) |before| {
|
||||
// Row ids are positive, so a non-positive cursor is a client bug, not
|
||||
// an empty page.
|
||||
if (before <= 0) return error.BadBefore;
|
||||
filter.before = before;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
pub fn message(err: FilterError) []const u8 {
|
||||
return switch (err) {
|
||||
error.BadState => "state must be active, resolved or all",
|
||||
error.BadSeverity => "severity must be warning or error",
|
||||
error.BadComponent => "component is not a valid filter",
|
||||
error.BadSince => "since must be a unix timestamp in seconds",
|
||||
error.BadUntil => "until must be a unix timestamp in seconds",
|
||||
error.BadLimit => "limit must be between 1 and 1000",
|
||||
error.BadBefore => "before must be a positive row id",
|
||||
};
|
||||
}
|
||||
|
||||
const unavailable_message = "diagnostics unavailable";
|
||||
|
||||
pub fn list(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = parseFilter(request.query, &buffers) catch |err| {
|
||||
return http_util.respondError(request, .bad_request, message(err));
|
||||
};
|
||||
|
||||
const store = state.events orelse
|
||||
return http_util.respondError(request, .service_unavailable, unavailable_message);
|
||||
|
||||
const page = store.selectEvents(io, request.arena, filter) catch |err| {
|
||||
// The one thing this handler logs: a database fault is a property of
|
||||
// the box, not of the request, and the client is told nothing about it.
|
||||
log.warn("diagnostics read failed: {s}", .{@errorName(err)});
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, page, &.{});
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const store = state.events orelse
|
||||
return http_util.respondError(request, .service_unavailable, unavailable_message);
|
||||
|
||||
const row = store.selectOne(io, request.arena, request.id.?) catch |err| {
|
||||
log.warn("diagnostics read failed: {s}", .{@errorName(err)});
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
};
|
||||
|
||||
// An id retention has removed and one that never existed are the same
|
||||
// answer, and the API does not pretend to tell them apart.
|
||||
const event = row orelse return http_util.respondError(request, .not_found, "not found");
|
||||
return http_util.respondJson(request, .ok, event, &.{});
|
||||
}
|
||||
|
||||
/// An open episode is the current state of the box, so it is not history to
|
||||
/// throw away — and the message says what would make it purgeable.
|
||||
const still_active_message = "the event is still active; it can be purged once it resolves";
|
||||
|
||||
/// `DELETE /api/diagnostics` — how many resolved events went.
|
||||
pub const PurgeResult = struct {
|
||||
purged: i64,
|
||||
};
|
||||
|
||||
/// `DELETE /api/diagnostics/{id}`. Resolution stays automatic; this is only
|
||||
/// about when the history disappears, which is the operator's call.
|
||||
///
|
||||
/// Classified `runtime_action` in the route table, not `config_write`: the
|
||||
/// event log is runtime state that no configuration file declares, so file
|
||||
/// authority has nothing to say about it and the router lets this through in
|
||||
/// both modes.
|
||||
pub fn purge(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const store = state.events orelse
|
||||
return http_util.respondError(request, .service_unavailable, unavailable_message);
|
||||
|
||||
// No log here: the store already latches and logs the false→true
|
||||
// transition, and a warning per retried request would spam.
|
||||
const outcome = store.purge(io, request.id.?) catch
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
|
||||
return switch (outcome) {
|
||||
.deleted => http_util.respondEmpty(request, .no_content),
|
||||
.active => http_util.respondError(request, .conflict, still_active_message),
|
||||
.absent => http_util.respondError(request, .not_found, "not found"),
|
||||
};
|
||||
}
|
||||
|
||||
/// `DELETE /api/diagnostics` — the whole resolved history at once. Active
|
||||
/// episodes are never touched, so an operator clearing the page cannot lose the
|
||||
/// events that are still true.
|
||||
pub fn purgeAll(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const store = state.events orelse
|
||||
return http_util.respondError(request, .service_unavailable, unavailable_message);
|
||||
|
||||
const purged = store.purgeAll(io) catch
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
|
||||
return http_util.respondJson(request, .ok, PurgeResult{ .purged = purged }, &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const migrations = @import("../../storage/migrations.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "an empty query string is the default page over everything" {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = try parseFilter("", &buffers);
|
||||
try testing.expectEqual(default_limit, filter.limit);
|
||||
try testing.expectEqual(events.State.all, filter.state);
|
||||
try testing.expectEqual(@as(?[]const u8, null), filter.severity);
|
||||
try testing.expectEqual(@as(?[]const u8, null), filter.component);
|
||||
try testing.expectEqual(@as(?i64, null), filter.before);
|
||||
try testing.expectEqual(@as(?i64, null), filter.since);
|
||||
}
|
||||
|
||||
test "every filter reaches the store untouched" {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = try parseFilter(
|
||||
"state=resolved&severity=error&component=query_log&since=100&until=200&limit=250&before=900",
|
||||
&buffers,
|
||||
);
|
||||
try testing.expectEqual(events.State.resolved, filter.state);
|
||||
try testing.expectEqualStrings("error", filter.severity.?);
|
||||
try testing.expectEqualStrings("query_log", filter.component.?);
|
||||
try testing.expectEqual(@as(?i64, 100), filter.since);
|
||||
try testing.expectEqual(@as(?i64, 200), filter.until);
|
||||
try testing.expectEqual(@as(u32, 250), filter.limit);
|
||||
try testing.expectEqual(@as(?i64, 900), filter.before);
|
||||
}
|
||||
|
||||
test "an empty component is no filter at all" {
|
||||
var buffers: Buffers = .{};
|
||||
try testing.expectEqual(@as(?[]const u8, null), (try parseFilter("component=", &buffers)).component);
|
||||
}
|
||||
|
||||
test "each malformed parameter names itself in a 400" {
|
||||
var buffers: Buffers = .{};
|
||||
try testing.expectError(error.BadState, parseFilter("state=open", &buffers));
|
||||
try testing.expectError(error.BadState, parseFilter("state=", &buffers));
|
||||
try testing.expectError(error.BadSeverity, parseFilter("severity=info", &buffers));
|
||||
try testing.expectError(error.BadSeverity, parseFilter("severity=WARNING", &buffers));
|
||||
try testing.expectError(error.BadSince, parseFilter("since=yesterday", &buffers));
|
||||
try testing.expectError(error.BadUntil, parseFilter("until=", &buffers));
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=0", &buffers));
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=1001", &buffers));
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=ten", &buffers));
|
||||
try testing.expectError(error.BadBefore, parseFilter("before=0", &buffers));
|
||||
try testing.expectError(error.BadBefore, parseFilter("before=-4", &buffers));
|
||||
try testing.expectError(error.BadComponent, parseFilter("component=%zz", &buffers));
|
||||
|
||||
var long: [max_component_len + 8]u8 = @splat('a');
|
||||
var text: std.ArrayList(u8) = .empty;
|
||||
defer text.deinit(testing.allocator);
|
||||
try text.appendSlice(testing.allocator, "component=");
|
||||
try text.appendSlice(testing.allocator, &long);
|
||||
try testing.expectError(error.BadComponent, parseFilter(text.items, &buffers));
|
||||
|
||||
// Every member of the error set has its own wording, and none of them is
|
||||
// the empty string.
|
||||
inline for (comptime std.meta.fieldNames(FilterError)) |name| {
|
||||
try testing.expect(message(@field(FilterError, name)).len != 0);
|
||||
}
|
||||
}
|
||||
|
||||
test "the limit cap is the store's" {
|
||||
var buffers: Buffers = .{};
|
||||
try testing.expectEqual(max_limit, (try parseFilter("limit=1000", &buffers)).limit);
|
||||
try testing.expectEqual(@as(u32, 1000), events.max_limit);
|
||||
}
|
||||
|
||||
/// A store over a migrated in-memory `config.db`, built in place: a `Store`
|
||||
/// holds a `*db.Db`, so a fixture that moved after `init` would leave that
|
||||
/// pointer behind.
|
||||
const Fixture = struct {
|
||||
threaded: std.Io.Threaded = undefined,
|
||||
io: std.Io = undefined,
|
||||
database: db.Db = undefined,
|
||||
store: events.Store = undefined,
|
||||
state: server.WebState = undefined,
|
||||
|
||||
fn init(self: *Fixture) !void {
|
||||
self.threaded = .init(testing.allocator, .{});
|
||||
errdefer self.threaded.deinit();
|
||||
self.io = self.threaded.io();
|
||||
|
||||
self.database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer self.database.close();
|
||||
try db.applyPragmas(&self.database, .{});
|
||||
_ = try migrations.migrate(&self.database);
|
||||
|
||||
self.store = try events.Store.init(self.io, &self.database, 1000);
|
||||
self.state = .{ .gpa = testing.allocator, .events = &self.store };
|
||||
}
|
||||
|
||||
fn deinit(self: *Fixture) void {
|
||||
self.database.close();
|
||||
self.threaded.deinit();
|
||||
}
|
||||
|
||||
fn seed(self: *Fixture) void {
|
||||
const store = &self.store;
|
||||
store.report(self.io, 1000, .blocklist_refresh, "https://a.example", "StevenBlack", .warning, "ConnectionTimedOut");
|
||||
store.report(self.io, 1100, .blocklist_refresh, "https://a.example", "StevenBlack", .warning, "ConnectionTimedOut");
|
||||
store.report(self.io, 1200, .listener_start, "doh", "doh", .@"error", "AddressInUse");
|
||||
store.report(self.io, 1300, .query_log_write, "batch", "batch", .@"error", "Busy");
|
||||
store.resolve(self.io, 1400, .query_log_write, "batch");
|
||||
}
|
||||
};
|
||||
|
||||
test "a page carries the events, the cursor and the active counts" {
|
||||
var fx: Fixture = .{};
|
||||
try fx.init();
|
||||
defer fx.deinit();
|
||||
fx.seed();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const page = try fx.store.selectEvents(fx.io, arena.allocator(), .{ .limit = 100 });
|
||||
try testing.expectEqual(@as(usize, 3), page.events.len);
|
||||
try testing.expectEqual(@as(?i64, null), page.next_before);
|
||||
try testing.expectEqual(events.Counts{ .warnings = 1, .errors = 1 }, page.active);
|
||||
|
||||
// Newest first, and the episode that repeated counts rather than repeats.
|
||||
try testing.expectEqualStrings("query_log.write", page.events[0].code);
|
||||
try testing.expectEqualStrings("blocklist.refresh", page.events[2].code);
|
||||
try testing.expectEqual(@as(i64, 2), page.events[2].occurrences);
|
||||
try testing.expectEqualStrings("StevenBlack", page.events[2].subject);
|
||||
}
|
||||
|
||||
test "a full page carries a cursor and the last page does not" {
|
||||
var fx: Fixture = .{};
|
||||
try fx.init();
|
||||
defer fx.deinit();
|
||||
fx.seed();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const gpa = arena.allocator();
|
||||
|
||||
const first = try fx.store.selectEvents(fx.io, gpa, .{ .limit = 2 });
|
||||
try testing.expectEqual(@as(usize, 2), first.events.len);
|
||||
try testing.expectEqual(first.events[1].id, first.next_before.?);
|
||||
|
||||
const second = try fx.store.selectEvents(fx.io, gpa, .{ .limit = 2, .before = first.next_before });
|
||||
try testing.expectEqual(@as(usize, 1), second.events.len);
|
||||
try testing.expectEqual(@as(?i64, null), second.next_before);
|
||||
}
|
||||
|
||||
test "the parsed filters narrow the page the store answers with" {
|
||||
var fx: Fixture = .{};
|
||||
try fx.init();
|
||||
defer fx.deinit();
|
||||
fx.seed();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const gpa = arena.allocator();
|
||||
var buffers: Buffers = .{};
|
||||
|
||||
const active = try fx.store.selectEvents(fx.io, gpa, try parseFilter("state=active", &buffers));
|
||||
try testing.expectEqual(@as(usize, 2), active.events.len);
|
||||
|
||||
const resolved = try fx.store.selectEvents(fx.io, gpa, try parseFilter("state=resolved", &buffers));
|
||||
try testing.expectEqual(@as(usize, 1), resolved.events.len);
|
||||
try testing.expectEqual(@as(?i64, 1400), resolved.events[0].resolved_at);
|
||||
|
||||
const errors = try fx.store.selectEvents(fx.io, gpa, try parseFilter("severity=error", &buffers));
|
||||
try testing.expectEqual(@as(usize, 2), errors.events.len);
|
||||
|
||||
const blocklist = try fx.store.selectEvents(fx.io, gpa, try parseFilter("component=blocklist", &buffers));
|
||||
try testing.expectEqual(@as(usize, 1), blocklist.events.len);
|
||||
try testing.expectEqualStrings("blocklist", blocklist.events[0].component);
|
||||
}
|
||||
|
||||
test "the wire object carries the label and never the subject key" {
|
||||
var fx: Fixture = .{};
|
||||
try fx.init();
|
||||
defer fx.deinit();
|
||||
|
||||
// A key that would be unmistakable on the wire if it ever leaked: the
|
||||
// upstream urls this store keys on can carry a token.
|
||||
fx.store.report(fx.io, 1000, .upstream_exchange, "https://dns.example/secret-token", "dns.example", .warning, "Timeout");
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const page = try fx.store.selectEvents(fx.io, arena.allocator(), .{});
|
||||
|
||||
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer allocating.deinit();
|
||||
try std.json.Stringify.value(page, .{}, &allocating.writer);
|
||||
const text = allocating.written();
|
||||
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "secret-token"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "subject_key"));
|
||||
try testing.expect(std.mem.startsWith(u8, text, "{\"events\":["));
|
||||
for ([_][]const u8{
|
||||
"\"id\":", "\"code\":\"upstream.exchange\"",
|
||||
"\"component\":\"upstream\"", "\"subject\":\"dns.example\"",
|
||||
"\"severity\":\"warning\"", "\"first_seen\":",
|
||||
"\"last_seen\":", "\"occurrences\":",
|
||||
"\"resolved_at\":null", "\"detail\":\"Timeout\"",
|
||||
"\"next_before\":null", "\"active\":{\"warnings\":1,\"errors\":0}",
|
||||
}) |field| {
|
||||
errdefer std.debug.print("missing {s} in {s}\n", .{ field, text });
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, field));
|
||||
}
|
||||
}
|
||||
|
||||
test "a detail page answers by id and reports an unknown one as absent" {
|
||||
var fx: Fixture = .{};
|
||||
try fx.init();
|
||||
defer fx.deinit();
|
||||
fx.seed();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const found = (try fx.store.selectOne(fx.io, arena.allocator(), 1)).?;
|
||||
try testing.expectEqualStrings("blocklist.refresh", found.code);
|
||||
// The 404 the handler answers with is this null.
|
||||
try testing.expect((try fx.store.selectOne(fx.io, arena.allocator(), 9999)) == null);
|
||||
}
|
||||
|
||||
test "the purge-all body is the count and nothing else" {
|
||||
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer allocating.deinit();
|
||||
try std.json.Stringify.value(PurgeResult{ .purged = 3 }, .{}, &allocating.writer);
|
||||
try testing.expectEqualStrings("{\"purged\":3}", allocating.written());
|
||||
|
||||
// The 409 says what would make the event purgeable, so it is not the
|
||||
// generic conflict wording.
|
||||
try testing.expect(still_active_message.len != 0);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, still_active_message, 1, "resolves"));
|
||||
}
|
||||
|
||||
test "a state with no store answers 503 rather than an empty page" {
|
||||
// The handler's only branch that does not need a request: a `WebState`
|
||||
// whose store failed to open reports the endpoint unavailable, and never
|
||||
// an empty list that would read as "nothing is wrong".
|
||||
const state: server.WebState = .{ .gpa = testing.allocator };
|
||||
try testing.expectEqual(@as(?*events.Store, null), state.events);
|
||||
try testing.expect(unavailable_message.len != 0);
|
||||
}
|
||||
+114
-1
@@ -25,6 +25,15 @@ pub const Disk = struct {
|
||||
sample_failures: u64,
|
||||
};
|
||||
|
||||
/// The diagnostics store's own state, not a summary of what it holds: `state`
|
||||
/// answers "is the operational log recording", and the two counts answer "what
|
||||
/// is open right now".
|
||||
pub const Diagnostics = struct {
|
||||
state: []const u8,
|
||||
active_warnings: u32,
|
||||
active_errors: u32,
|
||||
};
|
||||
|
||||
pub const Upstreams = struct {
|
||||
available: u32,
|
||||
total: u32,
|
||||
@@ -34,6 +43,7 @@ pub const Body = struct {
|
||||
status: []const u8,
|
||||
disk: Disk,
|
||||
upstreams: Upstreams,
|
||||
diagnostics: Diagnostics,
|
||||
queries_dropped: u64,
|
||||
writer_failed: bool,
|
||||
refreshes_gated: u64,
|
||||
@@ -61,6 +71,16 @@ pub const Input = struct {
|
||||
/// one overflow. Drops surface through the metric and through the API's
|
||||
/// per-window `complete` instead.
|
||||
history_flush_failing: bool = false,
|
||||
/// The diagnostics store exists. The benign default matches every other
|
||||
/// field here — a half-wired `Input` reports a box with nothing wrong — but
|
||||
/// `collect` must assign it explicitly, because in a serving process an
|
||||
/// absent store means `Store.init` failed.
|
||||
diagnostics_present: bool = true,
|
||||
/// The last diagnostics write failed. Current state, cleared by the next
|
||||
/// write that succeeds, like `history_flush_failing`.
|
||||
diagnostics_write_failed: bool = false,
|
||||
diagnostics_active_warnings: u32 = 0,
|
||||
diagnostics_active_errors: u32 = 0,
|
||||
refreshes_gated: u64 = 0,
|
||||
snapshot_generation: ?u64 = null,
|
||||
};
|
||||
@@ -68,6 +88,16 @@ pub const Input = struct {
|
||||
pub const status_ok = "ok";
|
||||
pub const status_degraded = "degraded";
|
||||
|
||||
pub const diagnostics_recording = "recording";
|
||||
pub const diagnostics_unavailable = "unavailable";
|
||||
|
||||
/// The operational log is not recording — either the store never opened or its
|
||||
/// writes are failing. Both mean the same thing to an operator: the record of
|
||||
/// what went wrong is not being kept.
|
||||
pub fn diagnosticsUnavailable(input: Input) bool {
|
||||
return !input.diagnostics_present or input.diagnostics_write_failed;
|
||||
}
|
||||
|
||||
/// Conditions an operator must act on, and every one of them is a fact about
|
||||
/// now rather than a count of the past: a disk that is filling stops the query
|
||||
/// log, a pool with nothing available stops resolution, a failed writer means
|
||||
@@ -76,7 +106,7 @@ pub const status_degraded = "degraded";
|
||||
/// the underlying condition does.
|
||||
pub fn degraded(input: Input) bool {
|
||||
return input.disk_state != .ok or input.upstreams_available == 0 or
|
||||
input.writer_failed or input.history_flush_failing;
|
||||
input.writer_failed or input.history_flush_failing or diagnosticsUnavailable(input);
|
||||
}
|
||||
|
||||
pub fn rollup(input: Input) Body {
|
||||
@@ -90,6 +120,11 @@ pub fn rollup(input: Input) Body {
|
||||
.sample_failures = input.disk_sample_failures,
|
||||
},
|
||||
.upstreams = .{ .available = input.upstreams_available, .total = input.upstreams_total },
|
||||
.diagnostics = .{
|
||||
.state = if (diagnosticsUnavailable(input)) diagnostics_unavailable else diagnostics_recording,
|
||||
.active_warnings = input.diagnostics_active_warnings,
|
||||
.active_errors = input.diagnostics_active_errors,
|
||||
},
|
||||
.queries_dropped = input.queries_dropped,
|
||||
.writer_failed = input.writer_failed,
|
||||
.refreshes_gated = input.refreshes_gated,
|
||||
@@ -128,6 +163,17 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
input.writer_failed = logger.writer_failed.load(.monotonic);
|
||||
}
|
||||
|
||||
// Assigned before the `if`, not inside it: the field's benign default is
|
||||
// `true`, so the natural `if (state.events) |store|` shape would report an
|
||||
// absent store as recording — the one case that must degrade.
|
||||
input.diagnostics_present = state.events != null;
|
||||
if (state.events) |store| {
|
||||
input.diagnostics_write_failed = store.writeFailed();
|
||||
const counts = store.activeCounts(io);
|
||||
input.diagnostics_active_warnings = counts.warnings;
|
||||
input.diagnostics_active_errors = counts.errors;
|
||||
}
|
||||
|
||||
if (state.history) |history| {
|
||||
input.history_flush_failing = history.snapshotStats(io).last_flush_failed;
|
||||
}
|
||||
@@ -148,6 +194,8 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const events_mod = @import("../../storage/events.zig");
|
||||
const migrations = @import("../../storage/migrations.zig");
|
||||
const history_mod = @import("../../upstream/history.zig");
|
||||
const logger_mod = @import("../../storage/logger.zig");
|
||||
const upstream_history_repo = @import("../../storage/repositories/upstream_history_repo.zig");
|
||||
@@ -172,6 +220,11 @@ test "the degraded matrix covers disk state, availability and the writer" {
|
||||
// right now, and it recovers on its own the moment a flush succeeds.
|
||||
.{ .input = withHistoryFailing(healthy, true), .degraded = true },
|
||||
.{ .input = withHistoryFailing(healthy, false), .degraded = false },
|
||||
// The operational log not recording is itself a fault an operator must
|
||||
// act on: whatever fails next will leave no record of having failed.
|
||||
.{ .input = withDiagnostics(healthy, false, false), .degraded = true },
|
||||
.{ .input = withDiagnostics(healthy, true, true), .degraded = true },
|
||||
.{ .input = withDiagnostics(healthy, true, false), .degraded = false },
|
||||
// Two faults at once still report one status.
|
||||
.{ .input = withWriterFailed(withDisk(healthy, .critical)), .degraded = true },
|
||||
// Some upstreams down is not degraded while one still answers.
|
||||
@@ -212,6 +265,66 @@ fn withHistoryFailing(input: Input, failing: bool) Input {
|
||||
return out;
|
||||
}
|
||||
|
||||
fn withDiagnostics(input: Input, present: bool, write_failed: bool) Input {
|
||||
var out = input;
|
||||
out.diagnostics_present = present;
|
||||
out.diagnostics_write_failed = write_failed;
|
||||
return out;
|
||||
}
|
||||
|
||||
test "the diagnostics block reports the state and the open counts" {
|
||||
const recording = rollup(.{
|
||||
.upstreams_available = 1,
|
||||
.diagnostics_active_warnings = 3,
|
||||
.diagnostics_active_errors = 1,
|
||||
});
|
||||
try testing.expectEqualStrings(diagnostics_recording, recording.diagnostics.state);
|
||||
try testing.expectEqual(@as(u32, 3), recording.diagnostics.active_warnings);
|
||||
try testing.expectEqual(@as(u32, 1), recording.diagnostics.active_errors);
|
||||
// Open episodes are what the box is doing, not a fault of the log: they do
|
||||
// not degrade on their own.
|
||||
try testing.expectEqualStrings(status_ok, recording.status);
|
||||
|
||||
// Failing writes: the counts are whatever was last read, and the state is
|
||||
// the honest one.
|
||||
const failing = rollup(.{ .upstreams_available = 1, .diagnostics_write_failed = true });
|
||||
try testing.expectEqualStrings(diagnostics_unavailable, failing.diagnostics.state);
|
||||
try testing.expectEqualStrings(status_degraded, failing.status);
|
||||
|
||||
const absent = rollup(.{ .upstreams_available = 1, .diagnostics_present = false });
|
||||
try testing.expectEqualStrings(diagnostics_unavailable, absent.diagnostics.state);
|
||||
try testing.expectEqualStrings(status_degraded, absent.status);
|
||||
}
|
||||
|
||||
test "collect reports an absent store as unavailable rather than as recording" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// `diagnostics_present` defaults to true like every other benign default,
|
||||
// so an assignment `collect` forgot would read as a healthy log here.
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
const absent = collect(&state, io);
|
||||
try testing.expect(!absent.diagnostics_present);
|
||||
try testing.expectEqualStrings(diagnostics_unavailable, rollup(absent).diagnostics.state);
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
var store = try events_mod.Store.init(io, &database, 1000);
|
||||
store.report(io, 1000, .disk_space, "data", "data", .warning, "low");
|
||||
store.report(io, 1000, .listener_start, "doh", "doh", .@"error", "AddressInUse");
|
||||
|
||||
state.events = &store;
|
||||
const present = collect(&state, io);
|
||||
try testing.expect(present.diagnostics_present);
|
||||
try testing.expect(!present.diagnostics_write_failed);
|
||||
try testing.expectEqual(@as(u32, 1), present.diagnostics_active_warnings);
|
||||
try testing.expectEqual(@as(u32, 1), present.diagnostics_active_errors);
|
||||
try testing.expectEqualStrings(diagnostics_recording, rollup(present).diagnostics.state);
|
||||
}
|
||||
|
||||
test "a history overflow that already happened does not degrade the rollup" {
|
||||
// `rows_dropped` is cumulative and the rollup is stateless, so the only
|
||||
// thing it could do with a drop count is latch on it. The accumulator's
|
||||
|
||||
@@ -30,6 +30,7 @@ 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 events_mod = @import("../storage/events.zig");
|
||||
const history_mod = @import("../upstream/history.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const logging = @import("../platform/logging.zig");
|
||||
@@ -115,6 +116,15 @@ pub const UpstreamSample = struct {
|
||||
success_rate: f32,
|
||||
};
|
||||
|
||||
/// The diagnostics store, as one scrape sees it. Two gauges and a counter,
|
||||
/// written out rather than reflected over a stats struct because they are not
|
||||
/// all the same kind of number.
|
||||
pub const DiagnosticsSample = struct {
|
||||
active_warnings: u32,
|
||||
active_errors: u32,
|
||||
write_failures: u64,
|
||||
};
|
||||
|
||||
/// Everything one scrape reports. A null section is a collaborator the state
|
||||
/// does not have.
|
||||
pub const Sample = struct {
|
||||
@@ -129,6 +139,11 @@ pub const Sample = struct {
|
||||
/// The upstream-history flush loop's counters (m26 ruling 7). Absent while
|
||||
/// no accumulator is wired, like every other collaborator.
|
||||
history: ?history_mod.Accumulator.Stats = null,
|
||||
/// The diagnostics store's open episodes and its failed writes. Absent
|
||||
/// while no store is wired, like every other collaborator — an operator
|
||||
/// distinguishes "no series" from "zero episodes" through `/api/health`,
|
||||
/// which says which of the two it is.
|
||||
diagnostics: ?DiagnosticsSample = null,
|
||||
blocklist: ?BlocklistSample = null,
|
||||
disk: ?DiskSample = null,
|
||||
/// One entry per enabled TLS endpoint (milestone-10 ruling 10). Rendered
|
||||
@@ -205,6 +220,15 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
|
||||
if (state.history) |history| sample.history = history.snapshotStats(io);
|
||||
|
||||
if (state.events) |store| {
|
||||
const counts = store.activeCounts(io);
|
||||
sample.diagnostics = .{
|
||||
.active_warnings = counts.warnings,
|
||||
.active_errors = counts.errors,
|
||||
.write_failures = store.writeFailures(),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.manager) |manager| {
|
||||
const generation: ?u64 = if (manager.acquire(io)) |acquired| gen: {
|
||||
defer acquired.release(io);
|
||||
@@ -388,6 +412,27 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.diagnostics) |diagnostics| {
|
||||
try gauge(
|
||||
w,
|
||||
"nxdns_diagnostics_active_warnings",
|
||||
"Operational event episodes open right now at warning severity.",
|
||||
diagnostics.active_warnings,
|
||||
);
|
||||
try gauge(
|
||||
w,
|
||||
"nxdns_diagnostics_active_errors",
|
||||
"Operational event episodes open right now at error severity.",
|
||||
diagnostics.active_errors,
|
||||
);
|
||||
try counter(
|
||||
w,
|
||||
"nxdns_diagnostics_write_failures_total",
|
||||
"Operational events dropped because the diagnostics database refused the write.",
|
||||
diagnostics.write_failures,
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.blocklist) |blocklist| {
|
||||
try counter(
|
||||
w,
|
||||
@@ -638,8 +683,10 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const local_tables = @import("../server/local_tables.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
/// A handler with no upstream reachable: every test here reads counters and
|
||||
@@ -781,6 +828,55 @@ test "the upstream-history family renders three counters and one gauge" {
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, bare, 1, "nxdns_upstream_history_"));
|
||||
}
|
||||
|
||||
test "the diagnostics family renders two gauges and one counter" {
|
||||
const text = try renderToString(testing.allocator, .{
|
||||
.diagnostics = .{ .active_warnings = 3, .active_errors = 1, .write_failures = 7 },
|
||||
});
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_active_warnings gauge\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_active_warnings 3\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_active_errors gauge\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_active_errors 1\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "# TYPE nxdns_diagnostics_write_failures_total counter\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_diagnostics_write_failures_total 7\n"));
|
||||
|
||||
// No store is an absent family, not a family of zeros: "no episodes open"
|
||||
// and "nothing is recording them" must not render the same.
|
||||
const bare = try renderToString(testing.allocator, .{});
|
||||
defer testing.allocator.free(bare);
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, bare, 1, "nxdns_diagnostics_"));
|
||||
}
|
||||
|
||||
test "collect reads the diagnostics store's open episodes and failed writes" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
var store = try events_mod.Store.init(io, &database, 1000);
|
||||
store.report(io, 1000, .disk_space, "data", "data", .warning, "low");
|
||||
|
||||
var state: server.WebState = .{ .gpa = testing.allocator, .events = &store };
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const sample = try collect(&state, io, arena.allocator());
|
||||
try testing.expectEqual(@as(u32, 1), sample.diagnostics.?.active_warnings);
|
||||
try testing.expectEqual(@as(u32, 0), sample.diagnostics.?.active_errors);
|
||||
try testing.expectEqual(@as(u64, 0), sample.diagnostics.?.write_failures);
|
||||
|
||||
// A write that cannot land is counted here, through the production path
|
||||
// rather than by poking the field.
|
||||
try database.exec("DROP TABLE operational_events;");
|
||||
store.report(io, 1100, .disk_space, "data", "data", .warning, "low");
|
||||
const after = try collect(&state, io, arena.allocator());
|
||||
try testing.expect(after.diagnostics.?.write_failures >= 1);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
+222
-1
@@ -256,6 +256,131 @@ paths:
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/diagnostics:
|
||||
get:
|
||||
summary: Operational event log
|
||||
description: |
|
||||
Failure episodes, newest first. One event is one subject failing
|
||||
continuously: it opens on the first failure, counts repeats in
|
||||
`occurrences`, and gets a `resolved_at` when the subject recovers. A
|
||||
subject that fails again opens a new event rather than reopening the
|
||||
old one. Keyset pagination — follow `next_before` until it is null.
|
||||
parameters:
|
||||
- name: state
|
||||
in: query
|
||||
schema: { type: string, enum: [active, resolved, all], default: all }
|
||||
- name: severity
|
||||
in: query
|
||||
schema: { type: string, enum: [warning, error] }
|
||||
- name: component
|
||||
in: query
|
||||
description: Matches the part of `code` before the dot, exactly.
|
||||
schema: { type: string, maxLength: 64 }
|
||||
- name: since
|
||||
in: query
|
||||
description: |
|
||||
Unix seconds. With `until`, selects episodes overlapping the
|
||||
window; an episode resolved exactly at `since` does not overlap.
|
||||
schema: { type: integer }
|
||||
- name: until
|
||||
in: query
|
||||
description: Unix seconds, exclusive.
|
||||
schema: { type: integer }
|
||||
- name: limit
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
|
||||
- name: before
|
||||
in: query
|
||||
description: Return events with id strictly below this cursor.
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
"200":
|
||||
description: One page.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DiagnosticsPage"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
delete:
|
||||
summary: Purge every resolved event
|
||||
description: |
|
||||
Deletes the resolved history and answers with how many rows went.
|
||||
Active events are never touched, so clearing the page cannot lose an
|
||||
episode that is still failing. Resolution stays automatic; this only
|
||||
decides when the history disappears. A runtime action, served in file
|
||||
mode too — the event log is not configuration.
|
||||
responses:
|
||||
"200":
|
||||
description: How many resolved events were removed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DiagnosticsPurge"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/diagnostics/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RowId"
|
||||
get:
|
||||
summary: One operational event
|
||||
description: |
|
||||
404 for an id that never existed and for one retention has removed —
|
||||
the API does not distinguish them.
|
||||
responses:
|
||||
"200":
|
||||
description: The event.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DiagnosticEvent"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
delete:
|
||||
summary: Purge one resolved event
|
||||
description: |
|
||||
Deletes a resolved event. An event that is still active answers 409 —
|
||||
an open episode is the current state of the box, not history — and an
|
||||
id no row holds answers 404. A runtime action, served in file mode too.
|
||||
responses:
|
||||
"204":
|
||||
description: Purged.
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/stats:
|
||||
get:
|
||||
summary: Totals for a period
|
||||
@@ -1664,11 +1789,21 @@ components:
|
||||
|
||||
Health:
|
||||
type: object
|
||||
required: [status, disk, upstreams, queries_dropped, writer_failed, refreshes_gated, snapshot_generation]
|
||||
required: [status, disk, upstreams, diagnostics, queries_dropped, writer_failed, refreshes_gated, snapshot_generation]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, degraded]
|
||||
diagnostics:
|
||||
type: object
|
||||
required: [state, active_warnings, active_errors]
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum: [recording, unavailable]
|
||||
description: unavailable when the event store failed to open or its writes are failing; either state degrades health.
|
||||
active_warnings: { type: integer }
|
||||
active_errors: { type: integer }
|
||||
disk:
|
||||
type: object
|
||||
required: [state, free_bytes, db_bytes, log_bytes, sample_failures]
|
||||
@@ -1760,6 +1895,92 @@ components:
|
||||
nullable: true
|
||||
description: Cursor for the next page; null on the last page.
|
||||
|
||||
DiagnosticEvent:
|
||||
type: object
|
||||
required: [id, code, component, subject, severity, first_seen, last_seen, occurrences, resolved_at, detail]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
code:
|
||||
type: string
|
||||
description: |
|
||||
The failure kind, as `component.name`. One of a fixed set of
|
||||
fifteen; new codes are added with new releases.
|
||||
enum:
|
||||
- disk.space
|
||||
- disk.probe
|
||||
- blocklist.refresh
|
||||
- blocklist.snapshot
|
||||
- blocklist.storage
|
||||
- certificate.reload
|
||||
- query_log.write
|
||||
- query_log.maintenance
|
||||
- query_log.recreated
|
||||
- upstream_history.write
|
||||
- upstream.exchange
|
||||
- client_names.storage
|
||||
- clients.storage
|
||||
- listener.start
|
||||
- configuration.load
|
||||
component:
|
||||
type: string
|
||||
description: The part of `code` before the dot, repeated for filtering.
|
||||
subject:
|
||||
type: string
|
||||
description: |
|
||||
What failed, as a display name: a blocklist source name, an
|
||||
endpoint, an operation. Redacted where it derives from a url; the
|
||||
store's internal identity for the subject is never exposed.
|
||||
severity:
|
||||
type: string
|
||||
enum: [warning, error]
|
||||
first_seen:
|
||||
type: integer
|
||||
description: When this episode opened, unix seconds.
|
||||
last_seen:
|
||||
type: integer
|
||||
description: The most recent failure of this episode, unix seconds.
|
||||
occurrences:
|
||||
type: integer
|
||||
description: How many failures this episode has held; at least 1.
|
||||
resolved_at:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: |
|
||||
When the subject recovered, unix seconds. Null while the episode is
|
||||
still open. A subject that fails again opens a new event rather than
|
||||
reopening this one.
|
||||
detail:
|
||||
type: string
|
||||
description: The last error of this episode, truncated to 512 bytes.
|
||||
|
||||
DiagnosticsPage:
|
||||
type: object
|
||||
required: [events, next_before, active]
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DiagnosticEvent"
|
||||
next_before:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Cursor for the next page; null on the last page.
|
||||
active:
|
||||
type: object
|
||||
required: [warnings, errors]
|
||||
description: Episodes open right now, whatever this page filtered to.
|
||||
properties:
|
||||
warnings: { type: integer }
|
||||
errors: { type: integer }
|
||||
|
||||
DiagnosticsPurge:
|
||||
type: object
|
||||
required: [purged]
|
||||
properties:
|
||||
purged:
|
||||
type: integer
|
||||
description: How many resolved events the purge removed; zero when there were none.
|
||||
|
||||
StatsTotals:
|
||||
type: object
|
||||
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us]
|
||||
|
||||
+12
-1
@@ -36,6 +36,7 @@ const auth = @import("handlers/auth.zig");
|
||||
const blocklists = @import("handlers/blocklists.zig");
|
||||
const certs = @import("handlers/certs.zig");
|
||||
const clients = @import("handlers/clients.zig");
|
||||
const diagnostics = @import("handlers/diagnostics.zig");
|
||||
const groups = @import("handlers/groups.zig");
|
||||
const health = @import("handlers/health.zig");
|
||||
const live = @import("handlers/live.zig");
|
||||
@@ -71,6 +72,14 @@ pub const table: []const router.RouteInfo = &.{
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
|
||||
|
||||
// Diagnostics: the operational event log (milestone 27). The two purges are
|
||||
// `runtime_action` — the event log is runtime state no configuration file
|
||||
// declares, so file authority has nothing to say about deleting from it.
|
||||
.{ .method = .GET, .pattern = "/api/diagnostics", .auth = .session, .policy = .read, .handler = diagnostics.list },
|
||||
.{ .method = .DELETE, .pattern = "/api/diagnostics", .auth = .session, .policy = .runtime_action, .handler = diagnostics.purgeAll },
|
||||
.{ .method = .GET, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .read, .handler = diagnostics.get },
|
||||
.{ .method = .DELETE, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .runtime_action, .handler = diagnostics.purge },
|
||||
|
||||
// Groups.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
|
||||
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
|
||||
@@ -143,7 +152,7 @@ const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the table carries every endpoint of the milestone" {
|
||||
try testing.expectEqual(@as(usize, 56), table.len);
|
||||
try testing.expectEqual(@as(usize, 60), table.len);
|
||||
}
|
||||
|
||||
test "no two entries claim the same method and pattern" {
|
||||
@@ -231,6 +240,8 @@ test "the runtime actions are exactly ruling 7's list" {
|
||||
"POST /api/auth/login",
|
||||
"POST /api/auth/logout",
|
||||
"POST /api/blocklists/update",
|
||||
"DELETE /api/diagnostics",
|
||||
"DELETE /api/diagnostics/{id}",
|
||||
"DELETE /api/clients/{id}",
|
||||
"POST /api/pause",
|
||||
"POST /api/certs/reload",
|
||||
|
||||
@@ -32,6 +32,7 @@ const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const dns_handler = @import("../server/handler.zig");
|
||||
const doh_server = @import("../server/doh_server.zig");
|
||||
const dot_server = @import("../server/dot_server.zig");
|
||||
const events_mod = @import("../storage/events.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const listener_core = @import("../server/listener.zig");
|
||||
const local_tables_mod = @import("../server/local_tables.zig");
|
||||
@@ -183,6 +184,11 @@ pub const WebState = struct {
|
||||
/// concurrent writes would misread each other's row counts.
|
||||
config_lock: std.Io.Mutex = .init,
|
||||
querylog_db: ?*db.Db = null,
|
||||
/// The diagnostics event store, which owns a third connection of its own
|
||||
/// and serializes every access — read and write — through its mutex. Null
|
||||
/// when `Store.init` failed, which `/api/health` reports as `unavailable`
|
||||
/// and treats as degraded.
|
||||
events: ?*events_mod.Store = null,
|
||||
|
||||
version: []const u8 = "",
|
||||
/// The `--admin-dev` asset directory, read by the dev-mode fallback. Empty
|
||||
|
||||
@@ -31,6 +31,7 @@ const auth = @import("auth.zig");
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const dns_handler = @import("../server/handler.zig");
|
||||
const events_mod = @import("../storage/events.zig");
|
||||
const fetcher = @import("../filter/fetcher.zig");
|
||||
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
@@ -58,6 +59,7 @@ const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const handlers_blocklists = @import("handlers/blocklists.zig");
|
||||
const handlers_certs = @import("handlers/certs.zig");
|
||||
const handlers_diagnostics = @import("handlers/diagnostics.zig");
|
||||
const handlers_health = @import("handlers/health.zig");
|
||||
const handlers_live = @import("handlers/live.zig");
|
||||
const handlers_lookup = @import("handlers/lookup.zig");
|
||||
@@ -277,6 +279,10 @@ const Env = struct {
|
||||
tmp: testing.TmpDir,
|
||||
config_db: db.Db,
|
||||
querylog_db: db.Db,
|
||||
/// The diagnostics store's own connection, as in production: the store
|
||||
/// serializes every access through its mutex and shares it with nobody.
|
||||
events_db: db.Db,
|
||||
events_store: events_mod.Store,
|
||||
http_client: std.http.Client,
|
||||
transfer_buf: [fetcher.min_transfer_buf]u8,
|
||||
redirect_buf: [fetcher.redirect_buffer_len]u8,
|
||||
@@ -317,6 +323,13 @@ const Env = struct {
|
||||
try self.querylog_db.exec(querylog_schema.ddl);
|
||||
try seedQueryLog(&self.querylog_db);
|
||||
|
||||
self.events_db = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer self.events_db.close();
|
||||
try db.applyPragmas(&self.events_db, .{});
|
||||
_ = try migrations.migrate(&self.events_db);
|
||||
self.events_store = try events_mod.Store.init(ioh, &self.events_db, seeded_now);
|
||||
seedEvents(ioh, &self.events_store);
|
||||
|
||||
// Real fetcher wiring; nothing in this suite downloads (the one
|
||||
// refreshAll in the contract walk runs with zero source rows).
|
||||
self.http_client = .{ .allocator = gpa, .io = ioh };
|
||||
@@ -388,6 +401,7 @@ const Env = struct {
|
||||
.hub = self.hub,
|
||||
.config_db = &self.config_db,
|
||||
.querylog_db = &self.querylog_db,
|
||||
.events = &self.events_store,
|
||||
.version = "w10-test",
|
||||
.started_unix = std.Io.Clock.real.now(ioh).toSeconds(),
|
||||
.fallback = options.fallback,
|
||||
@@ -419,6 +433,7 @@ const Env = struct {
|
||||
self.limiter.deinit();
|
||||
self.mgr.deinit(ioh);
|
||||
self.http_client.deinit();
|
||||
self.events_db.close();
|
||||
self.querylog_db.close();
|
||||
self.config_db.close();
|
||||
self.tmp.cleanup();
|
||||
@@ -480,6 +495,21 @@ fn seedQueryLog(database: *db.Db) !void {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed instant, like every other seeded timestamp here: the contract
|
||||
/// samples are byte-compared, so nothing the walk writes may come from a clock.
|
||||
const seeded_now: i64 = 1_787_118_000;
|
||||
|
||||
/// One active episode and one resolved one, so `/api/diagnostics` answers with
|
||||
/// both states and the committed contract sample describes a real page rather
|
||||
/// than an empty one.
|
||||
fn seedEvents(io: std.Io, store: *events_mod.Store) void {
|
||||
store.report(io, seeded_now, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut");
|
||||
store.report(io, seeded_now + 300, .blocklist_refresh, "https://lists.example/ads.txt", "StevenBlack", .warning, "download failed: ConnectionTimedOut");
|
||||
|
||||
store.report(io, seeded_now + 60, .upstream_history_write, "history", "history", .warning, "Busy");
|
||||
store.resolve(io, seeded_now + 120, .upstream_history_write, "history");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the contract table (ruling 23)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -627,6 +657,15 @@ const contract = [_]Contract{
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
|
||||
|
||||
// Diagnostics. The seeded store holds one active episode (id 1) and one
|
||||
// resolved one, so both the page and the detail answer with real rows.
|
||||
.{ .method = .GET, .pattern = "/api/diagnostics", .auth = .session, .policy = .read, .target = "/api/diagnostics?limit=10", .status = 200, .check = jsonShape(events_mod.EventsPage) },
|
||||
.{ .method = .GET, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .read, .target = "/api/diagnostics/1", .status = 200, .check = jsonShape(events_mod.Event) },
|
||||
// The purges follow the reads: id 2 is the seeded resolved episode, and the
|
||||
// sweep after it takes whatever resolved history is left (none).
|
||||
.{ .method = .DELETE, .pattern = "/api/diagnostics/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/diagnostics/2", .status = 204, .kind = .none },
|
||||
.{ .method = .DELETE, .pattern = "/api/diagnostics", .auth = .session, .policy = .runtime_action, .target = "/api/diagnostics", .status = 200, .check = jsonShape(handlers_diagnostics.PurgeResult) },
|
||||
|
||||
// Groups. The migrated schema seeds `default` as id 1; the POST creates
|
||||
// id 2, which the delete at the end of the walk removes.
|
||||
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
|
||||
@@ -1000,6 +1039,157 @@ fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
|
||||
try conn.request("POST", "/api/certs/reload", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// Diagnostics are runtime state, not configuration: purging resolved
|
||||
// history is served under file authority like any other runtime action.
|
||||
try conn.request("DELETE", "/api/diagnostics", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expectEqualStrings("{\"purged\":1}", response.body);
|
||||
|
||||
try conn.request("DELETE", "/api/diagnostics/1", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 409), response.status);
|
||||
}
|
||||
|
||||
fn diagnosticsRejections(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// Every bad parameter is a 400 whose message names the parameter, rather
|
||||
// than a filter silently dropped — which would answer a question the client
|
||||
// did not ask.
|
||||
const bad = [_]struct { target: []const u8, needle: []const u8 }{
|
||||
.{ .target = "/api/diagnostics?state=open", .needle = "state" },
|
||||
.{ .target = "/api/diagnostics?severity=info", .needle = "severity" },
|
||||
.{ .target = "/api/diagnostics?since=yesterday", .needle = "since" },
|
||||
.{ .target = "/api/diagnostics?until=", .needle = "until" },
|
||||
.{ .target = "/api/diagnostics?limit=0", .needle = "limit" },
|
||||
.{ .target = "/api/diagnostics?limit=1001", .needle = "limit" },
|
||||
.{ .target = "/api/diagnostics?before=0", .needle = "before" },
|
||||
};
|
||||
for (bad) |case| {
|
||||
try conn.request("GET", case.target, null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
errdefer std.debug.print("{s}: {d} {s}\n", .{ case.target, response.status, response.body });
|
||||
try testing.expectEqual(@as(u16, 400), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, case.needle));
|
||||
}
|
||||
|
||||
// The resolved episode the seed left behind is reachable by id, and an id
|
||||
// nothing holds is a 404 rather than an empty object.
|
||||
try conn.request("GET", "/api/diagnostics?state=resolved", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "upstream_history.write"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"active\":{\"warnings\":1,\"errors\":0}"));
|
||||
|
||||
try conn.request("GET", "/api/diagnostics/999999", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), response.status);
|
||||
}
|
||||
|
||||
fn diagnosticsPurge(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// Row 1 is the seeded active episode: still the state of the box, so the
|
||||
// purge is refused with a message that says what would change that.
|
||||
try conn.request("DELETE", "/api/diagnostics/1", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 409), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "still active"));
|
||||
|
||||
try conn.request("DELETE", "/api/diagnostics/999999", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), response.status);
|
||||
|
||||
// Row 2 is the seeded resolved episode.
|
||||
try conn.request("DELETE", "/api/diagnostics/2", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 204), response.status);
|
||||
try testing.expectEqualStrings("", response.body);
|
||||
|
||||
// Gone is a different answer from still open, even for a row that existed a
|
||||
// moment ago.
|
||||
try conn.request("DELETE", "/api/diagnostics/2", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), response.status);
|
||||
|
||||
// Nothing resolved is left, and the sweep says so rather than failing.
|
||||
try conn.request("DELETE", "/api/diagnostics", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expectEqualStrings("{\"purged\":0}", response.body);
|
||||
|
||||
// The active episode survived every one of those, counts included.
|
||||
try conn.request("GET", "/api/diagnostics", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "blocklist.refresh"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"active\":{\"warnings\":1,\"errors\":0}"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "upstream_history.write"));
|
||||
}
|
||||
|
||||
test "W10 milestone 27: a purge takes resolved events only, and says which of the three answers it gave" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, diagnosticsPurge, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn diagnosticsPurgeAll(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [8192]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
// A second resolved episode, so the count the sweep reports is a number it
|
||||
// had to compute rather than the one row the seed leaves.
|
||||
env.events_store.reportResolved(io, seeded_now, .query_log_recreated, "one-shot", "corrupt", .warning, "aside");
|
||||
|
||||
try conn.request("DELETE", "/api/diagnostics", null, null);
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expectEqualStrings("{\"purged\":2}", response.body);
|
||||
|
||||
try conn.request("GET", "/api/diagnostics?state=resolved", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "\"events\":[]"));
|
||||
|
||||
// And the episode that is still failing is untouched: the operator clearing
|
||||
// the page cannot lose what is still true.
|
||||
try conn.request("GET", "/api/diagnostics?state=active", null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "blocklist.refresh"));
|
||||
}
|
||||
|
||||
test "W10 milestone 27: purging all resolved events counts them and leaves the active ones" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, diagnosticsPurgeAll, .{ env.io(), env });
|
||||
}
|
||||
|
||||
test "W10 milestone 27: every diagnostics filter names itself in a 400, and an unknown id is a 404" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, diagnosticsRejections, .{ env.io(), env });
|
||||
}
|
||||
|
||||
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
|
||||
@@ -2197,6 +2387,15 @@ const contract_sample_walk = [_]ContractSample{
|
||||
.{ .name = "login", .ts_type = "LoginResponse", .method = "POST", .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200 },
|
||||
.{ .name = "logout", .ts_type = "LogoutResponse", .method = "POST", .target = "/api/auth/logout", .body = "{}", .status = 200 },
|
||||
|
||||
// Diagnostics, ahead of every write below: the seeded store holds one
|
||||
// active episode (id 1) and one resolved one, and a later pass that
|
||||
// reported an event of its own would move the page under the golden.
|
||||
.{ .name = "get_diagnostics", .ts_type = "DiagnosticsPage", .method = "GET", .target = "/api/diagnostics?limit=10", .status = 200 },
|
||||
.{ .name = "get_diagnostic", .ts_type = "DiagnosticEvent", .method = "GET", .target = "/api/diagnostics/1", .status = 200 },
|
||||
// The sweep runs after both reads and takes the seeded resolved episode;
|
||||
// the per-id purge answers 204, which has no body to sample.
|
||||
.{ .name = "purge_diagnostics", .ts_type = "DiagnosticsPurge", .method = "DELETE", .target = "/api/diagnostics", .status = 200 },
|
||||
|
||||
// Blocklists. The row is created disabled so the refresh below has a status
|
||||
// to report and still downloads nothing.
|
||||
.{ .name = "create_blocklist", .ts_type = "BlocklistEcho", .method = "POST", .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\",\"enabled\":false}", .status = 201 },
|
||||
|
||||
Reference in New Issue
Block a user