/** * Log Digest Module * Collects container logs hourly, generates daily summaries. * Gives users a single place to see what happened across all services * and guidance on where to look for more detail. */ const Docker = require('dockerode'); const EventEmitter = require('events'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const { DOCKER } = require('./constants'); const docker = new Docker(); const ERROR_PATTERNS = [ /\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i, /\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i, /\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i, /\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i ]; const WARNING_PATTERNS = [ /\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i, /\bslow\b/i, /\blatency\b/i ]; const EVENT_PATTERNS = [ { pattern: /\b(start(ed|ing)?|boot(ed|ing)?|init(ializ(ed|ing))?)\b/i, type: 'startup' }, { pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' }, { pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' }, { pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' }, { pattern: /\b(update|upgrade|migration)\b/i, type: 'update' } ]; class LogDigest extends EventEmitter { constructor() { super(); this.collectInterval = null; this.digestTimeout = null; this.running = false; this.hourlySummaries = []; // Ring buffer of hourly snapshots this.digestDir = null; // Set during start() this.lastCollect = null; this._lastCollectTimestamp = {}; // Per-container: last log timestamp fetched } /** * Start the log digest system. * @param {string} digestDir - Directory to write daily digest files */ start(digestDir) { if (this.running) return; this.running = true; this.digestDir = digestDir; // Ensure digest directory exists if (!fs.existsSync(digestDir)) { fs.mkdirSync(digestDir, { recursive: true }); } // Collect logs every hour this.collectInterval = setInterval(() => { this._collectHourlyLogs().catch(e => console.error('[LogDigest] Hourly collection failed:', e.message) ); }, DOCKER.DIGEST.COLLECT_INTERVAL); // Schedule daily digest generation this._scheduleDailyDigest(); // Run initial collection after 2 minutes setTimeout(() => { if (this.running) { this._collectHourlyLogs().catch(() => {}); } }, 2 * 60 * 1000); } stop() { if (!this.running) return; this.running = false; if (this.collectInterval) { clearInterval(this.collectInterval); this.collectInterval = null; } if (this.digestTimeout) { clearTimeout(this.digestTimeout); this.digestTimeout = null; } } /** * Collect logs from all managed containers for the last hour. */ async _collectHourlyLogs() { const now = new Date(); const sinceTimestamp = Math.floor((now.getTime() - DOCKER.DIGEST.COLLECT_INTERVAL) / 1000); const hourKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T${String(now.getHours()).padStart(2, '0')}:00`; const hourSummary = { hour: hourKey, timestamp: now.toISOString(), services: {} }; try { const containers = await docker.listContainers({ all: true }); const managed = containers.filter(c => c.Labels?.['sami.managed'] === 'true'); for (const containerInfo of managed) { const name = containerInfo.Names[0]?.replace(/^\//, '') || containerInfo.Id.slice(0, 12); const appId = containerInfo.Labels['sami.app'] || name; const isRunning = containerInfo.State === 'running'; const serviceSummary = { name, appId, state: containerInfo.State, errors: [], warnings: [], events: [], errorCount: 0, warningCount: 0, totalLines: 0 }; if (isRunning) { try { const container = docker.getContainer(containerInfo.Id); const logBuffer = await container.logs({ stdout: true, stderr: true, since: sinceTimestamp, tail: DOCKER.DIGEST.LOG_TAIL, timestamps: true }); const lines = this._parseDockerLogs(logBuffer); serviceSummary.totalLines = lines.length; for (const line of lines) { // Check for errors if (line.stream === 'stderr' || ERROR_PATTERNS.some(p => p.test(line.text))) { serviceSummary.errorCount++; if (serviceSummary.errors.length < 10) { serviceSummary.errors.push({ time: line.timestamp || hourKey, text: line.text.slice(0, 500) }); } continue; } // Check for warnings if (WARNING_PATTERNS.some(p => p.test(line.text))) { serviceSummary.warningCount++; if (serviceSummary.warnings.length < 5) { serviceSummary.warnings.push({ time: line.timestamp || hourKey, text: line.text.slice(0, 300) }); } continue; } // Check for notable events for (const { pattern, type } of EVENT_PATTERNS) { if (pattern.test(line.text)) { serviceSummary.events.push({ type, time: line.timestamp || hourKey, text: line.text.slice(0, 300) }); break; } } } } catch (logErr) { serviceSummary.errors.push({ time: now.toISOString(), text: `Failed to fetch logs: ${logErr.message}` }); serviceSummary.errorCount++; } } else { serviceSummary.events.push({ type: 'not_running', time: now.toISOString(), text: `Container is ${containerInfo.State}` }); } hourSummary.services[appId] = serviceSummary; } } catch (e) { console.error('[LogDigest] Container enumeration failed:', e.message); } // Add to ring buffer this.hourlySummaries.push(hourSummary); if (this.hourlySummaries.length > DOCKER.DIGEST.MAX_HOURLY_ENTRIES) { this.hourlySummaries.shift(); } this.lastCollect = now.toISOString(); this.emit('hourly-collected', hourSummary); return hourSummary; } /** * Parse Docker multiplexed log stream into lines. */ _parseDockerLogs(logData) { const lines = []; const buffer = Buffer.isBuffer(logData) ? logData : Buffer.from(logData); let offset = 0; while (offset < buffer.length) { if (offset + 8 > buffer.length) break; const streamType = buffer[0 + offset]; const size = buffer.readUInt32BE(4 + offset); if (offset + 8 + size > buffer.length) break; const text = buffer.slice(offset + 8, offset + 8 + size).toString('utf8').trim(); if (text) { // Try to extract timestamp from Docker's format: "2026-03-13T12:00:00.000000000Z message" let timestamp = null; let message = text; const tsMatch = text.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d+Z\s(.*)$/s); if (tsMatch) { timestamp = tsMatch[1]; message = tsMatch[2]; } lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', text: message, timestamp }); } offset += 8 + size; } return lines; } /** * Schedule the daily digest at the configured hour. */ _scheduleDailyDigest() { const now = new Date(); const targetHour = DOCKER.DIGEST.DIGEST_HOUR; const next = new Date(now); next.setHours(targetHour, 5, 0, 0); // 5 minutes past the hour if (next <= now) next.setDate(next.getDate() + 1); const delay = next.getTime() - now.getTime(); this.digestTimeout = setTimeout(() => { this.generateDailyDigest().catch(e => console.error('[LogDigest] Daily digest generation failed:', e.message) ); // Reschedule for tomorrow if (this.running) this._scheduleDailyDigest(); }, delay); } /** * Generate the daily digest from accumulated hourly summaries. * Can also be called on-demand. */ async generateDailyDigest(dateStr) { const date = dateStr || new Date(Date.now() - 86400000).toISOString().slice(0, 10); const relevantHours = this.hourlySummaries.filter(h => h.hour.startsWith(date)); // Aggregate per-service stats across all hours const serviceAgg = {}; const notableEvents = []; for (const hour of relevantHours) { for (const [appId, svc] of Object.entries(hour.services)) { if (!serviceAgg[appId]) { serviceAgg[appId] = { name: svc.name, appId, totalErrors: 0, totalWarnings: 0, totalLines: 0, lastState: svc.state, topErrors: [], events: [] }; } const agg = serviceAgg[appId]; agg.totalErrors += svc.errorCount; agg.totalWarnings += svc.warningCount; agg.totalLines += svc.totalLines; agg.lastState = svc.state; // Keep top errors (deduplicated-ish) for (const err of svc.errors) { if (agg.topErrors.length < 5) { agg.topErrors.push(err); } } // Collect notable events for (const evt of svc.events) { notableEvents.push({ ...evt, service: svc.name, appId }); } } } // Get Docker disk usage let diskUsage = null; try { const dockerMaintenance = require('./docker-maintenance'); diskUsage = await dockerMaintenance.getDiskUsage(); } catch (e) { // Module may not be loaded yet } // Build digest object const digest = { date, generatedAt: new Date().toISOString(), hoursCollected: relevantHours.length, services: serviceAgg, notableEvents: notableEvents.sort((a, b) => (a.time || '').localeCompare(b.time || '')), diskUsage, summary: { totalServices: Object.keys(serviceAgg).length, servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length, totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0), totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0) } }; // Write formatted digest file const formatted = this._formatDigest(digest); const filename = `digest-${date}.log`; const filepath = path.join(this.digestDir, filename); await fsp.writeFile(filepath, formatted, 'utf8'); // Also write JSON for API consumption const jsonPath = path.join(this.digestDir, `digest-${date}.json`); await fsp.writeFile(jsonPath, JSON.stringify(digest, null, 2), 'utf8'); // Cleanup old digests await this._cleanupOldDigests(); this.emit('digest-generated', { date, filepath, digest }); return digest; } /** * Format digest into human-readable text. */ _formatDigest(digest) { const lines = []; const hr = '='.repeat(55); const sr = '-'.repeat(55); lines.push(hr); lines.push(' DashCaddy Daily Log Digest'); lines.push(` ${digest.date}`); lines.push(` Generated: ${digest.generatedAt}`); lines.push(hr); lines.push(''); // Service summary table lines.push('-- Service Summary ' + '-'.repeat(36)); const services = Object.values(digest.services); if (services.length === 0) { lines.push(' No managed services found.'); } else { for (const svc of services) { const stateIcon = svc.lastState === 'running' ? 'OK' : '!!'; const errStr = `${svc.totalErrors} error${svc.totalErrors !== 1 ? 's' : ''}`; const warnStr = `${svc.totalWarnings} warning${svc.totalWarnings !== 1 ? 's' : ''}`; const flag = svc.totalErrors > 0 ? ' <-- investigate' : ''; lines.push(` ${svc.name.padEnd(18)} ${stateIcon.padEnd(10)} ${errStr.padEnd(14)} ${warnStr}${flag}`); } } lines.push(''); // Notable events const events = digest.notableEvents; if (events.length > 0) { lines.push('-- Notable Events ' + '-'.repeat(37)); for (const evt of events) { const time = (evt.time || '').slice(11, 16) || '??:??'; lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`); // Add guidance for where to look further const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`; if (evt.type === 'health_failure' || evt.type === 'restart') { const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00'; lines.push(` See: docker logs ${containerName} --since ${sinceDate}`); } } lines.push(''); } // Top errors per service const errServices = services.filter(s => s.totalErrors > 0); if (errServices.length > 0) { lines.push('-- Error Details ' + '-'.repeat(38)); for (const svc of errServices) { lines.push(` ${svc.name} (${svc.totalErrors} errors):`); for (const err of svc.topErrors) { const time = (err.time || '').slice(11, 16) || '??:??'; lines.push(` [${time}] ${err.text.slice(0, 100)}`); } const containerName = `${DOCKER.CONTAINER_PREFIX}${svc.appId}`; lines.push(` Full logs: docker logs ${containerName} --since ${digest.date}T00:00:00`); lines.push(''); } } // Docker disk usage if (digest.diskUsage) { lines.push('-- Docker Disk Usage ' + '-'.repeat(34)); const du = digest.diskUsage; lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`); lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`); lines.push(` Volumes: ${formatBytes(du.volumes.sizeBytes)} (${du.volumes.count} volumes)`); lines.push(` Build Cache: ${formatBytes(du.buildCache.sizeBytes)}`); lines.push(` Total: ${du.totalGB} GB`); if (du.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) { lines.push(` WARNING: Exceeds ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB threshold!`); lines.push(' Run: docker system prune -a (removes unused images/cache)'); } lines.push(''); } // Summary lines.push(sr); lines.push(` ${digest.summary.totalServices} service(s) monitored | ${digest.summary.totalErrors} error(s) | ${digest.summary.totalWarnings} warning(s)`); lines.push(` Hours collected: ${digest.hoursCollected}/24`); lines.push(hr); return lines.join('\n') + '\n'; } /** * Remove digest files older than MAX_DIGEST_FILES days. */ async _cleanupOldDigests() { if (!this.digestDir) return; try { const files = await fsp.readdir(this.digestDir); const digestFiles = files.filter(f => f.startsWith('digest-')).sort(); // Each date has .log + .json = 2 files per day const maxFiles = DOCKER.DIGEST.MAX_DIGEST_FILES * 2; if (digestFiles.length > maxFiles) { const toDelete = digestFiles.slice(0, digestFiles.length - maxFiles); for (const f of toDelete) { await fsp.unlink(path.join(this.digestDir, f)).catch(() => {}); } } } catch (e) { // Directory may not exist yet } } /** * Get the latest daily digest (JSON). */ async getLatestDigest() { if (!this.digestDir) return null; try { const files = await fsp.readdir(this.digestDir); const jsonFiles = files.filter(f => f.endsWith('.json')).sort(); if (jsonFiles.length === 0) return null; const latest = path.join(this.digestDir, jsonFiles[jsonFiles.length - 1]); return JSON.parse(await fsp.readFile(latest, 'utf8')); } catch (e) { return null; } } /** * Get digest for a specific date. */ async getDigestByDate(dateStr) { if (!this.digestDir) return null; const jsonPath = path.join(this.digestDir, `digest-${dateStr}.json`); try { return JSON.parse(await fsp.readFile(jsonPath, 'utf8')); } catch (e) { return null; } } /** * Get the formatted text version of a digest. */ async getDigestText(dateStr) { if (!this.digestDir) return null; const logPath = path.join(this.digestDir, `digest-${dateStr}.log`); try { return await fsp.readFile(logPath, 'utf8'); } catch (e) { return null; } } /** * List available digest dates. */ async listDigests() { if (!this.digestDir) return []; try { const files = await fsp.readdir(this.digestDir); return files .filter(f => f.endsWith('.json')) .map(f => f.replace('digest-', '').replace('.json', '')) .sort() .reverse(); } catch (e) { return []; } } /** * Get live data: current day's accumulated hourly summaries. */ getLiveData() { const today = new Date().toISOString().slice(0, 10); const todayHours = this.hourlySummaries.filter(h => h.hour.startsWith(today)); // Aggregate const serviceAgg = {}; for (const hour of todayHours) { for (const [appId, svc] of Object.entries(hour.services)) { if (!serviceAgg[appId]) { serviceAgg[appId] = { name: svc.name, appId, totalErrors: 0, totalWarnings: 0, lastState: svc.state, recentErrors: [] }; } serviceAgg[appId].totalErrors += svc.errorCount; serviceAgg[appId].totalWarnings += svc.warningCount; serviceAgg[appId].lastState = svc.state; for (const err of svc.errors) { if (serviceAgg[appId].recentErrors.length < 10) { serviceAgg[appId].recentErrors.push(err); } } } } return { date: today, hoursCollected: todayHours.length, lastCollect: this.lastCollect, services: serviceAgg }; } getStatus() { return { running: this.running, lastCollect: this.lastCollect, hourlySummaries: this.hourlySummaries.length, digestDir: this.digestDir }; } } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]; } module.exports = new LogDigest();