← back to Rentv 826 Tracker

tracker.js

166 lines

'use strict';

// rentv-826-tracker — sessionizes the nginx access log for the /826/ client area
// into the per-client Postgres DB (rentv_826). Server-side only; no client-page changes.
//
//   node tracker.js                 -> one-time backfill (if needed) then live-tail LIVE_LOG
//   node tracker.js --backfill-only -> just backfill BACKFILL_LOG, then exit
//
// Env (see .env): DATABASE_URL, LIVE_LOG, BACKFILL_LOG, CLIENT_SLUG, SESSION_GAP_MIN

const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { Pool } = require('pg');
const { parseLine } = require('./lib/parse');
const { isTrackable, ingestHit, closeStale } = require('./lib/sessionize');
const { writeDashboard } = require('./lib/dashboard');

// --- config -------------------------------------------------------------
function loadEnv() {
  const envPath = path.join(__dirname, '.env');
  if (fs.existsSync(envPath)) {
    for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
      const line = raw.trim();
      if (!line || line.startsWith('#')) continue;
      const i = line.indexOf('=');
      if (i === -1) continue;
      const k = line.slice(0, i).trim();
      if (!(k in process.env)) process.env[k] = line.slice(i + 1).trim();
    }
  }
}
loadEnv();

const CLIENT_SLUG = process.env.CLIENT_SLUG || 'boomer';
const GAP_MS = (parseInt(process.env.SESSION_GAP_MIN, 10) || 30) * 60 * 1000;
const LIVE_LOG = process.env.LIVE_LOG || '/var/log/nginx/rentv-826.access.log';
const BACKFILL_LOG = process.env.BACKFILL_LOG || '/var/log/nginx/rentv.access.log';
const POLL_MS = 3000;
const STATS_HTML = process.env.STATS_HTML || ''; // e.g. /var/www/rentv-826/stats.html; empty = disabled
const backfillOnly = process.argv.includes('--backfill-only');

async function refreshDashboard() {
  if (!STATS_HTML) return;
  try { await writeDashboard(pool, CLIENT_SLUG, STATS_HTML, fs); }
  catch (e) { log('dashboard write error:', e.message); }
}

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

function log(...a) { console.log(new Date().toISOString(), ...a); }

// --- ingest-state helpers ----------------------------------------------
async function getState(file) {
  const { rows } = await pool.query(`SELECT inode, byte_offset FROM ingest_state WHERE log_file=$1`, [file]);
  return rows[0] || null;
}
async function setState(file, inode, offset) {
  await pool.query(
    `INSERT INTO ingest_state (log_file, inode, byte_offset, updated_at)
     VALUES ($1,$2,$3, now())
     ON CONFLICT (log_file) DO UPDATE SET inode=EXCLUDED.inode, byte_offset=EXCLUDED.byte_offset, updated_at=now()`,
    [file, inode, offset]
  );
}

// Read [start,end) of a file, process complete lines, return the offset up to the last newline.
async function processRange(file, start, end) {
  if (end <= start) return start;
  let consumed = start;
  const stream = fs.createReadStream(file, { start, end: end - 1, encoding: 'utf8' });
  let buf = '';
  for await (const chunk of stream) {
    buf += chunk;
    let nl;
    while ((nl = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      consumed += Buffer.byteLength(line, 'utf8') + 1; // +1 for the '\n'
      const hit = parseLine(line);
      if (hit && isTrackable(hit, CLIENT_SLUG)) {
        try { await ingestHit(pool, CLIENT_SLUG, GAP_MS, hit); }
        catch (e) { log('ingest error:', e.message); }
      }
    }
  }
  // Any trailing partial line (no newline) is left for the next poll.
  return consumed;
}

// --- backfill -----------------------------------------------------------
async function backfill() {
  // Single-source mode: if the backfill log IS the live log, the offset-tracked live
  // tail reads it from byte 0 on first run — that initial pass IS the backfill. Running
  // a separate backfill here would double-count. So skip it.
  if (!BACKFILL_LOG || BACKFILL_LOG === LIVE_LOG) {
    log('single-source mode: first live-tail pass from offset 0 serves as backfill');
    return;
  }
  const marker = `BACKFILL:${BACKFILL_LOG}`;
  const done = await getState(marker);
  if (done) { log('backfill already done for', BACKFILL_LOG); return; }
  if (!fs.existsSync(BACKFILL_LOG)) { log('no backfill log at', BACKFILL_LOG); await setState(marker, 0, 0); return; }

  log('backfilling /826/ history from', BACKFILL_LOG, '...');
  const rl = readline.createInterface({ input: fs.createReadStream(BACKFILL_LOG, { encoding: 'utf8' }), crlfDelay: Infinity });
  let seen = 0, tracked = 0;
  for await (const line of rl) {
    seen++;
    const hit = parseLine(line);
    if (hit && isTrackable(hit, CLIENT_SLUG)) {
      tracked++;
      try { await ingestHit(pool, CLIENT_SLUG, GAP_MS, hit); }
      catch (e) { log('backfill ingest error:', e.message); }
    }
  }
  const sz = fs.statSync(BACKFILL_LOG).size;
  await setState(marker, 0, sz);
  log(`backfill complete: ${seen} lines scanned, ${tracked} /826/ authed hits ingested.`);
}

// --- live tail ----------------------------------------------------------
async function tailOnce() {
  let st;
  try { st = fs.statSync(LIVE_LOG); }
  catch { return; } // file not created yet (no /826/ traffic since nginx split)
  const state = await getState(LIVE_LOG);
  let start = 0;
  if (state) {
    if (Number(state.inode) === st.ino) start = Number(state.byte_offset);
    else start = 0; // rotation: inode changed, read from top of the new file
  }
  if (st.size < start) start = 0; // truncated
  if (st.size === start) return;
  const newOffset = await processRange(LIVE_LOG, start, st.size);
  await setState(LIVE_LOG, st.ino, newOffset);
}

async function liveLoop() {
  log('live-tailing', LIVE_LOG, `(client=${CLIENT_SLUG}, gap=${GAP_MS / 60000}min${STATS_HTML ? ', dashboard=' + STATS_HTML : ''})`);
  await refreshDashboard();
  let tick = 0;
  for (;;) {
    try {
      await tailOnce();
      if (++tick % 20 === 0) { await closeStale(pool, CLIENT_SLUG, GAP_MS); await refreshDashboard(); } // ~every 60s
    } catch (e) { log('tail error:', e.message); }
    await new Promise((r) => setTimeout(r, POLL_MS));
  }
}

// --- main ---------------------------------------------------------------
(async () => {
  try {
    await backfill();
    if (backfillOnly) { await pool.end(); return; }
    await liveLoop();
  } catch (e) {
    log('fatal:', e.message);
    process.exit(1);
  }
})();

process.on('SIGTERM', () => { log('SIGTERM, exiting'); pool.end().finally(() => process.exit(0)); });
process.on('SIGINT', () => { log('SIGINT, exiting'); pool.end().finally(() => process.exit(0)); });