1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-07 21:13:10 +00:00

feat: 添加 Prometheus 指标支持,跟踪在线设备和注册设备总数

This commit is contained in:
Sunwuyuan 2025-11-15 16:21:40 +08:00
parent 114069a999
commit 1d7078874b
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
8 changed files with 236 additions and 59 deletions

23
app.js
View File

@ -15,6 +15,7 @@ import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js"; import deviceAuthRouter from "./routes/device-auth.js";
import accountsRouter from "./routes/accounts.js"; import accountsRouter from "./routes/accounts.js";
import autoAuthRouter from "./routes/auto-auth.js"; import autoAuthRouter from "./routes/auto-auth.js";
import { register } from "./utils/metrics.js";
var app = express(); 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 // Mount the Apps router with API rate limiting
app.use("/apps", appsRouter); app.use("/apps", appsRouter);

View File

@ -7,6 +7,7 @@
import app from '../app.js'; import app from '../app.js';
import { createServer } from 'http'; import { createServer } from 'http';
import { initSocket } from '../utils/socket.js'; import { initSocket } from '../utils/socket.js';
import { initializeMetrics } from '../utils/metrics.js';
/** /**
* Get port from environment and store in Express. * Get port from environment and store in Express.
@ -24,6 +25,9 @@ var server = createServer(app);
// 初始化 Socket.IO 并绑定到 HTTP Server // 初始化 Socket.IO 并绑定到 HTTP Server
initSocket(server); initSocket(server);
// 初始化 Prometheus 指标
initializeMetrics();
/** /**
* Listen on provided port, on all network interfaces. * Listen on provided port, on all network interfaces.
*/ */

View File

@ -30,6 +30,7 @@
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0", "morgan": "~1.10.0",
"prom-client": "^15.1.3",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },

24
pnpm-lock.yaml generated
View File

@ -71,6 +71,9 @@ importers:
morgan: morgan:
specifier: ~1.10.0 specifier: ~1.10.0
version: 1.10.0 version: 1.10.0
prom-client:
specifier: ^15.1.3
version: 15.1.3
socket.io: socket.io:
specifier: ^4.8.1 specifier: ^4.8.1
version: 4.8.1 version: 4.8.1
@ -1433,6 +1436,9 @@ packages:
bignumber.js@9.3.0: bignumber.js@9.3.0:
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
birpc@2.6.1: birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
@ -2212,6 +2218,10 @@ packages:
typescript: typescript:
optional: true optional: true
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
protobufjs@7.5.4: protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -2391,6 +2401,9 @@ packages:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'} engines: {node: '>=18'}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
tinyexec@1.0.1: tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
@ -4035,6 +4048,8 @@ snapshots:
bignumber.js@9.3.0: {} bignumber.js@9.3.0: {}
bintrees@1.0.2: {}
birpc@2.6.1: {} birpc@2.6.1: {}
body-parser@2.2.0: body-parser@2.2.0:
@ -4792,6 +4807,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - magicast
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.0
tdigest: 0.1.2
protobufjs@7.5.4: protobufjs@7.5.4:
dependencies: dependencies:
'@protobufjs/aspromise': 1.1.2 '@protobufjs/aspromise': 1.1.2
@ -5067,6 +5087,10 @@ snapshots:
minizlib: 3.1.0 minizlib: 3.1.0
yallist: 5.0.0 yallist: 5.0.0
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
tinyexec@1.0.1: {} tinyexec@1.0.1: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:

View File

