claude-flow-tbowman01
Version:
Enterprise-grade AI agent orchestration with ruv-swarm integration (Alpha Release)
314 lines (268 loc) • 8.68 kB
text/typescript
/**
* Logging infrastructure for Claude-Flow
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { Buffer } from 'node:buffer';
import process from 'node:process';
import type { LoggingConfig } from '../utils/types.js';
import { formatBytes } from '../utils/helpers.js';
export interface ILogger {
debug(message: string, meta?: unknown): void;
info(message: string, meta?: unknown): void;
warn(message: string, meta?: unknown): void;
error(message: string, error?: unknown): void;
configure(config: LoggingConfig): Promise<void>;
level?: string;
}
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
interface LogEntry {
timestamp: string;
level: string;
message: string;
context: Record<string, unknown>;
data?: unknown;
error?: unknown;
}
/**
* Logger implementation with context support
*/
export class Logger implements ILogger {
private static instance: Logger;
private config: LoggingConfig;
private context: Record<string, unknown>;
private fileHandle?: fs.FileHandle;
private currentFileSize = 0;
private currentFileIndex = 0;
private isClosing = false;
get level(): string {
return this.config.level;
}
constructor(
config: LoggingConfig = {
level: 'info',
format: 'json',
destination: 'console',
},
context: Record<string, unknown> = {},
) {
// Validate file path if file destination
if ((config.destination === 'file' || config.destination === 'both') && !config.filePath) {
throw new Error('File path required for file logging');
}
this.config = config;
this.context = context;
}
/**
* Gets the singleton instance of the logger
*/
static getInstance(config?: LoggingConfig): Logger {
if (!Logger.instance) {
if (!config) {
// Use default config if none provided and not in test environment
const isTestEnv = process.env.CLAUDE_FLOW_ENV === 'test';
if (isTestEnv) {
throw new Error('Logger configuration required for initialization');
}
config = {
level: 'info',
format: 'json',
destination: 'console',
};
}
Logger.instance = new Logger(config);
}
return Logger.instance;
}
/**
* Updates logger configuration
*/
async configure(config: LoggingConfig): Promise<void> {
this.config = config;
// Reset file handle if destination changed
if (this.fileHandle && config.destination !== 'file' && config.destination !== 'both') {
await this.fileHandle.close();
delete this.fileHandle;
}
}
debug(message: string, meta?: unknown): void {
this.log(LogLevel.DEBUG, message, meta);
}
info(message: string, meta?: unknown): void {
this.log(LogLevel.INFO, message, meta);
}
warn(message: string, meta?: unknown): void {
this.log(LogLevel.WARN, message, meta);
}
error(message: string, error?: unknown): void {
this.log(LogLevel.ERROR, message, undefined, error);
}
/**
* Creates a child logger with additional context
*/
child(context: Record<string, unknown>): Logger {
return new Logger(this.config, { ...this.context, ...context });
}
/**
* Properly close the logger and release resources
*/
async close(): Promise<void> {
this.isClosing = true;
if (this.fileHandle) {
try {
await this.fileHandle.close();
} catch (error) {
console.error('Error closing log file handle:', error);
} finally {
delete this.fileHandle;
}
}
}
private log(level: LogLevel, message: string, data?: unknown, error?: unknown): void {
if (!this.shouldLog(level)) {
return;
}
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel[level],
message,
context: this.context,
data,
error,
};
const formatted = this.format(entry);
if (this.config.destination === 'console' || this.config.destination === 'both') {
this.writeToConsole(level, formatted);
}
if (this.config.destination === 'file' || this.config.destination === 'both') {
this.writeToFile(formatted);
}
}
private shouldLog(level: LogLevel): boolean {
const configLevel = LogLevel[this.config.level.toUpperCase() as keyof typeof LogLevel];
return level >= configLevel;
}
private format(entry: LogEntry): string {
if (this.config.format === 'json') {
// Handle error serialization for JSON format
const jsonEntry = { ...entry };
if (jsonEntry.error instanceof Error) {
jsonEntry.error = {
name: jsonEntry.error.name,
message: jsonEntry.error.message,
stack: jsonEntry.error.stack,
};
}
return JSON.stringify(jsonEntry);
}
// Text format
const contextStr =
Object.keys(entry.context).length > 0 ? ` ${JSON.stringify(entry.context)}` : '';
const dataStr = entry.data !== undefined ? ` ${JSON.stringify(entry.data)}` : '';
const errorStr =
entry.error !== undefined
? entry.error instanceof Error
? `\n Error: ${entry.error.message}\n Stack: ${entry.error.stack}`
: ` Error: ${JSON.stringify(entry.error)}`
: '';
return `[${entry.timestamp}] ${entry.level} ${entry.message}${contextStr}${dataStr}${errorStr}`;
}
private writeToConsole(level: LogLevel, message: string): void {
switch (level) {
case LogLevel.DEBUG:
console.debug(message);
break;
case LogLevel.INFO:
console.info(message);
break;
case LogLevel.WARN:
console.warn(message);
break;
case LogLevel.ERROR:
console.error(message);
break;
}
}
private async writeToFile(message: string): Promise<void> {
if (!this.config.filePath || this.isClosing) {
return;
}
try {
// Check if we need to rotate the log file
if (await this.shouldRotate()) {
await this.rotate();
}
// Open file handle if not already open
if (!this.fileHandle) {
this.fileHandle = await fs.open(this.config.filePath, 'a');
}
// Write the message
const data = Buffer.from(message + '\n', 'utf8');
await this.fileHandle.write(data);
this.currentFileSize += data.length;
} catch (error) {
console.error('Failed to write to log file:', error);
}
}
private async shouldRotate(): Promise<boolean> {
if (!this.config.maxFileSize || !this.config.filePath) {
return false;
}
try {
const stat = await fs.stat(this.config.filePath);
return stat.size >= this.config.maxFileSize;
} catch {
return false;
}
}
private async rotate(): Promise<void> {
if (!this.config.filePath || !this.config.maxFiles) {
return;
}
// Close current file
if (this.fileHandle) {
await this.fileHandle.close();
delete this.fileHandle;
}
// Rename current file
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const rotatedPath = `${this.config.filePath}.${timestamp}`;
await fs.rename(this.config.filePath, rotatedPath);
// Clean up old files
await this.cleanupOldFiles();
// Reset file size
this.currentFileSize = 0;
}
private async cleanupOldFiles(): Promise<void> {
if (!this.config.filePath || !this.config.maxFiles) {
return;
}
const dir = path.dirname(this.config.filePath);
const baseFileName = path.basename(this.config.filePath);
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.startsWith(baseFileName + '.')) {
files.push(entry.name);
}
}
// Sort files by timestamp (newest first)
files.sort().reverse();
// Remove old files
const filesToRemove = files.slice(this.config.maxFiles - 1);
for (const file of filesToRemove) {
await fs.unlink(path.join(dir, file));
}
} catch (error) {
console.error('Failed to cleanup old log files:', error);
}
}
}
// Export singleton instance with lazy initialization
export const logger = Logger.getInstance();