milestone 30: overview as a dashboard, explicit health contract, period aggregations
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
Gates / frontend (push) Successful in 1m32s
Gates / test (push) Successful in 1m54s
Gates / package (push) Successful in 5m28s
Gates / container (push) Successful in 14s
Gates / test-aarch64 (push) Failing after 3h10m0s
CI / gates (push) Failing after 3h11m55s
This commit is contained in:
+231
-23
@@ -1,8 +1,16 @@
|
||||
//! `GET /api/stats` and `GET /api/stats/timeseries` (ruling 13).
|
||||
//! 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 both endpoints:
|
||||
//! the totals cover exactly the span the chart draws, so a dashboard cannot
|
||||
//! show a sum that disagrees with the bars above it.
|
||||
//! 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
|
||||
@@ -10,7 +18,19 @@
|
||||
//! 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).
|
||||
//! 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");
|
||||
|
||||
@@ -97,7 +117,6 @@ pub const TotalsBody = struct {
|
||||
until: i64,
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
cached: u64,
|
||||
clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
/// Judged against `since`, which is the window this body reports on — so a
|
||||
@@ -115,6 +134,135 @@ pub const TimeseriesBody = struct {
|
||||
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,
|
||||
@@ -124,23 +272,19 @@ pub fn totals(
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
|
||||
const read = readTotals(state, io, database, span) catch |err| {
|
||||
return internal(request, "stats totals", err);
|
||||
};
|
||||
const covered = coverage.read(database, span.since) catch |err| {
|
||||
return internal(request, "stats coverage", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TotalsBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.queries = result.queries,
|
||||
.blocked = result.blocked,
|
||||
.cached = result.cached,
|
||||
.clients = result.distinct_clients,
|
||||
.avg_response_time_us = result.avg_response_time_us,
|
||||
.coverage = covered,
|
||||
.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,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -155,20 +299,85 @@ pub fn timeseries(
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
|
||||
const read = readTimeseries(state, io, database, span, out) catch |err| {
|
||||
return internal(request, "stats timeseries", err);
|
||||
};
|
||||
const covered = coverage.read(database, span.since) catch |err| {
|
||||
return internal(request, "stats coverage", 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..written],
|
||||
.coverage = covered,
|
||||
.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,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -321,7 +530,6 @@ test "the totals and the buckets agree over the same window" {
|
||||
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.cached);
|
||||
try testing.expectEqual(@as(u64, 1), result.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user