feat: implement centralized logger with file persistence
This commit is contained in:
118
shared/lib/logger.test.ts
Normal file
118
shared/lib/logger.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
162
shared/lib/logger.ts
Normal file
162
shared/lib/logger.ts
Normal file
@@ -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<void> | 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();
|
||||||
Reference in New Issue
Block a user