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
+31
View File
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import CoverageNotice from "./CoverageNotice";
import { formatTime } from "./format";
const WATERMARK = 1_700_000_000;
test("a window the log covers in full says nothing", () => {
const { container } = render(<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />);
expect(container.textContent).toBe("");
});
test("a window reaching past the watermark names the instant history starts", () => {
render(<CoverageNotice coverage={{ complete: false, available_since: WATERMARK }} />);
const notice = screen.getByRole("status");
expect(notice.textContent).toContain("Query history is available from");
expect(notice.textContent).toContain(formatTime(WATERMARK));
});
/**
* The server judges completeness, not the page: an unbounded request is
* incomplete whatever the watermark reads, and the notice must follow that
* verdict rather than compare timestamps itself.
*/
test("the server's verdict decides, not the numbers beside it", () => {
const { rerender, container } = render(
<CoverageNotice coverage={{ complete: true, available_since: WATERMARK }} />,
);
expect(container.textContent).toBe("");
rerender(<CoverageNotice coverage={{ complete: false, available_since: 0 }} />);
expect(screen.getByRole("status").textContent).toContain(formatTime(0));
});
+38
View File
@@ -0,0 +1,38 @@
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Coverage } from "@/lib/types";
import { colors } from "@/ui/tokens.stylex";
const styles = stylex.create({
notice: {
marginTop: "0.75rem",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceHover,
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textSecondary,
},
});
/**
* How far back the numbers on this page can reach.
*
* Rendered whenever a response says its window is incomplete, which includes
* the common case of a request with no lower bound at all. The line states the
* watermark and nothing more: the same incompleteness covers a log retention
* has pruned and one that simply has not been running long enough, and the
* response does not say which.
*/
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
if (coverage.complete) return null;
return (
<p role="status" {...stylex.props(styles.notice)}>
Query history is available from {formatTime(coverage.available_since)}.
</p>
);
}
+4
View File
@@ -26,6 +26,7 @@ import type {
Period,
QueriesFilter,
QueriesPage,
QueryDetail,
Rule,
RuleEcho,
RuleInput,
@@ -112,6 +113,9 @@ export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout",
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
request(`/api/queries${qs({ ...filter })}`);
/** Full provenance of one logged row. 404 when retention has removed it, 503 with no query log. */
export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/api/queries/${id}`);
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
+96 -28
View File
@@ -29,6 +29,7 @@ import type {
LookupResult,
PauseState,
QueriesPage,
QueryDetail,
Rule,
RuleEcho,
SettingsEnvelope,
@@ -415,76 +416,139 @@ export const sample_get_upstream_health: UpstreamHealth = {
};
export const sample_get_queries: QueriesPage = {
coverage: {
available_since: 0,
complete: false,
},
next_before: 0,
queries: [
{
block_reason: "",
blocked: true,
cache_hit: null,
client_ip: "192.0.2.11",
domain: "shop.example",
id: 0,
policy_action: "block",
policy_reason: "blocklist_wildcard",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "blocked",
ts: 0,
upstream: "",
},
{
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "news.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "upstream",
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d24.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "cache",
ts: 0,
upstream: "https://dns.example/dns-query",
upstream: "",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d23.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
route_kind: "upstream",
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d22.example",
id: 0,
policy_action: "allow",
policy_reason: "no_match",
qclass: 0,
qtype: 0,
rcode: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d21.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "blocklist_domain",
blocked: true,
cache_hit: null,
client_ip: "192.0.2.10",
domain: "d20.example",
id: 0,
qtype: 0,
response_time_us: 0,
route_kind: "cache",
ts: 0,
upstream: "",
},
],
};
export const sample_get_query_detail: QueryDetail = {
group: {
id: 0,
name: "kids",
},
id: 0,
policy: {
action: "block",
matched: "||tracker.example^",
reason: "blocklist_wildcard",
source_id: 0,
source_name: "StevenBlack",
},
request: {
client: "192.0.2.11",
domain: "shop.example",
qclass: 0,
qtype: 0,
time: 0,
},
response: {
duration_us: 0,
rcode: 0,
},
rewrites: {
cname_target: "cdn.tracker.example",
safe_search_target: "",
},
route: {
forward_zone: "",
kind: "blocked",
upstream: "",
},
};
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
cached: 0,
clients: 0,
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
queries: 0,
since: 0,
@@ -501,6 +565,10 @@ export const sample_get_stats_timeseries: StatsTimeseries = {
ts: 0,
},
],
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
until: 0,
+4
View File
@@ -25,6 +25,7 @@ export const queryKeys = {
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
diagnostic: (id: number) => ["diagnostics", "event", id] as const,
/** Prefix of every diagnostics entry, page and detail alike; the purge target. */
@@ -75,6 +76,9 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
placeholderData: keepPreviousData,
});
export const queryDetailQuery = (id: number) =>
queryOptions({ queryKey: queryKeys.queryDetail(id), queryFn: () => api.getQueryDetail(id) });
// Keyset pagination on `next_before`, exactly as the query log pages
// (handlers/diagnostics.zig copies the /api/queries contract). The active view
// polls on healthQuery's cadence because an episode opening is the same news a
+113 -3
View File
@@ -60,25 +60,133 @@ export interface LogoutResponse {
authenticated: false;
}
/**
* The three closed enums `src/storage/provenance.zig` stores, as values rather
* than bare types: the copy maps in `features/queries/provenanceCopy.ts` have to
* be proven exhaustive at runtime as well as by `tsc`, exactly as
* `DIAGNOSTIC_CODES` below.
*/
export const POLICY_ACTIONS = ["not_evaluated", "allow", "block"] as const;
export type PolicyAction = (typeof POLICY_ACTIONS)[number];
export const POLICY_REASONS = [
"rule_allow_exact",
"rule_block_exact",
"rule_allow_wildcard",
"rule_block_wildcard",
"rule_allow_regex",
"rule_block_regex",
"blocklist_exception",
"blocklist_domain",
"blocklist_wildcard",
"local_record",
"forward_zone",
"non_in_class",
"paused",
"snapshot_unavailable",
"no_match",
"protocol_error",
] as const;
export type PolicyReason = (typeof POLICY_REASONS)[number];
export const ROUTE_KINDS = ["blocked", "local", "forward_zone", "upstream", "cache", "rejected"] as const;
export type RouteKind = (typeof ROUTE_KINDS)[number];
/** The summary projection the query-log table scans; full provenance is at `/api/queries/{id}`. */
export interface QueryRow {
id: number;
ts: number;
domain: string;
client_ip: string;
qtype: number | null;
qclass: number;
rcode: number;
blocked: boolean;
block_reason: string;
response_time_us: number | null;
cache_hit: boolean | null;
upstream: string;
policy_action: PolicyAction;
policy_reason: PolicyReason;
route_kind: RouteKind;
}
/** SSE `event: query` payload: a QueryRow minus `id` (precedes persistence). */
export type LiveQueryEvent = Omit<QueryRow, "id">;
export interface ProvenanceRequest {
time: number;
domain: string;
client: string;
qtype: number | null;
qclass: number;
}
/** The group as a historical fact: the id may name a group since renamed or deleted. */
export interface ProvenanceGroup {
id: number | null;
name: string;
}
export interface ProvenancePolicy {
action: PolicyAction;
reason: PolicyReason;
matched: string;
source_id: number | null;
source_name: string;
}
export interface ProvenanceRewrites {
cname_target: string;
safe_search_target: string;
}
export interface ProvenanceRoute {
kind: RouteKind;
forward_zone: string;
/** Non-empty only for an attempted upstream or forward-zone exchange; already redacted. */
upstream: string;
}
export interface ProvenanceResponse {
/** The twelve-bit EDNS extended code, not the four header bits alone. */
rcode: number;
duration_us: number | null;
}
/**
* One query, fully explained, in the order a query meets the pipeline
* (`src/web/provenance_view.zig`). Every text field follows the repository
* convention: `""` means absent.
*/
export interface Provenance {
request: ProvenanceRequest;
group: ProvenanceGroup;
policy: ProvenancePolicy;
rewrites: ProvenanceRewrites;
route: ProvenanceRoute;
response: ProvenanceResponse;
}
/** `GET /api/queries/{id}`: the same six groups plus the row id. */
export interface QueryDetail extends Provenance {
id: number;
}
/** SSE `event: query` payload: the full provenance, minus an id it cannot have yet. */
export type LiveQueryEvent = Provenance;
/**
* How much of the requested window the query log can answer for. Retention
* deletes rows and advances the watermark in one transaction, so an empty
* window is distinguishable from a pruned one.
*/
export interface Coverage {
complete: boolean;
/** The oldest instant the log is complete for, unix seconds. */
available_since: number;
}
export interface QueriesPage {
queries: QueryRow[];
next_before: number | null;
coverage: Coverage;
}
export interface QueriesFilter {
@@ -171,6 +279,7 @@ export interface StatsTotals {
cached: number;
clients: number;
avg_response_time_us: number | null;
coverage: Coverage;
}
export interface Bucket {
@@ -186,6 +295,7 @@ export interface StatsTimeseries {
until: number;
bucket_seconds: number;
buckets: Bucket[];
coverage: Coverage;
}
export interface LookupResult {