Explorar o código

feat: add file logging with automatic rotation #35

- Add file logging support to logger.js with automatic rotation
- Log files are rotated when they reach MAX_LOG_SIZE (default 10MB)
- Keep up to MAX_LOG_FILES rotated files (default 5)
- File logging auto-enabled when running as systemd service
- Add FILE_LOGGING_ENABLED, LOG_FILE_PATH, MAX_LOG_SIZE, MAX_LOG_FILES env vars
- Update systemd service to enable file logging to /var/log/agent-manager.log
- Add write permission for /var/log directory in systemd service
- Strip ANSI color codes from file output for better readability
- Always include timestamps in file logs (even if disabled for console)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude hai 9 meses
pai
achega
b2155b3
Modificáronse 7 ficheiros con 269 adicións e 5 borrados
  1. 13 0
      .env.example
  2. 2 0
      .gitignore
  3. 8 0
      .smtp-config.json.example
  4. 3 1
      agent-manager.service
  5. 48 0
      email-rules.json.example
  6. 4 0
      src/index.js
  7. 191 4
      src/logger.js

+ 13 - 0
.env.example

@@ -8,6 +8,19 @@ WEBHOOK_SECRET=
 # Log level: DEBUG, INFO, WARN, ERROR (default: INFO)
 LOG_LEVEL=INFO
 
+# File Logging Configuration
+# Enable file logging (default: false, auto-enabled when running as systemd service)
+FILE_LOGGING_ENABLED=false
+
+# Log file path (default: /var/log/agent-manager.log)
+LOG_FILE_PATH=/var/log/agent-manager.log
+
+# Maximum log file size in bytes before rotation (default: 10485760 = 10MB)
+MAX_LOG_SIZE=10485760
+
+# Maximum number of rotated log files to keep (default: 5)
+MAX_LOG_FILES=5
+
 # Queue Configuration
 # Enable parallel job processing per repository (default: false)
 # When enabled, each Gogs repository gets its own FIFO queue

+ 2 - 0
.gitignore

@@ -10,3 +10,5 @@ yarn-error.log*
 queue.json
 commands.json
 .mcp-gogs.json
+.smtp-config.json
+email-rules.json

+ 8 - 0
.smtp-config.json.example

@@ -0,0 +1,8 @@
+{
+  "host": "mail.example.com",
+  "port": 1025,
+  "secure": false,
+  "user": "your-email@example.com",
+  "pass": "your-password",
+  "from": "noreply@example.com"
+}

+ 3 - 1
agent-manager.service

@@ -14,6 +14,8 @@ RestartSec=10s
 
 # Environment
 Environment=NODE_ENV=production
+Environment=FILE_LOGGING_ENABLED=true
+Environment=LOG_FILE_PATH=/var/log/agent-manager.log
 
 # Security hardening
 NoNewPrivileges=true
@@ -23,7 +25,7 @@ ProtectSystem=full
 # 'full' still protects /usr and /boot but allows execution (read-only)
 # Note: ProtectHome removed to allow Claude to work with repositories under /home/claude/
 # The service runs as 'claude' user which is already restricted to its own home directory
-ReadWritePaths=/home/claude
+ReadWritePaths=/home/claude /var/log
 
 # Logging
 StandardOutput=journal

+ 48 - 0
email-rules.json.example

