[object Object]

← back to Rentv 826 Tracker

rentv-826-tracker: server-side time-on-server tracker for /826/ client area (Steve Bloom) into per-client DB rentv_826

ad03d7ca96be38caa25712250f61b8e289701d6a · 2026-08-10 12:10:10 -0700 · steve

Files touched

Diff

commit ad03d7ca96be38caa25712250f61b8e289701d6a
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 10 12:10:10 2026 -0700

    rentv-826-tracker: server-side time-on-server tracker for /826/ client area (Steve Bloom) into per-client DB rentv_826
---
 .env.example      |  10 ++++
 .gitignore        |  10 ++++
 README.md         |  38 ++++++++++++++
 lib/parse.js      |  51 +++++++++++++++++++
 lib/sessionize.js |  77 ++++++++++++++++++++++++++++
 package.json      |  15 ++++++
 report.js         |  68 +++++++++++++++++++++++++
 tracker.js        | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 418 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f4ef816
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Per-client DB (rentv_826) — least-privilege role, loopback scram-sha-256
+DATABASE_URL=postgres://rentv_826:CHANGEME@127.0.0.1:5432/rentv_826
+# nginx log the /826/ location writes to (live tail source)
+LIVE_LOG=/var/log/nginx/rentv-826.access.log
+# historical log to backfill /826/ hits from, one time (pre-split)
+BACKFILL_LOG=/var/log/nginx/rentv.access.log
+# which client (row in clients table) this tracker attributes time to
+CLIENT_SLUG=boomer
+# inactivity gap (minutes) that ends a session
+SESSION_GAP_MIN=30
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..661415e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+!.env.example
+*.log
+tmp/
+dist/
+build/
+.next/
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0f73d8e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# rentv-826-tracker
+
+Server-side **time-on-server** tracker for the `/826/` client area
+("RENTV — Steve Bloom Concepts") on **rentv.agentabrams.com**.
+
+`/826/` is a static nginx location (`alias /var/www/rentv-826/`) behind its own
+Basic-Auth realm (`/etc/nginx/.htpasswd-826`). This service reads the nginx access
+log, sessionizes it, and records every visit + accumulated time into the per-client
+Postgres DB **`rentv_826`**. **No changes are made to the client's pages** — tracking
+is 100% server-side, attributed to the Basic-Auth user (`$remote_user`, e.g. `boomer`).
+
+## Data model (`rentv_826`)
+- `clients` — one row per client (`boomer` → Steve Bloom `sbloom@rentv.com`). General
+  per-client DB: this is the home for the /826/ portal's client data, time-tracking first.
+- `sessions` — one row per visit session (start, last-seen, duration, hit count, login, ip).
+- `page_hits` — every request within a session.
+- `ingest_state` — resumable log offset/inode + a one-time backfill marker.
+
+## Sessionization
+A session = a run of authenticated `/826/` requests by the same `(login, ip)` with no
+gap longer than `SESSION_GAP_MIN` (default 30 min). `duration = last_seen − started`
+(dwell on a session's final page is unknowable from logs — accepted approximation).
+Only **authenticated** hits count (401 pre-auth noise and bots are ignored).
+
+## Run
+```
+npm install
+cp .env.example .env   # fill DATABASE_URL
+node tracker.js            # backfill (once) + live-tail
+node tracker.js --backfill-only
+node report.js [client]    # time-on-server report
+```
+Deployed on Kamatera under pm2 as `rentv-826-tracker`.
+
+## nginx
+The `location ^~ /826/` block writes its own `access_log /var/log/nginx/rentv-826.access.log`
+so live tailing is clean; historical `/826/` hits are backfilled once from the
+server-level `rentv.access.log`.
diff --git a/lib/parse.js b/lib/parse.js
new file mode 100644
index 0000000..d561465
--- /dev/null
+++ b/lib/parse.js
@@ -0,0 +1,51 @@
+'use strict';
+
+// Parser for nginx "combined" log format:
+//   $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"
+// e.g.  1.2.3.4 - boomer [10/Aug/2026:06:53:12 +0000] "GET /826/ HTTP/1.1" 200 1024 "https://ref" "Mozilla/5.0"
+
+const LINE_RE = /^(\S+) \S+ (\S+) \[([^\]]+)\] "([^"]*)" (\d{3}) (\S+) "([^"]*)" "([^"]*)"/;
+
+const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
+
+// "10/Aug/2026:06:53:12 +0000" -> epoch ms (UTC-correct via the offset)
+function parseNginxTime(s) {
+  const m = /^(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})$/.exec(s);
+  if (!m) return null;
+  const [, dd, mon, yyyy, hh, mi, ss, tz] = m;
+  const month = MONTHS[mon];
+  if (month === undefined) return null;
+  const offMin = (tz[0] === '-' ? -1 : 1) * (parseInt(tz.slice(1, 3), 10) * 60 + parseInt(tz.slice(3, 5), 10));
+  const asUTC = Date.UTC(+yyyy, month, +dd, +hh, +mi, +ss);
+  return asUTC - offMin * 60 * 1000;
+}
+
+// Returns a normalized hit object, or null if the line is unparseable.
+function parseLine(line) {
+  const m = LINE_RE.exec(line);
+  if (!m) return null;
+  const [, remote_addr, remote_user_raw, time_local, request, status, bytes_raw, referer, ua] = m;
+  const ts = parseNginxTime(time_local);
+  if (ts == null) return null;
+
+  const reqParts = request.split(' ');
+  const method = reqParts[0] || null;
+  const path = reqParts[1] || null;
+
+  const remote_user = remote_user_raw === '-' ? null : remote_user_raw;
+  const bytes = bytes_raw === '-' ? 0 : parseInt(bytes_raw, 10) || 0;
+
+  return {
+    ip: remote_addr,
+    remote_user,
+    ts,
+    method,
+    path,
+    status: parseInt(status, 10),
+    bytes,
+    referer: referer === '-' ? null : referer,
+    ua: ua === '-' ? null : ua,
+  };
+}
+
+module.exports = { parseLine, parseNginxTime };
diff --git a/lib/sessionize.js b/lib/sessionize.js
new file mode 100644
index 0000000..10c30cf
--- /dev/null
+++ b/lib/sessionize.js
@@ -0,0 +1,77 @@
+'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 };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..607b03c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "rentv-826-tracker",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Server-side time-on-server tracker for the /826/ (Steve Bloom Concepts) client area on rentv.agentabrams.com. Sessionizes the nginx access log into the per-client Postgres DB rentv_826.",
+  "main": "tracker.js",
+  "scripts": {
+    "start": "node tracker.js",
+    "backfill": "node tracker.js --backfill-only",
+    "report": "node report.js"
+  },
+  "dependencies": {
+    "pg": "^8.12.0"
+  }
+}
diff --git a/report.js b/report.js
new file mode 100644
index 0000000..a022c32
--- /dev/null
+++ b/report.js
@@ -0,0 +1,68 @@
+'use strict';
+
+// Time-on-server report for the /826/ client area. Usage: node report.js [client_slug]
+const path = require('path');
+const fs = require('fs');
+const { Pool } = require('pg');
+
+(function loadEnv() {
+  const p = path.join(__dirname, '.env');
+  if (!fs.existsSync(p)) return;
+  for (const raw of fs.readFileSync(p, '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();
+  }
+})();
+
+const CLIENT = process.argv[2] || process.env.CLIENT_SLUG || 'boomer';
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+function fmt(sec) {
+  sec = Math.round(sec || 0);
+  const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+  return `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`;
+}
+
+(async () => {
+  const c = (await pool.query(`SELECT name, email FROM clients WHERE slug=$1`, [CLIENT])).rows[0] || { name: CLIENT };
+  const tot = (await pool.query(
+    `SELECT count(*) sessions, coalesce(sum(duration_seconds),0) secs, coalesce(sum(hit_count),0) hits,
+            min(started_at) first_seen, max(last_seen_at) last_seen
+     FROM sessions WHERE client_slug=$1`, [CLIENT])).rows[0];
+
+  console.log(`\n=== Time on server — ${c.name}${c.email ? ' <' + c.email + '>' : ''} (/826/) ===`);
+  console.log(`Total time on server : ${fmt(tot.secs)}   (${tot.sessions} sessions, ${tot.hits} page hits)`);
+  console.log(`First seen           : ${tot.first_seen ? new Date(tot.first_seen).toISOString() : '—'}`);
+  console.log(`Last seen            : ${tot.last_seen ? new Date(tot.last_seen).toISOString() : '—'}`);
+
+  const byUser = (await pool.query(
+    `SELECT coalesce(remote_user,'(anon)') u, count(*) n, coalesce(sum(duration_seconds),0) secs
+     FROM sessions WHERE client_slug=$1 GROUP BY 1 ORDER BY secs DESC`, [CLIENT])).rows;
+  if (byUser.length) {
+    console.log(`\nBy login:`);
+    for (const r of byUser) console.log(`  ${r.u.padEnd(14)} ${fmt(r.secs).padEnd(16)} ${r.n} sessions`);
+  }
+
+  const byDay = (await pool.query(
+    `SELECT to_char(date_trunc('day', started_at),'YYYY-MM-DD') d, count(*) n, coalesce(sum(duration_seconds),0) secs
+     FROM sessions WHERE client_slug=$1 GROUP BY 1 ORDER BY 1 DESC LIMIT 14`, [CLIENT])).rows;
+  if (byDay.length) {
+    console.log(`\nLast ${byDay.length} active days:`);
+    for (const r of byDay) console.log(`  ${r.d}  ${fmt(r.secs).padEnd(16)} ${r.n} sessions`);
+  }
+
+  const recent = (await pool.query(
+    `SELECT started_at, last_seen_at, duration_seconds, hit_count, remote_user, host(ip) ip
+     FROM sessions WHERE client_slug=$1 ORDER BY started_at DESC LIMIT 10`, [CLIENT])).rows;
+  if (recent.length) {
+    console.log(`\nMost recent sessions:`);
+    for (const r of recent) {
+      console.log(`  ${new Date(r.started_at).toISOString()}  ${fmt(r.duration_seconds).padEnd(16)} ${String(r.hit_count).padStart(3)} hits  ${r.remote_user || '(anon)'}  ${r.ip}`);
+    }
+  }
+  console.log('');
+  await pool.end();
+})().catch((e) => { console.error(e.message); process.exit(1); });
diff --git a/tracker.js b/tracker.js
new file mode 100644
index 0000000..4c11b3a
--- /dev/null
+++ b/tracker.js
@@ -0,0 +1,149 @@
+'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');
+
+// --- 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 backfillOnly = process.argv.includes('--backfill-only');
+
+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() {
+  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)`);
+  let tick = 0;
+  for (;;) {
+    try {
+      await tailOnce();
+      if (++tick % 20 === 0) await closeStale(pool, CLIENT_SLUG, GAP_MS); // ~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)); });

(oldest)  ·  back to Rentv 826 Tracker  ·  single-source log mode: read+filter shared rentv.access.log, 5d17a72 →