diff --git a/package-lock.json b/package-lock.json index 599325c..7e86061 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7453,6 +7453,11 @@ "node": ">=8" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -12428,6 +12433,18 @@ "node": ">=0.4.0" } }, + "node_modules/prom-client": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.2.tgz", + "integrity": "sha512-on3h1iXb04QFLLThrmVYg1SChBQ9N1c+nKAjebBjokBqipddH3uxmOUcEkTnzmJ8Jh/5TSUnUqS40i2QB2dJHQ==", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -14215,6 +14232,14 @@ "node": ">=6" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -16298,6 +16323,7 @@ "express-session": "^1.17.3", "openid-client": "^5.6.5", "prisma-dbml-generator": "^0.12.0", + "prom-client": "^15.1.2", "rate-limit-redis": "^4.2.0", "redis": "^4.6.12", "socket.io": "^4.7.2", diff --git a/packages/server/package.json b/packages/server/package.json index d6e8e03..95d5a34 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -39,6 +39,7 @@ "express-session": "^1.17.3", "openid-client": "^5.6.5", "prisma-dbml-generator": "^0.12.0", + "prom-client": "^15.1.2", "rate-limit-redis": "^4.2.0", "redis": "^4.6.12", "socket.io": "^4.7.2", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ae84901..68ef7c1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -28,6 +28,18 @@ if (!process.env.SESSION_SECRET) { process.exit(1); } +if (!process.env.NODE_APP_INSTANCE) { + Logger.warn( + "NODE_APP_INSTANCE is not defined, metrics will not include process label" + ); +} + +if (!process.env.PROMETHEUS_TOKEN) { + Logger.warn( + "PROMETHEUS_TOKEN is not defined, /metrics will not be accessable" + ); +} + if (!process.env.REDIS_HOST) { Logger.error("REDIS_HOST is not defined"); process.exit(1); diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index a59e11b..9d9cf94 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -141,6 +141,23 @@ class Canvas { return await this.canvasToRedis(); } + /** + * Get if a pixel is maybe empty + * @param x + * @param y + * @returns + */ + async isPixelEmpty(x: number, y: number) { + const redis = await Redis.getClient(); + const pixelColor = await redis.get(Redis.key("pixelColor", x, y)); + + if (pixelColor === null) { + return true; + } + + return pixelColor === "transparent"; + } + async getPixel(x: number, y: number) { return ( await prisma.pixel.findMany({ diff --git a/packages/server/src/lib/Express.ts b/packages/server/src/lib/Express.ts index 62632a4..9b0569a 100644 --- a/packages/server/src/lib/Express.ts +++ b/packages/server/src/lib/Express.ts @@ -9,6 +9,7 @@ import APIRoutes_client from "../api/client"; import APIRoutes_admin from "../api/admin"; import { Logger } from "./Logger"; import bodyParser from "body-parser"; +import { handleMetricsEndpoint } from "./Prometheus"; export const session = expressSession({ secret: process.env.SESSION_SECRET, @@ -93,6 +94,7 @@ export class ExpressServer { this.app.use(bodyParser.json()); this.app.use("/api", APIRoutes_client); this.app.use("/api/admin", APIRoutes_admin); + this.app.use("/metrics", handleMetricsEndpoint); this.httpServer.listen(parseInt(process.env.PORT), () => { Logger.info("Listening on :" + process.env.PORT); diff --git a/packages/server/src/lib/Prometheus.ts b/packages/server/src/lib/Prometheus.ts new file mode 100644 index 0000000..43f6cfb --- /dev/null +++ b/packages/server/src/lib/Prometheus.ts @@ -0,0 +1,113 @@ +import client, { register } from "prom-client"; +import { prisma } from "./prisma"; +import e from "express"; +import { SocketServer } from "./SocketServer"; +import Canvas from "./Canvas"; +import { Redis } from "./redis"; + +client.collectDefaultMetrics({ + labels: process.env.NODE_APP_INSTANCE + ? { + NODE_APP_INSTANCE: process.env.NODE_APP_INSTANCE, + } + : {}, +}); + +export const PixelCount = new client.Gauge({ + name: "pixel_count", + help: "total pixel count", + + async collect() { + this.set(await prisma.pixel.count()); + }, +}); + +export const UserCount = new client.Gauge({ + name: "user_count", + help: "total user count", + + async collect() { + this.set(await prisma.user.count()); + }, +}); + +export const OnlineUsers = new client.Gauge({ + name: "connected_count", + help: "total connected sockets", + + async collect() { + this.set((await SocketServer.instance.io.fetchSockets()).length); + }, +}); + +/** + * Rough estimate of empty pixels + */ +export const EmptyPixels = new client.Gauge({ + name: "empty_pixels", + help: "total number of empty pixels", + + async collect() { + let queries: Promise[] = []; + + for (let x = 0; x < Canvas.getCanvasConfig().size[0]; x++) { + for (let y = 0; y < Canvas.getCanvasConfig().size[1]; y++) { + queries.push(Canvas.isPixelEmpty(x, y)); + } + } + + let count = 0; + + const allSettled = await Promise.allSettled(queries); + for (const settle of allSettled) { + if (settle.status === "fulfilled") { + count += Number(settle.value); + } else { + count++; + } + } + + this.set(count); + }, +}); + +export const TotalPixels = new client.Gauge({ + name: "total_pixels", + help: "total number of pixels the canvas allows", + + async collect() { + const [width, height] = Canvas.getCanvasConfig().size; + + this.set(width * height); + }, +}); + +export const UniqueInstances = new client.Gauge({ + name: "instance_count", + help: "total number of unique instances", + + async collect() { + this.set(await prisma.instance.count()); + }, +}); + +export const handleMetricsEndpoint = async ( + req: e.Request, + res: e.Response +) => { + if (!process.env.PROMETHEUS_TOKEN) { + res.status(500); + res.send("PROMETHEUS_TOKEN is not set."); + return; + } + + if (req.headers.authorization !== "Bearer " + process.env.PROMETHEUS_TOKEN) { + res.status(401); + res.send("Invalid bearer token"); + return; + } + + res.setHeader("Content-Type", register.contentType); + res.send(await register.metrics()); + res.end(); +}; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 296854b..4347b15 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -19,12 +19,16 @@ declare global { namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production"; + NODE_APP_INSTANCE?: string; PORT: string; LOG_LEVEL?: string; SESSION_SECRET: string; + + PROMETHEUS_TOKEN?: string; + REDIS_HOST: string; - REDIS_SESSION_PREFIX: string; - REDIS_RATELIMIT_PREFIX: string; + REDIS_SESSION_PREFIX?: string; + REDIS_RATELIMIT_PREFIX?: string; /** * hostname that is used in the callback