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
575 lines
20 KiB
Zig
575 lines
20 KiB
Zig
//! 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);
|
||
}
|