Add per-server telemetry request diagnostics

This commit is contained in:
2026-09-19 18:59:30 -04:00
parent a182aeb950
commit 41891b4209
10 changed files with 614 additions and 14 deletions
+18
View File
@@ -367,6 +367,24 @@ CREATE TABLE IF NOT EXISTS server_telemetry (
received_at TEXT NOT NULL,
payload TEXT
);
-- Request diagnostics survive registry removal and also capture unknown names.
-- Bounded independently of the latest scoreboard and permanent leaderboard.
CREATE TABLE IF NOT EXISTS telemetry_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server TEXT NOT NULL,
received_at TEXT NOT NULL,
status INTEGER NOT NULL,
error TEXT,
event TEXT,
map TEXT,
round INTEGER,
player_count INTEGER,
content_type TEXT,
payload TEXT NOT NULL,
payload_truncated INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_telemetry_requests_server ON telemetry_requests(server, id DESC);
CREATE INDEX IF NOT EXISTS idx_telemetry_requests_received ON telemetry_requests(received_at);
-- Lifecycle history deliberately survives removal of the server registry row.
CREATE TABLE IF NOT EXISTS server_deployments (
id TEXT PRIMARY KEY,
@@ -0,0 +1,50 @@
import { db } from '../db/index.js';
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '../../telemetry-diagnostics.js';
const MAX_PAYLOAD_LENGTH = 16 * 1024;
// Bound storage even when requests claim many different, unregistered names.
const MAX_REQUESTS = 10_000;
const cutoff = () => new Date(Date.now() - TELEMETRY_REQUEST_RETENTION.maxAgeDays * 86_400_000).toISOString();
const textField = (value: unknown) => typeof value === 'string' ? value.slice(0, 255) : null;
export function recordTelemetryRequest(input: {
value: unknown;
receivedAt: string;
status: number;
error: string | null;
contentType: string | null;
}): void {
// Use only the top-level JSON field, never a player's name or a substring.
// Malformed JSON cannot be reliably attributed to an individual server.
if (!input.value || typeof input.value !== 'object' || Array.isArray(input.value)) return;
const body = input.value as Record<string, unknown>;
if (typeof body.server !== 'string' || !body.server.trim() || body.server.length > 255) return;
const payload = JSON.stringify(body);
const handle = db();
handle.transaction(() => {
handle.prepare(`INSERT INTO telemetry_requests
(server, received_at, status, error, event, map, round, player_count, content_type, payload, payload_truncated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
body.server, input.receivedAt, input.status, input.error?.slice(0, 2000) ?? null,
textField(body.event), textField(body.map),
Number.isSafeInteger(body.round) ? body.round as number : null,
Array.isArray(body.players) ? body.players.length : null,
textField(input.contentType), payload.slice(0, MAX_PAYLOAD_LENGTH), Number(payload.length > MAX_PAYLOAD_LENGTH)
);
handle.prepare('DELETE FROM telemetry_requests WHERE received_at < ?').run(cutoff());
handle.prepare(`DELETE FROM telemetry_requests WHERE server = ? AND id <= (
SELECT id FROM telemetry_requests WHERE server = ? ORDER BY id DESC LIMIT 1 OFFSET ?
)`).run(body.server, body.server, TELEMETRY_REQUEST_RETENTION.maxPerServer);
handle.prepare(`DELETE FROM telemetry_requests WHERE id <= (
SELECT id FROM telemetry_requests ORDER BY id DESC LIMIT 1 OFFSET ?
)`).run(MAX_REQUESTS);
})();
}
export function listTelemetryRequests(server: string): TelemetryRequestLogEntry[] {
const rows = db().prepare(`SELECT id, server, received_at AS receivedAt, status, error, event, map, round,
player_count AS playerCount, content_type AS contentType, payload, payload_truncated AS payloadTruncated
FROM telemetry_requests WHERE server = ? AND received_at >= ? ORDER BY id DESC LIMIT ?`)
.all(server, cutoff(), TELEMETRY_REQUEST_RETENTION.maxPerServer) as Array<Omit<TelemetryRequestLogEntry, 'payloadTruncated'> & { payloadTruncated: number }>;
return rows.map(row => ({ ...row, payloadTruncated: !!row.payloadTruncated }));
}