logger.js
1 /** 2 * Logger utility for console and file output 3 * Formats messages for Cline monitoring via page.on('console') and page.on('pageerror') 4 * Supports daily log rotation with configurable retention 5 */ 6 7 import fs from 'fs'; 8 import path from 'path'; 9 10 const colors = { 11 reset: '\x1b[0m', 12 bright: '\x1b[1m', 13 red: '\x1b[31m', 14 green: '\x1b[32m', 15 yellow: '\x1b[33m', 16 blue: '\x1b[34m', 17 magenta: '\x1b[35m', 18 cyan: '\x1b[36m', 19 }; 20 21 class Logger { 22 constructor(context = '333', options = {}) { 23 this.context = context; 24 this.logToFile = options.logToFile !== false && process.env.NODE_ENV !== 'test'; // default true, disabled in tests 25 this.logDir = options.logDir || process.env.LOGS_DIR || './logs'; 26 this.logFile = null; 27 this.logStream = null; 28 29 if (this.logToFile) { 30 this._initializeLogFile(); 31 } 32 } 33 34 /** 35 * Map module contexts to consolidated domain log files 36 * This keeps log files organized by domain (pipeline, outreach, etc.) 37 * rather than creating a separate file for each module 38 */ 39 _getLogDomain(context) { 40 const normalized = context.toLowerCase().replace(/[^a-z0-9-_]/g, ''); 41 42 const domainMap = { 43 // Pipeline stages 44 pipeline: 'pipeline', 45 pipelineservice: 'pipeline', 46 keywords: 'keywords', 47 keywordmanager: 'keywords', 48 keywordcounters: 'keywords', 49 serps: 'serps', 50 scraper: 'serps', 51 assets: 'assets', 52 capture: 'assets', 53 scoring: 'scoring', 54 score: 'scoring', 55 scoringcli: 'scoring', 56 rescoring: 'rescoring', 57 rescoringcli: 'rescoring', 58 enrich: 'enrich', 59 proposals: 'proposals', 60 proposalgeneratorv2: 'proposals', 61 62 // Outreach channels 63 outreach: 'outreach', 64 smsoutreach: 'outreach', 65 emailoutreach: 'outreach', 66 formoutreach: 'outreach', 67 xoutreach: 'outreach', 68 linkedinoutreach: 'outreach', 69 70 // Inbound processing 71 replies: 'inbound', 72 inboundsms: 'inbound', 73 inboundemail: 'inbound', 74 inboundprocessor: 'inbound', 75 76 // Dashboard 77 dashboard: 'dashboard', 78 overview: 'dashboard', 79 pipelinehealth: 'dashboard', 80 pipelinewidgets: 'dashboard', 81 systemhealth: 'dashboard', 82 cronjobs: 'dashboard', 83 coverage: 'dashboard', 84 compliance: 'dashboard', 85 conversations: 'dashboard', 86 87 // Agent system 88 agents: 'agents', 89 agentrunner: 'agents', 90 agentsystem: 'agents', 91 developer: 'agents', 92 qa: 'agents', 93 security: 'agents', 94 architect: 'agents', 95 monitor: 'agents', 96 triage: 'agents', 97 agentrunnerbackground: 'agents', 98 agentrunnerasync: 'agents', 99 100 // Cron jobs 101 cron: 'cron', 102 dailylogrotation: 'cron', 103 weeklyrepricing: 'cron', 104 syncemailevents: 'cron', 105 syncunsubscribes: 'cron', 106 107 // Utilities 108 dedupe: 'utils', 109 dedupelocale: 'utils', 110 dedupelocaleaware: 'utils', 111 backfillscreenshots: 'utils', 112 imageoptimizer: 'utils', 113 errorhandler: 'utils', 114 sitefilters: 'utils', 115 errorpagedetector: 'utils', 116 tlddetector: 'utils', 117 timezonedetector: 'utils', 118 domcropanalyzer: 'utils', 119 circuitbreaker: 'utils', 120 contactprioritizer: 'utils', 121 ratelimiter: 'utils', 122 stealthbrowser: 'utils', 123 summary: 'utils', 124 125 // Tests 126 test: 'tests', 127 testlogging: 'tests', 128 }; 129 130 return domainMap[normalized] || 'app'; 131 } 132 133 _initializeLogFile() { 134 // Create logs directory if it doesn't exist 135 if (!fs.existsSync(this.logDir)) { 136 fs.mkdirSync(this.logDir, { recursive: true }); 137 } 138 139 // Generate log filename with domain and date 140 const d = new Date(); 141 const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; // YYYY-MM-DD (local timezone) 142 const logDomain = this._getLogDomain(this.context); 143 this.logFile = path.join(this.logDir, `${logDomain}-${date}.log`); 144 145 // Open write stream in append mode 146 this.logStream = fs.createWriteStream(this.logFile, { flags: 'a' }); 147 148 // Handle stream errors 149 this.logStream.on('error', err => { 150 console.error(`[Logger] Failed to write to log file: ${err.message}`); 151 }); 152 } 153 154 _writeToFile(message) { 155 if (this.logStream && !this.logStream.destroyed) { 156 // Strip ANSI color codes for file output 157 // eslint-disable-next-line no-control-regex 158 const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, ''); 159 this.logStream.write(`${cleanMessage}\n`); 160 } 161 } 162 163 _format(level, message, data = null) { 164 const d = new Date(); 165 const pad = (n, w = 2) => String(n).padStart(w, '0'); 166 const offset = -d.getTimezoneOffset(); 167 const sign = offset >= 0 ? '+' : '-'; 168 const oh = Math.floor(Math.abs(offset) / 60); 169 const om = Math.abs(offset) % 60; 170 const timestamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${sign}${pad(oh)}:${pad(om)}`; 171 const prefix = `[${timestamp}] [${this.context}] [${level}]`; 172 173 if (data) { 174 return `${prefix} ${message}\n${JSON.stringify(data, null, 2)}`; 175 } 176 return `${prefix} ${message}`; 177 } 178 179 close() { 180 if (this.logStream && !this.logStream.destroyed) { 181 this.logStream.end(); 182 } 183 } 184 185 info(message, data = null) { 186 const formatted = this._format('INFO', message, data); 187 console.log(colors.blue + formatted + colors.reset); 188 this._writeToFile(formatted); 189 } 190 191 success(message, data = null) { 192 const formatted = this._format('SUCCESS', message, data); 193 console.log(colors.green + formatted + colors.reset); 194 this._writeToFile(formatted); 195 } 196 197 warn(message, data = null) { 198 const formatted = this._format('WARN', message, data); 199 console.warn(colors.yellow + formatted + colors.reset); 200 this._writeToFile(formatted); 201 } 202 203 error(message, error = null) { 204 const errorData = error 205 ? { 206 message: error.message, 207 stack: error.stack, 208 } 209 : null; 210 const formatted = this._format('ERROR', message, errorData); 211 console.error(colors.red + formatted + colors.reset); 212 this._writeToFile(formatted); 213 } 214 215 debug(message, data = null) { 216 const formatted = this._format('DEBUG', message, data); 217 if (process.env.DEBUG === 'true') { 218 console.log(colors.cyan + formatted + colors.reset); 219 } 220 this._writeToFile(formatted); 221 } 222 223 // Progress indicator for batch operations 224 progress(current, total, message = '') { 225 const percentage = Math.round((current / total) * 100); 226 const bar = 227 '█'.repeat(Math.floor(percentage / 2)) + '░'.repeat(50 - Math.floor(percentage / 2)); 228 process.stdout.write(`\r${colors.cyan}[${bar}] ${percentage}% ${message}${colors.reset}`); 229 230 if (current === total) { 231 console.log(''); // New line when complete 232 } 233 } 234 } 235 236 export default Logger;