diff --git a/src/utils/logger/logger.ts b/src/utils/logger/logger.ts index f2dfa9b..620bf79 100644 --- a/src/utils/logger/logger.ts +++ b/src/utils/logger/logger.ts @@ -1,134 +1,215 @@ /** - * Logger singleton with console and file output + * Logger with console and file output * Supports configurable settings and daily rotation + * Can run independently with provided config or use system defaults */ -import { config } from '$config'; -import { colors } from './colors.ts'; -import { logSettings } from './settings.ts'; -import type { LogEntry, LogOptions } from './types.ts'; +import { config } from "$config"; +import { colors } from "./colors.ts"; +import { logSettings } from "./settings.ts"; +import type { LogEntry, LoggerConfig, LogLevel, LogOptions } from "./types.ts"; class Logger { - private formatTimestamp(): string { - const timestamp = new Date().toISOString(); - return `${colors.grey}${timestamp}${colors.reset}`; - } + private config: Required; - private formatLevel(level: string, color: string): string { - return `${color}${level.padEnd(5)}${colors.reset}`; - } + constructor(config?: LoggerConfig) { + // Use provided config or sensible defaults + this.config = { + logsDir: config?.logsDir ?? "/tmp/logs", + enabled: config?.enabled ?? true, + fileLogging: config?.fileLogging ?? true, + consoleLogging: config?.consoleLogging ?? true, + minLevel: config?.minLevel ?? "INFO", + }; + } - private formatSource(source?: string): string { - if (!source) return ''; - return `${colors.grey}[${source}]${colors.reset}`; - } + private formatTimestamp(): string { + const timestamp = new Date().toISOString(); + return `${colors.grey}${timestamp}${colors.reset}`; + } - private formatMeta(meta?: unknown): string { - if (!meta) return ''; - return `${colors.grey}${JSON.stringify(meta)}${colors.reset}`; - } + private formatLevel(level: string, color: string): string { + return `${color}${level.padEnd(5)}${colors.reset}`; + } - /** - * Get log file path with daily rotation (YYYY-MM-DD.log) - */ - private getLogFilePath(): string { - const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD - return `${config.paths.logs}/${date}.log`; - } + private formatSource(source?: string): string { + if (!source) return ""; + return `${colors.grey}[${source}]${colors.reset}`; + } - private async log( - level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR', - color: string, - message: string, - options?: LogOptions - ): Promise { - // Check if this log level should be logged - if (!logSettings.shouldLog(level)) { - return; - } + private formatMeta(meta?: unknown): string { + if (!meta) return ""; + return `${colors.grey}${JSON.stringify(meta)}${colors.reset}`; + } - const timestamp = new Date().toISOString(); + /** + * Get log file path with daily rotation (YYYY-MM-DD.log) + */ + private getLogFilePath(): string { + const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + return `${this.config.logsDir}/${date}.log`; + } - // Console output (colored) - if (logSettings.isConsoleLoggingEnabled()) { - const consoleParts = [ - this.formatTimestamp(), - this.formatLevel(level, color), - message, - options?.source ? this.formatSource(options.source) : '', - options?.meta ? this.formatMeta(options.meta) : '' - ].filter(Boolean); + /** + * Check if logging is enabled + */ + private isEnabled(): boolean { + return this.config.enabled; + } - console.log(consoleParts.join(' | ')); - } + /** + * Check if file logging is enabled + */ + private isFileLoggingEnabled(): boolean { + return this.config.fileLogging; + } - // File output (JSON) - if (logSettings.isFileLoggingEnabled()) { - const logEntry: LogEntry = { - timestamp, - level, - message, - ...(options?.source ? { source: options.source } : {}), - ...(options?.meta ? { meta: options.meta } : {}) - }; + /** + * Check if console logging is enabled + */ + private isConsoleLoggingEnabled(): boolean { + return this.config.consoleLogging; + } - try { - const filePath = this.getLogFilePath(); + /** + * Check if a log level should be logged based on minimum level + */ + private shouldLog(level: LogLevel): boolean { + if (!this.isEnabled()) { + return false; + } - // Write to log file - await Deno.writeTextFile(filePath, JSON.stringify(logEntry) + '\n', { - append: true - }); - } catch (error) { - // If file write fails, at least we have console output - console.error('Failed to write to log file:', error); - } - } - } + const levels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"]; + const minIndex = levels.indexOf(this.config.minLevel); + const levelIndex = levels.indexOf(level); - async debug(message: string, options?: LogOptions): Promise { - await this.log('DEBUG', colors.cyan, message, options); - } + return levelIndex >= minIndex; + } - async info(message: string, options?: LogOptions): Promise { - await this.log('INFO', colors.green, message, options); - } + private async log( + level: LogLevel, + color: string, + message: string, + options?: LogOptions, + ): Promise { + // Check if this log level should be logged + if (!this.shouldLog(level)) { + return; + } - async warn(message: string, options?: LogOptions): Promise { - await this.log('WARN', colors.yellow, message, options); - } + const timestamp = new Date().toISOString(); - async error(message: string, options?: LogOptions): Promise { - await this.log('ERROR', colors.red, message, options); - } + // Console output (colored) + if (this.isConsoleLoggingEnabled()) { + const consoleParts = [ + this.formatTimestamp(), + this.formatLevel(level, color), + message, + options?.source ? this.formatSource(options.source) : "", + options?.meta ? this.formatMeta(options.meta) : "", + ].filter(Boolean); - async errorWithTrace(message: string, error?: Error, options?: LogOptions): Promise { - await this.log('ERROR', colors.red, message, options); + console.log(consoleParts.join(" | ")); + } - // Print stack trace to console - if (error?.stack && logSettings.isConsoleLoggingEnabled()) { - console.log(`${colors.grey}${error.stack}${colors.reset}`); - } + // File output (JSON) + if (this.isFileLoggingEnabled()) { + const logEntry: LogEntry = { + timestamp, + level, + message, + ...(options?.source ? { source: options.source } : {}), + ...(options?.meta ? { meta: options.meta } : {}), + }; - // Write stack trace to file - if (error?.stack && logSettings.isFileLoggingEnabled()) { - const traceEntry: LogEntry = { - timestamp: new Date().toISOString(), - level: 'ERROR', - message: 'Stack trace', - meta: { stack: error.stack } - }; + try { + const filePath = this.getLogFilePath(); - try { - const filePath = this.getLogFilePath(); - await Deno.writeTextFile(filePath, JSON.stringify(traceEntry) + '\n', { - append: true - }); - } catch (writeError) { - console.error('Failed to write stack trace to log file:', writeError); - } - } - } + // Ensure logs directory exists + try { + await Deno.mkdir(this.config.logsDir, { recursive: true }); + } catch { + // Directory might already exist + } + + // Write to log file + await Deno.writeTextFile(filePath, JSON.stringify(logEntry) + "\n", { + append: true, + }); + } catch (error) { + // If file write fails, at least we have console output + console.error("Failed to write to log file:", error); + } + } + } + + async debug(message: string, options?: LogOptions): Promise { + await this.log("DEBUG", colors.cyan, message, options); + } + + async info(message: string, options?: LogOptions): Promise { + await this.log("INFO", colors.green, message, options); + } + + async warn(message: string, options?: LogOptions): Promise { + await this.log("WARN", colors.yellow, message, options); + } + + async error(message: string, options?: LogOptions): Promise { + await this.log("ERROR", colors.red, message, options); + } + + async errorWithTrace( + message: string, + error?: Error, + options?: LogOptions, + ): Promise { + await this.log("ERROR", colors.red, message, options); + + // Print stack trace to console + if (error?.stack && this.isConsoleLoggingEnabled()) { + console.log(`${colors.grey}${error.stack}${colors.reset}`); + } + + // Write stack trace to file + if (error?.stack && this.isFileLoggingEnabled()) { + const traceEntry: LogEntry = { + timestamp: new Date().toISOString(), + level: "ERROR", + message: "Stack trace", + meta: { stack: error.stack }, + }; + + try { + const filePath = this.getLogFilePath(); + await Deno.writeTextFile(filePath, JSON.stringify(traceEntry) + "\n", { + append: true, + }); + } catch (writeError) { + console.error("Failed to write stack trace to log file:", writeError); + } + } + } } -export const logger = new Logger(); +// Export Logger class for creating custom instances (for testing) +export { Logger }; + +/** + * Default logger singleton for production use + * Uses system config and database settings + * + * For testing, create a standalone Logger instance: + * @example + * import { Logger } from './logger.ts'; + * const testLogger = new Logger({ logsDir: '/tmp/test-logs', minLevel: 'DEBUG' }); + * await testLogger.info('test message'); + */ +const settings = logSettings.get(); +export const logger = new Logger({ + logsDir: config.paths.logs, + enabled: settings.enabled === 1, + fileLogging: settings.file_logging === 1, + consoleLogging: settings.console_logging === 1, + minLevel: settings.min_level, +}); diff --git a/src/utils/logger/reader.ts b/src/utils/logger/reader.ts index 8cfd95d..3725041 100644 --- a/src/utils/logger/reader.ts +++ b/src/utils/logger/reader.ts @@ -4,78 +4,80 @@ * Supports reading from multiple files (for daily rotation) */ -import { config } from '$config'; -import type { LogEntry } from './types.ts'; +import { config } from "$config"; +import type { LogEntry } from "./types.ts"; /** * Get all log files sorted by modification time (newest first) */ async function getLogFiles(): Promise { - const logsDir = config.paths.logs; - const logFiles: Array<{ path: string; mtime: Date }> = []; + const logsDir = config.paths.logs; + const logFiles: Array<{ path: string; mtime: Date }> = []; - try { - for await (const entry of Deno.readDir(logsDir)) { - if (entry.isFile && entry.name.endsWith('.log')) { - const filePath = `${logsDir}/${entry.name}`; - try { - const stat = await Deno.stat(filePath); - if (stat.mtime) { - logFiles.push({ path: filePath, mtime: stat.mtime }); - } - } catch { - // Skip files we can't stat - } - } - } - } catch { - // If directory doesn't exist, return empty array - return []; - } + try { + for await (const entry of Deno.readDir(logsDir)) { + if (entry.isFile && entry.name.endsWith(".log")) { + const filePath = `${logsDir}/${entry.name}`; + try { + const stat = await Deno.stat(filePath); + if (stat.mtime) { + logFiles.push({ path: filePath, mtime: stat.mtime }); + } + } catch { + // Skip files we can't stat + } + } + } + } catch { + // If directory doesn't exist, return empty array + return []; + } - // Sort by modification time (newest first) - logFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + // Sort by modification time (newest first) + logFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - return logFiles.map((f) => f.path); + return logFiles.map((f) => f.path); } /** * Get all log files with metadata */ export async function getLogFilesList(): Promise< - Array<{ filename: string; path: string; size: number; modified: Date }> + Array<{ filename: string; path: string; size: number; modified: Date }> > { - const logsDir = config.paths.logs; - const logFiles: Array<{ filename: string; path: string; size: number; modified: Date }> = []; + const logsDir = config.paths.logs; + const logFiles: Array< + { filename: string; path: string; size: number; modified: Date } + > = []; - try { - for await (const entry of Deno.readDir(logsDir)) { - if (entry.isFile && entry.name.endsWith('.log')) { - const filePath = `${logsDir}/${entry.name}`; - try { - const stat = await Deno.stat(filePath); - if (stat.mtime) { - logFiles.push({ - filename: entry.name, - path: filePath, - size: stat.size, - modified: stat.mtime - }); - } - } catch { - // Skip files we can't stat - } - } - } - } catch { - // If directory doesn't exist, return empty array - return []; - } + try { + for await (const entry of Deno.readDir(logsDir)) { + if (entry.isFile && entry.name.endsWith(".log")) { + const filePath = `${logsDir}/${entry.name}`; + try { + const stat = await Deno.stat(filePath); + if (stat.mtime) { + logFiles.push({ + filename: entry.name, + path: filePath, + size: stat.size, + modified: stat.mtime, + }); + } + } catch { + // Skip files we can't stat + } + } + } + } catch { + // If directory doesn't exist, return empty array + return []; + } - // Sort by modification time (newest first) - logFiles.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + // Sort by modification time (newest first) + logFiles.sort((a, b) => b.modified.getTime() - a.modified.getTime()); - return logFiles; + return logFiles; } /** @@ -85,36 +87,38 @@ export async function getLogFilesList(): Promise< * @returns Array of log entries sorted by timestamp (oldest first) */ export async function readLogsFromFile( - filename: string, - count: number = 1000 + filename: string, + count: number = 1000, ): Promise { - try { - const logsDir = config.paths.logs; - const filePath = `${logsDir}/${filename}`; - const logs: LogEntry[] = []; + try { + const logsDir = config.paths.logs; + const filePath = `${logsDir}/${filename}`; + const logs: LogEntry[] = []; - const content = await Deno.readTextFile(filePath); - const lines = content.split('\n').filter((line) => line.trim()); + const content = await Deno.readTextFile(filePath); + const lines = content.split("\n").filter((line) => line.trim()); - // Parse each line as JSON - for (const line of lines) { - try { - const entry = JSON.parse(line) as LogEntry; - logs.push(entry); - } catch { - // Skip invalid JSON lines - } - } + // Parse each line as JSON + for (const line of lines) { + try { + const entry = JSON.parse(line) as LogEntry; + logs.push(entry); + } catch { + // Skip invalid JSON lines + } + } - // Sort by timestamp (oldest first) - logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + // Sort by timestamp (oldest first) + logs.sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); - // Return last N entries - return logs.slice(-count); - } catch (error) { - // If anything fails, return empty array - return []; - } + // Return last N entries + return logs.slice(-count); + } catch (_error) { + // If anything fails, return empty array + return []; + } } /** @@ -123,44 +127,46 @@ export async function readLogsFromFile( * @returns Array of log entries sorted by timestamp (oldest first) */ export async function readLastLogs(count: number = 1000): Promise { - try { - const logFiles = await getLogFiles(); - const logs: LogEntry[] = []; + try { + const logFiles = await getLogFiles(); + const logs: LogEntry[] = []; - // Read from newest files first until we have enough logs - for (const filePath of logFiles) { - try { - const content = await Deno.readTextFile(filePath); - const lines = content.split('\n').filter((line) => line.trim()); + // Read from newest files first until we have enough logs + for (const filePath of logFiles) { + try { + const content = await Deno.readTextFile(filePath); + const lines = content.split("\n").filter((line) => line.trim()); - // Parse each line as JSON - for (const line of lines) { - try { - const entry = JSON.parse(line) as LogEntry; - logs.push(entry); - } catch { - // Skip invalid JSON lines - } - } + // Parse each line as JSON + for (const line of lines) { + try { + const entry = JSON.parse(line) as LogEntry; + logs.push(entry); + } catch { + // Skip invalid JSON lines + } + } - // Stop if we have enough logs - if (logs.length >= count) { - break; - } - } catch { - // Skip files we can't read - } - } + // Stop if we have enough logs + if (logs.length >= count) { + break; + } + } catch { + // Skip files we can't read + } + } - // Sort by timestamp (oldest first) - logs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + // Sort by timestamp (oldest first) + logs.sort((a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); - // Return last N entries - return logs.slice(-count); - } catch (error) { - // If anything fails, return empty array - return []; - } + // Return last N entries + return logs.slice(-count); + } catch (_error) { + // If anything fails, return empty array + return []; + } } /** @@ -169,11 +175,11 @@ export async function readLastLogs(count: number = 1000): Promise { * @returns Parsed log entry or null if invalid */ export function parseLogLine(line: string): LogEntry | null { - try { - const trimmed = line.trim(); - if (!trimmed) return null; - return JSON.parse(trimmed) as LogEntry; - } catch { - return null; - } + try { + const trimmed = line.trim(); + if (!trimmed) return null; + return JSON.parse(trimmed) as LogEntry; + } catch { + return null; + } } diff --git a/src/utils/logger/settings.ts b/src/utils/logger/settings.ts index effac5f..500715f 100644 --- a/src/utils/logger/settings.ts +++ b/src/utils/logger/settings.ts @@ -1,101 +1,101 @@ -import { logSettingsQueries } from '$db/queries/logSettings.ts'; -import type { LogSettings } from '$db/queries/logSettings.ts'; +import { logSettingsQueries } from "$db/queries/logSettings.ts"; +import type { LogSettings } from "$db/queries/logSettings.ts"; /** * Log settings manager * Loads and caches log settings from database */ class LogSettingsManager { - private settings: LogSettings | null = null; - private initialized = false; + private settings: LogSettings | null = null; + private initialized = false; - /** - * Load settings from database - */ - load(): void { - try { - this.settings = logSettingsQueries.get() ?? null; - this.initialized = true; - } catch (error) { - console.error('Failed to load log settings:', error); - // Use defaults if database not available - this.settings = null; - this.initialized = false; - } - } + /** + * Load settings from database + */ + load(): void { + try { + this.settings = logSettingsQueries.get() ?? null; + this.initialized = true; + } catch (error) { + console.error("Failed to load log settings:", error); + // Use defaults if database not available + this.settings = null; + this.initialized = false; + } + } - /** - * Reload settings from database - * Call this after updating settings - */ - reload(): void { - this.load(); - } + /** + * Reload settings from database + * Call this after updating settings + */ + reload(): void { + this.load(); + } - /** - * Get current settings - * Returns defaults if not loaded - */ - get(): LogSettings { - if (!this.initialized || !this.settings) { - // Return defaults if not initialized - return { - id: 1, - retention_days: 30, - min_level: 'INFO', - enabled: 1, - file_logging: 1, - console_logging: 1, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; - } - return this.settings; - } + /** + * Get current settings + * Returns defaults if not loaded + */ + get(): LogSettings { + if (!this.initialized || !this.settings) { + // Return defaults if not initialized + return { + id: 1, + retention_days: 30, + min_level: "INFO", + enabled: 1, + file_logging: 1, + console_logging: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + } + return this.settings; + } - /** - * Check if logging is enabled - */ - isEnabled(): boolean { - return this.get().enabled === 1; - } + /** + * Check if logging is enabled + */ + isEnabled(): boolean { + return this.get().enabled === 1; + } - /** - * Check if file logging is enabled - */ - isFileLoggingEnabled(): boolean { - return this.get().file_logging === 1; - } + /** + * Check if file logging is enabled + */ + isFileLoggingEnabled(): boolean { + return this.get().file_logging === 1; + } - /** - * Check if console logging is enabled - */ - isConsoleLoggingEnabled(): boolean { - return this.get().console_logging === 1; - } + /** + * Check if console logging is enabled + */ + isConsoleLoggingEnabled(): boolean { + return this.get().console_logging === 1; + } - /** - * Get minimum log level - */ - getMinLevel(): 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' { - return this.get().min_level; - } + /** + * Get minimum log level + */ + getMinLevel(): "DEBUG" | "INFO" | "WARN" | "ERROR" { + return this.get().min_level; + } - /** - * Check if a log level should be logged - */ - shouldLog(level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'): boolean { - if (!this.isEnabled()) { - return false; - } + /** + * Check if a log level should be logged + */ + shouldLog(level: "DEBUG" | "INFO" | "WARN" | "ERROR"): boolean { + if (!this.isEnabled()) { + return false; + } - const minLevel = this.getMinLevel(); - const levels = ['DEBUG', 'INFO', 'WARN', 'ERROR']; - const minIndex = levels.indexOf(minLevel); - const levelIndex = levels.indexOf(level); + const minLevel = this.getMinLevel(); + const levels = ["DEBUG", "INFO", "WARN", "ERROR"]; + const minIndex = levels.indexOf(minLevel); + const levelIndex = levels.indexOf(level); - return levelIndex >= minIndex; - } + return levelIndex >= minIndex; + } } // Export singleton instance diff --git a/src/utils/logger/types.ts b/src/utils/logger/types.ts index 4af54c0..560903a 100644 --- a/src/utils/logger/types.ts +++ b/src/utils/logger/types.ts @@ -2,17 +2,36 @@ * Logger types and interfaces */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; + export interface LogOptions { - /** Optional metadata to include with the log */ - meta?: unknown; - /** Optional source/context tag (e.g., "database", "api") */ - source?: string; + /** Optional metadata to include with the log */ + meta?: unknown; + /** Optional source/context tag (e.g., "database", "api") */ + source?: string; } export interface LogEntry { - timestamp: string; - level: string; - message: string; - source?: string; - meta?: unknown; + timestamp: string; + level: string; + message: string; + source?: string; + meta?: unknown; +} + +/** + * Logger configuration + * Allows logger to run independently without config/database dependencies + */ +export interface LoggerConfig { + /** Directory where log files will be written (e.g., "/app/logs") */ + logsDir: string; + /** Master toggle for all logging */ + enabled?: boolean; + /** Enable file logging */ + fileLogging?: boolean; + /** Enable console logging */ + consoleLogging?: boolean; + /** Minimum log level to output */ + minLevel?: LogLevel; }