src/dashboard/log-files.js
Rama master · réplica de lectura del repositorio original.
/** Optional disk limits for the application's dated JSONL logs. Single writer per directory. */
import { mkdir, readdir, lstat, open, unlink } from 'node:fs/promises';
import { constants } from 'node:fs';
import { join } from 'node:path';
const DAY_MS = 86_400_000;
const MB = 1024 * 1024;
const LOG_NAME = /^(app|error)-(\d{4}-\d{2}-\d{2})(?:-(\d{6,}))?\.jsonl$/;
export function readLogPolicy(env = process.env) {
const limit = (raw, scale = 1) => {
if (raw == null || String(raw).trim() === '') return 0;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 0 && Number.isSafeInteger(value * scale)
? value * scale : 0;
};
return {
retentionDays: limit(env.LOG_RETENTION_DAYS),
maxFileBytes: limit(env.LOG_MAX_FILE_MB, MB),
maxTotalBytes: limit(env.LOG_MAX_TOTAL_MB, MB),
};
}
function ownedLog(name) {
const match = LOG_NAME.exec(name);
if (!match) return null;
const time = Date.parse(`${match[2]}T00:00:00.000Z`);
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== match[2]) return null;
const sequence = Number(match[3] || 0);
if (!Number.isSafeInteger(sequence)) return null;
return { name, kind: match[1], day: match[2], time, sequence };
}
/** Writes and cleanup share one queue, so a file is never removed during our append. */
export function createLogFileWriter({ dir, retentionDays = 0, maxFileBytes = 0,
maxTotalBytes = 0, now = Date.now, onError = () => {}, unlinkFile = unlink }) {
let pending = Promise.resolve();
let initialized = false;
let totalBytes = 0;
let retentionMidnight = null;
const files = new Map();
const current = new Map();
const sequences = new Map();
function enqueue(operation) {
pending = pending.then(operation).catch(error => {
// Re-read sizes after any partial write or failed cleanup before trying again.
initialized = false;
try { onError(error); } catch {}
});
return pending;
}
async function refresh() {
initialized = false;
await mkdir(dir, { recursive: true });
const found = new Map();
let bytes = 0;
for (const entry of await readdir(dir, { withFileTypes: true })) {
const file = ownedLog(entry.name);
if (!file || !entry.isFile()) continue; // Never follow links or recurse into subdirectories.
const stat = await lstat(join(dir, file.name));
if (!stat.isFile()) continue;
found.set(file.name, { ...file, size: stat.size });
bytes += stat.size;
const key = `${file.kind}-${file.day}`;
sequences.set(key, Math.max(sequences.get(key) || 0, file.sequence));
}
files.clear();
for (const [name, file] of found) files.set(name, file);
const today = new Date(now()).toISOString().slice(0, 10);
for (const key of sequences.keys()) {
if (!key.endsWith(`-${today}`)) sequences.delete(key);
}
totalBytes = bytes;
retentionMidnight = null;
initialized = true;
}
async function remove(file) {
try {
const stat = await lstat(join(dir, file.name));
if (!stat.isFile()) throw new Error('log path is no longer a regular file');
await unlinkFile(join(dir, file.name));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
totalBytes -= file.size;
files.delete(file.name);
}
function oldestFirst() {
return [...files.values()].sort((a, b) => a.time - b.time || a.sequence - b.sequence
|| a.name.localeCompare(b.name));
}
async function prune(requiredBytes = 0) {
const midnight = Math.floor(now() / DAY_MS) * DAY_MS;
const checkRetention = retentionDays > 0 && retentionMidnight !== midnight;
if (!checkRetention && !(maxTotalBytes > 0 && totalBytes + requiredBytes > maxTotalBytes)) return;
// N days includes today's UTC file and the previous N-1 calendar days.
let failed = false;
for (const file of oldestFirst()) {
const expired = checkRetention && file.time < midnight - (retentionDays - 1) * DAY_MS;
const overBudget = maxTotalBytes > 0 && totalBytes + requiredBytes > maxTotalBytes;
if (!expired && !overBudget) continue;
try {
await remove(file);
} catch (error) {
// Windows sharing violations / immutable files must not stop the log:
// capacity degrades, but appends keep flowing. Retry on the next pass.
failed = true;
try { onError(error); } catch {}
}
}
retentionMidnight = failed ? null : midnight;
}
async function append(kind, line) {
if (!initialized) await refresh();
const size = Buffer.byteLength(line);
if ((maxFileBytes > 0 && size > maxFileBytes) || (maxTotalBytes > 0 && size > maxTotalBytes)) {
throw new Error('log entry exceeds disk limit'); // Keep JSONL intact; memory/SSE/console still receive it.
}
await prune(size);
const day = new Date(now()).toISOString().slice(0, 10);
const key = `${kind}-${day}`;
let file = files.get(current.get(kind));
if (!file || file.day !== day) {
file = [...files.values()].filter(f => f.kind === kind && f.day === day)
.sort((a, b) => b.sequence - a.sequence)[0];
}
if (!file || (maxFileBytes > 0 && file.size + size > maxFileBytes)) {
const sequence = file || sequences.has(key) ? (sequences.get(key) || 0) + 1 : 0;
sequences.set(key, sequence);
const suffix = sequence ? `-${String(sequence).padStart(6, '0')}` : '';
file = { name: `${key}${suffix}.jsonl`, kind, day, time: Date.parse(`${day}T00:00:00Z`), sequence, size: 0 };
}
const path = join(dir, file.name);
// An existing symlink/directory with an owned filename is not permission to
// overwrite it. Open first and fstat the fd so a path swap between the check
// and the write cannot redirect the append; O_NOFOLLOW closes the same race
// on POSIX (Windows has no O_NOFOLLOW, so the pre-open lstat remains the
// best-effort guard there).
try {
const stat = await lstat(path);
if (!stat.isFile()) throw new Error('log path is not a regular file');
} catch (error) { if (error.code !== 'ENOENT') throw error; }
const handle = await open(path,
constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW || 0), 0o600);
try {
if (!(await handle.stat()).isFile()) throw new Error('log path is not a regular file');
await handle.write(line);
} finally {
await handle.close();
}
file.size += size;
totalBytes += size;
files.set(file.name, file);
current.set(kind, file.name);
}
function cleanup() {
return enqueue(async () => { await refresh(); await prune(); });
}
cleanup(); // Clean an existing installation even before the first new log entry.
const timer = setInterval(cleanup, 60 * 60 * 1000);
timer.unref?.();
return {
write(level, line) {
return enqueue(async () => {
await append('app', line);
if (level === 'warn' || level === 'error') await append('error', line);
});
},
cleanup,
flush: () => pending,
async close() { clearInterval(timer); await pending; },
};
}