The full specification for the visitor tracking, session recording, and replay playback system. Copy this and paste it into another site's build to recreate the feature.
# Visitor Log + Session Replay System
Build a complete visitor analytics system that tracks every site visitor's journey (pageviews, scroll depth, clicks, video engagement, unmutes, inquiries, reveal-code attempts) and records full DOM-level session replays playable back in the admin dashboard.
## Entities to create
### UserActivity
Tracks every pageview, scroll milestone (25/50/75/100%), and click on the site.
- action: enum ["click","scroll"]
- target: string (CSS selector for clicks, "25%"/"50%"/"75%"/"100%" for scroll, "pageview" for pageviews)
- path: string (current URL pathname)
- host: string
- scroll_depth: number
- session_id: string (persistent visitor ID stored in localStorage)
- details: string (JSON with click text, href, x/y coords)
- referrer: string
- campaign: string (marketing campaign code from URL/subdomain)
- user_agent: string
- ip: string
- city: string
- region: string
- country: string
Required: ["action","session_id"]
### SessionRecording
Stores uploaded rrweb DOM-mutation recordings, linked to a visitor session.
- session_id: string
- file_url: string (uploaded JSON file of rrweb events)
- started_at: string (date-time)
- event_count: number
- path: string
Required: ["session_id","file_url"]
### MusicEvent (optional — for video/audio engagement)
- action: string (e.g. "unmute","mute","play","pause")
- session_id: string
- path: string
### InquiryLog (optional — for inquiry badges)
- inquiry_type: string
- name: string
- contact: string
- subject: string
- message: string
- artwork_title: string
- channel: enum ["form","whatsapp"]
- session_id: string
Required: ["inquiry_type"]
### RevealAttempt (optional — for reveal-code badges)
- session_id: string
- result: string ("correct"/"incorrect")
- code_attempted: string
### IpTag (optional — for tagging known IPs)
- ip: string
- tag: string
Required: ["ip"]
### ArtColumnPref (optional — for persisting column visibility)
- sheet: string
- columns: array of { key, label, visible, w, numeric, format }
## Frontend Hook 1: useUserTracking (visitor tracking)
Install: rrweb (for recording), and use the base44 SDK client.
Create a hook that:
1. Generates a persistent session ID (localStorage key "activity_visitor_id", format "v_<timestamp>_<random>").
2. Detects the marketing campaign code from: URL ?campaign= param, subdomain (single letter/number), or sessionStorage fallback.
3. Auto-creates a Campaign entity if the campaign code doesn't exist yet.
4. Listens for click events (captures tag, id, class, role, innerText, href, x/y).
5. Listens for scroll events at 25/50/75/100% milestones (throttled to 350ms).
6. Records a "pageview" click event on initial load and whenever the SPA route changes (poll pathname every 1s).
7. Buffers events and flushes to a backend function "recordUserActivity" every 8 seconds, or when buffer hits 25 events, or on pagehide/visibilitychange.
8. Each event includes: action, target, path, details, session_id, referrer, host, campaign.
```js
import { useEffect, useRef } from 'react';
import { base44 } from '@/api/base44Client';
export function getActivitySessionId() {
let id = localStorage.getItem('activity_visitor_id');
if (!id) {
id = 'v_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
localStorage.setItem('activity_visitor_id', id);
}
return id;
}
export default function useUserTracking() {
const buffer = useRef([]);
const flushTimer = useRef(null);
const scrollTimer = useRef(null);
const scrollMilestones = useRef(new Set());
const lastPath = useRef('');
const flush = async () => {
if (buffer.current.length === 0) return;
const batch = buffer.current.splice(0, buffer.current.length);
try {
await base44.functions.invoke('recordUserActivity', { events: batch });
} catch (e) { /* drop on failure */ }
};
useEffect(() => {
const sessionId = getActivitySessionId();
const referrer = document.referrer || '';
const host = window.location.hostname || '';
const campaign = (() => {
try {
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get('campaign');
if (fromUrl) { sessionStorage.setItem('activity_campaign', fromUrl); return fromUrl; }
const parts = (window.location.hostname || '').split('.');
if (parts.length >= 3 && parts[0].toLowerCase() !== 'www') {
const sub = parts[0].toLowerCase();
if (/^([0-9]+|[a-z0-9]{1,2})$/i.test(sub)) { sessionStorage.setItem('activity_campaign', sub); return sub; }
}
return sessionStorage.getItem('activity_campaign') || '';
} catch { return ''; }
})();
if (campaign) {
base44.entities.Campaign.filter({ campaign_number: campaign })
.then((existing) => {
if (!existing || existing.length === 0) {
return base44.entities.Campaign.create({ campaign_number: campaign, name: '#' + campaign, status: 'active' });
}
}).catch(() => {});
}
const push = (event, eventReferrer) => {
buffer.current.push({ ...event, session_id: sessionId, referrer: eventReferrer !== undefined ? eventReferrer : referrer, host, ...(campaign ? { campaign } : {}) });
if (buffer.current.length >= 25) flush();
};
const describeTarget = (el) => {
if (!el || !el.tagName) return 'unknown';
const tag = el.tagName.toLowerCase();
const id = el.id ? '#' + el.id : '';
const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/).slice(0, 2).map((c) => '.' + c).join('') : '';
const role = el.getAttribute?.('data-track') || el.getAttribute?.('role') || '';
return [tag + id, cls, role && '[' + role + ']'].filter(Boolean).join(' ') || tag;
};
const handleClick = (e) => {
const el = e.target;
if (el.tagName && el.tagName.toLowerCase() === 'div') return;
const text = (el.innerText || el.textContent || '').trim().slice(0, 80);
const href = el.getAttribute?.('href') || '';
push({ action: 'click', target: describeTarget(el), path: window.location.pathname, details: JSON.stringify({ text, href, x: Math.round(e.clientX), y: Math.round(e.clientY) }) });
};
const handleScroll = () => {
const path = window.location.pathname;
if (path !== lastPath.current) { lastPath.current = path; scrollMilestones.current = new Set(); }
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const depth = docHeight > 0 ? Math.round((scrollTop / docHeight) * 100) : 0;
[25, 50, 75, 100].forEach((m) => {
if (depth >= m && !scrollMilestones.current.has(m)) {
scrollMilestones.current.add(m);
push({ action: 'scroll', target: m + '%', path, scroll_depth: m });
}
});
};
const throttledScroll = () => {
if (scrollTimer.current) return;
scrollTimer.current = setTimeout(() => { handleScroll(); scrollTimer.current = null; }, 350);
};
document.addEventListener('click', handleClick, true);
window.addEventListener('scroll', throttledScroll, { passive: true });
flushTimer.current = setInterval(flush, 8000);
push({ action: 'click', target: 'pageview', path: window.location.pathname });
lastPath.current = window.location.pathname;
const pathWatcher = setInterval(() => {
const p = window.location.pathname;
if (p !== lastPath.current) {
const internalRef = lastPath.current ? window.location.origin + lastPath.current : referrer;
lastPath.current = p;
scrollMilestones.current = new Set();
push({ action: 'click', target: 'pageview', path: p }, internalRef);
}
}, 1000);
const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); };
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', flush);
return () => {
flush();
document.removeEventListener('click', handleClick, true);
window.removeEventListener('scroll', throttledScroll);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pagehide', flush);
if (flushTimer.current) clearInterval(flushTimer.current);
if (scrollTimer.current) clearTimeout(scrollTimer.current);
clearInterval(pathWatcher);
};
}, []);
}
```
## Frontend Hook 2: useSessionRecorder (rrweb DOM recording)
Records DOM mutations using rrweb, buffers them, and uploads as a JSON file every 60 seconds (or on tab close). Links the recording to the same session_id used by the tracker. Excludes internal/admin routes.
```js
import { useEffect, useRef } from 'react';
import { record } from 'rrweb';
import { base44 } from '@/api/base44Client';
import { getActivitySessionId } from './useUserTracking';
const EXCLUDED_PREFIXES = ['/admin','/database','/wa','/m','/letterhead','/aiandi','/authenticate','/reset-password','/login','/register','/forgot-password'];
const isExcluded = (p) => EXCLUDED_PREFIXES.some((pre) => p === pre || p.startsWith(pre + '/'));
export default function useSessionRecorder() {
const events = useRef([]);
const lastFlushCount = useRef(0);
const flush = async () => {
if (events.current.length === 0) return;
if (events.current.length === lastFlushCount.current) return;
const sessionId = getActivitySessionId();
const blob = new Blob([JSON.stringify(events.current)], { type: 'application/json' });
const file = new File([blob], 'session-' + sessionId + '.json', { type: 'application/json' });
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const existing = await base44.entities.SessionRecording.filter({ session_id: sessionId }, '-created_date', 5);
if (existing && existing.length) {
await base44.entities.SessionRecording.update(existing[0].id, { file_url, event_count: events.current.length });
} else {
await base44.entities.SessionRecording.create({ session_id: sessionId, file_url, started_at: new Date().toISOString(), event_count: events.current.length, path: window.location.pathname });
}
lastFlushCount.current = events.current.length;
} catch { /* best-effort */ }
};
useEffect(() => {
if (isExcluded(window.location.pathname)) return;
let stopFn;
try {
stopFn = record({ emit(event) { events.current.push(event); }, maskAllInputs: true, maskInputOptions: { password: true, text: true, email: true, search: true, tel: true, url: true }, blockClass: 'rr-block' });
} catch { return; }
const interval = setInterval(flush, 60000);
const onVisibility = () => { if (document.visibilityState === 'hidden') flush(); };
const onHide = () => flush();
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', onHide);
return () => { flush(); if (stopFn) stopFn(); clearInterval(interval); document.removeEventListener('visibilitychange', onVisibility); window.removeEventListener('pagehide', onHide); };
}, []);
}
```
## Backend Function 1: recordUserActivity
Receives a batch of events, enriches each with IP + geolocation (use ip-api.com with geojs fallback), truncates strings to schema limits, and bulk-creates UserActivity records.
```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.38';
Deno.serve(async (req) => {
try {
const base44 = createClientFromRequest(req);
const body = await req.json();
const events = body?.events || [];
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || '';
const ua = req.headers.get('user-agent') || '';
let geo = { city: '', region: '', country: '' };
if (ip) {
try {
const r = await fetch('http://ip-api.com/json/' + ip + '?fields=city,regionName,countryName');
if (r.ok) { const j = await r.json(); geo = { city: j.city || '', region: j.regionName || '', country: j.countryName || '' }; }
} catch {}
}
const clean = events.filter(e => e && e.action && e.session_id).map(e => ({
...e,
target: String(e.target || '').slice(0, 200),
path: String(e.path || '').slice(0, 200),
details: String(e.details || '').slice(0, 1000),
referrer: String(e.referrer || '').slice(0, 500),
host: String(e.host || '').slice(0, 200),
campaign: String(e.campaign || '').slice(0, 50),
user_agent: ua.slice(0, 500),
ip,
city: geo.city, region: geo.region, country: geo.country,
}));
await base44.asServiceRole.entities.UserActivity.bulkCreate(clean);
return Response.json({ ok: true, saved: clean.length });
} catch (error) { return Response.json({ error: error.message }, { status: 500 }); }
});
```
## Backend Function 2: getVisitorLog
Fetches UserActivity, MusicEvent, InquiryLog, and RevealAttempt records for a time window, groups them by session_id, and builds one row per visitor session with: arrival time, referrer, device/OS/browser (parsed from UA), geo, landing page, time on landing, next page, scroll depth, video watched, unmuted, total clicks, page count, total duration, inquiry/reveal badges, and a full per-page journey (path, time, scroll, clicks per page).
Key details:
- Paginate UserActivity newest-first, stop when events predate the window.
- Group events by session_id, sort chronologically.
- Build a deduped page sequence from all events with a path.
- Per-page: max scroll depth, list of clicks (text + target).
- Session-level: max scroll across all pages, total clicks, total duration (last event ts - first event ts).
- "watchedVideo" = scrolled on "/" (home).
- "unmuted" = session_id exists in MusicEvent unmute set.
- Truncate session_id to 14 chars for the row's sid (this is the key the frontend recording map uses).
- Return { rows (max 500), total, eventsScanned }.
## Frontend: VisitorLogTable component
A spreadsheet-style admin table showing one row per visitor session. Features:
- Date range picker (1/7/30/90 days or custom range).
- Search across sessions, referrers, pages, locations, IPs.
- Filters: unmuted only, watched video only, by campaign.
- Expandable rows showing the full page-by-page journey (page, time, scroll %, clicks).
- Campaign grouping pills with session/click counts.
- IP tagging (click any IP to add/edit a tag, persisted to IpTag entity).
- Column visibility toggle (persisted to ArtColumnPref entity).
- CSV export with up to 20 pages of detail.
- Replay column: shows a play button when a SessionRecording exists for that session's 14-char sid prefix.
The recording map is built by loading SessionRecording entities and keying by the first 14 chars of session_id:
```js
const recs = await base44.entities.SessionRecording.list('-created_date', 500);
const map = {};
for (const r of recs) { if (r.session_id && r.file_url) map[String(r.session_id).slice(0, 14)] = r.file_url; }
setRecordingByUrl(map);
```
Then in the replay cell: `const url = recordingByUrl[r.sid];` — if url exists, show a play button that opens the SessionReplayer.
## Frontend: SessionReplayer component
A modal that fetches the rrweb JSON file, dynamically imports rrweb-player + its CSS, and mounts the player. CRITICAL: the mount div must always be in the DOM (not conditionally rendered), with the loading spinner overlaid on top — otherwise the player can't attach and loading hangs forever.
```jsx
import React, { useEffect, useRef, useState } from 'react';
import { Loader2, X } from 'lucide-react';
export default function SessionReplayer({ fileUrl, onClose }) {
const mountRef = useRef(null);
const playerRef = useRef(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(fileUrl);
if (!res.ok) throw new Error('fetch failed');
const events = await res.json();
if (cancelled) return;
if (!Array.isArray(events) || !events.length) throw new Error('empty recording');
const RRWebPlayer = (await import('rrweb-player')).default;
await import('rrweb-player/dist/style.css');
if (cancelled || !mountRef.current) return;
mountRef.current.innerHTML = '';
playerRef.current = new RRWebPlayer({ target: mountRef.current, props: { events, width: 900, height: 560, autoPlay: false } });
setLoading(false);
} catch (e) { if (!cancelled) { setError('Could not load recording'); setLoading(false); } }
})();
return () => { cancelled = true; try { if (playerRef.current?.$destroy) playerRef.current.$destroy(); } catch {} };
}, [fileUrl]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-sm font-semibold">Session replay</span>
<button onClick={onClose} className="text-zinc-400 hover:text-foreground"><X size={16} /></button>
</div>
<div className="p-4 flex items-center justify-center min-h-[400px] relative">
<div ref={mountRef} className="rrweb-replay" />
{loading && <div className="absolute inset-0 flex items-center justify-center bg-white/80"><div className="flex items-center gap-2 text-zinc-500 text-sm"><Loader2 size={16} className="animate-spin" /> Loading recording…</div></div>}
{error && !loading && <div className="absolute inset-0 flex items-center justify-center bg-white"><div className="text-sm text-red-600">{error}</div></div>}
</div>
</div>
</div>
);
}
```
## Wiring it up
In your app's root component (inside the auth provider), call both hooks:
```js
import useUserTracking from '@/hooks/useUserTracking';
import useSessionRecorder from '@/hooks/useSessionRecorder';
// ...
useUserTracking();
useSessionRecorder();
```
## npm packages required
- rrweb (DOM recording)
- rrweb-player (replay player)
- lucide-react (icons)
## Notes
- Session replays only work for real browser sessions (bots/crawlers don't execute JS, so they'll never have recordings — this is expected).
- The recorder flushes every 60 seconds, so very short visits (<60s with no pagehide) may not upload.
- The 14-char session_id prefix is the join key between the visitor log rows and the recording map — keep them consistent on both sides.
- maskAllInputs is enabled to avoid recording sensitive form data.