diff --git a/shared/lib/logger.test.ts b/shared/lib/logger.test.ts new file mode 100644 index 0000000..71ce86b --- /dev/null +++ b/shared/lib/logger.test.ts @@ -0,0 +1,118 @@ +import { expect, test, describe, beforeAll, afterAll, spyOn } from "bun:test"; +import { logger } from "./logger"; +import { existsSync, unlinkSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +describe("Logger", () => { + const logDir = join(process.cwd(), "logs"); + const logFile = join(logDir, "error.log"); + + beforeAll(() => { + // Cleanup if exists + try { + if (existsSync(logFile)) unlinkSync(logFile); + } catch (e) {} + }); + + test("should log info messages to console with correct format", () => { + const spy = spyOn(console, "log"); + const message = "Formatting test"; + logger.info("system", message); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + // Strict regex check for ISO timestamp and format + const regex = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[INFO\] \[SYSTEM\] Formatting test$/; + expect(callArgs).toMatch(regex); + } + spy.mockRestore(); + }); + + test("should write error logs to file with stack trace", async () => { + const errorMessage = "Test error message"; + const testError = new Error("Source error"); + + logger.error("system", errorMessage, testError); + + // Polling for file write instead of fixed timeout + let content = ""; + for (let i = 0; i < 20; i++) { + if (existsSync(logFile)) { + content = readFileSync(logFile, "utf-8"); + if (content.includes("Source error")) break; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + + expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[ERROR\] \[SYSTEM\] Test error message: Source error/); + expect(content).toContain("Stack Trace:"); + expect(content).toContain("Error: Source error"); + expect(content).toContain("logger.test.ts"); + }); + + test("should handle log directory creation failures gracefully", async () => { + const consoleSpy = spyOn(console, "error"); + + // We trigger an error by trying to use a path that is a file where a directory should be + const triggerFile = join(process.cwd(), "logs_fail_trigger"); + + try { + writeFileSync(triggerFile, "not a directory"); + + // Manually override paths for this test instance + const originalLogDir = (logger as any).logDir; + const originalLogPath = (logger as any).errorLogPath; + + (logger as any).logDir = triggerFile; + (logger as any).errorLogPath = join(triggerFile, "error.log"); + (logger as any).initialized = false; + + logger.error("system", "This should fail directory creation"); + + // Wait for async initialization attempt + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy.mock.calls.some(call => + String(call[0]).includes("Failed to initialize logger directory") + )).toBe(true); + + // Reset logger state + (logger as any).logDir = originalLogDir; + (logger as any).errorLogPath = originalLogPath; + (logger as any).initialized = false; + } finally { + if (existsSync(triggerFile)) unlinkSync(triggerFile); + consoleSpy.mockRestore(); + } + }); + + test("should include complex data objects in logs", () => { + const spy = spyOn(console, "log"); + const data = { userId: "123", tags: ["test"] }; + logger.info("bot", "Message with data", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + if (callArgs) { + expect(callArgs).toContain(` | Data: ${JSON.stringify(data)}`); + } + spy.mockRestore(); + }); + + test("should handle circular references in data objects", () => { + const spy = spyOn(console, "log"); + const data: any = { name: "circular" }; + data.self = data; + + logger.info("bot", "Circular test", data); + + expect(spy).toHaveBeenCalled(); + const callArgs = spy.mock.calls[0]?.[0]; + expect(callArgs).toContain("[Circular]"); + spy.mockRestore(); + }); +}); diff --git a/shared/lib/logger.ts b/shared/lib/logger.ts new file mode 100644 index 0000000..ed0cf61 --- /dev/null +++ b/shared/lib/logger.ts @@ -0,0 +1,162 @@ +import { join, resolve } from "path"; +import { appendFile, mkdir, stat } from "fs/promises"; +import { existsSync } from "fs"; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +const LogLevelNames = { + [LogLevel.DEBUG]: "DEBUG", + [LogLevel.INFO]: "INFO", + [LogLevel.WARN]: "WARN", + [LogLevel.ERROR]: "ERROR", +}; + +export type LogSource = "bot" | "web" | "shared" | "system"; + +export interface LogEntry { + timestamp: string; + level: string; + source: LogSource; + message: string; + data?: any; + stack?: string; +} + +class Logger { + private logDir: string; + private errorLogPath: string; + private initialized: boolean = false; + private initPromise: Promise | null = null; + + constructor() { + // Use resolve with __dirname or process.cwd() but make it more robust + // Since this is in shared/lib/, we can try to find the project root + // For now, let's stick to a resolved path from process.cwd() or a safer alternative + this.logDir = resolve(process.cwd(), "logs"); + this.errorLogPath = join(this.logDir, "error.log"); + } + + private async ensureInitialized() { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + try { + await mkdir(this.logDir, { recursive: true }); + this.initialized = true; + } catch (err: any) { + if (err.code === "EEXIST" || err.code === "ENOTDIR") { + try { + const stats = await stat(this.logDir); + if (stats.isDirectory()) { + this.initialized = true; + return; + } + } catch (statErr) {} + } + console.error(`[SYSTEM] Failed to initialize logger directory at ${this.logDir}:`, err); + } finally { + this.initPromise = null; + } + })(); + + return this.initPromise; + } + + private safeStringify(data: any): string { + try { + return JSON.stringify(data); + } catch (err) { + const seen = new WeakSet(); + return JSON.stringify(data, (key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + } + return value; + }); + } + } + + private formatMessage(entry: LogEntry): string { + const dataStr = entry.data ? ` | Data: ${this.safeStringify(entry.data)}` : ""; + const stackStr = entry.stack ? `\nStack Trace:\n${entry.stack}` : ""; + return `[${entry.timestamp}] [${entry.level}] [${entry.source.toUpperCase()}] ${entry.message}${dataStr}${stackStr}`; + } + + private async writeToErrorLog(formatted: string) { + await this.ensureInitialized(); + try { + await appendFile(this.errorLogPath, formatted + "\n"); + } catch (err) { + console.error("[SYSTEM] Failed to write to error log file:", err); + } + } + + private log(level: LogLevel, source: LogSource, message: string, errorOrData?: any) { + const timestamp = new Date().toISOString(); + const levelName = LogLevelNames[level]; + + const entry: LogEntry = { + timestamp, + level: levelName, + source, + message, + }; + + if (level === LogLevel.ERROR && errorOrData instanceof Error) { + entry.stack = errorOrData.stack; + entry.message = `${message}: ${errorOrData.message}`; + } else if (errorOrData !== undefined) { + entry.data = errorOrData; + } + + const formatted = this.formatMessage(entry); + + // Print to console + switch (level) { + case LogLevel.DEBUG: + console.debug(formatted); + break; + case LogLevel.INFO: + console.log(formatted); + break; + case LogLevel.WARN: + console.warn(formatted); + break; + case LogLevel.ERROR: + console.error(formatted); + break; + } + + // Persistent error logging + if (level === LogLevel.ERROR) { + this.writeToErrorLog(formatted).catch(() => { + // Silently fail to avoid infinite loops + }); + } + } + + debug(source: LogSource, message: string, data?: any) { + this.log(LogLevel.DEBUG, source, message, data); + } + + info(source: LogSource, message: string, data?: any) { + this.log(LogLevel.INFO, source, message, data); + } + + warn(source: LogSource, message: string, data?: any) { + this.log(LogLevel.WARN, source, message, data); + } + + error(source: LogSource, message: string, error?: any) { + this.log(LogLevel.ERROR, source, message, error); + } +} + +export const logger = new Logger();