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

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:
2026-08-22 09:16:40 +02:00
parent 7e6cb507d2
commit 0fd6bbd312
65 changed files with 7036 additions and 685 deletions
+27 -1
View File
@@ -14,6 +14,7 @@
const std = @import("std");
const coverage = @import("../coverage.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
@@ -99,6 +100,10 @@ pub const TotalsBody = struct {
cached: u64,
clients: u64,
avg_response_time_us: ?i64,
/// Judged against `since`, which is the window this body reports on — so a
/// dashboard can say "history starts here" instead of charting a pruned
/// stretch as a quiet one.
coverage: coverage.Coverage,
};
pub const TimeseriesBody = struct {
@@ -107,6 +112,7 @@ pub const TimeseriesBody = struct {
until: i64,
bucket_seconds: u32,
buckets: []const queries_repo.Bucket,
coverage: coverage.Coverage,
};
pub fn totals(
@@ -121,6 +127,9 @@ pub fn totals(
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
return internal(request, "stats totals", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TotalsBody{
.period = period.label(),
@@ -131,6 +140,7 @@ pub fn totals(
.cached = result.cached,
.clients = result.distinct_clients,
.avg_response_time_us = result.avg_response_time_us,
.coverage = covered,
}, &.{});
}
@@ -148,6 +158,9 @@ pub fn timeseries(
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
return internal(request, "stats timeseries", err);
};
const covered = coverage.read(database, span.since) catch |err| {
return internal(request, "stats coverage", err);
};
return http_util.respondJson(request, .ok, TimeseriesBody{
.period = period.label(),
@@ -155,6 +168,7 @@ pub fn timeseries(
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.buckets = out[0..written],
.coverage = covered,
}, &.{});
}
@@ -268,11 +282,23 @@ fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, ca
.domain = "example.com",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = blocked,
.block_reason = if (blocked) "blocklist_domain" else null,
.response_time_us = 1000,
.cache_hit = cached,
.upstream = null,
.group_id = 1,
.group_name = "default",
.policy_action = if (blocked) .block else .allow,
.policy_reason = if (blocked) .blocklist_domain else .no_match,
.matched = null,
.source_id = null,
.source_name = null,
.cname_target = null,
.safe_search_target = null,
.route_kind = if (blocked) .blocked else .upstream,
.forward_zone = null,
}};
try writer.writeBatch(&rows);
}