feat(admin/health): System Health Level 2 — метрики HTTP-запросов

backend/src/utils/metrics.js: лёгкие in-memory метрики (сброс при рестарте) —
всего запросов, req/min (скользящее окно), латентность avg/p50/p95/p99,
разбивка по статусам 2xx/3xx/4xx/5xx, топ маршрутов по частоте/латентности/
ошибкам (группировка по шаблону route.path, не по URL).

server.js: middleware (на /api, по res 'finish') пишет латентность и статус.
adminController.getMetrics + GET /api/admin/metrics (под admin-auth).

admin.js: health-страница переведена на refreshHealth/renderHealth (Level 1)
+ секция «Метрики запросов»: карточки req/min/всего/avg/p95/p99/5xx, цветная
полоса статусов, топ медленных/частых/ошибочных маршрутов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 18:27:58 +03:00
parent f7d27ecb91
commit 4a424505a8
5 changed files with 224 additions and 42 deletions
+7 -1
View File
@@ -713,6 +713,12 @@ function getHealth(_req, res) {
});
}
/* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */
const metrics = require('../utils/metrics');
function getMetrics(_req, res) {
res.json(metrics.snapshot());
}
/* ── Topics CRUD ─────────────────────────────────────────────────────── */
function getTopics(req, res) {
const { subject_id } = req.query;
@@ -802,7 +808,7 @@ module.exports = {
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
};
+1
View File
@@ -38,6 +38,7 @@ router.delete('/error-log', ctrl.clearErrorLog);
/* System health */
router.get('/health', ctrl.getHealth);
router.get('/metrics', ctrl.getMetrics);
/* Topics CRUD */
router.get('/topics', ctrl.getTopics);
+13
View File
@@ -138,6 +138,19 @@ const { requireFeature } = require('./middleware/features');
app.use('/api/classroom', rateLimit({ windowMs: 60_000, max: 6000, message: 'Слишком много запросов' }));
app.use('/api', rateLimit({ windowMs: 60_000, max: 600, message: 'Слишком много запросов, подождите минуту' }));
/* ── Request metrics (System Health Level 2) ── */
const metrics = require('./utils/metrics');
app.use((req, res, next) => {
if (!req.originalUrl.startsWith('/api')) return next();
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
const route = (req.baseUrl || '') + (req.route && req.route.path ? req.route.path : '');
metrics.record(req.method, route || req.path || '(unmatched)', res.statusCode, ms);
});
next();
});
/* ── Routes ── */
app.use('/api/auth', authRoutes);
app.use('/api/subjects', subjectRoutes);
+67
View File
@@ -0,0 +1,67 @@
'use strict';
/*
* Лёгкие in-memory метрики HTTP-запросов для System Health (Level 2).
* Сбрасываются при перезапуске процесса. Память ограничена кольцевыми буферами.
* Группировка по шаблону маршрута (req.route.path), а не по конкретному URL —
* чтобы /api/biochem/molecules/:id не плодил тысячи ключей.
*/
const MAX_LAT = 3000; // окно последних латентностей для перцентилей
const WINDOW_MS = 60_000; // окно для req/min
let total = 0;
let totalServerErrors = 0;
const statusClasses = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
const routeStats = new Map(); // "METHOD /path" -> { count, totalMs, maxMs, errors }
const latencies = []; // последние латентности (мс)
const recentTs = []; // метки времени последних запросов (для req/min)
const startedAt = Date.now();
function record(method, route, status, ms) {
total++;
const cls = Math.floor(status / 100) + 'xx';
if (statusClasses[cls] !== undefined) statusClasses[cls]++;
if (status >= 500) totalServerErrors++;
const key = method + ' ' + route;
let r = routeStats.get(key);
if (!r) { r = { count: 0, totalMs: 0, maxMs: 0, errors: 0 }; routeStats.set(key, r); }
r.count++; r.totalMs += ms; if (ms > r.maxMs) r.maxMs = ms;
if (status >= 400) r.errors++;
latencies.push(ms);
if (latencies.length > MAX_LAT) latencies.shift();
const now = Date.now();
recentTs.push(now);
const cutoff = now - WINDOW_MS;
while (recentTs.length && recentTs[0] < cutoff) recentTs.shift();
}
function _pct(sorted, p) {
if (!sorted.length) return 0;
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
}
function snapshot() {
const now = Date.now();
const cutoff = now - WINDOW_MS;
let reqPerMin = 0;
for (let i = recentTs.length - 1; i >= 0 && recentTs[i] >= cutoff; i--) reqPerMin++;
const sorted = [...latencies].sort((a, b) => a - b);
const avg = latencies.length ? latencies.reduce((s, x) => s + x, 0) / latencies.length : 0;
const routes = [...routeStats.entries()].map(([k, r]) => ({
route: k, count: r.count, avgMs: r.totalMs / r.count, maxMs: r.maxMs, errors: r.errors,
}));
return {
total, totalServerErrors, statusClasses,
reqPerMin,
avgMs: avg, p50: _pct(sorted, 50), p95: _pct(sorted, 95), p99: _pct(sorted, 99),
routeCount: routeStats.size,
sinceMs: now - startedAt,
topBusy: [...routes].sort((a, b) => b.count - a.count).slice(0, 8),
topSlow: [...routes].sort((a, b) => b.avgMs - a.avgMs).slice(0, 8),
topErrors: routes.filter(r => r.errors > 0).sort((a, b) => b.errors - a.errors).slice(0, 8),
};
}
module.exports = { record, snapshot };