Ctrl K
ComunidadGuías…

src/dashboard/log-files.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/** 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; },
  };
}