forked from syntaxbullet/aurorabot
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