UNPKG

visaire

Version:

A fully autonomous agentic CLI tool for interacting with large language models (Claude, Gemini, GPT) that can execute multi-step tasks automatically

630 lines (537 loc) 16.5 kB
const fs = require('fs-extra'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); /** * Advanced logging system with structured logging, metrics, and tracing * Implements comprehensive logging similar to Forge's logging capabilities */ class Logger { constructor(options = {}) { this.agentId = options.agentId || 'unknown'; this.level = options.level || 'info'; this.enableMetrics = options.enableMetrics !== false; this.enableTracing = options.enableTracing || false; this.logDir = options.logDir || '.visaire/logs'; this.maxLogFiles = options.maxLogFiles || 10; this.maxLogSize = options.maxLogSize || 10 * 1024 * 1024; // 10MB // Log levels this.levels = { debug: 0, info: 1, warn: 2, error: 3 }; this.currentLevel = this.levels[this.level] || 1; // Session tracking this.sessionId = uuidv4(); this.startTime = new Date(); // Metrics storage this.metrics = { logCounts: { debug: 0, info: 0, warn: 0, error: 0 }, actions: new Map(), performance: new Map(), errors: [] }; // Tracing storage this.traces = new Map(); this.activeTraces = new Set(); // Initialize logging this.initializeLogging(); } /** * Initialize logging directory and files */ async initializeLogging() { try { await fs.ensureDir(this.logDir); // Create session log file this.sessionLogFile = path.join(this.logDir, `session-${this.sessionId}.log`); this.metricsLogFile = path.join(this.logDir, `metrics-${this.sessionId}.json`); this.tracingLogFile = path.join(this.logDir, `tracing-${this.sessionId}.json`); // Initialize session log await this.writeSessionHeader(); // Cleanup old log files await this.cleanupOldLogs(); } catch (error) { console.error('Failed to initialize logging:', error.message); } } /** * Write session header to log file */ async writeSessionHeader() { const header = { sessionId: this.sessionId, agentId: this.agentId, startTime: this.startTime.toISOString(), level: this.level, enableMetrics: this.enableMetrics, enableTracing: this.enableTracing, platform: process.platform, nodeVersion: process.version, pid: process.pid }; await this.writeToFile(this.sessionLogFile, `SESSION_START: ${JSON.stringify(header)}\n`); } /** * Debug level logging */ debug(message, data = {}) { this.log('debug', message, data); } /** * Info level logging */ info(message, data = {}) { this.log('info', message, data); } /** * Warning level logging */ warn(message, data = {}) { this.log('warn', message, data); } /** * Error level logging */ error(message, data = {}) { this.log('error', message, data); // Store error for metrics if (this.enableMetrics) { this.metrics.errors.push({ message, data, timestamp: new Date().toISOString(), stack: data.stack || new Error().stack }); } } /** * Core logging method */ log(level, message, data = {}) { if (this.levels[level] < this.currentLevel) { return; // Skip if below current log level } const logEntry = { timestamp: new Date().toISOString(), sessionId: this.sessionId, agentId: this.agentId, level, message, data: this.sanitizeData(data), pid: process.pid }; // Add trace information if tracing is enabled if (this.enableTracing && this.activeTraces.size > 0) { logEntry.traces = Array.from(this.activeTraces); } // Console output with formatting this.outputToConsole(logEntry); // File output this.outputToFile(logEntry); // Update metrics if (this.enableMetrics) { this.metrics.logCounts[level]++; } } /** * Output formatted log to console */ outputToConsole(logEntry) { // Skip console output in non-debug mode for cleaner user experience if (this.level !== 'debug' && logEntry.level === 'debug') { return; } // For user-facing output, only show important messages if (this.level !== 'debug' && (logEntry.level === 'info' || logEntry.level === 'warn' || logEntry.level === 'error')) { // Check if this is a technical/internal log message const technicalPatterns = [ 'Tool registered', 'Agent initialized', 'Default tools initialized', 'Loaded persisted conversations', 'Tool execution failed', 'Prompt processing failed', 'LLM response failed' ]; const isTechnical = technicalPatterns.some(pattern => logEntry.message.includes(pattern) ); if (isTechnical) { return; // Skip technical messages in user mode } } const timestamp = new Date(logEntry.timestamp).toLocaleTimeString(); const level = logEntry.level.toUpperCase().padEnd(5); const message = logEntry.message; let output = `[${timestamp}] ${level} ${message}`; // Only add data in debug mode or for errors if (this.level === 'debug' || logEntry.level === 'error') { if (Object.keys(logEntry.data).length > 0) { const dataStr = JSON.stringify(logEntry.data); if (dataStr.length < 200) { output += ` | ${dataStr}`; } else { output += ` | {data: ${Object.keys(logEntry.data).length} fields}`; } } } // Color coding for console switch (logEntry.level) { case 'debug': console.log(`\x1b[36m${output}\x1b[0m`); // Cyan break; case 'info': console.log(`\x1b[32m${output}\x1b[0m`); // Green break; case 'warn': console.log(`\x1b[33m${output}\x1b[0m`); // Yellow break; case 'error': console.log(`\x1b[31m${output}\x1b[0m`); // Red break; default: console.log(output); } } /** * Output log entry to file */ async outputToFile(logEntry) { try { // Skip file output if session log file is not initialized yet if (!this.sessionLogFile) { return; } const logLine = JSON.stringify(logEntry) + '\n'; await this.writeToFile(this.sessionLogFile, logLine); } catch (error) { console.error('Failed to write to log file:', error.message); } } /** * Log action with structured data */ async logAction(component, action, data = {}) { const actionEntry = { id: uuidv4(), timestamp: new Date().toISOString(), component, action, data: this.sanitizeData(data), sessionId: this.sessionId, agentId: this.agentId }; this.info(`${component}:${action}`, data); // Store action for metrics if (this.enableMetrics) { const actionKey = `${component}:${action}`; if (!this.metrics.actions.has(actionKey)) { this.metrics.actions.set(actionKey, { count: 0, lastExecuted: null, averageData: {} }); } const actionMetrics = this.metrics.actions.get(actionKey); actionMetrics.count++; actionMetrics.lastExecuted = actionEntry.timestamp; } return actionEntry.id; } /** * Log performance metrics */ async logPerformance(operation, duration, metadata = {}) { const perfEntry = { operation, duration, metadata: this.sanitizeData(metadata), timestamp: new Date().toISOString() }; this.debug(`Performance: ${operation}`, { duration, ...metadata }); // Store performance data if (this.enableMetrics) { if (!this.metrics.performance.has(operation)) { this.metrics.performance.set(operation, { count: 0, totalDuration: 0, averageDuration: 0, minDuration: Infinity, maxDuration: 0 }); } const perfMetrics = this.metrics.performance.get(operation); perfMetrics.count++; perfMetrics.totalDuration += duration; perfMetrics.averageDuration = perfMetrics.totalDuration / perfMetrics.count; perfMetrics.minDuration = Math.min(perfMetrics.minDuration, duration); perfMetrics.maxDuration = Math.max(perfMetrics.maxDuration, duration); } } /** * Start a trace */ startTrace(name, metadata = {}) { if (!this.enableTracing) { return null; } const traceId = uuidv4(); const trace = { id: traceId, name, startTime: Date.now(), metadata: this.sanitizeData(metadata), events: [], status: 'active' }; this.traces.set(traceId, trace); this.activeTraces.add(traceId); this.debug(`Trace started: ${name}`, { traceId, metadata }); return traceId; } /** * Add event to trace */ addTraceEvent(traceId, event, data = {}) { if (!this.enableTracing || !this.traces.has(traceId)) { return; } const trace = this.traces.get(traceId); trace.events.push({ timestamp: Date.now(), event, data: this.sanitizeData(data) }); this.debug(`Trace event: ${event}`, { traceId, data }); } /** * End a trace */ endTrace(traceId, result = {}) { if (!this.enableTracing || !this.traces.has(traceId)) { return; } const trace = this.traces.get(traceId); trace.endTime = Date.now(); trace.duration = trace.endTime - trace.startTime; trace.result = this.sanitizeData(result); trace.status = 'completed'; this.activeTraces.delete(traceId); this.debug(`Trace completed: ${trace.name}`, { traceId, duration: trace.duration, events: trace.events.length }); return trace; } /** * Get current metrics */ getMetrics() { if (!this.enableMetrics) { return null; } return { sessionId: this.sessionId, agentId: this.agentId, startTime: this.startTime.toISOString(), uptime: Date.now() - this.startTime.getTime(), logCounts: { ...this.metrics.logCounts }, actions: Object.fromEntries(this.metrics.actions), performance: Object.fromEntries(this.metrics.performance), errorCount: this.metrics.errors.length, recentErrors: this.metrics.errors.slice(-5) }; } /** * Get trace information */ getTraces(options = {}) { if (!this.enableTracing) { return null; } const { active = false, limit = 50 } = options; let traces = Array.from(this.traces.values()); if (active) { traces = traces.filter(trace => trace.status === 'active'); } // Sort by start time (most recent first) traces.sort((a, b) => b.startTime - a.startTime); return traces.slice(0, limit); } /** * Export logs to file */ async exportLogs(format = 'json', outputPath = null) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const defaultPath = path.join(this.logDir, `export-${timestamp}.${format}`); const exportPath = outputPath || defaultPath; try { const exportData = { sessionId: this.sessionId, agentId: this.agentId, exportTime: new Date().toISOString(), metrics: this.getMetrics(), traces: this.getTraces(), logs: await this.readSessionLogs() }; if (format === 'json') { await fs.writeJson(exportPath, exportData, { spaces: 2 }); } else if (format === 'txt') { const textContent = this.formatLogsAsText(exportData); await fs.writeFile(exportPath, textContent, 'utf8'); } this.info('Logs exported', { path: exportPath, format }); return exportPath; } catch (error) { this.error('Failed to export logs', { error: error.message, path: exportPath }); throw error; } } /** * Read session logs from file */ async readSessionLogs() { try { if (await fs.pathExists(this.sessionLogFile)) { const content = await fs.readFile(this.sessionLogFile, 'utf8'); return content.split('\n') .filter(line => line.trim()) .map(line => { try { return JSON.parse(line); } catch { return { raw: line }; } }); } } catch (error) { this.warn('Failed to read session logs', { error: error.message }); } return []; } /** * Format logs as text */ formatLogsAsText(exportData) { let text = `Visaire Agent Log Export\n`; text += `Session ID: ${exportData.sessionId}\n`; text += `Agent ID: ${exportData.agentId}\n`; text += `Export Time: ${exportData.exportTime}\n`; text += `\n${'='.repeat(50)}\n\n`; if (exportData.metrics) { text += `METRICS:\n`; text += `Log Counts: ${JSON.stringify(exportData.metrics.logCounts)}\n`; text += `Error Count: ${exportData.metrics.errorCount}\n`; text += `Uptime: ${exportData.metrics.uptime}ms\n\n`; } if (exportData.logs && exportData.logs.length > 0) { text += `LOGS:\n`; for (const log of exportData.logs) { if (log.timestamp && log.level && log.message) { text += `[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`; } } } return text; } /** * Sanitize data for logging (remove sensitive information) */ sanitizeData(data) { if (!data || typeof data !== 'object') { return data; } const sanitized = { ...data }; const sensitiveKeys = ['password', 'token', 'key', 'secret', 'auth', 'apikey', 'api_key']; for (const key of Object.keys(sanitized)) { const lowerKey = key.toLowerCase(); if (sensitiveKeys.some(sensitive => lowerKey.includes(sensitive))) { sanitized[key] = '[REDACTED]'; } } return sanitized; } /** * Write to file with error handling */ async writeToFile(filePath, content) { try { await fs.appendFile(filePath, content); } catch (error) { console.error(`Failed to write to ${filePath}:`, error.message); } } /** * Cleanup old log files */ async cleanupOldLogs() { try { const files = await fs.readdir(this.logDir); const logFiles = files .filter(file => file.startsWith('session-') && file.endsWith('.log')) .map(file => ({ name: file, path: path.join(this.logDir, file), stats: null })); // Get file stats for (const file of logFiles) { try { file.stats = await fs.stat(file.path); } catch (error) { // Skip files that can't be accessed } } // Sort by modification time (oldest first) const validFiles = logFiles .filter(file => file.stats) .sort((a, b) => a.stats.mtime - b.stats.mtime); // Remove excess files if (validFiles.length > this.maxLogFiles) { const filesToRemove = validFiles.slice(0, validFiles.length - this.maxLogFiles); for (const file of filesToRemove) { await fs.remove(file.path); this.debug('Removed old log file', { file: file.name }); } } } catch (error) { this.warn('Failed to cleanup old logs', { error: error.message }); } } /** * Get session ID */ getSessionId() { return this.sessionId; } /** * End logging session */ async endSession() { try { // Write session end marker const endMarker = { sessionId: this.sessionId, endTime: new Date().toISOString(), duration: Date.now() - this.startTime.getTime(), finalMetrics: this.getMetrics() }; await this.writeToFile(this.sessionLogFile, `SESSION_END: ${JSON.stringify(endMarker)}\n`); // Write final metrics to file if (this.enableMetrics) { await fs.writeJson(this.metricsLogFile, this.getMetrics(), { spaces: 2 }); } // Write traces to file if (this.enableTracing) { await fs.writeJson(this.tracingLogFile, this.getTraces(), { spaces: 2 }); } this.info('Logging session ended', { duration: endMarker.duration }); } catch (error) { console.error('Failed to end logging session:', error.message); } } } module.exports = Logger;