← back to Rentv 826 Tracker

lib/sessionize.js

78 lines

'use strict';

// Sessionization: given an ordered stream of authenticated /826/ hits, group them
// into sessions per (client, remote_user, ip). A new session starts when the gap
// since the last hit exceeds SESSION_GAP. Duration = last_seen - started (dwell on
// the final page of a session is unknowable from logs alone — accepted approximation).

// Decide whether a parsed hit is in-scope for a client's time-on-server tracking.
function isTrackable(hit, clientSlug) {
  if (!hit || !hit.path) return false;
  // Scope to the /826/ area only.
  if (!(hit.path === '/826' || hit.path.startsWith('/826/'))) return false;
  // Only authenticated traffic counts as real client time (skips 401 pre-auth noise + bots).
  if (!hit.remote_user) return false;
  return true;
}

async function openSession(pool, clientSlug, h) {
  const { rows } = await pool.query(
    `INSERT INTO sessions (client_slug, remote_user, ip, user_agent, started_at, last_seen_at, hit_count, duration_seconds, is_open)
     VALUES ($1,$2,$3,$4,$5,$5,1,0,true)
     RETURNING id`,
    [clientSlug, h.remote_user, h.ip, h.ua, new Date(h.ts)]
  );
  return rows[0].id;
}

// Ingest one trackable hit. gapMs = inactivity threshold. Processes hits in time order.
async function ingestHit(pool, clientSlug, gapMs, h) {
  const { rows } = await pool.query(
    `SELECT id, started_at, last_seen_at FROM sessions
     WHERE client_slug=$1 AND is_open
       AND coalesce(remote_user,'')=coalesce($2,'') AND ip=$3
     ORDER BY last_seen_at DESC LIMIT 1`,
    [clientSlug, h.remote_user, h.ip]
  );

  let sessionId;
  if (rows.length) {
    const s = rows[0];
    const lastSeen = new Date(s.last_seen_at).getTime();
    const started = new Date(s.started_at).getTime();
    const withinGap = h.ts - lastSeen <= gapMs && h.ts >= lastSeen;
    if (withinGap) {
      const dur = Math.max(0, Math.round((h.ts - started) / 1000));
      await pool.query(
        `UPDATE sessions SET last_seen_at=$1, hit_count=hit_count+1, duration_seconds=$2 WHERE id=$3`,
        [new Date(h.ts), dur, s.id]
      );
      sessionId = s.id;
    } else {
      await pool.query(`UPDATE sessions SET is_open=false WHERE id=$1`, [s.id]);
      sessionId = await openSession(pool, clientSlug, h);
    }
  } else {
    sessionId = await openSession(pool, clientSlug, h);
  }

  await pool.query(
    `INSERT INTO page_hits (session_id, ts, method, path, status, bytes, referer, user_agent)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
    [sessionId, new Date(h.ts), h.method, h.path, h.status, h.bytes, h.referer, h.ua]
  );
  return sessionId;
}

// Mark sessions with no activity for > gapMs as closed (housekeeping for live mode).
async function closeStale(pool, clientSlug, gapMs) {
  await pool.query(
    `UPDATE sessions SET is_open=false
     WHERE client_slug=$1 AND is_open
       AND last_seen_at < now() - ($2::int * interval '1 millisecond')`,
    [clientSlug, gapMs]
  );
}

module.exports = { isTrackable, ingestHit, closeStale };