//! `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); }