From 1d7078874babb4cfc7b327c10eb26de84b6697d0 Mon Sep 17 00:00:00 2001 From: Sunwuyuan Date: Sat, 15 Nov 2025 16:21:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Prometheus=20?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E6=94=AF=E6=8C=81=EF=BC=8C=E8=B7=9F=E8=B8=AA?= =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E8=AE=BE=E5=A4=87=E5=92=8C=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E6=80=BB=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 23 +++++++++ bin/www | 4 ++ package.json | 1 + pnpm-lock.yaml | 24 ++++++++++ routes/device.js | 119 ++++++++++++++++++++++++++--------------------- utils/kvStore.js | 15 +++++- utils/metrics.js | 49 +++++++++++++++++++ utils/socket.js | 60 ++++++++++++++++++++++-- 8 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 utils/metrics.js diff --git a/app.js b/app.js index 513d8f3..f616da9 100644 --- a/app.js +++ b/app.js @@ -15,6 +15,7 @@ import deviceRouter from "./routes/device.js"; import deviceAuthRouter from "./routes/device-auth.js"; import accountsRouter from "./routes/accounts.js"; import autoAuthRouter from "./routes/auto-auth.js"; +import { register } from "./utils/metrics.js"; var app = express(); @@ -76,6 +77,28 @@ app.get("/check", (req, res) => { }); }); +// Prometheus metrics endpoint with token auth +app.get("/metrics", async (req, res) => { + try { + // 检查 token 验证 + const metricsToken = process.env.METRICS_TOKEN; + if (metricsToken) { + const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token; + if (!providedToken || providedToken !== metricsToken) { + return res.status(401).json({ + error: "Unauthorized", + message: "Valid metrics token required" + }); + } + } + + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); + } catch (err) { + res.status(500).end(err.message); + } +}); + // Mount the Apps router with API rate limiting app.use("/apps", appsRouter); diff --git a/bin/www b/bin/www index 2546a83..8af85ae 100644 --- a/bin/www +++ b/bin/www @@ -7,6 +7,7 @@ import app from '../app.js'; import { createServer } from 'http'; import { initSocket } from '../utils/socket.js'; +import { initializeMetrics } from '../utils/metrics.js'; /** * Get port from environment and store in Express. @@ -24,6 +25,9 @@ var server = createServer(app); // 初始化 Socket.IO 并绑定到 HTTP Server initSocket(server); +// 初始化 Prometheus 指标 +initializeMetrics(); + /** * Listen on provided port, on all network interfaces. */ diff --git a/package.json b/package.json index 1943598..f4c37ea 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "js-base64": "^3.7.7", "jsonwebtoken": "^9.0.2", "morgan": "~1.10.0", + "prom-client": "^15.1.3", "socket.io": "^4.8.1", "uuid": "^11.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 998164d..9b5194f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: morgan: specifier: ~1.10.0 version: 1.10.0 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 socket.io: specifier: ^4.8.1 version: 4.8.1 @@ -1433,6 +1436,9 @@ packages: bignumber.js@9.3.0: resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + birpc@2.6.1: resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} @@ -2212,6 +2218,10 @@ packages: typescript: optional: true + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -2391,6 +2401,9 @@ packages: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} @@ -4035,6 +4048,8 @@ snapshots: bignumber.js@9.3.0: {} + bintrees@1.0.2: {} + birpc@2.6.1: {} body-parser@2.2.0: @@ -4792,6 +4807,11 @@ snapshots: transitivePeerDependencies: - magicast + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.0 + tdigest: 0.1.2 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -5067,6 +5087,10 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + tinyexec@1.0.1: {} tinyglobby@0.2.15: diff --git a/routes/device.js b/routes/device.js index 16147a3..efda58d 100644 --- a/routes/device.js +++ b/routes/device.js @@ -6,6 +6,7 @@ import crypto from "crypto"; import errors from "../utils/errors.js"; import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; import { getOnlineDevices } from "../utils/socket.js"; +import { registeredDevicesTotal } from "../utils/metrics.js"; const prisma = new PrismaClient(); @@ -47,49 +48,57 @@ router.post( return next(errors.createError(400, "设备名称是必需的")); } - // 检查UUID是否已存在 - const existingDevice = await prisma.device.findUnique({ - where: { uuid }, - }); + try { + // 检查UUID是否已存在 + const existingDevice = await prisma.device.findUnique({ + where: { uuid }, + }); - if (existingDevice) { - return next(errors.createError(409, "设备UUID已存在")); + if (existingDevice) { + return next(errors.createError(409, "设备UUID已存在")); + } + + // 处理 namespace:如果没有提供,则使用 uuid + const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; + + // 检查 namespace 是否已被使用 + const existingNamespace = await prisma.device.findUnique({ + where: { namespace: deviceNamespace }, + }); + + if (existingNamespace) { + return next(errors.createError(409, "该 namespace 已被使用")); + } + + // 创建设备 + const device = await prisma.device.create({ + data: { + uuid, + name: deviceName, + namespace: deviceNamespace, + }, + }); + + // 为新设备创建默认的自动登录配置 + await createDefaultAutoAuth(device.id); + + // 更新注册设备总数指标 + const totalDevices = await prisma.device.count(); + registeredDevicesTotal.set(totalDevices); + + return res.status(201).json({ + success: true, + device: { + id: device.id, + uuid: device.uuid, + name: device.name, + namespace: device.namespace, + createdAt: device.createdAt, + }, + }); + } catch (error) { + throw error; } - - // 处理 namespace:如果没有提供,则使用 uuid - const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid; - - // 检查 namespace 是否已被使用 - const existingNamespace = await prisma.device.findUnique({ - where: { namespace: deviceNamespace }, - }); - - if (existingNamespace) { - return next(errors.createError(409, "该 namespace 已被使用")); - } - - // 创建设备 - const device = await prisma.device.create({ - data: { - uuid, - name: deviceName, - namespace: deviceNamespace, - }, - }); - - // 为新设备创建默认的自动登录配置 - await createDefaultAutoAuth(device.id); - - return res.status(201).json({ - success: true, - device: { - id: device.id, - uuid: device.uuid, - name: device.name, - namespace: device.namespace, - createdAt: device.createdAt, - }, - }); }) ); @@ -347,18 +356,22 @@ router.delete( } } - await prisma.device.update({ - where: { id: device.id }, - data: { - password: null, - passwordHint: null, - }, - }); + try { + await prisma.device.update({ + where: { id: device.id }, + data: { + password: null, + passwordHint: null, + }, + }); - return res.json({ - success: true, - message: "密码删除成功", - }); + return res.json({ + success: true, + message: "密码删除成功", + }); + } catch (error) { + throw error; + } }) ); @@ -394,4 +407,4 @@ router.get( res.json({ success: true, devices }); }) -); \ No newline at end of file +); diff --git a/utils/kvStore.js b/utils/kvStore.js index 839ac64..8179c4b 100644 --- a/utils/kvStore.js +++ b/utils/kvStore.js @@ -1,4 +1,6 @@ import { PrismaClient } from "@prisma/client"; +import { keysTotal } from "./metrics.js"; + const prisma = new PrismaClient(); class KVStore { /** @@ -84,6 +86,10 @@ class KVStore { }, }); + // 更新键总数指标 + const totalKeys = await prisma.kVStore.count(); + keysTotal.set(totalKeys); + // 返回带有设备ID和原始键的结果 return { deviceId, @@ -111,10 +117,17 @@ class KVStore { }, }, }); + + // 更新键总数指标 + const totalKeys = await prisma.kVStore.count(); + keysTotal.set(totalKeys); + return item ? { ...item, deviceId, key } : null; } catch (error) { // 忽略记录不存在的错误 - if (error.code === "P2025") return null; + if (error.code === "P2025") { + return null; + } throw error; } } diff --git a/utils/metrics.js b/utils/metrics.js new file mode 100644 index 0000000..8354612 --- /dev/null +++ b/utils/metrics.js @@ -0,0 +1,49 @@ +import client from 'prom-client'; + +// 创建自定义注册表(不包含默认指标) +const register = new client.Registry(); + +// 当前在线设备数(连接了 SocketIO 的设备) +export const onlineDevicesGauge = new client.Gauge({ + name: 'classworks_online_devices_total', + help: 'Total number of online devices (connected via SocketIO)', + registers: [register], +}); + +// 已注册设备总数 +export const registeredDevicesTotal = new client.Gauge({ + name: 'classworks_registered_devices_total', + help: 'Total number of registered devices', + registers: [register], +}); + +// 已创建键总数(不区分设备) +export const keysTotal = new client.Gauge({ + name: 'classworks_keys_total', + help: 'Total number of keys across all devices', + registers: [register], +}); + +// 初始化指标数据 +export async function initializeMetrics() { + try { + const { PrismaClient } = await import('@prisma/client'); + const prisma = new PrismaClient(); + + // 获取已注册设备总数 + const deviceCount = await prisma.device.count(); + registeredDevicesTotal.set(deviceCount); + + // 获取已创建键总数 + const keyCount = await prisma.kVStore.count(); + keysTotal.set(keyCount); + + await prisma.$disconnect(); + console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount); + } catch (error) { + console.error('Failed to initialize metrics:', error); + } +} + +// 导出注册表用于 /metrics 端点 +export { register }; \ No newline at end of file diff --git a/utils/socket.js b/utils/socket.js index 3b264d1..3e7f503 100644 --- a/utils/socket.js +++ b/utils/socket.js @@ -11,12 +11,15 @@ import { Server } from "socket.io"; import { PrismaClient } from "@prisma/client"; +import { onlineDevicesGauge } from "./metrics.js"; // Socket.IO 单例实例 let io = null; // 在线设备映射:uuid -> Set const onlineMap = new Map(); +// 在线 token 映射:token -> Set (用于指标统计) +const onlineTokens = new Map(); const prisma = new PrismaClient(); /** @@ -64,7 +67,12 @@ export function initSocket(server) { include: { device: { select: { uuid: true } } }, }); const uuid = appInstall?.device?.uuid; - if (uuid) leaveDeviceRoom(socket, uuid); + if (uuid) { + leaveDeviceRoom(socket, uuid); + // 移除 token 连接跟踪 + removeTokenConnection(token, socket.id); + if (socket.data.tokens) socket.data.tokens.delete(token); + } } catch { // ignore } @@ -105,6 +113,10 @@ export function initSocket(server) { socket.on("disconnect", () => { const uuids = Array.from(socket.data.deviceUuids || []); uuids.forEach((u) => removeOnline(u, socket.id)); + + // 清理 token 连接跟踪 + const tokens = Array.from(socket.data.tokens || []); + tokens.forEach((token) => removeTokenConnection(token, socket.id)); }); }); @@ -133,6 +145,24 @@ function joinDeviceRoom(socket, uuid) { io.to(uuid).emit("device-joined", { uuid, connections: set.size }); } +/** + * 跟踪 token 连接用于指标统计 + * @param {import('socket.io').Socket} socket + * @param {string} token + */ +function trackTokenConnection(socket, token) { + if (!socket.data.tokens) socket.data.tokens = new Set(); + socket.data.tokens.add(token); + + // 记录 token 连接 + const set = onlineTokens.get(token) || new Set(); + set.add(socket.id); + onlineTokens.set(token, set); + + // 更新在线设备数指标(基于不同的 token 数量) + onlineDevicesGauge.set(onlineTokens.size); +} + /** * 让 socket 离开设备房间并更新在线表 * @param {import('socket.io').Socket} socket @@ -155,6 +185,24 @@ function removeOnline(uuid, socketId) { } } +/** + * 移除 token 连接跟踪 + * @param {string} token + * @param {string} socketId + */ +function removeTokenConnection(token, socketId) { + const set = onlineTokens.get(token); + if (!set) return; + set.delete(socketId); + if (set.size === 0) { + onlineTokens.delete(token); + } else { + onlineTokens.set(token, set); + } + // 更新在线设备数指标(基于不同的 token 数量) + onlineDevicesGauge.set(onlineTokens.size); +} + /** * 广播某设备下 KV 键已变更 * @param {string} uuid 设备 uuid @@ -167,12 +215,12 @@ export function broadcastKeyChanged(uuid, payload) { /** * 获取在线设备列表 - * @returns {Array<{uuid:string, connections:number}>} + * @returns {Array<{token:string, connections:number}>} */ export function getOnlineDevices() { const list = []; - for (const [uuid, set] of onlineMap.entries()) { - list.push({ uuid, connections: set.size }); + for (const [token, set] of onlineTokens.entries()) { + list.push({ token, connections: set.size }); } // 默认按连接数降序 return list.sort((a, b) => b.connections - a.connections); @@ -198,8 +246,10 @@ async function joinByToken(socket, token) { const uuid = appInstall?.device?.uuid; if (uuid) { joinDeviceRoom(socket, uuid); + // 跟踪 token 连接用于指标统计 + trackTokenConnection(socket, token); // 可选:回执 - socket.emit("joined", { by: "token", uuid }); + socket.emit("joined", { by: "token", uuid, token }); } else { socket.emit("join-error", { by: "token", reason: "invalid_token" }); }