refactor(replay): remove session replay functionality and related components to streamline codebase
This commit is contained in:
329
public/script.js
329
public/script.js
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Pulse - Privacy-First Tracking Script
|
||||
* Lightweight, no cookies, GDPR compliant
|
||||
* Includes optional session replay with privacy controls
|
||||
*/
|
||||
|
||||
(function() {
|
||||
@@ -28,17 +27,6 @@
|
||||
let clsObserved = false;
|
||||
let performanceInsightsEnabled = false;
|
||||
|
||||
// * Session Replay State
|
||||
let replayEnabled = false;
|
||||
let replayMode = 'disabled';
|
||||
let replayId = null;
|
||||
let replaySettings = null;
|
||||
let rrwebStopFn = null;
|
||||
let replayEvents = [];
|
||||
let chunkInterval = null;
|
||||
const CHUNK_SIZE = 50;
|
||||
const CHUNK_INTERVAL_MS = 10000;
|
||||
|
||||
// * Minimal Web Vitals Observer
|
||||
function observeMetrics() {
|
||||
try {
|
||||
@@ -109,11 +97,6 @@
|
||||
// * Send metrics when user leaves or hides the page
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// * Flush replay immediately (page may be torn down soon)
|
||||
if (replayEnabled) {
|
||||
sendReplayChunk();
|
||||
endReplaySession();
|
||||
}
|
||||
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
|
||||
setTimeout(sendMetrics, 150);
|
||||
}
|
||||
@@ -223,314 +206,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// * SESSION REPLAY FUNCTIONALITY
|
||||
// ==========================================
|
||||
|
||||
// * Fetch replay settings from API
|
||||
async function fetchReplaySettings() {
|
||||
try {
|
||||
const res = await fetch(apiUrl + '/api/v1/replay-settings/' + encodeURIComponent(domain));
|
||||
if (res.ok) {
|
||||
replaySettings = await res.json();
|
||||
replayMode = replaySettings.replay_mode;
|
||||
|
||||
// * Set performance insights enabled flag
|
||||
performanceInsightsEnabled = replaySettings.enable_performance_insights === true;
|
||||
|
||||
// Check sampling rate
|
||||
if (replayMode !== 'disabled') {
|
||||
const shouldRecord = Math.random() * 100 < replaySettings.replay_sampling_rate;
|
||||
if (!shouldRecord) {
|
||||
replayMode = 'disabled';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start for anonymous_skeleton mode (no consent needed)
|
||||
if (replayMode === 'anonymous_skeleton') {
|
||||
startReplay(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail - replay not critical
|
||||
}
|
||||
}
|
||||
|
||||
// * Initialize replay session on server
|
||||
async function initReplaySession(isSkeletonMode) {
|
||||
try {
|
||||
const res = await fetch(apiUrl + '/api/v1/replays', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: domain,
|
||||
session_id: getSessionId(),
|
||||
entry_page: window.location.pathname,
|
||||
is_skeleton_mode: isSkeletonMode,
|
||||
consent_given: !isSkeletonMode,
|
||||
device_type: detectDeviceType(),
|
||||
browser: detectBrowser(),
|
||||
os: detectOS()
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
replayId = data.id;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// * Load rrweb library dynamically
|
||||
function loadRrweb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof window.rrweb !== 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.11/dist/rrweb.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// * Start recording session
|
||||
async function startReplay(isSkeletonMode) {
|
||||
if (replayEnabled) return;
|
||||
|
||||
// Load rrweb if not already loaded
|
||||
try {
|
||||
await loadRrweb();
|
||||
} catch (e) {
|
||||
console.warn('[Ciphera] Failed to load rrweb library');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.rrweb === 'undefined') return;
|
||||
|
||||
// Initialize session on server first
|
||||
const initialized = await initReplaySession(isSkeletonMode);
|
||||
if (!initialized) return;
|
||||
|
||||
replayEnabled = true;
|
||||
|
||||
// Configure masking based on mode and settings
|
||||
const maskConfig = {
|
||||
// Always mask sensitive inputs
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
tel: true,
|
||||
// In skeleton mode, mask all text inputs
|
||||
text: isSkeletonMode,
|
||||
textarea: isSkeletonMode,
|
||||
select: isSkeletonMode
|
||||
},
|
||||
// Mask all text in skeleton mode
|
||||
maskAllText: isSkeletonMode || (replaySettings && replaySettings.replay_mask_all_text),
|
||||
// Mask all inputs by default (can be overridden in settings)
|
||||
maskAllInputs: replaySettings ? replaySettings.replay_mask_all_inputs : true,
|
||||
// Custom classes for masking
|
||||
maskTextClass: 'ciphera-mask',
|
||||
blockClass: 'ciphera-block',
|
||||
// Mask elements with data-ciphera-mask attribute
|
||||
maskTextSelector: '[data-ciphera-mask]',
|
||||
// Block elements with data-ciphera-block attribute
|
||||
blockSelector: '[data-ciphera-block]',
|
||||
// Custom input masking function for credit cards
|
||||
maskInputFn: (text, element) => {
|
||||
// Mask credit card patterns
|
||||
if (/^\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}$/.test(text)) {
|
||||
return '****-****-****-****';
|
||||
}
|
||||
// Mask email patterns
|
||||
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) {
|
||||
return '***@***.***';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
rrwebStopFn = window.rrweb.record({
|
||||
emit(event) {
|
||||
replayEvents.push(event);
|
||||
|
||||
// Send chunk when threshold reached
|
||||
if (replayEvents.length >= CHUNK_SIZE) {
|
||||
sendReplayChunk();
|
||||
}
|
||||
},
|
||||
...maskConfig,
|
||||
// Privacy: Don't record external resources
|
||||
recordCanvas: false,
|
||||
collectFonts: false,
|
||||
// Sampling for mouse movement (reduce data)
|
||||
sampling: {
|
||||
mousemove: true,
|
||||
mouseInteraction: true,
|
||||
scroll: 150,
|
||||
input: 'last'
|
||||
},
|
||||
// Inline styles for replay accuracy
|
||||
inlineStylesheet: true,
|
||||
// Slim snapshot to reduce size
|
||||
slimDOMOptions: {
|
||||
script: true,
|
||||
comment: true,
|
||||
headFavicon: true,
|
||||
headWhitespace: true,
|
||||
headMetaDescKeywords: true,
|
||||
headMetaSocial: true,
|
||||
headMetaRobots: true,
|
||||
headMetaHttpEquiv: true,
|
||||
headMetaAuthorship: true,
|
||||
headMetaVerification: true
|
||||
}
|
||||
});
|
||||
|
||||
// Set up periodic chunk sending
|
||||
chunkInterval = setInterval(sendReplayChunk, CHUNK_INTERVAL_MS);
|
||||
} catch (e) {
|
||||
replayEnabled = false;
|
||||
replayId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// * Redact common PII-like URL query/fragment parameters in replay JSON before sending
|
||||
function redactPiiInReplayJson(jsonStr) {
|
||||
return jsonStr.replace(
|
||||
/([?&])(email|token|session|auth|password|secret|api_key|apikey|access_token|refresh_token)=[^&"'\s]*/gi,
|
||||
'$1$2=***'
|
||||
);
|
||||
}
|
||||
|
||||
// * Send chunk of events to server
|
||||
async function sendReplayChunk() {
|
||||
if (!replayId || replayEvents.length === 0) return;
|
||||
|
||||
const chunk = replayEvents.splice(0, CHUNK_SIZE);
|
||||
const eventsCount = chunk.length;
|
||||
let data = JSON.stringify(chunk);
|
||||
data = redactPiiInReplayJson(data);
|
||||
|
||||
try {
|
||||
// Try to compress if available
|
||||
let body;
|
||||
let headers = { 'X-Events-Count': eventsCount.toString() };
|
||||
|
||||
if (typeof CompressionStream !== 'undefined') {
|
||||
const blob = new Blob([data]);
|
||||
const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
||||
body = await new Response(stream).blob();
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
headers['Content-Type'] = 'application/octet-stream';
|
||||
} else {
|
||||
body = new Blob([data], { type: 'application/json' });
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
await fetch(apiUrl + '/api/v1/replays/' + replayId + '/chunks', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: body,
|
||||
keepalive: true
|
||||
});
|
||||
} catch (e) {
|
||||
// Re-queue events on failure
|
||||
replayEvents.unshift(...chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// * End replay session
|
||||
function endReplaySession() {
|
||||
if (!replayEnabled || !replayId) return;
|
||||
|
||||
// Clear interval
|
||||
if (chunkInterval) {
|
||||
clearInterval(chunkInterval);
|
||||
chunkInterval = null;
|
||||
}
|
||||
|
||||
// Stop recording
|
||||
if (rrwebStopFn) {
|
||||
rrwebStopFn();
|
||||
rrwebStopFn = null;
|
||||
}
|
||||
|
||||
// Send remaining events
|
||||
if (replayEvents.length > 0) {
|
||||
const chunk = replayEvents.splice(0);
|
||||
let data = JSON.stringify(chunk);
|
||||
data = redactPiiInReplayJson(data);
|
||||
navigator.sendBeacon(
|
||||
apiUrl + '/api/v1/replays/' + replayId + '/chunks',
|
||||
new Blob([data], { type: 'application/json' })
|
||||
);
|
||||
}
|
||||
|
||||
// Mark session as ended
|
||||
navigator.sendBeacon(apiUrl + '/api/v1/replays/' + replayId + '/end');
|
||||
|
||||
replayEnabled = false;
|
||||
replayId = null;
|
||||
}
|
||||
|
||||
// * Device detection helpers
|
||||
function detectDeviceType() {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile';
|
||||
if (/tablet|ipad/.test(ua)) return 'tablet';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function detectBrowser() {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes('chrome') && !ua.includes('edg')) return 'Chrome';
|
||||
if (ua.includes('firefox')) return 'Firefox';
|
||||
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari';
|
||||
if (ua.includes('edg')) return 'Edge';
|
||||
if (ua.includes('opera')) return 'Opera';
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectOS() {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes('windows')) return 'Windows';
|
||||
if (ua.includes('mac os') || ua.includes('macos')) return 'macOS';
|
||||
if (ua.includes('linux')) return 'Linux';
|
||||
if (ua.includes('android')) return 'Android';
|
||||
if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) return 'iOS';
|
||||
return null;
|
||||
}
|
||||
|
||||
// * Public API for replay control (ciphera for backward compat, pulse for Pulse branding)
|
||||
const replayApi = function(cmd) {
|
||||
if (cmd === 'disableReplay') {
|
||||
endReplaySession();
|
||||
} else if (cmd === 'getReplayMode') {
|
||||
return replayMode;
|
||||
} else if (cmd === 'isReplayEnabled') {
|
||||
return replayEnabled;
|
||||
}
|
||||
};
|
||||
window.pulse = window.pulse || replayApi;
|
||||
window.ciphera = window.ciphera || replayApi;
|
||||
|
||||
// * Track initial pageview
|
||||
trackPageview();
|
||||
|
||||
// * Fetch replay settings (async, doesn't block pageview)
|
||||
fetchReplaySettings();
|
||||
|
||||
// * Track SPA navigation: MutationObserver (DOM updates) and history.pushState/replaceState
|
||||
// * (some SPAs change the URL without a DOM mutation we observe)
|
||||
let lastUrl = location.href;
|
||||
@@ -550,11 +228,4 @@
|
||||
// * Track popstate (browser back/forward)
|
||||
window.addEventListener('popstate', trackPageview);
|
||||
|
||||
// * Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (replayEnabled) {
|
||||
sendReplayChunk();
|
||||
endReplaySession();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user