From 2c42cf65cba3cac71efc606151b2ea10809cfd8d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 11 Dec 2025 14:48:23 +0100 Subject: [PATCH] feat(super-sync-server): Add basic monitoring tool This commit introduces a lightweight monitoring CLI tool for the super-sync-server. Changes include: - Enhanced logging: now supports writing logs to a file () when is set, enabling persistent log storage. - New monitoring script: provides commands for: - : Displays system vitals (CPU, RAM) and database connectivity/size. - : Shows top 20 users by data storage usage within the database. - : Allows tailing, searching, and filtering server logs from the file. - update: Added a script to easily run the tool. --- packages/super-sync-server/package.json | 3 +- packages/super-sync-server/scripts/monitor.ts | 169 ++++++++++++++++++ packages/super-sync-server/src/logger.ts | 32 ++++ 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 packages/super-sync-server/scripts/monitor.ts diff --git a/packages/super-sync-server/package.json b/packages/super-sync-server/package.json index 72a53933c9..ad60ca6243 100644 --- a/packages/super-sync-server/package.json +++ b/packages/super-sync-server/package.json @@ -9,7 +9,8 @@ "dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts", "test": "vitest run", "delete-user": "ts-node scripts/delete-user.ts", - "clear-data": "ts-node scripts/clear-data.ts" + "clear-data": "ts-node scripts/clear-data.ts", + "monitor": "ts-node scripts/monitor.ts" }, "dependencies": { "@fastify/cors": "^11.1.0", diff --git a/packages/super-sync-server/scripts/monitor.ts b/packages/super-sync-server/scripts/monitor.ts new file mode 100644 index 0000000000..ffbd4aa47d --- /dev/null +++ b/packages/super-sync-server/scripts/monitor.ts @@ -0,0 +1,169 @@ +import { prisma, disconnectDb } from '../src/db'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; + +const LOG_FILE_PATH = path.join(process.cwd(), 'logs', 'app.log'); + +const formatBytes = (bytes: number, decimals = 2) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +}; + +const showStats = async () => { + console.log('\n--- System Vitals ---'); + console.log(`Hostname: ${os.hostname()}`); + console.log(`OS: ${os.type()} ${os.release()} (${os.arch()})`); + console.log(`CPUs: ${os.cpus().length}`); + + const loadAvg = os.loadavg(); + console.log( + `Load Avg: ${loadAvg[0].toFixed(2)}, ${loadAvg[1].toFixed(2)}, ${loadAvg[2].toFixed(2)}`, + ); + + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + console.log(`Memory: ${formatBytes(usedMem)} used / ${formatBytes(totalMem)} total`); + + console.log('\n--- Database Connection ---'); + try { + await prisma.$queryRaw`SELECT 1`; + console.log('Status: Connected ✅'); + + // Get DB Size + const dbSizeResult: any[] = await prisma.$queryRaw` + SELECT pg_size_pretty(pg_database_size(current_database())) as size; + `; + console.log(`DB Size: ${dbSizeResult[0]?.size}`); + } catch (error) { + console.log('Status: Disconnected ❌'); + console.error('Error:', error); + } +}; + +const showUsage = async () => { + console.log('\n--- User Storage Usage (Top 20) ---'); + try { + // Calculate size of operations and snapshot data per user + const users: any[] = await prisma.$queryRaw` + SELECT + u.id, + u.email, + ( + COALESCE(SUM(pg_column_size(o.payload)), 0) + + COALESCE(SUM(pg_column_size(s.snapshot_data)), 0) + ) as total_bytes + FROM users u + LEFT JOIN operations o ON u.id = o.user_id + LEFT JOIN user_sync_state s ON u.id = s.user_id + GROUP BY u.id + ORDER BY total_bytes DESC + LIMIT 20; + `; + + if (users.length === 0) { + console.log('No users found.'); + } else { + console.table( + users.map((u) => ({ + ID: u.id, + Email: u.email, + Usage: formatBytes(Number(u.total_bytes)), + })), + ); + } + } catch (error) { + console.error('Error fetching usage data:', error); + } +}; + +const showLogs = async (args: string[]) => { + console.log('\n--- Server Logs ---'); + + if (!fs.existsSync(LOG_FILE_PATH)) { + console.error(`Log file not found at: ${LOG_FILE_PATH}`); + console.error( + 'Ensure LOG_TO_FILE=true is set in .env and the server has written logs.', + ); + return; + } + + const searchIndex = args.indexOf('--search'); + const searchTerm = searchIndex !== -1 ? args[searchIndex + 1] : null; + + const tailIndex = args.indexOf('--tail'); + const tailCount = tailIndex !== -1 ? parseInt(args[tailIndex + 1], 10) : 100; + + const onlyErrors = args.includes('--error'); + + const fileStream = fs.createReadStream(LOG_FILE_PATH); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const lines: string[] = []; + + for await (const line of rl) { + let include = true; + + if (onlyErrors && !line.includes('"level":"ERROR"') && !line.includes('[ERROR]')) { + include = false; + } + + if (searchTerm && !line.toLowerCase().includes(searchTerm.toLowerCase())) { + include = false; + } + + if (include) { + lines.push(line); + if (lines.length > tailCount) { + lines.shift(); // Keep only the last N lines + } + } + } + + lines.forEach((line) => console.log(line)); +}; + +const main = async () => { + const args = process.argv.slice(2); + const command = args[0]; + + try { + switch (command) { + case 'stats': + await showStats(); + break; + case 'usage': + await showUsage(); + break; + case 'logs': + await showLogs(args); + break; + default: + console.log('SuperSync Monitor CLI'); + console.log('Usage: npm run monitor -- [flags]'); + console.log('\nCommands:'); + console.log(' stats Show system vitals and DB status'); + console.log(' usage Show top 20 users by storage usage'); + console.log(' logs Show server logs'); + console.log(' --tail Show last n lines (default 100)'); + console.log(' --search "s" Filter logs by term'); + console.log(' --error Show only errors'); + break; + } + } catch (err) { + console.error('Unexpected error:', err); + } finally { + await disconnectDb(); + } +}; + +main(); diff --git a/packages/super-sync-server/src/logger.ts b/packages/super-sync-server/src/logger.ts index d7420a7e4f..1287a165f3 100644 --- a/packages/super-sync-server/src/logger.ts +++ b/packages/super-sync-server/src/logger.ts @@ -1,5 +1,30 @@ +import * as fs from 'fs'; +import * as path from 'path'; + const getTimestamp = (): string => new Date().toISOString(); +let logFileStream: fs.WriteStream | null = null; + +if (process.env.LOG_TO_FILE === 'true') { + const logDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir); + } + logFileStream = fs.createWriteStream(path.join(logDir, 'app.log'), { flags: 'a' }); +} + +const writeToFile = (level: string, message: string, ...args: unknown[]) => { + if (logFileStream) { + const logEntry = JSON.stringify({ + timestamp: getTimestamp(), + level, + message, + args: args.length ? args : undefined, + }); + logFileStream.write(logEntry + '\n'); + } +}; + export interface AuditLogEntry { event: string; userId: number; @@ -17,16 +42,20 @@ export const Logger = { debug: (message: string, ...args: unknown[]): void => { if (process.env.LOG_LEVEL === 'debug') { console.log(`${getTimestamp()} [DEBUG] ${message}`, ...args); + writeToFile('DEBUG', message, ...args); } }, info: (message: string, ...args: unknown[]): void => { console.log(`${getTimestamp()} [INFO] ${message}`, ...args); + writeToFile('INFO', message, ...args); }, warn: (message: string, ...args: unknown[]): void => { console.warn(`${getTimestamp()} [WARN] ${message}`, ...args); + writeToFile('WARN', message, ...args); }, error: (message: string, ...args: unknown[]): void => { console.error(`${getTimestamp()} [ERROR] ${message}`, ...args); + writeToFile('ERROR', message, ...args); }, /** @@ -46,5 +75,8 @@ export const Logger = { ...entry, }; console.log(JSON.stringify(logEntry)); + if (logFileStream) { + logFileStream.write(JSON.stringify(logEntry) + '\n'); + } }, };