Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
153 lines
4.6 KiB
TypeScript
153 lines
4.6 KiB
TypeScript
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", "void"],
|
|
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";
|
|
}
|