declarative relay deployment on hetzner relay-eval.waow.tech
atproto relay
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

relay-eval: add /api/phi/history endpoint

single-host coverage history with precomputed coverage_pct + a
summary block (mean/min/max coverage, connected-run count).
complements /api/phi/monitors — monitors for "what is the state
now", history for "what was the state over the last N runs".

usage:
GET /api/phi/history?name=<host>&limit=<n>

default limit = 288 (~24h at 5-min eval cadence), max = 2016 (~7d).

response shape:
{
"name": "...",
"limit": N,
"points": [ {ts, coverage_pct, events, dids, connected}, ... ],
"summary": {
"mean_coverage_pct": N,
"min_coverage_pct": N,
"max_coverage_pct": N,
"connected_runs": N,
"total_runs": N
}
}

coverage semantics match /api/phi/monitors: unique_dids / MAX(unique_dids)
per run, self-normalizing against replay. summary stats are computed
over connected points only; disconnected runs are reported separately
as the connected_runs count so phi can say "alive 272/288 runs".

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+164
+108
relay-eval/src/server.zig
··· 77 77 try serveTrend(allocator, stream, store, limit); 78 78 } else if (std.mem.eql(u8, path, "/api/phi/monitors")) { 79 79 try servePhiMonitors(allocator, stream, store); 80 + } else if (std.mem.eql(u8, path, "/api/phi/history")) { 81 + try servePhiHistory(allocator, stream, store, query); 80 82 } else if (std.mem.eql(u8, path, "/og")) { 81 83 try serveOgPng(allocator, stream, og_png_path); 82 84 } else if (std.mem.eql(u8, path, "/og.svg")) { ··· 533 535 } 534 536 535 537 try json.append(allocator, ']'); 538 + try sendResponse(stream, "200 OK", "application/json", json.items); 539 + } 540 + 541 + // --- phi history endpoint --- 542 + // 543 + // GET /api/phi/history?name=<host>&limit=<n> 544 + // 545 + // returns per-run coverage history for a single host, oldest → newest, with 546 + // precomputed coverage_pct so phi doesn't have to do math. includes a summary 547 + // block with mean/min/max coverage + connected-run count for the window. 548 + // 549 + // coverage semantics match /api/phi/monitors: 550 + // coverage_pct = unique_dids / max(unique_dids in that run) × 100 551 + // self-normalizing — replay inflates only the replaying host's own numbers. 552 + 553 + const default_history_limit: u32 = 288; // ~24h at 5-min eval cadence 554 + const max_history_limit: u32 = 2016; // 7d 555 + 556 + fn parseQueryString(query: []const u8, key: []const u8) ?[]const u8 { 557 + var it = std.mem.splitScalar(u8, query, '&'); 558 + while (it.next()) |param| { 559 + if (std.mem.startsWith(u8, param, key) and 560 + param.len > key.len and 561 + param[key.len] == '=') 562 + { 563 + return param[key.len + 1 ..]; 564 + } 565 + } 566 + return null; 567 + } 568 + 569 + fn servePhiHistory(allocator: std.mem.Allocator, stream: std.net.Stream, store: *Store, query: []const u8) !void { 570 + const name = parseQueryString(query, "name") orelse { 571 + try sendResponse(stream, "400 Bad Request", "application/json", "{\"error\":\"missing required query parameter: name\"}"); 572 + return; 573 + }; 574 + 575 + // validate name: only hostnames we might plausibly track (alphanumerics, 576 + // dots, hyphens). cheap prevention against weird injection attempts. 577 + for (name) |ch| { 578 + const ok = (ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or 579 + (ch >= '0' and ch <= '9') or ch == '.' or ch == '-'; 580 + if (!ok) { 581 + try sendResponse(stream, "400 Bad Request", "application/json", "{\"error\":\"invalid name\"}"); 582 + return; 583 + } 584 + } 585 + 586 + const limit: u32 = if (parseQueryString(query, "limit")) |l| 587 + std.math.clamp(std.fmt.parseInt(u32, l, 10) catch default_history_limit, 2, max_history_limit) 588 + else 589 + default_history_limit; 590 + 591 + const points = try store.getHostHistory(allocator, name, limit); 592 + defer { 593 + for (points) |p| allocator.free(p.timestamp); 594 + allocator.free(points); 595 + } 596 + 597 + // summary: mean/min/max of clamped coverage over connected points; 598 + // connected-run count stands on its own so phi can say "alive 280/288" 599 + var sum_cov: f64 = 0; 600 + var connected_count: u32 = 0; 601 + var min_cov: f64 = std.math.inf(f64); 602 + var max_cov: f64 = 0; 603 + for (points) |p| { 604 + if (!p.connected) continue; 605 + connected_count += 1; 606 + const c_clamped = @min(p.coverage, 1.0); 607 + sum_cov += c_clamped; 608 + if (c_clamped < min_cov) min_cov = c_clamped; 609 + if (c_clamped > max_cov) max_cov = c_clamped; 610 + } 611 + if (connected_count == 0) { 612 + min_cov = 0; 613 + max_cov = 0; 614 + } 615 + const mean_cov: f64 = if (connected_count > 0) sum_cov / @as(f64, @floatFromInt(connected_count)) else 0; 616 + 617 + var json: std.ArrayList(u8) = .empty; 618 + defer json.deinit(allocator); 619 + 620 + try json.print(allocator, "{{\"name\":\"{s}\",\"limit\":{d},\"points\":[", .{ name, limit }); 621 + for (points, 0..) |p, i| { 622 + if (i > 0) try json.append(allocator, ','); 623 + const pct_hundredths: u32 = @intFromFloat(std.math.clamp(p.coverage, 0, 10.0) * 10000.0 + 0.5); 624 + try json.print(allocator, 625 + "{{\"ts\":\"{s}\",\"coverage_pct\":{d}.{d:0>2},\"events\":{d},\"dids\":{d},\"connected\":{}}}", 626 + .{ p.timestamp, pct_hundredths / 100, pct_hundredths % 100, p.events, p.unique_dids, p.connected }, 627 + ); 628 + } 629 + try json.appendSlice(allocator, "],\"summary\":{"); 630 + 631 + const mean_h: u32 = @intFromFloat(mean_cov * 10000.0 + 0.5); 632 + const min_h: u32 = @intFromFloat(min_cov * 10000.0 + 0.5); 633 + const max_h: u32 = @intFromFloat(max_cov * 10000.0 + 0.5); 634 + try json.print(allocator, 635 + "\"mean_coverage_pct\":{d}.{d:0>2},\"min_coverage_pct\":{d}.{d:0>2},\"max_coverage_pct\":{d}.{d:0>2},\"connected_runs\":{d},\"total_runs\":{d}}}}}", 636 + .{ 637 + mean_h / 100, mean_h % 100, 638 + min_h / 100, min_h % 100, 639 + max_h / 100, max_h % 100, 640 + connected_count, points.len, 641 + }, 642 + ); 643 + 536 644 try sendResponse(stream, "200 OK", "application/json", json.items); 537 645 } 538 646
+56
relay-eval/src/store.zig
··· 436 436 return out.toOwnedSlice(allocator); 437 437 } 438 438 439 + pub const HostHistoryPoint = struct { 440 + run_id: i64, 441 + timestamp: []const u8, 442 + unique_dids: u32, 443 + events: u64, 444 + // coverage = unique_dids / max(unique_dids in the same run), clamped 445 + // implicitly by sqlite to 0 when max is 0. caller treats > 1.0 as 446 + // replay and may clip at render time. 447 + coverage: f64, 448 + connected: bool, 449 + }; 450 + 451 + /// get per-run history for a single host, oldest → newest, limited to 452 + /// the last N valid runs. used by /api/phi/history. 453 + pub fn getHostHistory(self: *Store, allocator: std.mem.Allocator, host: []const u8, last_n: u32) ![]HostHistoryPoint { 454 + const sql = 455 + \\SELECT r.id, r.timestamp, rs.unique_dids, rs.events, rs.connected, 456 + \\ CAST(rs.unique_dids AS REAL) / NULLIF(mpr.m, 0) AS coverage 457 + \\ FROM runs r 458 + \\ JOIN relay_stats rs ON rs.run_id = r.id 459 + \\ JOIN ( 460 + \\ SELECT run_id, MAX(unique_dids) AS m 461 + \\ FROM relay_stats 462 + \\ GROUP BY run_id 463 + \\ ) mpr ON mpr.run_id = r.id 464 + \\ WHERE rs.host = ? 465 + \\ AND r.id IN ( 466 + \\ SELECT id FROM runs r2 467 + \\ WHERE r2.id IN (SELECT run_id FROM relay_stats GROUP BY run_id HAVING SUM(connected) > COUNT(*) / 2) 468 + \\ ORDER BY id DESC LIMIT ? 469 + \\ ) 470 + \\ ORDER BY r.id ASC 471 + ; 472 + var stmt: ?*c.sqlite3_stmt = null; 473 + if (c.sqlite3_prepare_v2(self.db, sql, -1, &stmt, null) != c.SQLITE_OK) { 474 + return self.sqlError(); 475 + } 476 + defer _ = c.sqlite3_finalize(stmt); 477 + 478 + _ = c.sqlite3_bind_text(stmt, 1, host.ptr, @intCast(host.len), SQLITE_STATIC); 479 + _ = c.sqlite3_bind_int(stmt, 2, @intCast(last_n)); 480 + 481 + var out: std.ArrayList(HostHistoryPoint) = .empty; 482 + while (c.sqlite3_step(stmt) == c.SQLITE_ROW) { 483 + try out.append(allocator, .{ 484 + .run_id = c.sqlite3_column_int64(stmt, 0), 485 + .timestamp = try self.colText(allocator, stmt, 1), 486 + .unique_dids = @intCast(c.sqlite3_column_int(stmt, 2)), 487 + .events = @intCast(c.sqlite3_column_int64(stmt, 3)), 488 + .connected = c.sqlite3_column_int(stmt, 4) != 0, 489 + .coverage = c.sqlite3_column_double(stmt, 5), 490 + }); 491 + } 492 + return out.toOwnedSlice(allocator); 493 + } 494 + 439 495 pub const MonitorStateRow = struct { 440 496 status: []const u8, 441 497 last_changed: []const u8,