@@ -0,0 +1,48 @@
+{
+  "rules": [
+    {
+      "name": "mention-notification",
+      "event": "issue_comment",
+      "condition": {
+        "type": "mention",
+        "value": "username"
+      },
+      "recipients": ["{{mentioned_user}}"],
+      "subject": "You were mentioned in {{full_repo}}#{{issue_number}}",
+      "body": "Hi @{{mentioned_user}},\n\nYou were mentioned in issue #{{issue_number}} - {{issue_title}}\n\nRepository: {{full_repo}}\nComment by: {{sender}}\n\nComment:\n{{comment_body}}\n\n---\nThis is an automated notification from the Gogs webhook server."
+    },
+    {
+      "name": "push-to-main",
+      "event": "push",
+      "condition": {
+        "type": "branch",
+        "value": "main"
+      },
+      "recipients": ["dev-team@example.com"],
+      "subject": "Push to {{full_repo}}/{{branch}} by {{sender}}",
+      "body": "New push to {{full_repo}} branch {{branch}}\n\nPusher: {{sender}}\nCommit: {{commit}}\nMessage: {{commit_msg}}\n\n---\nThis is an automated notification from the Gogs webhook server."
+    },
+    {
+      "name": "issue-assigned",
+      "event": "issues",
+      "condition": {
+        "type": "action",
+        "value": "assigned"
+      },
+      "recipients": ["{{assignee}}"],
+      "subject": "Issue assigned: {{full_repo}}#{{issue_number}}",
+      "body": "Hi,\n\nYou have been assigned to issue #{{issue_number}} - {{issue_title}}\n\nRepository: {{full_repo}}\nAssigned by: {{sender}}\n\n---\nThis is an automated notification from the Gogs webhook server."
+    },
+    {
+      "name": "pr-opened",
+      "event": "pull_request",
+      "condition": {
+        "type": "action",
+        "value": "opened"
+      },
+      "recipients": ["dev-team@example.com"],
+      "subject": "New PR: {{full_repo}}#{{pr_number}}",
+      "body": "New pull request opened in {{full_repo}}\n\nPR #{{pr_number}}: {{issue_title}}\nAuthor: {{sender}}\n\n---\nThis is an automated notification from the Gogs webhook server."
+    }
+  ]
+}

+ 4 - 0
src/index.js

@@ -5,6 +5,7 @@ import { WebhookServer } from './server.js';
 import logger from './logger.js';
 import config from './config.js';
 import jobQueue from './jobQueue.js';
+import emailNotifier from './emailNotifier.js';
 
 // Get server configuration
 const serverConfig = config.getServerConfig();
@@ -50,6 +51,9 @@ if (globalConfigs.length > 0) {
   });
 }
 
+// Register email notifier callbacks
+emailNotifier.register(handler);
+
 // You can still add custom callbacks here
 // Example:
 // handler.on('push', (payload, headers) => {

+ 191 - 4
src/logger.js

@@ -2,6 +2,9 @@
  * Logger module for webhook events
  */
 
