Checkpoint/plugins/stats.js

132 lines
4.2 KiB
JavaScript

import { registerPlugin, rootDir, loadConfig } from '../index.js';
import { Level } from 'level';
import ttl from 'level-ttl';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { Readable } from 'stream';
import cookie from 'cookie';
import { getRealIP } from '../utils/network.js';
import { parseDuration } from '../utils/time.js';
// Load stats configuration
const statsConfig = {};
await loadConfig('stats', statsConfig);
// Map configuration to internal structure
const enabled = statsConfig.Core.Enabled;
const statsTTL = parseDuration(statsConfig.Storage.StatsTTL);
const statsUIPath = statsConfig.WebUI.StatsUIPath;
const statsAPIPath = statsConfig.WebUI.StatsAPIPath;
// Determine __dirname for ES modules
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Adds createReadStream support to LevelDB instances using async iterator.
*/
function addReadStreamSupport(dbInstance) {
if (!dbInstance.createReadStream) {
dbInstance.createReadStream = (opts) =>
Readable.from(
(async function* () {
for await (const [key, value] of dbInstance.iterator(opts)) {
yield { key, value };
}
})(),
);
}
return dbInstance;
}
// Initialize LevelDB for stats under db/stats with TTL and stream support
const statsDBPath = path.join(rootDir, 'db', 'stats');
await fs.mkdir(statsDBPath, { recursive: true });
let rawStatsDB = new Level(statsDBPath, { valueEncoding: 'json' });
rawStatsDB = addReadStreamSupport(rawStatsDB);
const statsDB = ttl(rawStatsDB, { defaultTTL: statsTTL });
addReadStreamSupport(statsDB);
/**
* Record a stat event with a metric name and optional data.
* @param {string} metric
* @param {object} data
*/
function recordEvent(metric, data = {}) {
// Skip if statsDB is not initialized
if (typeof statsDB === 'undefined' || !statsDB || typeof statsDB.put !== 'function') {
console.warn(`stats: cannot record "${metric}", statsDB not available`);
return;
}
const timestamp = Date.now();
// key includes metric and timestamp and a random suffix to avoid collisions
const key = `${metric}:${timestamp}:${Math.random().toString(36).slice(2, 8)}`;
try {
// Use callback form to avoid promise chaining
statsDB.put(key, { timestamp, metric, ...data }, (err) => {
if (err) console.error('stats: failed to record event', err);
});
} catch (err) {
console.error('stats: failed to record event', err);
}
}
// Handler for serving the stats HTML UI
async function handleStatsPage(req, res) {
const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`);
if (url.pathname !== statsUIPath) return false;
try {
const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8');
res.status(200).type('html').send(html);
return true;
} catch (e) {
res.status(404).send('Stats UI not found');
return true;
}
}
// Handler for stats API
async function handleStatsAPI(req, res) {
const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`);
if (url.pathname !== statsAPIPath) return false;
const metric = url.searchParams.get('metric');
const start = parseInt(url.searchParams.get('start') || '0', 10);
const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10);
const result = [];
// Iterate over keys for this metric in the time range
for await (const [key, value] of statsDB.iterator({
gte: `${metric}:${start}`,
lte: `${metric}:${end}\uffff`,
})) {
result.push(value);
}
res.status(200).json(result);
return true;
}
// Middleware for stats plugin
function StatsMiddleware() {
return {
middleware: async (req, res, next) => {
// Always serve stats UI and API first, bypassing auth
const pageHandled = await handleStatsPage(req, res);
if (pageHandled) return;
const apiHandled = await handleStatsAPI(req, res);
if (apiHandled) return;
// For any other routes, do not handle
return next();
}
};
}
// Register the stats plugin
if (enabled) {
registerPlugin('stats', StatsMiddleware());
} else {
console.log('Stats plugin disabled via config');
}
// Export recordEvent for other plugins to use
export { recordEvent };