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

View File

@@ -702,6 +702,35 @@ export type Database = {
[_ in never]: never;
};
Functions: {
developer_keys_list: { Args: { p_owner: string }; Returns: Json };
developer_key_create: {
Args: {
p_owner: string;
p_name: string;
p_scopes: string[];
p_token_hash: string;
p_prefix: string;
p_expires_days: number;
};
Returns: Json;
};
developer_key_revoke: {
Args: { p_owner: string; p_key: string };
Returns: boolean;
};
developer_key_consume: { Args: { p_token_hash: string }; Returns: Json };
prometheus_status: {
Args: { p_reviewer: string };
Returns: Json;
};
configure_prometheus: {
Args: { p_reviewer: string; p_token_hash: string | null };
Returns: Json;
};
read_prometheus_metrics: {
Args: { p_token_hash: string };
Returns: Json;
};
receive_bug_close: {
Args: { p_report: string; p_external: string };
Returns: boolean;

71
shared/developer-api.ts Normal file
View File

@@ -0,0 +1,71 @@
import { z } from "zod";
export const developerScopes = [
"maps:read",
"account:read",
"account:maps:read",
"account:games:read",
] as const;
export type DeveloperScope = (typeof developerScopes)[number];
export type DeveloperKey = {
id: string;
name: string;
prefix: string;
scopes: DeveloperScope[];
createdAt: string;
expiresAt: string;
revokedAt: string | null;
lastUsedAt: string | null;
};
export const developerKeyInput = z
.object({
name: z.string().trim().min(1).max(60),
scopes: z
.array(z.enum(developerScopes))
.min(1)
.max(4)
.refine(
(values) => new Set(values).size === values.length,
"Duplicate permissions",
),
expiresDays: z.number().int().min(1).max(365).default(90),
})
.strict();
export function developerPagination(url: URL, search = false) {
const allowed = new Set(
search ? ["limit", "offset", "q"] : ["limit", "offset"],
);
for (const key of url.searchParams.keys()) {
if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1)
throw new Error("Unsupported or duplicate query parameter");
}
const integer = (key: string, fallback: number, min: number, max: number) => {
const raw = url.searchParams.get(key);
if (raw === null) return fallback;
if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${key}`);
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < min || value > max)
throw new Error(`Invalid ${key}`);
return value;
};
const q = (url.searchParams.get("q") ?? "").trim();
if (q.length > 80) throw new Error("Search must be 80 characters or fewer");
return {
limit: integer("limit", 20, 1, 50),
offset: integer("offset", 0, 0, 10000),
q,
};
}
export type DeveloperPagination = ReturnType<typeof developerPagination>;
export function developerPage(total: number, page: DeveloperPagination) {
return {
limit: page.limit,
offset: page.offset,
total,
nextOffset:
page.offset + page.limit < total && page.offset + page.limit <= 10000
? page.offset + page.limit
: null,
};
}

86
shared/developer-docs.ts Normal file
View File

@@ -0,0 +1,86 @@
export const developerScopes = [
{
id: "maps:read",
name: ["Public maps", "Общие карты"],
description: [
"Read the published map catalog and map details.",
"Читать каталог опубликованных карт и сведения о картах.",
],
},
{
id: "account:read",
name: ["My profile", "Мой профиль"],
description: [
"Read your account profile and username appearance.",
"Читать профиль вашего аккаунта и оформление имени.",
],
},
{
id: "account:maps:read",
name: ["My maps, including private maps", "Мои карты, включая личные"],
description: [
"Read your own map records, including unpublished maps.",
"Читать ваши карты, в том числе неопубликованные.",
],
},
{
id: "account:games:read",
name: ["My game history", "Моя история игр"],
description: ["Read your game history.", "Читать вашу историю игр."],
},
] as const;
export const developerEndpoints = [
{
path: "/maps",
scope: "maps:read",
description: [
"Published maps. Supports q, limit and offset.",
"Опубликованные карты. Поддерживает q, limit и offset.",
],
},
{
path: "/maps/{id}",
scope: "maps:read",
description: [
"Details of one published map.",
"Сведения об одной опубликованной карте.",
],
},
{
path: "/me",
scope: "account:read",
description: [
"The key owner's profile and username appearance.",
"Профиль и оформление имени владельца ключа.",
],
},
{
path: "/me/maps",
scope: "account:maps:read",
description: [
"Your own maps, including private maps. Supports limit and offset.",
"Ваши карты, включая личные. Поддерживает limit и offset.",
],
},
{
path: "/me/games",
scope: "account:games:read",
description: [
"Your game history. Supports limit and offset.",
"Ваша история игр. Поддерживает limit и offset.",
],
},
] as const;
export const developerPythonExample = `import os
import requests
response = requests.get(
"https://minegeo.vercel.app/api/dev/v1/maps",
headers={"Authorization": f"Bearer {os.environ['MINEGEO_API_KEY']}"},
params={"limit": 20, "offset": 0},
timeout=20,
)
response.raise_for_status()
print(response.json())`;

208
shared/developer-openapi.ts Normal file
View File

@@ -0,0 +1,208 @@
const string = { type: "string" };
const date = { type: "string", format: "date-time" };
const id = { type: "string", format: "uuid" };
const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` });
const pagination = [
{
name: "limit",
in: "query",
schema: { type: "integer", minimum: 1, maximum: 50, default: 20 },
},
{
name: "offset",
in: "query",
schema: { type: "integer", minimum: 0, maximum: 10000, default: 0 },
},
];
const success = (schema: unknown) => ({
description: "Success",
content: { "application/json": { schema } },
});
const errors = Object.fromEntries(
[
[400, "Invalid query or map ID"],
[401, "Missing, invalid, expired or revoked key"],
[403, "Required permission missing"],
[404, "Resource or endpoint not found"],
[429, "Quota exceeded; wait Retry-After seconds"],
[503, "Temporarily unavailable"],
].map(([status, description]) => [
status,
{ description, content: { "application/json": { schema: ref("Error") } } },
]),
);
const list = (name: string, item: string) => ({
type: "object",
required: [name, "pagination"],
properties: {
[name]: { type: "array", items: ref(item) },
pagination: ref("Pagination"),
},
});
const operation = (
summary: string,
scope: string,
schema: unknown,
parameters: unknown[] = [],
) => ({
summary,
description: `Requires the ${scope} key permission.`,
"x-required-scope": scope,
security: [{ DeveloperKey: [] }],
parameters,
responses: { 200: success(schema), ...errors },
});
export const developerOpenApi = {
openapi: "3.1.0",
info: {
title: "MineGeo Developer API",
version: "1.0.0",
description:
"Read-only HTTPS API. Bearer keys are managed at /dev/api. 60 requests per key per fixed minute; 180 requests per account per minute shared across keys. All authenticated requests, including validation/permission errors, consume quota. A 429 response includes Retry-After; successful authorized responses include RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until reset). X-RateLimit-Reset is a Unix timestamp. Requests with invalid keys cannot access account data. No upload, publication, gameplay writes or moderator access is granted.",
},
servers: [{ url: "/api/dev/v1" }],
paths: {
"/maps": {
get: operation("List published maps", "maps:read", list("maps", "Map"), [
...pagination,
{
name: "q",
in: "query",
schema: { type: "string", maxLength: 80 },
description: "Case-insensitive map-name search",
},
]),
},
"/maps/{id}": {
get: operation(
"Get a published map",
"maps:read",
{
type: "object",
required: ["map"],
properties: { map: ref("Map") },
},
[{ name: "id", in: "path", required: true, schema: id }],
),
},
"/me": {
get: operation("Get the key owner's profile", "account:read", {
type: "object",
required: ["account"],
properties: { account: ref("Account") },
}),
},
"/me/maps": {
get: operation(
"List the owner's maps, including private maps",
"account:maps:read",
list("maps", "Map"),
[
...pagination,
{ name: "q", in: "query", schema: { type: "string", maxLength: 80 } },
],
),
},
"/me/games": {
get: operation(
"List the owner's solo-game history",
"account:games:read",
list("games", "Game"),
pagination,
),
},
},
components: {
securitySchemes: {
DeveloperKey: {
type: "http",
scheme: "bearer",
bearerFormat: "mg_dev_<64 hex characters>",
},
},
schemas: {
Error: {
type: "object",
required: ["error"],
properties: {
error: {
type: "object",
required: ["code", "message"],
properties: { code: string, message: string },
},
},
},
Pagination: {
type: "object",
properties: {
limit: { type: "integer" },
offset: { type: "integer" },
total: { type: "integer" },
nextOffset: { type: ["integer", "null"] },
},
},
Map: {
type: "object",
properties: {
id,
name: string,
description: string,
source: string,
state: string,
bounds: {
type: "object",
properties: {
minX: { type: "number" },
minZ: { type: "number" },
maxX: { type: "number" },
maxZ: { type: "number" },
},
},
panoramaCount: { type: "integer" },
createdAt: date,
updatedAt: date,
url: {
type: "string",
description:
"Relative MineGeo map page path; private maps still require their owner's browser login.",
},
},
},
Account: {
type: "object",
properties: {
id,
displayName: string,
createdAt: date,
appearance: {
type: "object",
properties: {
nameGlow: { type: "boolean" },
glowColor: string,
bugFinder: { type: "boolean" },
customPrefix: { type: ["string", "null"] },
customPrefixIcon: string,
},
},
},
},
Game: {
type: "object",
properties: {
id,
difficulty: { type: "string", enum: ["easy", "normal", "hard"] },
createdAt: date,
currentRound: { type: "integer", minimum: 0, maximum: 5 },
complete: { type: "boolean" },
score: {
type: ["integer", "null"],
minimum: 0,
maximum: 25000,
description:
"Final score only; null until a complete five-round result is available.",
},
},
},
},
},
};

3
shared/forum.json Normal file
View File

@@ -0,0 +1,3 @@
{
"url": "https://minegeo.discourse.group/"
}

236
shared/grafana-dashboard.ts Normal file
View File

@@ -0,0 +1,236 @@
// Grafana's classic import model prompts for the user's Prometheus data source.
// This template contains query definitions only, never credentials or player data.
const datasource = { type: "prometheus", uid: "${DS_PROMETHEUS}" };
const filter = 'job="minegeo",instance=~"$instance"';
const panels = [
[
"Scrape health",
"stat",
`min(up{${filter}})`,
"short",
"Shows whether Prometheus can scrape MineGeo. 1 = healthy, 0 = failed.",
],
[
"Registered accounts",
"stat",
`sum(minegeo_accounts{${filter}})`,
"short",
"Currently retained profiles.",
],
[
"Published maps",
"stat",
`sum(minegeo_maps{${filter},state="published"})`,
"short",
"Maps available in the public catalog.",
],
[
"Published panoramas",
"stat",
`sum(minegeo_published_panoramas{${filter}})`,
"short",
"Playable panorama locations in published maps.",
],
[
"Solo games started · last 24h",
"stat",
`sum(minegeo_games_started_24h{${filter}})`,
"short",
"Rolling 24-hour window, not a counter or page-view total.",
],
[
"Parties created · last 24h",
"stat",
`sum(minegeo_rooms_created_24h{${filter}})`,
"short",
"Rolling 24-hour count of created multiplayer rooms.",
],
[
"Map catalog over time",
"timeseries",
`sum by (state) (minegeo_maps{${filter}})`,
"short",
"Current retained map inventory. Counts can decrease after deletion.",
],
[
"Solo games by difficulty and completion",
"timeseries",
`sum by (difficulty,status) (minegeo_games{${filter}})`,
"short",
"Incomplete includes abandoned games. Complete means all five rounds ended.",
],
[
"Multiplayer room lifecycle",
"timeseries",
`sum by (state) (minegeo_rooms{${filter}})`,
"short",
"Stored room lifecycle states. These are not a count of online users.",
],
[
"Upload processing",
"timeseries",
`sum by (state) (minegeo_upload_jobs{${filter}})`,
"short",
"Queued, running, completed, failed and cancelled jobs.",
],
[
"Player bug reports",
"timeseries",
`sum by (state) (minegeo_bug_reports{${filter}})`,
"short",
"Retained reports. Closed reports are deleted after three days, so counts may fall.",
],
[
"Content moderation reports",
"timeseries",
`sum by (state) (minegeo_content_reports{${filter}})`,
"short",
"Open and resolved map-content reports.",
],
[
"Discord delivery",
"timeseries",
`sum by (state) (minegeo_discord_announcements{${filter}})`,
"short",
"Retained announcement delivery states.",
],
[
"Exporter read time",
"timeseries",
`max(minegeo_exporter_read_duration_seconds{${filter}})`,
"s",
"Time spent reading and validating a database snapshot; not site request latency.",
],
[
"Snapshot age",
"timeseries",
`time() - min(minegeo_snapshot_timestamp_seconds{${filter}})`,
"s",
"Snapshot age grows when scraping stops. The database caches aggregates for 30 seconds.",
],
] as const;
export function grafanaDashboard() {
return {
__inputs: [
{
name: "DS_PROMETHEUS",
label: "Prometheus",
description: "Choose the Prometheus data source that scrapes MineGeo.",
type: "datasource",
pluginId: "prometheus",
pluginName: "Prometheus",
},
],
id: null,
uid: "minegeo-community",
title: "MineGeo · Community analytics",
version: 1,
schemaVersion: 39,
tags: ["minegeo", "prometheus"],
timezone: "browser",
editable: true,
graphTooltip: 1,
refresh: "1m",
time: { from: "now-24h", to: "now" },
description:
"Aggregate MineGeo product metrics. All MineGeo series are gauges; no personal data or per-process counters. History starts when Prometheus begins scraping.",
templating: {
list: [
{
name: "instance",
label: "MineGeo instance",
type: "query",
datasource,
query: {
query: 'label_values(minegeo_accounts{job="minegeo"}, instance)',
refId: "instance",
},
refresh: 1,
multi: false,
includeAll: false,
sort: 1,
},
],
},
panels: panels.map(([title, type, expr, unit, description], index) => {
const stat = index < 6;
return {
id: index + 1,
title,
type,
description,
datasource,
gridPos: stat
? { x: (index % 3) * 8, y: Math.floor(index / 3) * 4, w: 8, h: 4 }
: {
x: ((index - 6) % 2) * 12,
y: 8 + Math.floor((index - 6) / 2) * 8,
w: 12,
h: 8,
},
targets: [
{
refId: "A",
expr,
datasource,
legendFormat: "{{state}} {{difficulty}} {{status}}",
range: !stat,
instant: stat,
},
],
fieldConfig: {
defaults: {
unit,
min: 0,
decimals: unit === "short" ? 0 : 2,
color: { mode: stat ? "thresholds" : "palette-classic" },
thresholds: {
mode: "absolute",
steps:
index === 0
? [
{ color: "red", value: null },
{ color: "green", value: 1 },
]
: [{ color: "green", value: null }],
},
...(stat
? {}
: {
custom: {
drawStyle: "line",
lineInterpolation: "smooth",
lineWidth: 2,
fillOpacity: 12,
showPoints: "never",
spanNulls: false,
},
}),
},
overrides: [],
},
options: stat
? {
colorMode: "value",
graphMode: "area",
justifyMode: "auto",
textMode: "auto",
reduceOptions: {
calcs: ["lastNotNull"],
fields: "",
values: false,
},
}
: {
legend: {
displayMode: "list",
placement: "bottom",
showLegend: true,
},
tooltip: { mode: "multi", sort: "desc" },
},
};
}),
};
}

View File

@@ -1,4 +1,5 @@
{
"Forum": "Форум",
"Site language": "Язык сайта",
"Automatic": "Автоматически",
"MineGeo home": "Главная MineGeo",
@@ -849,5 +850,39 @@
"Download the portable app, run the EXE, and start exploring. No installation needed.": "Скачайте портативное приложение, запустите EXE и начните исследование. Установка не нужна.",
"Windows 10/11 · 64-bit · Version {version} · Internet required": "Windows 10/11 · 64 бита · Версия {version} · Нужен интернет",
"The app connects to minegeo.vercel.app and keeps you signed in on this device.": "Приложение подключается к minegeo.vercel.app и сохраняет вход на этом устройстве.",
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе."
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе.",
"Prometheus & Grafana": "Prometheus и Grafana",
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.": "Следите за играми, картами, мультиплеером, загрузками и обращениями на своей панели Grafana. Для управления доступом нужны права администратора.",
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.": "Экспортируются только общие показатели. Имена пользователей, сообщения, электронные адреса и идентификаторы карт не передаются.",
"Loading metrics setup…": "Загружаем настройки метрик…",
"Metrics enabled": "Метрики включены",
"Metrics disabled": "Метрики отключены",
"Metrics endpoint": "Адрес метрик",
"Copy endpoint": "Копировать адрес",
"Last successful scrape: {time}": "Последний успешный сбор: {time}",
"Not scraped yet": "Сбор ещё не выполнялся",
"Use the hosted HTTPS site to generate a production scrape config.": "Откройте опубликованный сайт по HTTPS, чтобы создать конфигурацию сбора метрик.",
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.": "Создайте токен ниже и скопируйте конфигурацию в prometheus.yml. Если раздел scrape_configs уже есть, добавьте в него только задание MineGeo.",
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.": "Перезагрузите конфигурацию или перезапустите Prometheus. На странице Targets проверьте, что задание minegeo имеет статус UP.",
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.": "В Grafana добавьте свой сервер Prometheus как источник данных. Импортируйте скачанный JSON панели и выберите этот источник.",
"History starts with the first scrape. Counts are cached for 30 seconds; room states are not online presence. This integration does not export page views or Vercel CPU statistics.": "История начинается с первого сбора. Показатели кешируются на 30 секунд; состояния комнат не показывают число игроков онлайн. Просмотры страниц и статистика процессора Vercel не экспортируются.",
"Rotate metrics token": "Заменить токен метрик",
"Generate metrics token": "Создать токен метрик",
"Disable metrics access": "Отключить доступ к метрикам",
"Download Grafana dashboard": "Скачать панель Grafana",
"Confirm metrics access change": "Подтвердить изменение доступа к метрикам",
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.": "Замена сразу отключит старый токен. После этого обновите конфигурацию Prometheus.",
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.": "Отключение сразу остановит сбор метрик. Prometheus покажет для этой цели статус DOWN.",
"Confirm change": "Подтвердить изменение",
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.": "Храните конфигурацию в тайне. Токен показывается один раз и разрешает только чтение общих метрик. Замена или отключение токена отзывает доступ.",
"Prometheus scrape config": "Конфигурация сбора Prometheus",
"Copy Prometheus config": "Копировать конфигурацию Prometheus",
"Metrics token": "Токен метрик",
"Hide generated credentials": "Скрыть созданные данные доступа",
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.": "Существующий токен нельзя получить повторно. Используйте сохранённую конфигурацию или замените токен.",
"New metrics token generated. Copy the config now; it cannot be recovered later.": "Создан новый токен метрик. Скопируйте конфигурацию сейчас: восстановить токен позже нельзя.",
"Metrics access disabled. Existing scrape credentials no longer work.": "Доступ к метрикам отключён. Прежние данные доступа больше не работают.",
"Copied to clipboard.": "Скопировано в буфер обмена.",
"Could not copy. Select the text and copy it manually.": "Не удалось скопировать. Выделите текст и скопируйте его вручную.",
"Status": "Статус сервисов"
}

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";
}