Add developer API, Prometheus metrics, and community links

This commit is contained in:
2026-10-12 19:59:37 +05:00
parent 3cc3136762
commit 018be5049e
50 changed files with 4087 additions and 31 deletions

152
shared/prometheus.ts Normal file
View File

@@ -0,0 +1,152 @@
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<string>();
const declared = new Set<string>();
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<string, readonly string[]> = 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";
}