refactor(logger): make Logger independently testable

- Add LoggerConfig interface for dependency injection
  - Logger class now accepts optional config in constructor
  - Default singleton still uses system config/database (no breaking changes)
  - Enables standalone usage for testing without full system init
  - Add auto-creation of logs directory if missing
This commit is contained in:
Sam Chau
2025-10-21 07:57:13 +10:30
parent 0a9b287825
commit 1884e9308f
4 changed files with 423 additions and 317 deletions

View File

@@ -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<LoggerConfig>;
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<void> {
// 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<void> {
await this.log('DEBUG', colors.cyan, message, options);
}
return levelIndex >= minIndex;
}
async info(message: string, options?: LogOptions): Promise<void> {
await this.log('INFO', colors.green, message, options);
}
private async log(
level: LogLevel,
color: string,
message: string,
options?: LogOptions,
): Promise<void> {
// Check if this log level should be logged
if (!this.shouldLog(level)) {
return;
}
async warn(message: string, options?: LogOptions): Promise<void> {
await this.log('WARN', colors.yellow, message, options);
}
const timestamp = new Date().toISOString();
async error(message: string, options?: LogOptions): Promise<void> {
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<void> {
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<void> {
await this.log("DEBUG", colors.cyan, message, options);
}
async info(message: string, options?: LogOptions): Promise<void> {
await this.log("INFO", colors.green, message, options);
}
async warn(message: string, options?: LogOptions): Promise<void> {
await this.log("WARN", colors.yellow, message, options);
}
async error(message: string, options?: LogOptions): Promise<void> {
await this.log("ERROR", colors.red, message, options);
}
async errorWithTrace(
message: string,
error?: Error,
options?: LogOptions,
): Promise<void> {
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,
});

View File

@@ -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<string[]> {
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<LogEntry[]> {
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<LogEntry[]> {
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<LogEntry[]> {
* @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;
}
}

View File

@@ -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

View File

@@ -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;
}