Add developer API, Prometheus metrics, and community links
This commit is contained in:
152
shared/prometheus.ts
Normal file
152
shared/prometheus.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user