import { Clock } from "@mcpx/toolkit-core/time";
import fs from "fs";
import { DateTime } from "luxon";
import path from "path";
import { Logger } from "winston";
import { LOG_FLAGS } from "../../log-flags.js";
import { AuditLog } from "../../model/audit-log-type.js";
export interface AuditLogPersistence {
persist(events: AuditLog[]): Promise<void>;
cleanup(): Promise<void>;
}
export class FileAuditLogPersistence implements AuditLogPersistence {
constructor(
private readonly auditLogDir: string,
private readonly retentionHours: number,
private readonly clock: Clock,
private readonly logger: Logger,
) {
this.ensureDirectoryExists();
}
async persist(events: AuditLog[]): Promise<void> {
if (events.length === 0) {
return;
}
// Clean up old files before persisting new events
await this.cleanup();
// Group events by hour for efficient batching
const eventsByHour = new Map<string, string[]>();
const persistTime = this.clock.now();
// Pre-serialize all events to avoid repeated JSON.stringify calls
for (const event of events) {
event.createdAt = persistTime;
const hourKey = this.getHourKey(event.timestamp);
const currentHourEvents = eventsByHour.get(hourKey) || [];
currentHourEvents.push(JSON.stringify(event));
eventsByHour.set(hourKey, currentHourEvents);
}
// Write each hour group efficiently
const writePromises = Array.from(eventsByHour.entries()).map(
async ([hourKey, jsonLines]) => {
const filename = this.getFilenameForHour(hourKey);
const filepath = path.join(this.auditLogDir, filename);
try {
await fs.promises.access(filepath).catch(async () => {
await fs.promises.writeFile(filepath, "");
});
// Single write operation with all lines concatenated
const content = jsonLines.join("\n") + "\n";
await fs.promises.appendFile(filepath, content);
if (LOG_FLAGS.LOG_AUDIT_LOG_PERSISTENCE) {
this.logger.debug(
`Persisted ${jsonLines.length} events to ${filename}`,
);
}
} catch (error) {
this.logger.error(`Failed to persist events to ${filename}`, {
error,
});
throw error;
}
},
);
// Wait for all writes to complete
await Promise.all(writePromises);
}
async cleanup(): Promise<void> {
try {
const cutoffTime = DateTime.fromJSDate(this.clock.now())
.minus({ hours: this.retentionHours })
.toJSDate();
const files = await this.getAuditLogFiles();
let deletedCount = 0;
for (const file of files) {
const fileTime = this.parseTimeFromFilename(file);
if (!fileTime) continue;
if (fileTime < cutoffTime) {
try {
await fs.promises.unlink(path.join(this.auditLogDir, file));
deletedCount++;
this.logger.debug(`Deleted old audit log file: ${file}`);
} catch (error) {
this.logger.warn(`Failed to delete old audit log file: ${file}`, {
error,
});
}
}
}
if (deletedCount > 0) {
this.logger.info(`Cleaned up ${deletedCount} old audit log files`);
}
} catch (error) {
this.logger.error("Error during audit log cleanup", { error });
}
}
private async ensureDirectoryExists(): Promise<void> {
try {
await fs.promises.access(this.auditLogDir);
} catch {
await fs.promises.mkdir(this.auditLogDir, { recursive: true });
this.logger.debug(`Created audit log directory: ${this.auditLogDir}`);
}
}
private getHourKey(timestamp: Date): string {
return timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}
private getFilenameForHour(hourKey: string): string {
return `audit-${hourKey}.jsonl`;
}
private async getAuditLogFiles(): Promise<string[]> {
try {
return await fs.promises.readdir(this.auditLogDir);
} catch (error) {
this.logger.warn("Failed to read audit log directory", { error });
return [];
}
}
private parseTimeFromFilename(filename: string): Date | null {
try {
// Extract time from filename like "audit-2025-07-25T13.jsonl"
const match = filename.match(/audit-(\d{4}-\d{2}-\d{2}T\d{2})\.jsonl/);
if (match) {
return new Date(match[1] + ":00:00.000Z");
}
} catch (error) {
this.logger.warn(`Failed to parse time from filename: ${filename}`, {
error,
});
}
return null;
}
}