+import fs from 'fs';
+import path from 'path';
+
 // Log levels
 const LOG_LEVELS = {
   DEBUG: 0,
@@ -25,6 +28,25 @@ export class Logger {
     const levelStr = (options.logLevel || process.env.LOG_LEVEL || 'INFO').toUpperCase();
     this.logLevel = LOG_LEVELS[levelStr] ?? LOG_LEVELS.INFO;
 
+    // File logging configuration
+    this.fileLoggingEnabled = options.fileLoggingEnabled !== undefined
+      ? options.fileLoggingEnabled
+      : (process.env.FILE_LOGGING_ENABLED === 'true' || isSystemd);
+    this.logFilePath = options.logFilePath ||
+      process.env.LOG_FILE_PATH ||
+      '/var/log/agent-manager.log';
+    this.maxLogSize = options.maxLogSize ||
+      parseInt(process.env.MAX_LOG_SIZE || '10485760', 10); // 10MB default
+    this.maxLogFiles = options.maxLogFiles ||
+      parseInt(process.env.MAX_LOG_FILES || '5', 10); // Keep 5 rotated files
+
+    // Initialize file logging
+    this.fileStream = null;
+    this.currentLogSize = 0;
+    if (this.fileLoggingEnabled) {
+      this._initFileLogging();
+    }
+
     // Track metrics for queue health reporting
     this.metrics = {
       jobsProcessed24h: 0,
@@ -38,6 +60,113 @@ export class Logger {
     this._startMetricsReset();
   }
 
+  _initFileLogging() {
+    try {
+      // Ensure log directory exists
+      const logDir = path.dirname(this.logFilePath);
+      if (!fs.existsSync(logDir)) {
+        fs.mkdirSync(logDir, { recursive: true, mode: 0o755 });
+      }
+
+      // Check if log file exists and get its size
+      if (fs.existsSync(this.logFilePath)) {
+        const stats = fs.statSync(this.logFilePath);
+        this.currentLogSize = stats.size;
+
+        // Check if rotation is needed immediately
+        if (this.currentLogSize >= this.maxLogSize) {
+          this._rotateLogFile();
+        }
+      }
+
+      // Open file stream in append mode
+      this.fileStream = fs.createWriteStream(this.logFilePath, {
+        flags: 'a',
+        encoding: 'utf8'
+      });
+
+      this.fileStream.on('error', (err) => {
+        console.error('File logging error:', err.message);
+        this.fileLoggingEnabled = false;
+        this.fileStream = null;
+      });
+    } catch (err) {
+      console.error('Failed to initialize file logging:', err.message);
+      this.fileLoggingEnabled = false;
+      this.fileStream = null;
+    }
+  }
+
+  _rotateLogFile() {
+    try {
+      // Close current stream
+      if (this.fileStream) {
+        this.fileStream.end();
+        this.fileStream = null;
+      }
+
+      // Rotate existing log files
+      for (let i = this.maxLogFiles - 1; i >= 1; i--) {
+        const oldPath = `${this.logFilePath}.${i}`;
+        const newPath = `${this.logFilePath}.${i + 1}`;
+
+        if (fs.existsSync(oldPath)) {
+          if (i + 1 > this.maxLogFiles) {
+            // Delete oldest log file
+            fs.unlinkSync(oldPath);
+          } else {
+            fs.renameSync(oldPath, newPath);
+          }
+        }
+      }
+
+      // Rotate current log file
+      if (fs.existsSync(this.logFilePath)) {
+        fs.renameSync(this.logFilePath, `${this.logFilePath}.1`);
+      }
+
+      // Reset size counter and create new file
+      this.currentLogSize = 0;
+      this.fileStream = fs.createWriteStream(this.logFilePath, {
+        flags: 'a',
+        encoding: 'utf8'
+      });
+
+      this.fileStream.on('error', (err) => {
+        console.error('File logging error:', err.message);
+        this.fileLoggingEnabled = false;
+        this.fileStream = null;
+      });
+    } catch (err) {
+      console.error('Log rotation failed:', err.message);
+      this.fileLoggingEnabled = false;
+    }
+  }
+
+  _writeToFile(message) {
+    if (!this.fileLoggingEnabled || !this.fileStream) {
+      return;
+    }
+
+    try {
+      // Always include timestamp in file logs (even if disabled for console)
+      const timestamp = `[${new Date().toISOString()}]`;
+      // Strip ANSI color codes from message for file output
+      const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
+      const logLine = `${timestamp} ${cleanMessage}\n`;
+
+      this.fileStream.write(logLine);
+      this.currentLogSize += Buffer.byteLength(logLine);
+
+      // Check if rotation is needed
+      if (this.currentLogSize >= this.maxLogSize) {
+        this._rotateLogFile();
+      }
+    } catch (err) {
+      console.error('Failed to write to log file:', err.message);
+    }
+  }
+
   _startMetricsReset() {
     // Reset metrics every 24 hours
     setInterval(() => {
@@ -73,36 +202,52 @@ export class Logger {
   debug(message, data = null) {
     if (!this._shouldLog('DEBUG')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} DEBUG: ${message}`;
     console.log(`${timestamp} ${this._colorize('DEBUG', '90')}:`, message);
+    this._writeToFile(logMessage);
     if (data) {
-      console.log(JSON.stringify(data, null, 2));
+      const dataStr = JSON.stringify(data, null, 2);
+      console.log(dataStr);
+      this._writeToFile(dataStr);
     }
   }
 
   info(message, data = null) {
     if (!this._shouldLog('INFO')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} INFO: ${message}`;
     console.log(`${timestamp} ${this._colorize('INFO', '36')}:`, message);
+    this._writeToFile(logMessage);
     if (data) {
-      console.log(JSON.stringify(data, null, 2));
+      const dataStr = JSON.stringify(data, null, 2);
+      console.log(dataStr);
+      this._writeToFile(dataStr);
     }
   }
 
   warn(message, data = null) {
     if (!this._shouldLog('WARN')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} WARN: ${message}`;
     console.warn(`${timestamp} ${this._colorize('WARN', '33')}:`, message);
+    this._writeToFile(logMessage);
     if (data) {
-      console.warn(JSON.stringify(data, null, 2));
+      const dataStr = JSON.stringify(data, null, 2);
+      console.warn(dataStr);
+      this._writeToFile(dataStr);
     }
   }
 
   error(message, error = null) {
     if (!this._shouldLog('ERROR')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} ERROR: ${message}`;
     console.error(`${timestamp} ${this._colorize('ERROR', '31')}:`, message);
+    this._writeToFile(logMessage);
     if (error) {
+      const errorStr = error.stack || error.toString();
       console.error(error);
+      this._writeToFile(errorStr);
     }
   }
 
@@ -114,6 +259,13 @@ export class Logger {
     console.log(`${this._colorize('-'.repeat(80), '33')}`);
     console.log(JSON.stringify(payload, null, 2));
     console.log(`${this._colorize('='.repeat(80), '33')}\n`);
+
+    // Write to file
+    this._writeToFile(`${'='.repeat(80)}`);
+    this._writeToFile(`${timestamp} WEBHOOK - Event: ${event}`);
+    this._writeToFile(`${'-'.repeat(80)}`);
+    this._writeToFile(JSON.stringify(payload, null, 2));
+    this._writeToFile(`${'='.repeat(80)}`);
   }
 
   // Job lifecycle logging methods
@@ -121,21 +273,27 @@ export class Logger {
     if (!this._shouldLog('INFO')) return;
     const timestamp = this._getTimestamp();
     const issueInfo = issueNumber ? ` - Issue #${issueNumber}` : '';
+    const logMessage = `${timestamp} JOB QUEUED: Job #${jobId} (${eventType}${issueInfo}) - ${commandName}`;
     console.log(`${timestamp} ${this._colorize('JOB QUEUED', '36')}: Job #${jobId} (${eventType}${issueInfo}) - ${commandName}`);
+    this._writeToFile(logMessage);
   }
 
   jobSkipped(jobId, eventType, issueNumber, reason) {
     if (!this._shouldLog('INFO')) return;
     const timestamp = this._getTimestamp();
     const issueInfo = issueNumber ? ` - Issue #${issueNumber}` : '';
+    const logMessage = `${timestamp} JOB SKIPPED: Job #${jobId} (${eventType}${issueInfo}) - Reason: ${reason}`;
     console.log(`${timestamp} ${this._colorize('JOB SKIPPED', '33')}: Job #${jobId} (${eventType}${issueInfo}) - Reason: ${reason}`);
+    this._writeToFile(logMessage);
   }
 
   jobStarted(jobId, eventType, issueNumber, commandName) {
     if (!this._shouldLog('INFO')) return;
     const timestamp = this._getTimestamp();
     const issueInfo = issueNumber ? ` - Issue #${issueNumber}` : '';
+    const logMessage = `${timestamp} JOB STARTED: Job #${jobId} (${eventType}${issueInfo}) - ${commandName}`;
     console.log(`${timestamp} ${this._colorize('JOB STARTED', '32')}: Job #${jobId} (${eventType}${issueInfo}) - ${commandName}`);
+    this._writeToFile(logMessage);
   }
 
   jobCompleted(jobId, eventType, issueNumber, durationMs) {
@@ -143,7 +301,9 @@ export class Logger {
     const timestamp = this._getTimestamp();
     const issueInfo = issueNumber ? ` - Issue #${issueNumber}` : '';
     const duration = this._formatDuration(durationMs);
+    const logMessage = `${timestamp} JOB COMPLETED: Job #${jobId} (${eventType}${issueInfo}) - Duration: ${duration}`;
     console.log(`${timestamp} ${this._colorize('JOB COMPLETED', '32')}: Job #${jobId} (${eventType}${issueInfo}) - Duration: ${duration}`);
+    this._writeToFile(logMessage);
 
     // Track metrics
     this.metrics.jobsProcessed24h++;
@@ -158,7 +318,9 @@ export class Logger {
     if (!this._shouldLog('ERROR')) return;
     const timestamp = this._getTimestamp();
     const issueInfo = issueNumber ? ` - Issue #${issueNumber}` : '';
+    const logMessage = `${timestamp} JOB FAILED: Job #${jobId} (${eventType}${issueInfo}) - Error: ${error}`;
     console.error(`${timestamp} ${this._colorize('JOB FAILED', '31')}: Job #${jobId} (${eventType}${issueInfo}) - Error: ${error}`);
+    this._writeToFile(logMessage);
 
     // Track metrics
     this.metrics.jobsFailed24h++;
@@ -169,7 +331,9 @@ export class Logger {
     if (!this._shouldLog('INFO')) return;
     const timestamp = this._getTimestamp();
     const timeSince = this._formatDuration(timeSinceLastMs);
+    const logMessage = `${timestamp} DUPLICATE: ${eventType} for Issue #${issueNumber} (last processed ${timeSince} ago)`;
     console.log(`${timestamp} ${this._colorize('DUPLICATE', '33')}: ${eventType} for Issue #${issueNumber} (last processed ${timeSince} ago)`);
+    this._writeToFile(logMessage);
 
     // Track metrics
     this.metrics.jobsDuplicated24h++;
@@ -178,22 +342,30 @@ export class Logger {
   deduplicationCleanup(entriesRemoved) {
     if (!this._shouldLog('DEBUG')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} CLEANUP: Removed ${entriesRemoved} expired deduplication entries`;
     console.log(`${timestamp} ${this._colorize('CLEANUP', '90')}: Removed ${entriesRemoved} expired deduplication entries`);
+    this._writeToFile(logMessage);
   }
 
   // Branch filtering logging
   branchFiltered(branch, filterBranch, commandName) {
     if (!this._shouldLog('DEBUG')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} FILTERED: Command '${commandName}' skipped - branch '${branch}' != '${filterBranch}'`;
     console.log(`${timestamp} ${this._colorize('FILTERED', '90')}: Command '${commandName}' skipped - branch '${branch}' != '${filterBranch}'`);
+    this._writeToFile(logMessage);
   }
 
   // Variable substitution logging (debug level)
   variablesExtracted(eventType, variables) {
     if (!this._shouldLog('DEBUG')) return;
     const timestamp = this._getTimestamp();
+    const logMessage = `${timestamp} VARIABLES: Extracted from ${eventType}`;
     console.log(`${timestamp} ${this._colorize('VARIABLES', '90')}: Extracted from ${eventType}`);
-    console.log(JSON.stringify(variables, null, 2));
+    this._writeToFile(logMessage);
+    const dataStr = JSON.stringify(variables, null, 2);
+    console.log(dataStr);
+    this._writeToFile(dataStr);
   }
 
   // Queue health metrics
@@ -221,6 +393,21 @@ export class Logger {
       console.log(`  Current job:         #${stats.currentJob.id} (${stats.currentJob.eventType})`);
     }
     console.log(`${this._colorize('='.repeat(80), '36')}\n`);
+
+    // Write to file
+    this._writeToFile(`${'='.repeat(80)}`);
+    this._writeToFile(`${timestamp} QUEUE STATS`);
+    this._writeToFile(`${'-'.repeat(80)}`);
+    this._writeToFile(`  Pending jobs:        ${stats.pending}`);
+    this._writeToFile(`  Running jobs:        ${stats.running}`);
+    this._writeToFile(`  Processed (24h):     ${this.metrics.jobsProcessed24h}`);
+    this._writeToFile(`  Failed (24h):        ${this.metrics.jobsFailed24h}`);
+    this._writeToFile(`  Deduplicated (24h):  ${this.metrics.jobsDuplicated24h}`);
+    this._writeToFile(`  Avg processing time: ${this._formatDuration(avgProcessingTime)}`);
+    if (stats.currentJob) {
+      this._writeToFile(`  Current job:         #${stats.currentJob.id} (${stats.currentJob.eventType})`);
+    }
+    this._writeToFile(`${'='.repeat(80)}`);
   }
 }