mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat(logs): add Logs Dashboard with real-time Console Viewer
- Consolidated 4-tab Logs page: Request Logs, Proxy Logs, Audit Logs, Console - Terminal-style Console Log Viewer with level filter, search, auto-scroll - Console interceptor captures all console.log/warn/error to JSON log file - Integrated in Next.js instrumentation.ts for both dev and prod - Log rotation by size + retention-based cleanup - Fixed pino logger file transport (pino/file targets only) - Moved initAuditLog() to instrumentation.ts for proper initialization - Bumped version to 1.0.3 - Updated CHANGELOG, all 8 README translations, and .env.example
This commit is contained in:
127
src/lib/consoleInterceptor.ts
Normal file
127
src/lib/consoleInterceptor.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Console Log Interceptor — captures console output to a log file.
|
||||
*
|
||||
* Monkey-patches console.log, console.info, console.warn, console.error,
|
||||
* and console.debug to also append JSON log entries to a file. This allows
|
||||
* the Console Log Viewer to display application logs in real-time.
|
||||
*
|
||||
* Call initConsoleInterceptor() once at server startup (before any logging).
|
||||
*
|
||||
* @module lib/consoleInterceptor
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const logToFile = process.env.LOG_TO_FILE !== "false";
|
||||
const logFilePath = resolve(process.env.LOG_FILE_PATH || "logs/application/app.log");
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Map console method names to log levels.
|
||||
*/
|
||||
const LEVEL_MAP: Record<string, string> = {
|
||||
debug: "debug",
|
||||
log: "info",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure the log directory exists.
|
||||
*/
|
||||
function ensureDir() {
|
||||
const dir = dirname(logFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract component name from message patterns like [COMPONENT] or [component].
|
||||
*/
|
||||
function extractComponent(msg: string): string {
|
||||
const match = msg.match(/^\[([^\]]+)\]/);
|
||||
return match ? match[1] : "app";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert arguments to a string message, handling objects and errors.
|
||||
*/
|
||||
function argsToMessage(args: unknown[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (arg instanceof Error) return `${arg.message}\n${arg.stack || ""}`;
|
||||
if (typeof arg === "object" && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a JSON log entry to the log file.
|
||||
*/
|
||||
function writeEntry(level: string, args: unknown[]) {
|
||||
try {
|
||||
const message = argsToMessage(args);
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
component: extractComponent(message),
|
||||
message,
|
||||
};
|
||||
appendFileSync(logFilePath, JSON.stringify(entry) + "\n");
|
||||
} catch {
|
||||
// Silently fail — never break the app over log writing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the console interceptor.
|
||||
* Patches console.log, console.info, console.warn, console.error, console.debug
|
||||
* to also write to the log file.
|
||||
*
|
||||
* Safe to call multiple times — only initializes once.
|
||||
*/
|
||||
export function initConsoleInterceptor(): void {
|
||||
if (!logToFile || initialized) return;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
} catch {
|
||||
// Can't create log dir — skip interception
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
// Save original methods
|
||||
const originalMethods = {
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
debug: console.debug.bind(console),
|
||||
};
|
||||
|
||||
// Patch each console method
|
||||
for (const [method, level] of Object.entries(LEVEL_MAP)) {
|
||||
const original = originalMethods[method as keyof typeof originalMethods];
|
||||
if (!original) continue;
|
||||
|
||||
(console as unknown as Record<string, unknown>)[method] = (...args: unknown[]) => {
|
||||
// Call original console method
|
||||
original(...args);
|
||||
// Also write to file
|
||||
writeEntry(level, args);
|
||||
};
|
||||
}
|
||||
}
|
||||
136
src/lib/logRotation.ts
Normal file
136
src/lib/logRotation.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Log Rotation & Cleanup — manages application log file rotation.
|
||||
*
|
||||
* Handles:
|
||||
* - Rotating log files when they exceed max size
|
||||
* - Cleaning up old log files past retention period
|
||||
* - Creating the log directory on startup
|
||||
*
|
||||
* Configuration via env vars:
|
||||
* - LOG_TO_FILE: enable file logging (default: true)
|
||||
* - LOG_FILE_PATH: path to log file (default: logs/application/app.log)
|
||||
* - LOG_MAX_FILE_SIZE: max file size before rotation (default: 50MB)
|
||||
* - LOG_RETENTION_DAYS: days to keep old logs (default: 7)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, statSync, renameSync, readdirSync, unlinkSync } from "fs";
|
||||
import { dirname, join, basename, extname } from "path";
|
||||
|
||||
const DEFAULT_LOG_PATH = "logs/application/app.log";
|
||||
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const DEFAULT_RETENTION_DAYS = 7;
|
||||
|
||||
function parseFileSize(raw: string | undefined): number {
|
||||
if (!raw) return DEFAULT_MAX_SIZE;
|
||||
const match = raw.match(/^(\d+)\s*(k|m|g|kb|mb|gb)?$/i);
|
||||
if (!match) return DEFAULT_MAX_SIZE;
|
||||
const num = parseInt(match[1], 10);
|
||||
const unit = (match[2] || "").toLowerCase();
|
||||
switch (unit) {
|
||||
case "k":
|
||||
case "kb":
|
||||
return num * 1024;
|
||||
case "m":
|
||||
case "mb":
|
||||
return num * 1024 * 1024;
|
||||
case "g":
|
||||
case "gb":
|
||||
return num * 1024 * 1024 * 1024;
|
||||
default:
|
||||
return num;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogConfig() {
|
||||
const logToFile = process.env.LOG_TO_FILE !== "false";
|
||||
const logFilePath = process.env.LOG_FILE_PATH || join(process.cwd(), DEFAULT_LOG_PATH);
|
||||
const maxFileSize = parseFileSize(process.env.LOG_MAX_FILE_SIZE);
|
||||
const retentionDays = parseInt(
|
||||
process.env.LOG_RETENTION_DAYS || String(DEFAULT_RETENTION_DAYS),
|
||||
10
|
||||
);
|
||||
|
||||
return { logToFile, logFilePath, maxFileSize, retentionDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the log directory exists.
|
||||
*/
|
||||
export function ensureLogDir(logFilePath: string): void {
|
||||
const dir = dirname(logFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the log file if it exceeds the max size.
|
||||
* Renames current file to app.YYYY-MM-DD_HHmmss.log
|
||||
*/
|
||||
export function rotateIfNeeded(logFilePath: string, maxFileSize: number): void {
|
||||
try {
|
||||
if (!existsSync(logFilePath)) return;
|
||||
const stats = statSync(logFilePath);
|
||||
if (stats.size < maxFileSize) return;
|
||||
|
||||
const dir = dirname(logFilePath);
|
||||
const ext = extname(logFilePath);
|
||||
const base = basename(logFilePath, ext);
|
||||
const now = new Date();
|
||||
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
now.getDate()
|
||||
).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(
|
||||
now.getMinutes()
|
||||
).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
|
||||
const rotatedPath = join(dir, `${base}.${ts}${ext}`);
|
||||
renameSync(logFilePath, rotatedPath);
|
||||
} catch {
|
||||
// If rotation fails, continue writing to the same file
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove log files older than the retention period.
|
||||
*/
|
||||
export function cleanupOldLogs(logFilePath: string, retentionDays: number): void {
|
||||
try {
|
||||
const dir = dirname(logFilePath);
|
||||
if (!existsSync(dir)) return;
|
||||
|
||||
const ext = extname(logFilePath);
|
||||
const base = basename(logFilePath, ext);
|
||||
const files = readdirSync(dir);
|
||||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const file of files) {
|
||||
// Match rotated files like app.2026-02-19_030000.log
|
||||
if (file.startsWith(base + ".") && file.endsWith(ext) && file !== basename(logFilePath)) {
|
||||
const filePath = join(dir, file);
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cleanup is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize log rotation — call once at application startup.
|
||||
* Creates directories, rotates if needed, and cleans up old files.
|
||||
*/
|
||||
export function initLogRotation(): void {
|
||||
const config = getLogConfig();
|
||||
if (!config.logToFile) return;
|
||||
|
||||
ensureLogDir(config.logFilePath);
|
||||
rotateIfNeeded(config.logFilePath, config.maxFileSize);
|
||||
cleanupOldLogs(config.logFilePath, config.retentionDays);
|
||||
}
|
||||
Reference in New Issue
Block a user