@clipwhisperer/common
Version:
ClipWhisperer Common - Shared library providing core utilities, database schemas, authentication, bucket management, and common functionality across all ClipWhisperer microservices
444 lines (367 loc) • 12.6 kB
text/typescript
import { randomUUID } from 'crypto';
import { createWriteStream, WriteStream } from 'fs';
import { mkdir } from 'fs/promises';
import { join } from 'path';
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
export type LogContext = Record<string, any>;
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: LogContext;
correlationId?: string;
service?: string;
component?: string;
error?: {
name: string;
message: string;
stack?: string;
};
metadata?: {
pid: number;
hostname: string;
version: string;
};
}
export interface LogTransport {
name: string;
write(entry: LogEntry): Promise<void>;
flush?(): Promise<void>;
close?(): Promise<void>;
}
/**
* Console transport for logging to stdout/stderr
*/
export class ConsoleTransport implements LogTransport {
public readonly name = 'console';
private readonly colors: boolean;
constructor(colors = true) {
this.colors = colors;
}
public async write(entry: LogEntry): Promise<void> {
const formatted = this.formatEntry(entry);
if (entry.level === 'error' || entry.level === 'warn') {
console.error(formatted);
} else {
console.log(formatted);
}
}
private formatEntry(entry: LogEntry): string {
const timestamp = entry.timestamp;
const level = this.colors ? this.colorizeLevel(entry.level) : entry.level.toUpperCase();
const service = entry.service ? `[${entry.service}]` : '';
const component = entry.component ? `[${entry.component}]` : '';
const correlationId = entry.correlationId ? `(${entry.correlationId.slice(0, 8)})` : '';
let message = `${timestamp} ${level} ${service}${component}${correlationId} ${entry.message}`;
if (entry.context && Object.keys(entry.context).length > 0) {
message += ` | Context: ${JSON.stringify(entry.context)}`;
}
if (entry.error) {
message += `\n Error: ${entry.error.name}: ${entry.error.message}`;
if (entry.error.stack) {
message += `\n Stack: ${entry.error.stack}`;
}
}
return message;
}
private colorizeLevel(level: LogLevel): string {
if (!this.colors) return level.toUpperCase();
const colors = {
error: '\x1b[31m', // Red
warn: '\x1b[33m', // Yellow
info: '\x1b[36m', // Cyan
debug: '\x1b[35m', // Magenta
trace: '\x1b[37m', // White
};
const reset = '\x1b[0m';
return `${colors[level]}${level.toUpperCase()}${reset}`;
}
}
/**
* File transport for logging to files with rotation
*/
export class FileTransport implements LogTransport {
public readonly name = 'file';
private writeStream: WriteStream | null = null;
private readonly logPath: string;
private readonly maxFileSize: number;
private readonly maxFiles: number;
private currentFileSize = 0;
constructor(
logPath: string,
maxFileSize = 10 * 1024 * 1024, // 10MB
maxFiles = 5
) {
this.logPath = logPath;
this.maxFileSize = maxFileSize;
this.maxFiles = maxFiles;
}
public async write(entry: LogEntry): Promise<void> {
if (!this.writeStream) {
await this.createWriteStream();
}
const formatted = JSON.stringify(entry) + '\n';
// Check if we need to rotate the log file
if (this.currentFileSize + formatted.length > this.maxFileSize) {
await this.rotateLogFile();
}
return new Promise((resolve, reject) => {
this.writeStream!.write(formatted, (error) => {
if (error) {
reject(error);
} else {
this.currentFileSize += formatted.length;
resolve();
}
});
});
}
public async flush(): Promise<void> {
if (this.writeStream) {
return new Promise((resolve) => {
this.writeStream!.end(resolve);
});
}
}
public async close(): Promise<void> {
if (this.writeStream) {
await this.flush();
this.writeStream = null;
}
}
private async createWriteStream(): Promise<void> {
// Ensure log directory exists
await mkdir(join(this.logPath, '..'), { recursive: true });
this.writeStream = createWriteStream(this.logPath, { flags: 'a' });
this.currentFileSize = 0; // Reset size counter
}
private async rotateLogFile(): Promise<void> {
if (this.writeStream) {
await this.close();
}
// Rotate existing files
for (let i = this.maxFiles - 1; i > 0; i--) {
const oldFile = `${this.logPath}.${i}`;
const newFile = `${this.logPath}.${i + 1}`;
try {
const fs = await import('fs/promises');
await fs.rename(oldFile, newFile);
} catch {
// File doesn't exist, continue
}
}
// Move current log to .1
try {
const fs = await import('fs/promises');
await fs.rename(this.logPath, `${this.logPath}.1`);
} catch {
// File doesn't exist, continue
}
await this.createWriteStream();
}
}
/**
* Enterprise-grade structured logger with multiple transports and correlation tracking
*/
export class Logger {
private static instance: Logger;
private transports: Map<string, LogTransport> = new Map();
private logLevel: LogLevel;
private correlationId: string | null = null;
private service: string | null = null;
private component: string | null = null;
private readonly logLevels: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
trace: 4,
};
private constructor() {
this.logLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
this.initializeTransports();
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
private async initializeTransports(): Promise<void> {
// Add console transport
this.addTransport(new ConsoleTransport(process.env.LOG_COLORS !== 'false'));
// Add file transport if log directory is specified
const logDir = process.env.LOG_DIRECTORY || './logs';
const logFile = join(logDir, 'application.log');
this.addTransport(new FileTransport(logFile));
}
public setCorrelationId(correlationId: string): void {
this.correlationId = correlationId;
}
public setService(service: string): void {
this.service = service;
}
public setComponent(component: string): void {
this.component = component;
}
public clearContext(): void {
this.correlationId = null;
this.service = null;
this.component = null;
}
public child(context: { service?: string; component?: string; correlationId?: string }): Logger {
const childLogger = Object.create(this);
childLogger.service = context.service || this.service;
childLogger.component = context.component || this.component;
childLogger.correlationId = context.correlationId || this.correlationId;
return childLogger;
}
public error(message: string, context?: LogContext, error?: Error): void {
this.log('error', message, context, error);
}
public warn(message: string, context?: LogContext): void {
this.log('warn', message, context);
}
public info(message: string, context?: LogContext): void {
this.log('info', message, context);
}
public debug(message: string, context?: LogContext): void {
this.log('debug', message, context);
}
public trace(message: string, context?: LogContext): void {
this.log('trace', message, context);
}
public log(level: LogLevel, message: string, context?: LogContext, error?: Error): void {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
context,
correlationId: this.correlationId || undefined,
service: this.service || undefined,
component: this.component || undefined,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack,
} : undefined,
metadata: {
pid: process.pid,
hostname: require('os').hostname(),
version: process.version,
},
};
// Write to all transports (fire and forget for performance)
this.writeToTransports(entry).catch(console.error);
}
private shouldLog(level: LogLevel): boolean {
return this.logLevels[level] <= this.logLevels[this.logLevel];
}
private async writeToTransports(entry: LogEntry): Promise<void> {
const writePromises = Array.from(this.transports.values()).map(transport =>
transport.write(entry).catch(error => {
console.error(`Failed to write to transport ${transport.name}:`, error);
})
);
await Promise.allSettled(writePromises);
}
public addTransport(transport: LogTransport): void {
this.transports.set(transport.name, transport);
}
public removeTransport(name: string): boolean {
return this.transports.delete(name);
}
public getTransports(): LogTransport[] {
return Array.from(this.transports.values());
}
public async flush(): Promise<void> {
const flushPromises = Array.from(this.transports.values())
.filter(transport => transport.flush)
.map(transport => transport.flush!());
await Promise.allSettled(flushPromises);
}
public async close(): Promise<void> {
const closePromises = Array.from(this.transports.values())
.filter(transport => transport.close)
.map(transport => transport.close!());
await Promise.allSettled(closePromises);
this.transports.clear();
}
public async performance<T>(operation: string, fn: () => Promise<T>): Promise<T> {
return this.performanceSync(operation, fn);
}
public async performanceSync<T>(operation: string, fn: () => Promise<T>): Promise<T> {
const startTime = Date.now();
const correlationId = this.generateCorrelationId();
this.info(`Starting operation: ${operation}`, {
operation,
correlationId,
startTime: new Date(startTime).toISOString()
});
try {
const result = await fn();
const duration = Date.now() - startTime;
this.info(`Operation completed: ${operation}`, {
operation,
correlationId,
duration,
status: 'success'
});
return result;
} catch (error) {
const duration = Date.now() - startTime;
this.error(`Operation failed: ${operation}`, {
operation,
correlationId,
duration,
status: 'error'
}, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
public metric(name: string, value: number, tags?: Record<string, string>): void {
this.debug(`Metric: ${name}`, { metric: name, value, tags });
}
public counter(name: string, increment = 1, tags?: Record<string, string>): void {
this.metric(name, increment, { ...tags, type: 'counter' });
}
public gauge(name: string, value: number, tags?: Record<string, string>): void {
this.metric(name, value, { ...tags, type: 'gauge' });
}
public histogram(name: string, value: number, tags?: Record<string, string>): void {
this.metric(name, value, { ...tags, type: 'histogram' });
}
public audit(action: string, resource: string, context?: LogContext): void {
this.info(`Audit: ${action}`, {
audit: true,
action,
resource,
...context
});
}
public security(event: string, details: LogContext): void {
this.warn(`Security event: ${event}`, {
security: true,
event,
...details
});
}
public health(service: string, status: 'healthy' | 'unhealthy' | 'degraded', details?: LogContext): void {
const level = status === 'healthy' ? 'info' : 'warn';
this.log(level, `Health check: ${service} is ${status}`, {
health: true,
service,
status,
...details
});
}
public static generateCorrelationId(): string {
return randomUUID();
}
public generateCorrelationId(): string {
return Logger.generateCorrelationId();
}
}
// Export singleton instance for convenience
export default Logger.getInstance();