307 lines
9.3 KiB
TypeScript
307 lines
9.3 KiB
TypeScript
/**
|
|
* Logger Utility
|
|
* Centralized logging with levels, file output, and log retrieval for UI.
|
|
*
|
|
* File writes use an async buffered writer so that high-frequency logging
|
|
* (e.g. during gateway startup) never blocks the Electron main thread.
|
|
* Only the final `process.on('exit')` handler uses synchronous I/O to
|
|
* guarantee the last few messages are flushed before the process exits.
|
|
*/
|
|
import { app } from 'electron';
|
|
import { join } from 'path';
|
|
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
import { appendFile, open, readdir, stat } from 'fs/promises';
|
|
|
|
/**
|
|
* Log levels
|
|
*/
|
|
export enum LogLevel {
|
|
DEBUG = 0,
|
|
INFO = 1,
|
|
WARN = 2,
|
|
ERROR = 3,
|
|
}
|
|
|
|
/**
|
|
* Current log level (can be changed at runtime)
|
|
*/
|
|
// Default to INFO in packaged builds to reduce sync-like overhead from
|
|
// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics.
|
|
// Note: app.isPackaged may not be available before app.isReady(), but the
|
|
// logger is initialised after that point so this is safe.
|
|
let currentLevel = LogLevel.DEBUG;
|
|
|
|
/**
|
|
* Log file path
|
|
*/
|
|
let logFilePath: string | null = null;
|
|
let logDir: string | null = null;
|
|
|
|
/**
|
|
* In-memory ring buffer for recent logs (useful for UI display)
|
|
*/
|
|
const RING_BUFFER_SIZE = 500;
|
|
const recentLogs: string[] = [];
|
|
|
|
// ── Async write buffer ───────────────────────────────────────────
|
|
|
|
/** Pending log lines waiting to be flushed to disk. */
|
|
let writeBuffer: string[] = [];
|
|
/** Timer for the next scheduled flush. */
|
|
let flushTimer: NodeJS.Timeout | null = null;
|
|
/** Whether a flush is currently in progress. */
|
|
let flushing = false;
|
|
|
|
const FLUSH_INTERVAL_MS = 500;
|
|
const FLUSH_SIZE_THRESHOLD = 20;
|
|
|
|
async function flushBuffer(): Promise<void> {
|
|
if (flushing || writeBuffer.length === 0 || !logFilePath) return;
|
|
flushing = true;
|
|
const batch = writeBuffer.join('');
|
|
writeBuffer = [];
|
|
try {
|
|
await appendFile(logFilePath, batch);
|
|
} catch {
|
|
// Silently fail if we can't write to file
|
|
} finally {
|
|
flushing = false;
|
|
}
|
|
}
|
|
|
|
/** Synchronous flush for the `exit` handler — guaranteed to write. */
|
|
function flushBufferSync(): void {
|
|
if (writeBuffer.length === 0 || !logFilePath) return;
|
|
try {
|
|
appendFileSync(logFilePath, writeBuffer.join(''));
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
writeBuffer = [];
|
|
}
|
|
|
|
// Ensure all buffered data reaches disk before the process exits.
|
|
process.on('exit', flushBufferSync);
|
|
|
|
// ── Initialisation ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Initialize logger — safe to call before app.isReady()
|
|
*/
|
|
export function initLogger(): void {
|
|
try {
|
|
// In production, default to INFO to reduce log volume and overhead.
|
|
if (app.isPackaged && currentLevel < LogLevel.INFO) {
|
|
currentLevel = LogLevel.INFO;
|
|
}
|
|
|
|
logDir = join(app.getPath('userData'), 'logs');
|
|
|
|
if (!existsSync(logDir)) {
|
|
mkdirSync(logDir, { recursive: true });
|
|
}
|
|
|
|
const timestamp = new Date().toISOString().split('T')[0];
|
|
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
|
|
|
// Write a separator for new session (sync is OK — happens once at startup)
|
|
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
|
|
appendFileSync(logFilePath, sessionHeader);
|
|
} catch (error) {
|
|
console.error('Failed to initialize logger:', error);
|
|
}
|
|
}
|
|
|
|
// ── Level / path accessors ───────────────────────────────────────
|
|
|
|
export function setLogLevel(level: LogLevel): void {
|
|
currentLevel = level;
|
|
}
|
|
|
|
export function getLogDir(): string | null {
|
|
return logDir;
|
|
}
|
|
|
|
export function getLogFilePath(): string | null {
|
|
return logFilePath;
|
|
}
|
|
|
|
// ── Formatting ───────────────────────────────────────────────────
|
|
|
|
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
|
const timestamp = new Date().toISOString();
|
|
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
|
|
if (arg instanceof Error) {
|
|
return `${arg.message}\n${arg.stack || ''}`;
|
|
}
|
|
if (typeof arg === 'object') {
|
|
try {
|
|
return JSON.stringify(arg, null, 2);
|
|
} catch {
|
|
return String(arg);
|
|
}
|
|
}
|
|
return String(arg);
|
|
}).join(' ') : '';
|
|
|
|
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
|
|
}
|
|
|
|
// ── Core write ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Write to ring buffer + schedule an async flush to disk.
|
|
*/
|
|
function writeLog(formatted: string): void {
|
|
// Ring buffer (always synchronous — in-memory only)
|
|
recentLogs.push(formatted);
|
|
if (recentLogs.length > RING_BUFFER_SIZE) {
|
|
recentLogs.shift();
|
|
}
|
|
|
|
// Async file write via buffer
|
|
if (logFilePath) {
|
|
writeBuffer.push(formatted + '\n');
|
|
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
|
|
// Buffer is large enough — flush immediately (non-blocking)
|
|
void flushBuffer();
|
|
} else if (!flushTimer) {
|
|
// Schedule a flush after a short delay
|
|
flushTimer = setTimeout(() => {
|
|
flushTimer = null;
|
|
void flushBuffer();
|
|
}, FLUSH_INTERVAL_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Public log methods ───────────────────────────────────────────
|
|
|
|
export function debug(message: string, ...args: unknown[]): void {
|
|
if (currentLevel <= LogLevel.DEBUG) {
|
|
const formatted = formatMessage('DEBUG', message, ...args);
|
|
console.debug(formatted);
|
|
writeLog(formatted);
|
|
}
|
|
}
|
|
|
|
export function info(message: string, ...args: unknown[]): void {
|
|
if (currentLevel <= LogLevel.INFO) {
|
|
const formatted = formatMessage('INFO', message, ...args);
|
|
console.info(formatted);
|
|
writeLog(formatted);
|
|
}
|
|
}
|
|
|
|
export function warn(message: string, ...args: unknown[]): void {
|
|
if (currentLevel <= LogLevel.WARN) {
|
|
const formatted = formatMessage('WARN', message, ...args);
|
|
console.warn(formatted);
|
|
writeLog(formatted);
|
|
}
|
|
}
|
|
|
|
export function error(message: string, ...args: unknown[]): void {
|
|
if (currentLevel <= LogLevel.ERROR) {
|
|
const formatted = formatMessage('ERROR', message, ...args);
|
|
console.error(formatted);
|
|
writeLog(formatted);
|
|
}
|
|
}
|
|
|
|
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
|
|
|
|
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
|
const filtered = minLevel != null
|
|
? recentLogs.filter(line => {
|
|
if (minLevel <= LogLevel.DEBUG) return true;
|
|
if (minLevel === LogLevel.INFO) return !line.includes('] [DEBUG');
|
|
if (minLevel === LogLevel.WARN) return line.includes('] [WARN') || line.includes('] [ERROR');
|
|
return line.includes('] [ERROR');
|
|
})
|
|
: recentLogs;
|
|
|
|
return count ? filtered.slice(-count) : [...filtered];
|
|
}
|
|
|
|
/**
|
|
* Read the current day's log file content (last N lines).
|
|
* Uses async I/O to avoid blocking.
|
|
*/
|
|
export async function readLogFile(tailLines = 200): Promise<string> {
|
|
if (!logFilePath) return '(No log file found)';
|
|
const safeTailLines = Math.max(1, Math.floor(tailLines));
|
|
try {
|
|
const file = await open(logFilePath, 'r');
|
|
try {
|
|
const fileStat = await file.stat();
|
|
if (fileStat.size === 0) return '';
|
|
|
|
const chunkSize = 64 * 1024;
|
|
let position = fileStat.size;
|
|
let content = '';
|
|
let lineCount = 0;
|
|
|
|
while (position > 0 && lineCount <= safeTailLines) {
|
|
const bytesToRead = Math.min(chunkSize, position);
|
|
position -= bytesToRead;
|
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
await file.read(buffer, 0, bytesToRead, position);
|
|
content = `${buffer.toString('utf-8')}${content}`;
|
|
lineCount = content.split('\n').length - 1;
|
|
}
|
|
|
|
const lines = content.split('\n');
|
|
if (lines.length <= safeTailLines) return content;
|
|
return lines.slice(-safeTailLines).join('\n');
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
} catch (err) {
|
|
return `(Failed to read log file: ${err})`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List available log files.
|
|
* Uses async I/O to avoid blocking.
|
|
*/
|
|
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
|
|
if (!logDir) return [];
|
|
try {
|
|
const files = await readdir(logDir);
|
|
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
|
|
for (const f of files) {
|
|
if (!f.endsWith('.log')) continue;
|
|
const fullPath = join(logDir, f);
|
|
const s = await stat(fullPath);
|
|
results.push({
|
|
name: f,
|
|
path: fullPath,
|
|
size: s.size,
|
|
modified: s.mtime.toISOString(),
|
|
});
|
|
}
|
|
return results.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logger namespace export
|
|
*/
|
|
export const logger = {
|
|
debug,
|
|
info,
|
|
warn,
|
|
error,
|
|
setLevel: setLogLevel,
|
|
init: initLogger,
|
|
getLogDir,
|
|
getLogFilePath,
|
|
getRecentLogs,
|
|
readLogFile,
|
|
listLogFiles,
|
|
};
|