overview: one endpoint, live projections and a response cache (m36)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user