All files / src/cron rotateLogs.js

95.55% Statements 43/45
92.3% Branches 12/13
100% Functions 4/4
97.67% Lines 42/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104                    1x 1x 1x   1x 1x           1x       8x 8x 8x 8x         7x             8x     8x           8x 1x         8x 4x     604x     4x 4x 4x 4x 4x 4x   4x     4x 4x 604x   4x   4x       8x       8x 8x   8x 2x 2x 2x 2x     8x 1x 1x       1x  
/**
 * Cron: Rotate old logs from Firestore to R2.
 *
 * 1. Reads retentionHours from Firestore logConfig/settings (default 48).
 * 2. Queries logs older than the retention cutoff (limit 500).
 * 3. Archives them as NDJSON to R2.
 * 4. Batch-deletes them from Firestore.
 * 5. Prunes R2 log files older than 90 days.
 */
 
const { db } = require('../utils/firebase');
const r2 = require('../utils/r2');
const log = require('../utils/log');
 
const DEFAULT_RETENTION_HOURS = 48;
const PRUNE_DAYS = 90;
 
// Per-tick page size for the logs query. Pattern matches expireBans.js +
// expireDataExports.js + subscriptions.js: a hard cap protects Spark-tier
// quota, and we surface a truncation warning when the page is full so an
// operator can see backlog growth before the next tick eats it.
const CRON_LIMIT = 500;
 
async function rotateLogs() {
  // 1. Read config
  let retentionHours = DEFAULT_RETENTION_HOURS;
  try {
    const configDoc = await db.collection('logConfig').doc('settings').get();
    if (
      configDoc.exists &&
      configDoc.data().retentionHours !== null &&
      configDoc.data().retentionHours !== undefined
    ) {
      retentionHours = configDoc.data().retentionHours;
    }
  } catch (err) {
    log.error('cron', 'rotateLogs: failed to read config, using default', { error: err.message });
  }
 
  // 2. Calculate cutoff
  const cutoff = new Date(Date.now() - retentionHours * 3600000).toISOString();
 
  // 3. Query expired logs
  const snapshot = await db
    .collection('logs')
    .where('timestamp', '<', cutoff)
    .orderBy('timestamp')
    .limit(CRON_LIMIT)
    .get();
  if (snapshot.size === CRON_LIMIT) {
    log.warn('cron', 'rotateLogs: hit CRON_LIMIT — possible truncation', {
      limit: CRON_LIMIT,
    });
  }
 
  if (!snapshot.empty) {
    const docs = snapshot.docs;
 
    // 3a. Build NDJSON
    const ndjson = docs.map((d) => JSON.stringify({ id: d.id, ...d.data() })).join('\n');
 
    // 3b. Write to R2
    const now = new Date();
    const yyyy = now.getUTCFullYear();
    const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(now.getUTCDate()).padStart(2, '0');
    const hh = String(now.getUTCHours()).padStart(2, '0');
    const key = `logs/${yyyy}/${mm}/${dd}/${hh}-${Date.now()}.ndjson`;
 
    await r2.putObject(key, ndjson, 'application/x-ndjson');
 
    // 4. Batch delete from Firestore (max 500 per batch)
    const batch = db.batch();
    for (const doc of docs) {
      batch.delete(doc.ref);
    }
    await batch.commit();
 
    log.info('cron', 'rotateLogs: archived logs', { count: docs.length, key });
  }
 
  // 5. Prune R2 logs older than 90 days
  await pruneOldLogs();
}
 
async function pruneOldLogs() {
  const keys = await r2.listObjects('logs/');
  const cutoffDate = new Date(Date.now() - PRUNE_DAYS * 24 * 3600000);
 
  const toDelete = keys.filter((key) => {
    const match = key.match(/^logs\/(\d{4})\/(\d{2})\/(\d{2})\//);
    Iif (!match) return false;
    const keyDate = new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
    return keyDate < cutoffDate;
  });
 
  if (toDelete.length > 0) {
    await r2.deleteObjects(toDelete);
    log.info('cron', 'rotateLogs: pruned old log files', { count: toDelete.length });
  }
}
 
module.exports = rotateLogs;