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:
@@ -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));
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user