import { z } from "zod"; export const prometheusAction = z .object({ action: z.enum(["rotate", "disable"]) }) .strict(); export type PrometheusStatus = { enabled: boolean; endpoint: string; updatedAt: string | null; lastScrapedAt: string | null; }; export function prometheusConfig(endpoint: string, token: string) { const url = new URL(endpoint); if ( url.protocol !== "https:" || url.username || url.password || url.search || url.hash ) throw new Error("A public HTTPS metrics endpoint is required"); if (!/^mg_metrics_[a-f0-9]{64}$/.test(token)) throw new Error("Invalid metrics token"); // JSON strings are also YAML quoted scalars; never interpolate raw host input. return `scrape_configs: - job_name: minegeo scrape_interval: 60s scrape_timeout: 20s scheme: https metrics_path: ${JSON.stringify(url.pathname)} follow_redirects: false authorization: type: Bearer credentials: ${JSON.stringify(token)} static_configs: - targets: [${JSON.stringify(url.host)}] `; } export const metricDefinitions = { minegeo_accounts: { help: "Registered MineGeo profiles currently retained.", labels: {}, }, minegeo_maps: { help: "Retained maps by publication state.", labels: { state: [ "uploading", "processing", "draft", "review", "published", "rejected", "unpublished", "failed", ], }, }, minegeo_published_panoramas: { help: "Panoramas in currently published maps.", labels: {}, }, minegeo_games: { help: "Retained solo games by difficulty and completion state.", labels: { difficulty: ["easy", "normal", "hard"], status: ["incomplete", "complete"], }, }, minegeo_games_started_24h: { help: "Solo games created during the rolling last 24 hours.", labels: {}, }, minegeo_rooms: { help: "Retained multiplayer rooms by lifecycle state, not live online presence.", labels: { state: ["lobby", "loading", "playing", "results", "finished", "closed"], }, }, minegeo_rooms_created_24h: { help: "Multiplayer rooms created during the rolling last 24 hours.", labels: {}, }, minegeo_upload_jobs: { help: "Retained map upload jobs by processing state.", labels: { state: ["queued", "running", "complete", "failed", "cancelled"] }, }, minegeo_bug_reports: { help: "Retained player bug reports by support state.", labels: { state: ["draft", "pending", "open", "done"] }, }, minegeo_content_reports: { help: "Retained content reports by moderation state.", labels: { state: ["open", "resolved", "confirmed", "dismissed"] }, }, minegeo_discord_announcements: { help: "Retained Discord announcements by delivery state.", labels: { state: ["pending", "sending", "sent", "skipped", "failed"] }, }, minegeo_snapshot_timestamp_seconds: { help: "Unix timestamp when the database snapshot was collected.", labels: {}, }, } as const; const sampleSchema = z .object({ metric: z.string(), labels: z.record(z.string(), z.string()), value: z.number().finite().nonnegative(), }) .strict(); /** Reject unknown names/labels rather than exporting user-provided dimensions. */ export function formatMetrics(input: unknown) { const samples = z.array(sampleSchema).max(100).parse(input); const lines: string[] = []; const seen = new Set(); const declared = new Set(); for (const sample of samples) { if (!Object.hasOwn(metricDefinitions, sample.metric)) throw new Error("Unknown metric"); const definition = metricDefinitions[sample.metric as keyof typeof metricDefinitions]; const allowed: Record = definition.labels; const keys = Object.keys(sample.labels).sort(); if ( keys.length !== Object.keys(allowed).length || keys.some((k) => !allowed[k]?.includes(sample.labels[k])) ) throw new Error("Invalid metric labels"); const labels = keys .map((k) => `${k}=${JSON.stringify(sample.labels[k])}`) .join(","); const name = sample.metric + (labels ? `{${labels}}` : ""); if (seen.has(name)) throw new Error("Duplicate metric sample"); seen.add(name); if (!declared.has(sample.metric)) { lines.push( `# HELP ${sample.metric} ${definition.help}`, `# TYPE ${sample.metric} gauge`, ); declared.add(sample.metric); } lines.push(`${name} ${sample.value}`); } if (declared.size !== Object.keys(metricDefinitions).length) throw new Error("Incomplete metrics snapshot"); return lines.join("\n") + "\n"; }