Files
nxdns/src/web/handlers/stats.zig
T
mokhtar cc23c97218
Gates / frontend (push) Successful in 1m34s
Gates / test (push) Successful in 2m3s
Gates / test-aarch64 (push) Failing after 3h13m33s
Gates / package (push) Successful in 5m20s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 6h30m45s
milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
2026-08-22 23:31:37 +02:00

575 lines
20 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The five period endpoints: `GET /api/stats` and `/api/stats/timeseries`
//! (ruling 13), and `/api/stats/types`, `/api/stats/routes` and
//! `/api/stats/clients` (milestone 30).
//!
//! One period grammar, four widths, and one window shared by all five: a
//! request for the same period gets the same `since`/`until` from every
//! endpoint, so the totals describe exactly the span the charts draw rather
//! than a neighbouring one.
//!
//! That is window coherence, not identical counts. Each endpoint is its own
//! request against its own snapshot, so queries logged between two of them move
//! one panel and not the other. Only a box with nothing writing to it — a test
//! — can expect the breakdowns to sum to the totals exactly.
//!
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
//! width divides a day, so flooring the current time to a multiple of the width
//! puts each bucket on the same boundary a human reads off a clock, and two
//! requests a second apart return the same bucket starts. The last bucket is
//! the one in progress; it fills as the period runs.
//!
//! The aggregates run on the web task's own query-log connection (m7 ruling 21),
//! which every connection task shares. SQLite's serialized mode makes one call
//! safe; it does not make a transaction safe, so `WebState.querylog_lock` covers
//! the whole read and a second BEGIN can never land inside the first. Each
//! response takes one deferred read transaction, so its aggregate and the
//! `coverage` beside it describe one database state: retention cannot prune
//! between them and hand a client pre-prune rows tagged with a post-prune
//! watermark. Deferred, not `db.Tx`'s BEGIN IMMEDIATE, which would stall the
//! logger and retention behind an HTTP response.
//!
//! The lock is released before the response is written: the body is already
//! built in the request arena, and holding a database lock across a socket
//! write would let one slow client serialize every other reader.
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");
const server = @import("../server.zig");
const log = std.log.scoped(.web_stats);
/// The four periods ruling 13 defines. The tag names are the wire spellings.
pub const Period = enum {
@"1h",
@"24h",
@"7d",
@"30d",
pub fn parse(text: []const u8) ?Period {
return std.meta.stringToEnum(Period, text);
}
/// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h.
pub fn bucketSeconds(self: Period) u32 {
return switch (self) {
.@"1h" => 60,
.@"24h" => 30 * 60,
.@"7d" => 60 * 60,
.@"30d" => 6 * 60 * 60,
};
}
pub fn bucketCount(self: Period) u32 {
return switch (self) {
.@"1h" => 60,
.@"24h" => 48,
.@"7d" => 168,
.@"30d" => 120,
};
}
pub fn label(self: Period) []const u8 {
return @tagName(self);
}
};
pub const default_period: Period = .@"24h";
/// The widest period's bucket count, so one stack array serves every request.
pub const max_buckets = 168;
comptime {
for (std.enums.values(Period)) |period| {
std.debug.assert(period.bucketCount() <= max_buckets);
// The UTC alignment argument holds only while every width divides a day.
std.debug.assert(86_400 % period.bucketSeconds() == 0);
}
}
pub const Window = struct {
/// Inclusive, on the bucket grid.
since: i64,
/// Exclusive: the end of the bucket that `now` falls in.
until: i64,
bucket_seconds: u32,
bucket_count: u32,
};
pub fn window(period: Period, now_unix: i64) Window {
const width: i64 = period.bucketSeconds();
const count: i64 = period.bucketCount();
const until = @divFloor(now_unix, width) * width + width;
return .{
.since = until - width * count,
.until = until,
.bucket_seconds = period.bucketSeconds(),
.bucket_count = period.bucketCount(),
};
}
pub const TotalsBody = struct {
period: []const u8,
since: i64,
until: i64,
queries: u64,
blocked: 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 {
period: []const u8,
since: i64,
until: i64,
bucket_seconds: u32,
buckets: []const queries_repo.Bucket,
coverage: coverage.Coverage,
};
pub const TypesBody = struct {
period: []const u8,
since: i64,
until: i64,
types: []const queries_repo.TypeCount,
coverage: coverage.Coverage,
};
pub const RoutesBody = struct {
period: []const u8,
since: i64,
until: i64,
routes: []const queries_repo.RouteCount,
coverage: coverage.Coverage,
};
pub const ClientsBody = struct {
period: []const u8,
since: i64,
until: i64,
bucket_seconds: u32,
clients: []const queries_repo.ClientSeries,
other: []const u64,
coverage: coverage.Coverage,
};
/// Everything one response reads from the query log, so the caller can end the
/// transaction and drop the lock before it serializes anything.
fn Read(comptime T: type) type {
return struct {
data: T,
coverage: coverage.Coverage,
};
}
const ReadScope = server.QuerylogRead;
fn readTotals(
state: *server.WebState,
io: std.Io,
database: *db.Db,
span: Window,
) db.Error!Read(queries_repo.StatsTotals) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(queries_repo.StatsTotals) = .{
.data = try queries_repo.statsTotals(database, span.since, span.until),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readTimeseries(
state: *server.WebState,
io: std.Io,
database: *db.Db,
span: Window,
out: []queries_repo.Bucket,
) db.Error!Read(usize) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(usize) = .{
.data = try queries_repo.timeseries(database, span.since, span.bucket_seconds, out),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readTypes(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read([]const queries_repo.TypeCount) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const list = try queries_repo.statsTypes(database, arena, span.since, span.until);
const read: Read([]const queries_repo.TypeCount) = .{
.data = list.items,
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readRoutes(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read([]const queries_repo.RouteCount) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const list = try queries_repo.statsRoutes(database, arena, span.since, span.until);
const read: Read([]const queries_repo.RouteCount) = .{
.data = list.items,
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
fn readClients(
state: *server.WebState,
io: std.Io,
database: *db.Db,
arena: std.mem.Allocator,
span: Window,
) db.Error!Read(queries_repo.ClientsBreakdown) {
var scope = try ReadScope.open(state, io, database);
errdefer scope.abort();
const read: Read(queries_repo.ClientsBreakdown) = .{
.data = try queries_repo.statsClients(
database,
arena,
span.since,
span.bucket_seconds,
span.bucket_count,
),
.coverage = try coverage.read(database, span.since),
};
try scope.commit();
return read;
}
pub fn totals(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readTotals(state, io, database, span) catch |err| {
return internal(request, "stats totals", err);
};
return http_util.respondJson(request, .ok, TotalsBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.queries = read.data.queries,
.blocked = read.data.blocked,
.clients = read.data.distinct_clients,
.avg_response_time_us = read.data.avg_response_time_us,
.coverage = read.coverage,
}, &.{});
}
pub fn timeseries(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
var buckets: [max_buckets]queries_repo.Bucket = undefined;
const out = buckets[0..span.bucket_count];
const read = readTimeseries(state, io, database, span, out) catch |err| {
return internal(request, "stats timeseries", err);
};
return http_util.respondJson(request, .ok, TimeseriesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.buckets = out[0..read.data],
.coverage = read.coverage,
}, &.{});
}
pub fn types(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readTypes(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats types", err);
};
return http_util.respondJson(request, .ok, TypesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.types = read.data,
.coverage = read.coverage,
}, &.{});
}
pub fn routes(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readRoutes(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats routes", err);
};
return http_util.respondJson(request, .ok, RoutesBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.routes = read.data,
.coverage = read.coverage,
}, &.{});
}
pub fn clients(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
const period = periodParam(request.query) catch return badPeriod(request);
const database = state.querylog_db orelse return unavailable(request);
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
const read = readClients(state, io, database, request.arena, span) catch |err| {
return internal(request, "stats clients", err);
};
return http_util.respondJson(request, .ok, ClientsBody{
.period = period.label(),
.since = span.since,
.until = span.until,
.bucket_seconds = span.bucket_seconds,
.clients = read.data.clients,
.other = read.data.other,
.coverage = read.coverage,
}, &.{});
}
pub const PeriodError = error{BadPeriod};
/// An absent `period` is the default; anything else it cannot read is a 400,
/// never a silent fallback — a typo must not return a window nobody asked for.
fn periodParam(query: []const u8) PeriodError!Period {
var buf: [8]u8 = undefined;
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
const text = found orelse return default_period;
return Period.parse(text) orelse error.BadPeriod;
}
fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
}
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
return http_util.respondError(request, .service_unavailable, "query log unavailable");
}
/// The one thing this file logs. A failed aggregate is a fault in the box, not
/// a property of the request, and the client is told nothing beyond "internal
/// error" (ruling 8, PLAN §19).
fn internal(
request: *http_util.Request,
what: []const u8,
err: db.Error,
) http_util.HandlerError!void {
log.warn("{s} failed: {s}", .{ what, @errorName(err) });
return http_util.respondError(request, .internal_server_error, "internal error");
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const querylog_schema = @import("../../storage/querylog_schema.zig");
const testing = std.testing;
test "the period grammar accepts exactly the four spellings" {
try testing.expectEqual(Period.@"1h", Period.parse("1h").?);
try testing.expectEqual(Period.@"24h", Period.parse("24h").?);
try testing.expectEqual(Period.@"7d", Period.parse("7d").?);
try testing.expectEqual(Period.@"30d", Period.parse("30d").?);
try testing.expectEqual(@as(?Period, null), Period.parse("12h"));
try testing.expectEqual(@as(?Period, null), Period.parse("1H"));
try testing.expectEqual(@as(?Period, null), Period.parse(""));
}
test "an absent period defaults and a bad one is rejected" {
try testing.expectEqual(default_period, try periodParam(""));
try testing.expectEqual(default_period, try periodParam("limit=5"));
try testing.expectEqual(Period.@"7d", try periodParam("period=7d"));
try testing.expectError(error.BadPeriod, periodParam("period=12h"));
try testing.expectError(error.BadPeriod, periodParam("period=%2"));
// Longer than any spelling: rejected rather than truncated to "1h".
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
}
test "each period spans its own bucket width times its count" {
for (std.enums.values(Period)) |period| {
const span = window(period, 1_700_000_000);
const width: i64 = period.bucketSeconds();
try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since);
}
}
test "the window sits on the UTC grid and ends with the bucket in progress" {
// 2023-11-14T22:13:20Z, which is not on any bucket boundary.
const now: i64 = 1_700_000_000;
const span = window(.@"24h", now);
try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800));
try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800));
try testing.expect(span.until > now);
try testing.expect(span.until - now <= 1800);
try testing.expectEqual(@as(u32, 48), span.bucket_count);
}
test "two requests inside one bucket see the same window" {
// A bucket boundary, so the offsets below stay inside one minute.
const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60);
const first = window(.@"1h", boundary);
const second = window(.@"1h", boundary + 59);
try testing.expectEqual(first.since, second.since);
try testing.expectEqual(first.until, second.until);
const next = window(.@"1h", boundary + 60);
try testing.expectEqual(first.until + 60, next.until);
}
test "a timestamp exactly on a boundary starts a new bucket" {
const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600);
try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600));
try testing.expectEqual(@as(u32, 168), span.bucket_count);
}
fn openLog() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
try database.exec(querylog_schema.ddl);
return database;
}
fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void {
const rows = [_]queries_repo.Row{.{
.timestamp = timestamp,
.domain = "example.com",
.client_ip = "192.0.2.10",
.qtype = 1,
.qclass = 1,
.rcode = 0,
.blocked = blocked,
.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);
}
test "the totals and the buckets agree over the same window" {
var database = try openLog();
defer database.close();
const now: i64 = 1_700_000_000;
const span = window(.@"1h", now);
var writer = try queries_repo.BatchWriter.init(&database);
defer writer.deinit();
// One row in the first bucket, two in the last, one just outside.
try writeRow(&writer, span.since, false, false);
try writeRow(&writer, span.until - 1, true, false);
try writeRow(&writer, span.until - 2, false, true);
try writeRow(&writer, span.since - 1, false, false);
const result = try queries_repo.statsTotals(&database, span.since, span.until);
try testing.expectEqual(@as(u64, 3), result.queries);
try testing.expectEqual(@as(u64, 1), result.blocked);
try testing.expectEqual(@as(u64, 1), result.distinct_clients);
try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);
var buckets: [max_buckets]queries_repo.Bucket = undefined;
const out = buckets[0..span.bucket_count];
const written = try queries_repo.timeseries(&database, span.since, span.bucket_seconds, out);
try testing.expectEqual(@as(usize, 60), written);
var summed: u64 = 0;
var blocked: u64 = 0;
for (out) |bucket| {
summed += bucket.queries;
blocked += bucket.blocked;
}
try testing.expectEqual(result.queries, summed);
try testing.expectEqual(result.blocked, blocked);
try testing.expectEqual(span.since, out[0].ts);
try testing.expectEqual(@as(u64, 1), out[0].queries);
try testing.expectEqual(@as(u64, 2), out[59].queries);
try testing.expectEqual(span.until - span.bucket_seconds, out[59].ts);
}
test "an empty window reports zeros with a null mean" {
var database = try openLog();
defer database.close();
const span = window(.@"30d", 1_700_000_000);
const result = try queries_repo.statsTotals(&database, span.since, span.until);
try testing.expectEqual(@as(u64, 0), result.queries);
try testing.expectEqual(@as(?i64, null), result.avg_response_time_us);
var buckets: [max_buckets]queries_repo.Bucket = undefined;
const out = buckets[0..span.bucket_count];
try testing.expectEqual(@as(usize, 120), try queries_repo.timeseries(
&database,
span.since,
span.bucket_seconds,
out,
));
for (out) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries);
}