import { json } from '@sveltejs/kit'; import { getServer, listServers } from '$lib/server/fleet/servers.js'; import { saveTelemetry } from '$lib/server/fleet/telemetry.js'; import { emit } from '$lib/server/pipeline/bus.js'; import { db } from '$lib/server/db/index.js'; import { acceptTestSignal, parseTestSignal } from '$lib/server/fleet/beta-tests.js'; // Reporters fetch this once at process startup and keep the token for that boot. export async function GET({ url }: { url: URL }) { const server = url.searchParams.get('server'); if (!server || !getServer(server)) return json({ error: 'Unknown server' }, { status: 404 }); const runs = db().prepare(`SELECT r.id AS runId, r.beta_id AS betaId FROM beta_test_runs r JOIN betas b ON b.id = r.beta_id JOIN beta_test_plans p ON p.beta_id = b.id WHERE r.server = ? AND r.retired_at IS NULL AND b.state IN ('running','ready-promote') AND p.outcome IN ('active','passed')`).all(server); return json({ runs }, { headers: { 'Cache-Control': 'no-store' } }); } export async function POST({ request }: { request: Request }) { console.log('[telemetry] received POST /api/telemetry'); // Encode payloads as JSON so script-supplied newlines cannot forge log lines. // Bound the preview to keep malformed requests from flooding container logs. const raw = await request.text(); const context = { contentType: request.headers.get('content-type'), payloadPreview: raw.slice(0, 4096), payloadTruncated: raw.length > 4096 }; function reject(status: number, error: string, details: Record = {}) { console.log(`[telemetry] rejected ${status}: ${JSON.stringify({ error, ...context, ...details })}`); return json({ ok: false, error, details }, { status }); } if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') { return reject(415, 'Content-Type must be application/json', { receivedContentType: context.contentType }); } let body; try { body = JSON.parse(raw); } catch (error) { return reject(400, 'Malformed JSON', { parseError: (error as Error).message }); } if (!body || typeof body !== 'object' || Array.isArray(body)) { return reject(400, 'Payload must be a JSON object'); } const issues: string[] = []; if (body.runId === undefined && body.event !== 'round_change') issues.push('Session telemetry requires runId'); if (typeof body.server !== 'string' || !body.server.length || body.server.length > 255) { issues.push('server must be a registered server name (1–255 characters)'); } for (const field of ['round', 'players']) { if (!Number.isSafeInteger(body[field]) || body[field] < 0) { issues.push(`${field} must be a nonnegative safe integer (JSON number, not a string)`); } } if (issues.length) { return reject(400, 'Invalid telemetry payload', { issues }); } const server = getServer(body.server); if (!server) { const registeredServers = listServers().filter((entry) => !entry.destroyed_at).map((entry) => entry.name); return reject(404, 'Server is not registered', { receivedServer: body.server, hint: 'Send the exact Electric Cherry server name in the server field; matching is case-sensitive.', registeredServers }); } if (server.destroyed_at) { return reject(404, 'Server has been destroyed', { receivedServer: body.server, destroyedAt: server.destroyed_at }); } let test: { accepted: boolean; reason?: string } | undefined; if (body.runId !== undefined) { try { const signal = parseTestSignal(body); test = acceptTestSignal(body.server, body.runId, body.eventId, signal); } catch (error) { return reject(400, (error as Error).message); } if (body.event !== 'round_change') return json({ ok: true, test }); } const telemetry = { server: body.server as string, round: body.round as number, players: body.players as number, receivedAt: new Date().toISOString() }; saveTelemetry(telemetry); console.log(`[telemetry] accepted round_change ${JSON.stringify(telemetry)}`); emit({ type: 'server-telemetry', entries: [telemetry] }); return json({ ok: true, telemetry, test }); }