/**
* Logger
*
* Comprehensive logging system for Git MCP server with multiple log levels,
* structured logging, and configurable output formats.
*/
import { promises as fs } from 'fs';
import path from 'path';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: string;
metadata?: Record<string, any>;
error?: {
name: string;
message: string;
stack?: string;
};
operation?: string;
provider?: string;
toolName?: string;
projectPath?: string;
executionTime?: number;
}
export interface LoggerConfig {
level: LogLevel;
enableConsole: boolean;
enableFile: boolean;
logFilePath?: string;
maxFileSize?: number; // in bytes
maxFiles?: number;
format: 'json' | 'text';
includeTimestamp: boolean;
includeMetadata: boolean;
colorize: boolean;
}
export class Logger {
private config: LoggerConfig;
private logLevels: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
fatal: 4
};
private colors: Record<LogLevel, string> = {
debug: '\x1b[36m', // Cyan
info: '\x1b[32m', // Green
warn: '\x1b[33m', // Yellow
error: '\x1b[31m', // Red
fatal: '\x1b[35m' // Magenta
};
private resetColor = '\x1b[0m';
constructor(config: Partial<LoggerConfig> = {}) {
this.config = {
level: 'info',
enableConsole: true,
enableFile: false,
format: 'text',
includeTimestamp: true,
includeMetadata: true,
colorize: true,
maxFileSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
...config
};
// Set log file path if file logging is enabled but no path provided
if (this.config.enableFile && !this.config.logFilePath) {
this.config.logFilePath = path.join(process.cwd(), 'logs', 'git-mcp.log');
}
}
/**
* Log debug message
*/
debug(message: string, context?: string, metadata?: Record<string, any>): void {
this.log('debug', message, context, metadata);
}
/**
* Log info message
*/
info(message: string, context?: string, metadata?: Record<string, any>): void {
this.log('info', message, context, metadata);
}
/**
* Log warning message
*/
warn(message: string, context?: string, metadata?: Record<string, any>): void {
this.log('warn', message, context, metadata);
}
/**
* Log error message
*/
error(message: string, error?: Error, context?: string, metadata?: Record<string, any>): void {
const errorInfo = error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined;
this.log('error', message, context, metadata, errorInfo);
}
/**
* Log fatal error message
*/
fatal(message: string, error?: Error, context?: string, metadata?: Record<string, any>): void {
const errorInfo = error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined;
this.log('fatal', message, context, metadata, errorInfo);
}
/**
* Log operation start
*/
operationStart(
operation: string,
toolName?: string,
provider?: string,
projectPath?: string,
metadata?: Record<string, any>
): void {
this.log('info', `Starting operation: ${operation}`, 'OPERATION', {
...metadata,
operation,
toolName,
provider,
projectPath,
phase: 'start'
});
}
/**
* Log operation completion
*/
operationComplete(
operation: string,
executionTime: number,
success: boolean,
toolName?: string,
provider?: string,
projectPath?: string,
metadata?: Record<string, any>
): void {
const level: LogLevel = success ? 'info' : 'error';
const status = success ? 'completed' : 'failed';
this.log(level, `Operation ${status}: ${operation} (${executionTime}ms)`, 'OPERATION', {
...metadata,
operation,
toolName,
provider,
projectPath,
executionTime,
success,
phase: 'complete'
});
}
/**
* Log Git command execution
*/
gitCommand(
command: string,
args: string[],
projectPath?: string,
success?: boolean,
executionTime?: number,
output?: string
): void {
const fullCommand = `git ${command} ${args.join(' ')}`;
const level: LogLevel = success === false ? 'error' : 'debug';
this.log(level, `Git command: ${fullCommand}`, 'GIT', {
command,
args,
projectPath,
success,
executionTime,
output: output?.substring(0, 500) // Truncate long output
});
}
/**
* Log provider API call
*/
providerCall(
provider: string,
endpoint: string,
method: string,
success?: boolean,
statusCode?: number,
executionTime?: number,
metadata?: Record<string, any>
): void {
const level: LogLevel = success === false ? 'error' : 'debug';
this.log(level, `${provider} API: ${method} ${endpoint}`, 'PROVIDER', {
...metadata,
provider,
endpoint,
method,
success,
statusCode,
executionTime
});
}
/**
* Log configuration events
*/
configuration(
event: 'loaded' | 'validated' | 'error',
details: Record<string, any>
): void {
const level: LogLevel = event === 'error' ? 'error' : 'info';
this.log(level, `Configuration ${event}`, 'CONFIG', details);
}
/**
* Log server events
*/
server(
event: 'starting' | 'started' | 'stopping' | 'stopped' | 'error',
details?: Record<string, any>
): void {
const level: LogLevel = event === 'error' ? 'error' : 'info';
this.log(level, `Server ${event}`, 'SERVER', details);
}
/**
* Log performance metrics
*/
performance(
operation: string,
metrics: {
executionTime: number;
memoryUsage?: number;
cpuUsage?: number;
[key: string]: any;
}
): void {
this.log('debug', `Performance: ${operation}`, 'PERFORMANCE', metrics);
}
/**
* Core logging method
*/
private log(
level: LogLevel,
message: string,
context?: string,
metadata?: Record<string, any>,
error?: { name: string; message: string; stack?: string }
): void {
// Check if log level is enabled
if (this.logLevels[level] < this.logLevels[this.config.level]) {
return;
}
const logEntry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
context,
metadata: this.config.includeMetadata ? metadata : undefined,
error,
operation: metadata?.operation,
provider: metadata?.provider,
toolName: metadata?.toolName,
projectPath: metadata?.projectPath,
executionTime: metadata?.executionTime
};
// Output to console
if (this.config.enableConsole) {
this.logToConsole(logEntry);
}
// Output to file
if (this.config.enableFile && this.config.logFilePath) {
this.logToFile(logEntry).catch(err => {
console.error('Failed to write to log file:', err);
});
}
}
/**
* Log to console with formatting
*/
private logToConsole(entry: LogEntry): void {
let output = '';
// Add color if enabled
if (this.config.colorize) {
output += this.colors[entry.level];
}
// Add timestamp
if (this.config.includeTimestamp) {
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
output += `[${timestamp}] `;
}
// Add level
output += `${entry.level.toUpperCase().padEnd(5)} `;
// Add context
if (entry.context) {
output += `[${entry.context}] `;
}
// Add message
output += entry.message;
// Reset color
if (this.config.colorize) {
output += this.resetColor;
}
// Format based on configuration
if (this.config.format === 'json') {
console.log(JSON.stringify(entry, null, 2));
} else {
console.log(output);
// Add metadata in text format if available
if (entry.metadata && Object.keys(entry.metadata).length > 0) {
console.log(' Metadata:', JSON.stringify(entry.metadata, null, 2));
}
// Add error details if available
if (entry.error) {
console.log(' Error:', entry.error.message);
if (entry.error.stack) {
console.log(' Stack:', entry.error.stack);
}
}
}
}
/**
* Log to file with rotation
*/
private async logToFile(entry: LogEntry): Promise<void> {
if (!this.config.logFilePath) return;
try {
// Ensure log directory exists
const logDir = path.dirname(this.config.logFilePath);
await fs.mkdir(logDir, { recursive: true });
// Check file size and rotate if needed
await this.rotateLogFileIfNeeded();
// Format log entry
const logLine = this.config.format === 'json'
? JSON.stringify(entry) + '\n'
: this.formatTextLogEntry(entry) + '\n';
// Append to log file
await fs.appendFile(this.config.logFilePath, logLine, 'utf8');
} catch (error) {
// Don't throw errors from logging to avoid infinite loops
console.error('Failed to write to log file:', error);
}
}
/**
* Format log entry for text output
*/
private formatTextLogEntry(entry: LogEntry): string {
let line = '';
// Timestamp
if (this.config.includeTimestamp) {
line += `${entry.timestamp} `;
}
// Level
line += `${entry.level.toUpperCase().padEnd(5)} `;
// Context
if (entry.context) {
line += `[${entry.context}] `;
}
// Message
line += entry.message;
// Add metadata as key=value pairs
if (entry.metadata && Object.keys(entry.metadata).length > 0) {
const metadataStr = Object.entries(entry.metadata)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join(' ');
line += ` | ${metadataStr}`;
}
// Add error information
if (entry.error) {
line += ` | error="${entry.error.message}"`;
if (entry.error.stack) {
line += ` stack="${entry.error.stack.replace(/\n/g, '\\n')}"`;
}
}
return line;
}
/**
* Rotate log file if it exceeds maximum size
*/
private async rotateLogFileIfNeeded(): Promise<void> {
if (!this.config.logFilePath || !this.config.maxFileSize) return;
try {
const stats = await fs.stat(this.config.logFilePath);
if (stats.size >= this.config.maxFileSize) {
await this.rotateLogFiles();
}
} catch (error) {
// File doesn't exist yet, no need to rotate
if ((error as any).code !== 'ENOENT') {
console.error('Error checking log file size:', error);
}
}
}
/**
* Rotate log files
*/
private async rotateLogFiles(): Promise<void> {
if (!this.config.logFilePath || !this.config.maxFiles) return;
const logDir = path.dirname(this.config.logFilePath);
const logName = path.basename(this.config.logFilePath, path.extname(this.config.logFilePath));
const logExt = path.extname(this.config.logFilePath);
try {
// Remove oldest log file if we've reached the limit
const oldestLogPath = path.join(logDir, `${logName}.${this.config.maxFiles - 1}${logExt}`);
try {
await fs.unlink(oldestLogPath);
} catch {
// File doesn't exist, ignore
}
// Rotate existing log files
for (let i = this.config.maxFiles - 2; i >= 1; i--) {
const currentPath = path.join(logDir, `${logName}.${i}${logExt}`);
const nextPath = path.join(logDir, `${logName}.${i + 1}${logExt}`);
try {
await fs.rename(currentPath, nextPath);
} catch {
// File doesn't exist, ignore
}
}
// Move current log file to .1
const firstRotatedPath = path.join(logDir, `${logName}.1${logExt}`);
await fs.rename(this.config.logFilePath, firstRotatedPath);
} catch (error) {
console.error('Error rotating log files:', error);
}
}
/**
* Update logger configuration
*/
updateConfig(newConfig: Partial<LoggerConfig>): void {
this.config = { ...this.config, ...newConfig };
}
/**
* Get current configuration
*/
getConfig(): LoggerConfig {
return { ...this.config };
}
/**
* Clear log files
*/
async clearLogs(): Promise<void> {
if (!this.config.logFilePath) return;
const logDir = path.dirname(this.config.logFilePath);
const logName = path.basename(this.config.logFilePath, path.extname(this.config.logFilePath));
const logExt = path.extname(this.config.logFilePath);
try {
// Remove main log file
try {
await fs.unlink(this.config.logFilePath);
} catch {
// File doesn't exist, ignore
}
// Remove rotated log files
if (this.config.maxFiles) {
for (let i = 1; i < this.config.maxFiles; i++) {
const rotatedPath = path.join(logDir, `${logName}.${i}${logExt}`);
try {
await fs.unlink(rotatedPath);
} catch {
// File doesn't exist, ignore
}
}
}
} catch (error) {
console.error('Error clearing log files:', error);
}
}
/**
* Get log statistics
*/
async getLogStats(): Promise<{
totalSize: number;
fileCount: number;
oldestEntry?: string;
newestEntry?: string;
}> {
if (!this.config.logFilePath) {
return { totalSize: 0, fileCount: 0 };
}
const logDir = path.dirname(this.config.logFilePath);
const logName = path.basename(this.config.logFilePath, path.extname(this.config.logFilePath));
const logExt = path.extname(this.config.logFilePath);
let totalSize = 0;
let fileCount = 0;
let oldestEntry: string | undefined;
let newestEntry: string | undefined;
try {
// Check main log file
try {
const stats = await fs.stat(this.config.logFilePath);
totalSize += stats.size;
fileCount++;
// Get first and last lines for timestamps
const content = await fs.readFile(this.config.logFilePath, 'utf8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length > 0) {
oldestEntry = lines[0];
newestEntry = lines[lines.length - 1];
}
} catch {
// File doesn't exist
}
// Check rotated log files
if (this.config.maxFiles) {
for (let i = 1; i < this.config.maxFiles; i++) {
const rotatedPath = path.join(logDir, `${logName}.${i}${logExt}`);
try {
const stats = await fs.stat(rotatedPath);
totalSize += stats.size;
fileCount++;
} catch {
// File doesn't exist
}
}
}
} catch (error) {
console.error('Error getting log statistics:', error);
}
return {
totalSize,
fileCount,
oldestEntry,
newestEntry
};
}
}
// Create default logger instance
export const logger = new Logger({
level: (process.env.LOG_LEVEL as LogLevel) || 'info',
enableConsole: process.env.LOG_CONSOLE !== 'false',
enableFile: process.env.LOG_FILE === 'true',
logFilePath: process.env.LOG_FILE_PATH,
format: (process.env.LOG_FORMAT as 'json' | 'text') || 'text',
colorize: process.env.LOG_COLORIZE !== 'false'
});