Files
tspm/ts/daemon/crashlogmanager.ts

265 lines
8.1 KiB
TypeScript

import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { IProcessLog } from '../shared/protocol/ipc.types.js';
import type { ProcessId } from '../shared/protocol/id.js';
/**
* Manages crash log storage for failed processes
*/
export class CrashLogManager {
private crashLogsDir: string;
private readonly MAX_CRASH_LOGS = 100;
private readonly MAX_LOG_SIZE_BYTES = 1024 * 1024; // 1MB
constructor() {
this.crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs');
}
/**
* Save a crash log for a failed process
*/
public async saveCrashLog(
processId: ProcessId,
processName: string,
logs: IProcessLog[],
exitCode: number | null,
signal: string | null,
restartCount: number,
memoryUsage?: number
): Promise<void> {
try {
// Ensure directory exists
await this.ensureCrashLogsDir();
// Generate filename with timestamp
const timestamp = new Date();
const dateStr = this.formatDate(timestamp);
const sanitizedName = this.sanitizeFilename(processName);
const filename = `${dateStr}_${processId}_${sanitizedName}.log`;
const filepath = plugins.path.join(this.crashLogsDir, filename);
// Get recent logs that fit within size limit
const recentLogs = this.getRecentLogs(logs, this.MAX_LOG_SIZE_BYTES);
// Create crash report
const crashReport = this.formatCrashReport({
processId,
processName,
timestamp,
exitCode,
signal,
restartCount,
memoryUsage,
logs: recentLogs
});
// Write crash log
await plugins.smartfile.memory.toFs(crashReport, filepath);
// Rotate old logs if needed
await this.rotateOldLogs();
console.log(`Crash log saved: ${filename}`);
} catch (error) {
console.error(`Failed to save crash log for process ${processId}:`, error);
}
}
/**
* Format date for filename: YYYY-MM-DD_HH-mm-ss
*/
private formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`;
}
/**
* Sanitize process name for use in filename
*/
private sanitizeFilename(name: string): string {
// Replace problematic characters with underscore
return name
.replace(/[^a-zA-Z0-9-_]/g, '_')
.replace(/_+/g, '_')
.substring(0, 50); // Limit length
}
/**
* Get recent logs that fit within the size limit
*/
private getRecentLogs(logs: IProcessLog[], maxBytes: number): IProcessLog[] {
if (logs.length === 0) return [];
// Start from the end and work backwards
const recentLogs: IProcessLog[] = [];
let currentSize = 0;
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
const logSize = this.estimateLogSize(log);
if (currentSize + logSize > maxBytes && recentLogs.length > 0) {
// Would exceed limit, stop adding
break;
}
recentLogs.unshift(log);
currentSize += logSize;
}
return recentLogs;
}
/**
* Estimate size of a log entry in bytes
*/
private estimateLogSize(log: IProcessLog): number {
// Format: [timestamp] [type] message\n
const formatted = `[${new Date(log.timestamp).toISOString()}] [${log.type}] ${log.message}\n`;
return Buffer.byteLength(formatted, 'utf8');
}
/**
* Format a crash report with metadata and logs
*/
private formatCrashReport(data: {
processId: ProcessId;
processName: string;
timestamp: Date;
exitCode: number | null;
signal: string | null;
restartCount: number;
memoryUsage?: number;
logs: IProcessLog[];
}): string {
const lines: string[] = [
'================================================================================',
'TSPM CRASH REPORT',
'================================================================================',
`Process: ${data.processName} (ID: ${data.processId})`,
`Date: ${data.timestamp.toISOString()}`,
`Exit Code: ${data.exitCode ?? 'N/A'}`,
`Signal: ${data.signal ?? 'N/A'}`,
`Restart Attempt: ${data.restartCount}/10`,
];
if (data.memoryUsage !== undefined && data.memoryUsage > 0) {
lines.push(`Memory Usage: ${this.humanReadableBytes(data.memoryUsage)}`);
}
lines.push(
'================================================================================',
'',
`LAST ${data.logs.length} LOG ENTRIES:`,
'--------------------------------------------------------------------------------',
''
);
// Add log entries
for (const log of data.logs) {
const timestamp = new Date(log.timestamp).toISOString();
const type = log.type.toUpperCase().padEnd(6);
lines.push(`[${timestamp}] [${type}] ${log.message}`);
}
lines.push(
'',
'================================================================================',
'END OF CRASH REPORT',
'================================================================================',
''
);
return lines.join('\n');
}
/**
* Convert bytes to human-readable format
*/
private humanReadableBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Ensure crash logs directory exists
*/
private async ensureCrashLogsDir(): Promise<void> {
await plugins.smartfile.fs.ensureDir(this.crashLogsDir);
}
/**
* Rotate old crash logs when exceeding max count
*/
private async rotateOldLogs(): Promise<void> {
try {
// Get all crash log files
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log');
if (files.length <= this.MAX_CRASH_LOGS) {
return; // No rotation needed
}
// Get file stats and sort by modification time (oldest first)
const fileStats = await Promise.all(
files.map(async (file) => {
const filepath = plugins.path.join(this.crashLogsDir, file);
const stats = await plugins.smartfile.fs.stat(filepath);
return { filepath, mtime: stats.mtime.getTime() };
})
);
fileStats.sort((a, b) => a.mtime - b.mtime);
// Delete oldest files to stay under limit
const filesToDelete = fileStats.length - this.MAX_CRASH_LOGS;
for (let i = 0; i < filesToDelete; i++) {
await plugins.smartfile.fs.remove(fileStats[i].filepath);
console.log(`Rotated old crash log: ${plugins.path.basename(fileStats[i].filepath)}`);
}
} catch (error) {
console.error('Failed to rotate crash logs:', error);
}
}
/**
* Get list of crash logs for a specific process
*/
public async getCrashLogsForProcess(processId: ProcessId): Promise<string[]> {
try {
await this.ensureCrashLogsDir();
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, `*_${processId}_*.log`);
return files.map(file => plugins.path.join(this.crashLogsDir, file));
} catch (error) {
console.error(`Failed to get crash logs for process ${processId}:`, error);
return [];
}
}
/**
* Clean up all crash logs (for maintenance)
*/
public async cleanupAllCrashLogs(): Promise<void> {
try {
await this.ensureCrashLogsDir();
const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log');
for (const file of files) {
const filepath = plugins.path.join(this.crashLogsDir, file);
await plugins.smartfile.fs.remove(filepath);
}
console.log(`Cleaned up ${files.length} crash logs`);
} catch (error) {
console.error('Failed to cleanup crash logs:', error);
}
}
}