milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
query rows gain qclass, rcode, group, policy action and reason, the matched rule or list entry with its source, cname and safe-search targets, route kind, forward zone, and the resolver that actually answered — the pool and local markers die. servfails are logged and name the resolver that lost; post-parse protocol refusals become rows. a detail page at /queries/:id renders the ordered explanation, and coverage watermarks distinguish an empty history from a missing one. the schema fingerprint changes: existing query history is recreated with the old file kept aside and the reset filed as a resolved diagnostic. fixes an oversized udp reply being rebuilt as noerror, which handed clients a truncated nxdomain as success.
This commit is contained in:
+33
-37
@@ -23,7 +23,7 @@ const std = @import("std");
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const provenance_view = @import("../provenance_view.zig");
|
||||
const server = @import("../server.zig");
|
||||
const sse = @import("../sse.zig");
|
||||
|
||||
@@ -36,32 +36,13 @@ pub const heartbeat_interval: std.Io.Clock.Duration = .{
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// One event's `data:` payload — the `/api/queries` row fields (ruling 20),
|
||||
/// minus `id`: a live entry precedes persistence, so no row id exists yet.
|
||||
pub const EventView = struct {
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
};
|
||||
/// One event's `data:` payload: the shared full-provenance DTO, exactly. A live
|
||||
/// event says everything `GET /api/queries/{id}` would say about the same query
|
||||
/// except its id, which does not exist yet — the entry precedes its own insert.
|
||||
pub const EventView = provenance_view.Provenance;
|
||||
|
||||
pub fn view(entry: *const sse.Entry) EventView {
|
||||
return .{
|
||||
.ts = entry.timestamp,
|
||||
.domain = entry.domain(),
|
||||
.client_ip = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.blocked = entry.blocked,
|
||||
.block_reason = entry.blockReason(),
|
||||
.response_time_us = entry.response_time_us,
|
||||
.cache_hit = entry.cache_hit,
|
||||
.upstream = entry.upstream(),
|
||||
};
|
||||
return provenance_view.fromEntry(entry);
|
||||
}
|
||||
|
||||
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||
@@ -139,14 +120,18 @@ pub fn stream(
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the event payload carries the /api/queries row fields, minus id" {
|
||||
const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields;
|
||||
test "the event payload is the detail body minus its id, name and type for name" {
|
||||
const detail_fields = @typeInfo(provenance_view.QueryDetail).@"struct".fields;
|
||||
const view_fields = @typeInfo(EventView).@"struct".fields;
|
||||
comptime {
|
||||
std.debug.assert(view_fields.len == row_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id"));
|
||||
for (row_fields[1..], view_fields) |row_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name));
|
||||
std.debug.assert(view_fields.len == detail_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, detail_fields[0].name, "id"));
|
||||
for (detail_fields[1..], view_fields) |detail_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, detail_field.name, view_field.name));
|
||||
// Names alone would let a group keep its key while changing what it
|
||||
// holds, which is the drift a live viewer would see and a detail
|
||||
// page would not.
|
||||
std.debug.assert(detail_field.type == view_field.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,11 +142,19 @@ test "a frame is one event line and one data line of JSON" {
|
||||
.domain = "ads.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist_domain",
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_domain,
|
||||
.matched = "ads.example",
|
||||
.source_id = 3,
|
||||
.source_name = "StevenBlack",
|
||||
.route_kind = .blocked,
|
||||
.response_time_us = 42,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
@@ -172,10 +165,12 @@ test "a frame is one event line and one data line of JSON" {
|
||||
try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {"));
|
||||
try testing.expect(std.mem.endsWith(u8, frame, "}\n\n"));
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"time\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"group\":{\"id\":1,\"name\":\"default\"}"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"reason\":\"blocklist_domain\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"source_name\":\"StevenBlack\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"kind\":\"blocked\""));
|
||||
}
|
||||
|
||||
test "an unlogged field stays null and an empty string stays a string" {
|
||||
@@ -191,6 +186,7 @@ test "an unlogged field stays null and an empty string stays a string" {
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"duration_us\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"id\":null"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user