@ -6,6 +6,7 @@ import crypto from "crypto";
import errors from "../utils/errors.js"; import errors from "../utils/errors.js";
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js"; import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
import { getOnlineDevices } from "../utils/socket.js"; import { getOnlineDevices } from "../utils/socket.js";
import { registeredDevicesTotal } from "../utils/metrics.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -47,49 +48,57 @@ router.post(
return next(errors.createError(400, "设备名称是必需的")); return next(errors.createError(400, "设备名称是必需的"));
} }
// 检查UUID是否已存在 try {
const existingDevice = await prisma.device.findUnique({ // 检查UUID是否已存在
where: { uuid }, const existingDevice = await prisma.device.findUnique({
}); where: { uuid },
});
if (existingDevice) { if (existingDevice) {
return next(errors.createError(409, "设备UUID已存在")); 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({ try {
where: { id: device.id }, await prisma.device.update({
data: { where: { id: device.id },
password: null, data: {
passwordHint: null, password: null,
}, passwordHint: null,
}); },
});
return res.json({ return res.json({
success: true, success: true,
message: "密码删除成功", message: "密码删除成功",
}); });
} catch (error) {
throw error;
}
}) })
); );
@ -394,4 +407,4 @@ router.get(
res.json({ success: true, devices }); res.json({ success: true, devices });
}) })
); );

View File

@ -1,4 +1,6 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { keysTotal } from "./metrics.js";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
class KVStore { class KVStore {
/** /**
@ -84,6 +86,10 @@ class KVStore {
}, },
}); });
// 更新键总数指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
// 返回带有设备ID和原始键的结果 // 返回带有设备ID和原始键的结果
return { return {
deviceId, deviceId,
@ -111,10 +117,17 @@ class KVStore {
}, },
}, },
}); });
// 更新键总数指标
const totalKeys = await prisma.kVStore.count();
keysTotal.set(totalKeys);
return item ? { ...item, deviceId, key } : null; return item ? { ...item, deviceId, key } : null;
} catch (error) { } catch (error) {
// 忽略记录不存在的错误 // 忽略记录不存在的错误
if (error.code === "P2025") return null; if (error.code === "P2025") {
return null;
}
throw error; throw error;
} }
} }

49
utils/metrics.js Normal file
View File

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

View File

@ -11,12 +11,15 @@
import { Server } from "socket.io"; import { Server } from "socket.io";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { onlineDevicesGauge } from "./metrics.js";
// Socket.IO 单例实例 // Socket.IO 单例实例
let io = null; let io = null;
// 在线设备映射uuid -> Set<socketId> // 在线设备映射uuid -> Set<socketId>
const onlineMap = new Map(); const onlineMap = new Map();
// 在线 token 映射token -> Set<socketId> (用于指标统计)
const onlineTokens = new Map();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/** /**
@ -64,7 +67,12 @@ export function initSocket(server) {
include: { device: { select: { uuid: true } } }, include: { device: { select: { uuid: true } } },
}); });
const uuid = appInstall?.device?.uuid; 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 { } catch {
// ignore // ignore
} }
@ -105,6 +113,10 @@ export function initSocket(server) {
socket.on("disconnect", () => { socket.on("disconnect", () => {
const uuids = Array.from(socket.data.deviceUuids || []); const uuids = Array.from(socket.data.deviceUuids || []);
uuids.forEach((u) => removeOnline(u, socket.id)); 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 }); 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 离开设备房间并更新在线表 * socket 离开设备房间并更新在线表
* @param {import('socket.io').Socket} 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 键已变更 * 广播某设备下 KV 键已变更
* @param {string} uuid 设备 uuid * @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() { export function getOnlineDevices() {
const list = []; const list = [];
for (const [uuid, set] of onlineMap.entries()) { for (const [token, set] of onlineTokens.entries()) {
list.push({ uuid, connections: set.size }); list.push({ token, connections: set.size });
} }
// 默认按连接数降序 // 默认按连接数降序
return list.sort((a, b) => b.connections - a.connections); return list.sort((a, b) => b.connections - a.connections);
@ -198,8 +246,10 @@ async function joinByToken(socket, token) {
const uuid = appInstall?.device?.uuid; const uuid = appInstall?.device?.uuid;
if (uuid) { if (uuid) {
joinDeviceRoom(socket, uuid); joinDeviceRoom(socket, uuid);
// 跟踪 token 连接用于指标统计
trackTokenConnection(socket, token);
// 可选:回执 // 可选:回执
socket.emit("joined", { by: "token", uuid }); socket.emit("joined", { by: "token", uuid, token });
} else { } else {
socket.emit("join-error", { by: "token", reason: "invalid_token" }); socket.emit("join-error", { by: "token", reason: "invalid_token" });
} }