overview: one endpoint, live projections and a response cache (m36)

This commit is contained in:
2026-08-27 17:48:20 +02:00
parent 7cdb74ec17
commit c8470724ce
40 changed files with 3298 additions and 2242 deletions
+77 -97
View File
@@ -83,7 +83,7 @@ const handlers_lookup = @import("handlers/lookup.zig");
const handlers_pause = @import("handlers/pause.zig");
const handlers_queries = @import("handlers/queries.zig");
const handlers_settings = @import("handlers/settings.zig");
const handlers_stats = @import("handlers/stats.zig");
const handlers_overview = @import("handlers/overview.zig");
const handlers_version = @import("handlers/version.zig");
const Certificate = std.crypto.Certificate;
@@ -680,6 +680,7 @@ const Env = struct {
self.state.live_hash.deinit(gpa);
self.state.proxies.deinit(gpa);
self.state.overview_cache.deinit(gpa);
self.tables.deinit(gpa);
gpa.destroy(self.hub);
self.limiter.deinit();
@@ -738,7 +739,7 @@ fn seedQueryLog(database: *db.Db) !void {
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
);
var writer = try queries_repo.BatchWriter.init(database);
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
defer writer.deinit();
var domain_buf: [32]u8 = undefined;
@@ -838,7 +839,7 @@ fn seedQueryLog(database: *db.Db) !void {
const recent_clients = 3;
fn seedRecentTraffic(database: *db.Db, now: i64) !void {
var writer = try queries_repo.BatchWriter.init(database);
var writer = try queries_repo.BatchWriter.init(testing.allocator, database);
defer writer.deinit();
const Shape = struct {
@@ -1048,15 +1049,11 @@ const contract = [_]Contract{
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
// Query log, stats, live stream, upstream health.
// Query log, overview, live stream, upstream health.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .target = "/api/queries/27", .status = 200, .check = jsonShape(provenance_view.QueryDetail) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/stats/types", .auth = .session, .policy = .read, .target = "/api/stats/types?period=1h", .status = 200, .check = jsonShape(handlers_stats.TypesBody) },
.{ .method = .GET, .pattern = "/api/stats/routes", .auth = .session, .policy = .read, .target = "/api/stats/routes?period=1h", .status = 200, .check = jsonShape(handlers_stats.RoutesBody) },
.{ .method = .GET, .pattern = "/api/stats/clients", .auth = .session, .policy = .read, .target = "/api/stats/clients?period=1h", .status = 200, .check = jsonShape(handlers_stats.ClientsBody) },
.{ .method = .GET, .pattern = "/api/overview", .auth = .session, .policy = .read, .target = "/api/overview?period=1h", .status = 200, .check = jsonShape(handlers_overview.Body) },
// Diagnostics. The seeded store holds one active episode (id 1) and one
// resolved one, so both the page and the detail answer with real rows.
@@ -2587,11 +2584,7 @@ fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
const targets = [_][]const u8{
"/api/queries/1",
"/api/queries?limit=1",
"/api/stats",
"/api/stats/timeseries",
"/api/stats/types",
"/api/stats/routes",
"/api/stats/clients",
"/api/overview",
};
for (targets) |target| {
try conn.request("GET", target, null, null);
@@ -2657,27 +2650,20 @@ fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
);
try testing.expect(!partial.coverage.complete);
// The stats endpoints judge the same watermark against their own aligned
// window, which for any live period starts well after the seeded rows.
try conn.request("GET", "/api/stats?period=1h", null, null);
const totals = try std.json.parseFromSliceLeaky(
handlers_stats.TotalsBody,
// The overview judges the same watermark against its own aligned window,
// which for any live period starts well after the seeded rows.
try conn.request("GET", "/api/overview?period=1h", null, null);
const overview_body = try std.json.parseFromSliceLeaky(
handlers_overview.Body,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
);
try testing.expectEqual(seeded_available_since, totals.coverage.available_since);
try testing.expectEqual(totals.since >= seeded_available_since, totals.coverage.complete);
try conn.request("GET", "/api/stats/timeseries?period=1h", null, null);
const series = try std.json.parseFromSliceLeaky(
handlers_stats.TimeseriesBody,
arena,
(try conn.receive(&body_buf)).body,
.{ .ignore_unknown_fields = false },
try testing.expectEqual(seeded_available_since, overview_body.coverage.available_since);
try testing.expectEqual(
overview_body.since >= seeded_available_since,
overview_body.coverage.complete,
);
try testing.expectEqual(totals.since, series.since);
try testing.expectEqual(totals.coverage.complete, series.coverage.complete);
}
fn getJson(
@@ -2708,33 +2694,43 @@ fn emptyAggregations(io: std.Io, env: *Env) anyerror!void {
var body_buf: [256 * 1024]u8 = undefined;
// This environment's only rows are the fixed 2023 seed, so every live
// window is empty. The empty bodies are exact, not merely parseable.
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
try testing.expectEqualStrings("1h", types_body.period);
try testing.expectEqual(@as(usize, 0), types_body.types.len);
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
try testing.expectEqual(@as(usize, 0), routes_body.routes.len);
// window is empty. The empty body is exact, not merely parseable.
const body = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h", &body_buf);
try testing.expectEqualStrings("1h", body.period);
try testing.expectEqual(@as(u64, 0), body.totals.queries);
try testing.expectEqual(@as(?i64, null), body.totals.avg_response_time_us);
try testing.expectEqual(@as(usize, 0), body.types.len);
try testing.expectEqual(@as(usize, 0), body.routes.len);
// `other` is present and bucket-count sized even here: a chart must never
// have to invent the residual series.
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
try testing.expectEqual(@as(usize, 0), clients.clients.len);
try testing.expectEqual(@as(u32, 60), clients.bucket_seconds);
try testing.expectEqual(@as(usize, 60), clients.other.len);
for (clients.other) |count| try testing.expectEqual(@as(u64, 0), count);
try testing.expectEqual(@as(usize, 0), body.clients.len);
try testing.expectEqual(@as(u32, 60), body.bucket_seconds);
try testing.expectEqual(@as(usize, 60), body.buckets.len);
try testing.expectEqual(@as(usize, 60), body.other.len);
for (body.other) |count| try testing.expectEqual(@as(u64, 0), count);
// A window nobody covers is still reported as such, not as a quiet hour.
try testing.expectEqual(seeded_available_since, types_body.coverage.available_since);
try testing.expect(types_body.coverage.complete);
try testing.expectEqual(seeded_available_since, body.coverage.available_since);
try testing.expect(body.coverage.complete);
for ([_][]const u8{ "/api/stats/types", "/api/stats/routes", "/api/stats/clients" }) |path| {
var target_buf: [64]u8 = undefined;
const target = try std.fmt.bufPrint(&target_buf, "{s}?period=12h", .{path});
try conn.request("GET", target, null, null);
const bad = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), bad.status);
try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of"));
try conn.request("GET", "/api/overview?period=12h", null, null);
const bad = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), bad.status);
try testing.expect(std.mem.containsAtLeast(u8, bad.body, 1, "period must be one of"));
// Milestone 36 removed the five per-panel endpoints. They are gone from the
// table, not merely unreferenced by the admin, so the server refuses them.
for ([_][]const u8{
"/api/stats",
"/api/stats/timeseries",
"/api/stats/types",
"/api/stats/routes",
"/api/stats/clients",
}) |gone| {
try conn.request("GET", gone, null, null);
const missing = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), missing.status);
}
}
@@ -2759,23 +2755,16 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
var body_buf: [256 * 1024]u8 = undefined;
const totals = try getJson(handlers_stats.TotalsBody, arena, &conn, "/api/stats?period=1h", &body_buf);
const series = try getJson(handlers_stats.TimeseriesBody, arena, &conn, "/api/stats/timeseries?period=1h", &body_buf);
const types_body = try getJson(handlers_stats.TypesBody, arena, &conn, "/api/stats/types?period=1h", &body_buf);
const routes_body = try getJson(handlers_stats.RoutesBody, arena, &conn, "/api/stats/routes?period=1h", &body_buf);
const clients = try getJson(handlers_stats.ClientsBody, arena, &conn, "/api/stats/clients?period=1h", &body_buf);
const body = try getJson(handlers_overview.Body, arena, &conn, "/api/overview?period=1h", &body_buf);
// Nothing writes to this box between the five requests, so the window is
// one state and conservation is a real assertion rather than a race.
try testing.expectEqual(totals.since, series.since);
try testing.expectEqual(totals.since, types_body.since);
try testing.expectEqual(totals.since, routes_body.since);
try testing.expectEqual(totals.since, clients.since);
try testing.expect(totals.queries > 0);
// One response over one snapshot, so conservation is a property of the
// payload rather than of a quiet box between five requests.
try testing.expect(body.totals.queries > 0);
const totals = body.totals;
var typed: u64 = 0;
var null_qtype_rows: usize = 0;
for (types_body.types) |row| {
for (body.types) |row| {
typed += row.count;
if (row.qtype == null) null_qtype_rows += 1;
}
@@ -2786,7 +2775,7 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
var routed: u64 = 0;
var null_source_upstreams: usize = 0;
var named_upstreams: usize = 0;
for (routes_body.routes) |row| {
for (body.routes) |row| {
routed += row.count;
if (row.route != .upstream) continue;
if (row.source == null) null_source_upstreams += 1 else named_upstreams += 1;
@@ -2795,17 +2784,20 @@ fn populatedAggregations(io: std.Io, env: *Env) anyerror!void {
try testing.expectEqual(@as(usize, 1), null_source_upstreams);
try testing.expectEqual(@as(usize, 2), named_upstreams);
try testing.expectEqual(@as(usize, recent_clients), clients.clients.len);
try testing.expectEqual(series.buckets.len, clients.other.len);
for (clients.clients) |entry| try testing.expectEqual(series.buckets.len, entry.buckets.len);
try testing.expectEqual(@as(usize, recent_clients), body.clients.len);
try testing.expectEqual(body.buckets.len, body.other.len);
for (body.clients) |entry| try testing.expectEqual(body.buckets.len, entry.buckets.len);
// Per bucket, not just over the window: a series off by one bucket would
// still sum correctly in total.
for (series.buckets, 0..) |bucket, at| {
var summed: u64 = clients.other[at];
for (clients.clients) |entry| summed += entry.buckets[at];
var bucketed: u64 = 0;
for (body.buckets, 0..) |bucket, at| {
bucketed += bucket.queries;
var summed: u64 = body.other[at];
for (body.clients) |entry| summed += entry.buckets[at];
try testing.expectEqual(bucket.queries, summed);
}
try testing.expectEqual(totals.queries, bucketed);
}
test "W10 milestone 30: the three breakdowns conserve the totals over one window" {
@@ -2826,11 +2818,8 @@ fn hammerQuerylog(io: std.Io, env: *Env) anyerror!void {
var body_buf: [256 * 1024]u8 = undefined;
const targets = [_][]const u8{
"/api/stats?period=1h",
"/api/stats/timeseries?period=1h",
"/api/stats/types?period=1h",
"/api/stats/routes?period=1h",
"/api/stats/clients?period=1h",
"/api/overview?period=1h",
"/api/overview?period=24h",
"/api/queries?limit=5",
"/api/queries/27",
};
@@ -2875,7 +2864,7 @@ fn failedCommitIsBounded(io: std.Io, env: *Env) anyerror!void {
// the connection recovers (the rollback attempt worked, so the next
// `BEGIN` is not refused).
db.read_tx_faults.failNextCommit();
try conn.request("GET", "/api/stats/types?period=1h", null, null);
try conn.request("GET", "/api/overview?period=1h", null, null);
const failed = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 500), failed.status);
try testing.expect(std.mem.containsAtLeast(u8, failed.body, 1, "internal error"));
@@ -2883,16 +2872,13 @@ fn failedCommitIsBounded(io: std.Io, env: *Env) anyerror!void {
// Same connection, same shared query-log handle: a request after the fault
// is an ordinary 200. This is the assertion the double-unlock bug failed —
// it panicked here instead of answering.
try conn.request("GET", "/api/stats/types?period=1h", null, null);
try conn.request("GET", "/api/overview?period=1h", null, null);
const recovered = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), recovered.status);
// And every other query-log route still works on that connection.
for ([_][]const u8{
"/api/stats?period=1h",
"/api/stats/timeseries?period=1h",
"/api/stats/routes?period=1h",
"/api/stats/clients?period=1h",
"/api/overview?period=24h",
"/api/queries?limit=5",
"/api/queries/27",
}) |target| {
@@ -3012,7 +2998,7 @@ fn credentialSweep(
// a closed queue is empty, and a zero flush interval makes it commit the
// batch it holds rather than wait for company.
query_logger.shutdown(io);
try query_logger.runWriter(io, &env.querylog_db, null);
try query_logger.runWriter(io, testing.allocator, &env.querylog_db, null);
try testing.expectEqual(@as(u64, 1), query_logger.rows_written.load(.monotonic));
var stmt = try env.querylog_db.prepare(
@@ -3905,15 +3891,13 @@ test "drift guard c: the health rollup matches the five objects it documents" {
try expectSchemaMatches(gpa, handlers_health.Body, "Health");
}
test "drift guard c: the stats schemas match the structs that serialize them" {
test "drift guard c: the overview schema matches the struct that serializes it" {
// Guard b counts operations and guard a matches paths, so neither noticed
// that `cached` outlived the field it documented. This one would have.
// It recurses, so `Bucket`, `ClientSeries`, `TypeCount` and `RouteCount`
// are held to their schemas here too.
const gpa = testing.allocator;
try expectSchemaMatches(gpa, handlers_stats.TotalsBody, "StatsTotals");
try expectSchemaMatches(gpa, handlers_stats.TimeseriesBody, "StatsTimeseries");
try expectSchemaMatches(gpa, handlers_stats.TypesBody, "StatsTypes");
try expectSchemaMatches(gpa, handlers_stats.RoutesBody, "StatsRoutes");
try expectSchemaMatches(gpa, handlers_stats.ClientsBody, "StatsClients");
try expectSchemaMatches(gpa, handlers_overview.Body, "Overview");
}
test "drift guard c: the query-log schemas match the structs that serialize them" {
@@ -4094,8 +4078,6 @@ const contract_sample_walk = [_]ContractSample{
// a matched pattern, so the golden exercises every nested object rather
// than a row of nulls.
.{ .name = "get_query_detail", .ts_type = "QueryDetail", .method = "GET", .target = "/api/queries/27", .status = 200 },
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },
// Pause: the GET before the POST, so one sample carries `until: null` and
// the other the deadline.
@@ -4122,13 +4104,11 @@ const contract_sample_walk = [_]ContractSample{
.{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
};
/// The three period aggregations, captured against an environment with live
/// traffic in it: over the fixed 2023 seed every one of them would answer with
/// an empty array, which describes no field at all.
/// The overview, captured against an environment with live traffic in it: over
/// the fixed 2023 seed its four breakdowns would every one answer with an empty
/// array, which describes no field at all.
const stats_sample_walk = [_]ContractSample{
.{ .name = "get_stats_types", .ts_type = "StatsTypes", .method = "GET", .target = "/api/stats/types?period=1h", .status = 200 },
.{ .name = "get_stats_routes", .ts_type = "StatsRoutes", .method = "GET", .target = "/api/stats/routes?period=1h", .status = 200 },
.{ .name = "get_stats_clients", .ts_type = "StatsClients", .method = "GET", .target = "/api/stats/clients?period=1h", .status = 200 },
.{ .name = "get_overview", .ts_type = "Overview", .method = "GET", .target = "/api/overview?period=1h", .status = 200 },
};
/// A session-authenticated environment answers this without a cookie.