diff --git a/src/app.js b/src/app.js index 6b31d21..8b3c592 100644 --- a/src/app.js +++ b/src/app.js @@ -3,25 +3,21 @@ const net = require("net"); const setupMiddleware = require("./middleware"); -const { winstonLogger } = require("./utils/logging"); +const { + winstonLogger, + handleUncaughtException, + handleUnhandledRejection, +} = require("./utils/logging"); const { startTokenCleanup } = require("./utils/tokenCleanup"); +const { cleanupOldSessions } = require("./utils/logManager"); const SERVER_PORT = process.env.TEST_PORT || process.env.SERVER_PORT || 3400; const SERVER_LISTEN_LOG = (port) => `Server listening on http://localhost:${port}`; const NODE_ENV_LOG = `NODE_ENV: ${process.env.NODE_ENV}`; -const UNCUGHT_EXCEPTION_MSG = "Uncaught Exception:"; -const UNHANDLED_REJECTION_MSG = "Unhandled Rejection:"; -function handleUncaughtException(err) { - winstonLogger.error(UNCUGHT_EXCEPTION_MSG, err.stack || err); -} - -function handleUnhandledRejection(reason) { - winstonLogger.error(UNHANDLED_REJECTION_MSG, reason?.stack || reason); -} - +cleanupOldSessions(); startTokenCleanup(); const app = setupMiddleware(); diff --git a/src/utils/logManager.js b/src/utils/logManager.js new file mode 100644 index 0000000..b03a0ae --- /dev/null +++ b/src/utils/logManager.js @@ -0,0 +1,359 @@ +const fs = require("fs"); +const path = require("path"); +const { winstonLogger } = require("./logging"); + +const logDir = path.join(__dirname, "../../logs"); + +class LogManager { + constructor(logDir, options = {}) { + this.logDir = logDir; + this.serverStart = Date.now(); + this.isDevelopment = process.env.NODE_ENV !== "production"; + + // Configurable thresholds + this.config = { + development: { + maxSessionCount: 25, // Fewer sessions in dev + sessionRetentionHours: 1, // Very short retention + maxTotalSizeMB: 50, // Small disk footprint + maxDiskUsagePercent: 85, // Panic threshold + cleanupIntervalMinutes: 15, // Frequent cleanup + emergencyCleanupRatio: 0.7, // Keep only 30% newest in emergency + }, + production: { + maxSessionCount: 100, + sessionRetentionHours: 24, + maxTotalSizeMB: 200, + maxDiskUsagePercent: 90, + cleanupIntervalMinutes: 60, + emergencyCleanupRatio: 0.5, + }, + ...options, // Allow override + }; + + this.currentConfig = this.isDevelopment + ? this.config.development + : this.config.production; + this.lastCleanupFile = path.join(logDir, ".last-cleanup"); + this.metricsFile = path.join(logDir, ".cleanup-metrics"); + + winstonLogger.info( + `LogManager initialized for ${this.isDevelopment ? "development" : "production"}` + ); + winstonLogger.info(`Config:`, this.currentConfig); + } + + // Main cleanup orchestrator + cleanup(force = false) { + try { + const metrics = this.getMetrics(); + winstonLogger.info(`\n=== Log Cleanup Started ===`); + winstonLogger.info(`Current sessions: ${metrics.sessionCount}`); + winstonLogger.info(`Total size: ${metrics.totalSizeMB.toFixed(2)}MB`); + winstonLogger.info(`Disk usage: ${metrics.diskUsagePercent.toFixed(1)}%`); + + // Emergency cleanup if disk is critically full + if (metrics.diskUsagePercent > this.currentConfig.maxDiskUsagePercent) { + winstonLogger.info(`🚨 EMERGENCY CLEANUP: Disk usage critical!`); + return this.emergencyCleanup(); + } + + // Size-based cleanup if total size exceeded + if (metrics.totalSizeMB > this.currentConfig.maxTotalSizeMB) { + winstonLogger.info(`📦 SIZE-BASED CLEANUP: Total size exceeded`); + return this.sizeLimitedCleanup(); + } + + // Regular cleanup if time-based or forced + if (force || this.shouldRunRegularCleanup()) { + winstonLogger.info(`🕒 REGULAR CLEANUP: Time-based maintenance`); + return this.regularCleanup(); + } + + winstonLogger.info(`✅ No cleanup needed`); + return { cleaned: false, reason: "thresholds not met" }; + } catch (error) { + winstonLogger.error(`❌ Cleanup failed:`, error); + return { cleaned: false, error: error.message }; + } + } + + // Get current metrics + getMetrics() { + const sessionsDir = path.join(this.logDir, "sessions"); + if (!fs.existsSync(sessionsDir)) { + return { + sessionCount: 0, + totalSizeMB: 0, + diskUsagePercent: 0, + sessions: [], + }; + } + + const sessions = this.getSessionsWithMetadata(); + const totalSize = this.calculateDirectorySize(sessionsDir); + const diskUsage = this.getDiskUsage(); + + return { + sessionCount: sessions.length, + totalSizeMB: totalSize / (1024 * 1024), + diskUsagePercent: diskUsage, + sessions: sessions, + }; + } + + // Get sessions with rich metadata + getSessionsWithMetadata() { + const sessionsDir = path.join(this.logDir, "sessions"); + if (!fs.existsSync(sessionsDir)) return []; + + return fs + .readdirSync(sessionsDir) + .map((sessionFolder) => { + const sessionPath = path.join(sessionsDir, sessionFolder); + try { + if (!fs.statSync(sessionPath).isDirectory()) return null; + + const files = fs.readdirSync(sessionPath); + let latestMtime = 0; + let totalSize = 0; + + files.forEach((file) => { + const filePath = path.join(sessionPath, file); + const stat = fs.statSync(filePath); + latestMtime = Math.max(latestMtime, stat.mtimeMs); + totalSize += stat.size; + }); + + const ageHours = (Date.now() - latestMtime) / (1000 * 60 * 60); + + return { + folder: sessionFolder, + path: sessionPath, + latestMtime, + ageHours, + sizeMB: totalSize / (1024 * 1024), + fileCount: files.length, + isCurrentSession: latestMtime >= this.serverStart, + isStale: ageHours > this.currentConfig.sessionRetentionHours, + }; + } catch (error) { + winstonLogger.warn( + `Error processing session ${sessionFolder}:`, + error.message + ); + return null; + } + }) + .filter(Boolean) + .sort((a, b) => b.latestMtime - a.latestMtime); // Newest first + } + + // Emergency cleanup - keep only newest sessions + emergencyCleanup() { + const sessions = this.getSessionsWithMetadata(); + const keepCount = Math.floor( + sessions.length * this.currentConfig.emergencyCleanupRatio + ); + const sessionsToDelete = sessions.slice(keepCount); + + winstonLogger.info( + `Keeping ${keepCount} newest sessions, deleting ${sessionsToDelete.length}` + ); + + let deletedCount = 0; + let freedMB = 0; + + sessionsToDelete.forEach((session) => { + if (this.deleteSession(session)) { + deletedCount++; + freedMB += session.sizeMB; + } + }); + + this.updateMetrics({ deletedCount, freedMB, type: "emergency" }); + return { cleaned: true, type: "emergency", deletedCount, freedMB }; + } + + // Size-limited cleanup - delete until under threshold + sizeLimitedCleanup() { + const sessions = this.getSessionsWithMetadata(); + const targetSizeMB = this.currentConfig.maxTotalSizeMB * 0.8; // Clean to 80% of limit + + let currentSizeMB = sessions.reduce((sum, s) => sum + s.sizeMB, 0); + let deletedCount = 0; + let freedMB = 0; + + // Delete oldest first until we're under the target + for ( + let i = sessions.length - 1; + i >= 0 && currentSizeMB > targetSizeMB; + i-- + ) { + const session = sessions[i]; + if (!session.isCurrentSession) { + if (this.deleteSession(session)) { + deletedCount++; + freedMB += session.sizeMB; + currentSizeMB -= session.sizeMB; + } + } + } + + this.updateMetrics({ deletedCount, freedMB, type: "size-limited" }); + return { cleaned: true, type: "size-limited", deletedCount, freedMB }; + } + + // Regular cleanup - age and count based + regularCleanup() { + const sessions = this.getSessionsWithMetadata(); + let deletedCount = 0; + let freedMB = 0; + + sessions.forEach((session, index) => { + const shouldDeleteByAge = !session.isCurrentSession && session.isStale; + const shouldDeleteByCount = index >= this.currentConfig.maxSessionCount; + + if (shouldDeleteByAge || shouldDeleteByCount) { + if (this.deleteSession(session)) { + deletedCount++; + freedMB += session.sizeMB; + + const reason = shouldDeleteByAge + ? `age (${session.ageHours.toFixed(1)}h)` + : "count limit"; + winstonLogger.info( + ` Deleted ${session.folder} - ${reason} - ${session.sizeMB.toFixed(2)}MB` + ); + } + } + }); + + this.updateLastCleanup(); + this.updateMetrics({ deletedCount, freedMB, type: "regular" }); + return { cleaned: true, type: "regular", deletedCount, freedMB }; + } + + // Helper methods + deleteSession(session) { + try { + fs.rmSync(session.path, { recursive: true, force: true }); + return true; + } catch (error) { + winstonLogger.error(`Failed to delete ${session.path}:`, error.message); + return false; + } + } + + shouldRunRegularCleanup() { + try { + const lastCleanup = fs.readFileSync(this.lastCleanupFile, "utf8"); + const timeSinceLastCleanup = Date.now() - parseInt(lastCleanup); + const cleanupInterval = + this.currentConfig.cleanupIntervalMinutes * 60 * 1000; + return timeSinceLastCleanup > cleanupInterval; + } catch { + return true; // First run + } + } + + updateLastCleanup() { + fs.writeFileSync(this.lastCleanupFile, Date.now().toString()); + } + + updateMetrics(data) { + const metrics = { + timestamp: new Date().toISOString(), + environment: this.isDevelopment ? "development" : "production", + ...data, + }; + + // Append to metrics log + const logLine = JSON.stringify(metrics) + "\n"; + fs.appendFileSync(this.metricsFile, logLine); + } + + calculateDirectorySize(dirPath) { + let totalSize = 0; + + const calculateSize = (itemPath) => { + try { + const stat = fs.statSync(itemPath); + if (stat.isDirectory()) { + fs.readdirSync(itemPath).forEach((item) => + calculateSize(path.join(itemPath, item)) + ); + } else { + totalSize += stat.size; + } + } catch (error) { + // Skip inaccessible files + } + }; + + if (fs.existsSync(dirPath)) { + calculateSize(dirPath); + } + + return totalSize; + } + + getDiskUsage() { + try { + const stats = fs.statSync(this.logDir); + // This is a simplified version - in production you might want to use a library + // like 'diskusage' or 'statvfs' for accurate disk space calculation + return 0; // Placeholder - implement based on your system + } catch { + return 0; + } + } + + // Utility method to show current status + status() { + const metrics = this.getMetrics(); + winstonLogger.info(`\n=== Log Manager Status ===`); + winstonLogger.info( + `Environment: ${this.isDevelopment ? "development" : "production"}` + ); + winstonLogger.info( + `Sessions: ${metrics.sessionCount} (max: ${this.currentConfig.maxSessionCount})` + ); + winstonLogger.info( + `Total size: ${metrics.totalSizeMB.toFixed(2)}MB (max: ${this.currentConfig.maxTotalSizeMB}MB)` + ); + winstonLogger.info( + `Retention: ${this.currentConfig.sessionRetentionHours}h` + ); + + if (metrics.sessions.length > 0) { + const oldest = metrics.sessions[metrics.sessions.length - 1]; + const newest = metrics.sessions[0]; + winstonLogger.info( + `Oldest session: ${oldest.ageHours.toFixed(1)}h old (${oldest.sizeMB.toFixed(2)}MB)` + ); + winstonLogger.info( + `Newest session: ${newest.ageHours.toFixed(1)}h old (${newest.sizeMB.toFixed(2)}MB)` + ); + } + + return metrics; + } +} + +// Usage: +const logManager = new LogManager(logDir, { + // Optional custom config overrides + development: { + maxSessionCount: 15, // Even more aggressive for your dev setup + sessionRetentionHours: 0.5, // 30 minutes + }, +}); + +// Call this on server start +function cleanupOldSessions() { + return logManager.cleanup(); +} + +// Export for use in other modules +module.exports = { LogManager, cleanupOldSessions }; diff --git a/src/utils/logging.js b/src/utils/logging.js index 660e795..1bc8004 100644 --- a/src/utils/logging.js +++ b/src/utils/logging.js @@ -18,6 +18,8 @@ debug: "blue", }, }; +const UNCUGHT_EXCEPTION_MSG = "Uncaught Exception:"; +const UNHANDLED_REJECTION_MSG = "Unhandled Rejection:"; const fs = require("fs"); const path = require("path"); @@ -242,26 +244,13 @@ ], }); -// Clean up old session directories (optional) -function cleanupOldSessions() { - const sessionsDir = path.join(logDir, "sessions"); - if (fs.existsSync(sessionsDir)) { - const sessions = fs.readdirSync(sessionsDir); - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - 30); // Keep 30 days of sessions - - sessions.forEach((sessionFolder) => { - const sessionPath = path.join(sessionsDir, sessionFolder); - const stats = fs.statSync(sessionPath); - if (stats.isDirectory() && stats.mtime < cutoffDate) { - fs.rmSync(sessionPath, { recursive: true, force: true }); - } - }); - } +function handleUncaughtException(err) { + winstonLogger.error(UNCUGHT_EXCEPTION_MSG, err.stack || err); } -// Run cleanup on startup -cleanupOldSessions(); +function handleUnhandledRejection(reason) { + winstonLogger.error(UNHANDLED_REJECTION_MSG, reason?.stack || reason); +} if ( process.env.NODE_ENV !== "production" && @@ -273,4 +262,6 @@ module.exports = { manualLogger, winstonLogger, + handleUncaughtException, + handleUnhandledRejection, };