86 lines
4.1 KiB
JavaScript
86 lines
4.1 KiB
JavaScript
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { statfs } from 'node:fs/promises';
|
|
|
|
const routeKey = (path) => path
|
|
.replace(/\/api\/media\/[^/]+/g, '/api/media/:id')
|
|
.replace(/\/api\/download\/[^/]+/g, '/api/download/:id')
|
|
.replace(/\/api\/admin\/uploads\/[^/]+/g, '/api/admin/uploads/:id')
|
|
.replace(/\?.*$/, '');
|
|
const percentile = (values, p) => values.length ? values[Math.min(values.length - 1, Math.ceil(values.length * p) - 1)] : 0;
|
|
|
|
export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adapters = {}, now = () => Date.now(), snapshotIntervalMs = 60_000 } = {}) {
|
|
const routes = new Map();
|
|
const snapshots = [];
|
|
let inflight = 0;
|
|
let lastCpu = process.cpuUsage();
|
|
let lastCpuAt = now();
|
|
let snapshotTimer;
|
|
|
|
async function sample() {
|
|
const cpuNow = process.cpuUsage();
|
|
const elapsed = Math.max(1, now() - lastCpuAt) * 1000;
|
|
const cpuPct = Math.min(100 * (cpuNow.user + cpuNow.system - lastCpu.user - lastCpu.system) / elapsed, 100);
|
|
lastCpu = cpuNow; lastCpuAt = now();
|
|
const volumes = await Promise.all(diskPaths.map(async ({ name, path }) => {
|
|
try { const fs = await statfs(path); return { name, freeBytes: Number(fs.bavail) * Number(fs.bsize), totalBytes: Number(fs.blocks) * Number(fs.bsize) }; }
|
|
catch { return { name, unavailable: true }; }
|
|
}));
|
|
const routeMetrics = Object.fromEntries([...routes].map(([key, v]) => {
|
|
const sorted = [...v.latencies].sort((a, b) => a - b);
|
|
return [key, { p50: percentile(sorted, .5), p95: percentile(sorted, .95), p99: percentile(sorted, .99), requests: v.requests, errors: v.errors, errorRate: v.requests ? v.errors / v.requests : 0 }];
|
|
}));
|
|
const [ytdlp, media, transcription, cache] = await Promise.all([
|
|
adapters.ytdlp?.() ?? null, adapters.media?.() ?? null, adapters.transcription?.() ?? null, adapters.cache?.() ?? null,
|
|
]);
|
|
return {
|
|
at: now(), routes: routeMetrics, inflight, rssBytes: process.memoryUsage().rss, cpuPercent: cpuPct,
|
|
ytdlp, media, transcription, cache, volumes,
|
|
};
|
|
}
|
|
|
|
async function snapshot() {
|
|
const value = await sample(); snapshots.push(value);
|
|
while (snapshots.length && now() - snapshots[0].at > 24 * 3600_000) snapshots.shift();
|
|
if (snapshotFile) {
|
|
try { await mkdir(dirname(snapshotFile), { recursive: true }); await appendFile(snapshotFile, JSON.stringify(value) + '\n'); }
|
|
catch (error) { console.warn('[monitor] snapshot write failed:', error.message); }
|
|
}
|
|
return value;
|
|
}
|
|
async function history() {
|
|
const cutoff = now() - 24 * 3600_000;
|
|
const out = snapshots.filter(x => x.at >= cutoff);
|
|
if (snapshotFile) {
|
|
try {
|
|
const persisted = (await readFile(snapshotFile, 'utf8')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= cutoff);
|
|
return [...persisted, ...out].slice(-1440);
|
|
} catch { /* no file yet */ }
|
|
}
|
|
return out.slice(-1440);
|
|
}
|
|
|
|
function middleware() {
|
|
return async (c, next) => {
|
|
const started = now(); inflight++;
|
|
let status = 500;
|
|
try { await next(); status = c.res.status; }
|
|
finally {
|
|
inflight--;
|
|
const key = routeKey(c.req.path);
|
|
let row = routes.get(key);
|
|
if (!row) { row = { latencies: [], requests: 0, errors: 0 }; routes.set(key, row); }
|
|
row.requests++; if (status >= 500) row.errors++;
|
|
row.latencies.push(Math.max(0, now() - started)); if (row.latencies.length > 1000) row.latencies.shift();
|
|
}
|
|
};
|
|
}
|
|
|
|
if (snapshotIntervalMs > 0) { snapshotTimer = setInterval(() => snapshot().catch(() => {}), snapshotIntervalMs); snapshotTimer.unref?.(); }
|
|
return { middleware, sample, snapshot, history, close: () => clearInterval(snapshotTimer) };
|
|
}
|
|
|
|
export function registerRequestMonitorRoutes(app, { adminAuth, monitor }) {
|
|
app.get('/api/admin/ops/metrics', adminAuth, async c => c.json({ ok: true, current: await monitor.sample(), history: await monitor.history() }, 200, { 'Cache-Control': 'no-store' }));
|
|
}
|