511 lines
13 KiB
JavaScript
511 lines
13 KiB
JavaScript
import { record } from 'rrweb';
|
|
|
|
(window => {
|
|
const { document } = window;
|
|
const { currentScript } = document;
|
|
|
|
if (!currentScript) return;
|
|
|
|
const _data = 'data-';
|
|
const attr = currentScript.getAttribute.bind(currentScript);
|
|
const config = value => attr(`${_data}${value}`);
|
|
|
|
const website = config('website-id');
|
|
const hostUrl = config('host-url');
|
|
|
|
if (!website) return;
|
|
|
|
const host =
|
|
hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
|
|
const hostBase = host.replace(/\/$/, '');
|
|
const endpoint = `${hostBase}__COLLECT_REPLAY_ENDPOINT__`;
|
|
const configEndpoint = `${hostBase}__RECORDER_CONFIG_ENDPOINT__`.replace('{websiteId}', website);
|
|
|
|
const REPLAY_FLUSH_EVENT_COUNT = 100;
|
|
const REPLAY_FLUSH_INTERVAL = 2000;
|
|
const HEATMAP_FLUSH_EVENT_COUNT = 20;
|
|
const HEATMAP_FLUSH_INTERVAL = 5000;
|
|
|
|
let replayEnabled = false;
|
|
let heatmapEnabled = false;
|
|
let sampleRate = 0.15;
|
|
let heatmapSampleRate = 0.15;
|
|
let maskLevel = 'moderate';
|
|
let maxDuration = 300000;
|
|
let blockSelector = '';
|
|
|
|
let replayBuffer = [];
|
|
let heatmapBuffer = [];
|
|
let replayStopFn = null;
|
|
let replayFlushTimer = null;
|
|
let heatmapFlushTimer = null;
|
|
let replayStartTime = null;
|
|
let replayStopped = false;
|
|
let heatmapStarted = false;
|
|
|
|
const getSessionCache = () => window.umami?.getSession?.()?.cache;
|
|
|
|
const sendPayload = (type, payload, useKeepalive = false) => {
|
|
const cache = getSessionCache();
|
|
|
|
if (!cache) return;
|
|
|
|
const body = JSON.stringify({
|
|
type,
|
|
payload: {
|
|
website,
|
|
...payload,
|
|
},
|
|
});
|
|
|
|
const keepalive = useKeepalive && body.length < 60000;
|
|
|
|
return fetch(endpoint, {
|
|
keepalive,
|
|
method: 'POST',
|
|
body,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-umami-cache': cache,
|
|
},
|
|
credentials: 'omit',
|
|
}).catch(() => {});
|
|
};
|
|
|
|
const flushReplay = (useKeepalive = false) => {
|
|
if (!replayBuffer.length) return;
|
|
|
|
const events = replayBuffer;
|
|
replayBuffer = [];
|
|
|
|
sendPayload(
|
|
'record',
|
|
{
|
|
events,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
},
|
|
useKeepalive,
|
|
);
|
|
};
|
|
|
|
const flushHeatmap = (useKeepalive = false) => {
|
|
if (!heatmapBuffer.length) return;
|
|
|
|
const events = heatmapBuffer;
|
|
heatmapBuffer = [];
|
|
|
|
sendPayload(
|
|
'heatmap',
|
|
{
|
|
events,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
},
|
|
useKeepalive,
|
|
);
|
|
};
|
|
|
|
const scheduleHeatmapFlush = () => {
|
|
if (heatmapFlushTimer) clearTimeout(heatmapFlushTimer);
|
|
heatmapFlushTimer = setTimeout(() => flushHeatmap(), HEATMAP_FLUSH_INTERVAL);
|
|
};
|
|
|
|
const queueHeatmapEvent = event => {
|
|
heatmapBuffer.push({
|
|
...event,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
if (heatmapBuffer.length >= HEATMAP_FLUSH_EVENT_COUNT) {
|
|
flushHeatmap();
|
|
return;
|
|
}
|
|
|
|
scheduleHeatmapFlush();
|
|
};
|
|
|
|
const stopReplay = () => {
|
|
if (replayStopped) return;
|
|
|
|
replayStopped = true;
|
|
|
|
if (replayFlushTimer) clearInterval(replayFlushTimer);
|
|
flushReplay();
|
|
|
|
if (replayStopFn) {
|
|
replayStopFn();
|
|
replayStopFn = null;
|
|
}
|
|
};
|
|
|
|
const getMaskConfig = level => {
|
|
switch (level) {
|
|
case 'strict':
|
|
return {
|
|
maskAllInputs: true,
|
|
maskTextSelector: '*',
|
|
};
|
|
default:
|
|
return {
|
|
maskAllInputs: true,
|
|
};
|
|
}
|
|
};
|
|
|
|
const shouldSample = value => {
|
|
if (value >= 1) return true;
|
|
if (value <= 0) return false;
|
|
|
|
return Math.random() <= value;
|
|
};
|
|
|
|
const measureElementWidth = element => {
|
|
if (!element) return 0;
|
|
|
|
const rect = element.getBoundingClientRect?.();
|
|
|
|
return Math.max(
|
|
element.scrollWidth || 0,
|
|
element.offsetWidth || 0,
|
|
element.clientWidth || 0,
|
|
rect?.width || 0,
|
|
);
|
|
};
|
|
|
|
const measureElementHeight = element => {
|
|
if (!element) return 0;
|
|
|
|
const rect = element.getBoundingClientRect?.();
|
|
|
|
return Math.max(
|
|
element.scrollHeight || 0,
|
|
element.offsetHeight || 0,
|
|
element.clientHeight || 0,
|
|
rect?.height || 0,
|
|
);
|
|
};
|
|
|
|
const measureDocumentBounds = doc => {
|
|
const body = doc?.body;
|
|
|
|
if (!body) {
|
|
return { width: 0, height: 0 };
|
|
}
|
|
|
|
let maxRight = 0;
|
|
let maxBottom = 0;
|
|
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
|
let node = walker.currentNode;
|
|
|
|
while (node) {
|
|
const rect = node.getBoundingClientRect?.();
|
|
|
|
if (rect && (rect.width > 0 || rect.height > 0)) {
|
|
maxRight = Math.max(maxRight, rect.right);
|
|
maxBottom = Math.max(maxBottom, rect.bottom);
|
|
}
|
|
|
|
node = walker.nextNode();
|
|
}
|
|
|
|
return {
|
|
width: Math.max(0, Math.round(maxRight)),
|
|
height: Math.max(0, Math.round(maxBottom)),
|
|
};
|
|
};
|
|
|
|
const createPageMetrics = () => {
|
|
const computePageMetrics = ({ includeBounds = false } = {}) => {
|
|
const scrollingElement = document.scrollingElement || document.documentElement;
|
|
const firstChild = document.body?.firstElementChild;
|
|
const bounds = includeBounds ? measureDocumentBounds(document) : null;
|
|
const pageW = Math.max(
|
|
bounds?.width || 0,
|
|
measureElementWidth(scrollingElement),
|
|
measureElementWidth(document.documentElement),
|
|
measureElementWidth(document.body),
|
|
measureElementWidth(firstChild),
|
|
);
|
|
const pageH = Math.max(
|
|
bounds?.height || 0,
|
|
measureElementHeight(scrollingElement),
|
|
measureElementHeight(document.documentElement),
|
|
measureElementHeight(document.body),
|
|
measureElementHeight(firstChild),
|
|
);
|
|
const scrollLeft = scrollingElement?.scrollLeft || window.scrollX || 0;
|
|
const scrollTop = scrollingElement?.scrollTop || window.scrollY || 0;
|
|
|
|
return {
|
|
pageW,
|
|
pageH,
|
|
scrollLeft,
|
|
scrollTop,
|
|
};
|
|
};
|
|
|
|
const computeScrollPct = () => {
|
|
const { pageH, scrollTop } = computePageMetrics();
|
|
const visible = scrollTop + window.innerHeight;
|
|
|
|
return {
|
|
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
|
|
pageH,
|
|
};
|
|
};
|
|
|
|
return {
|
|
computePageMetrics,
|
|
computeScrollPct,
|
|
};
|
|
};
|
|
|
|
const beginReplayCapture = () => {
|
|
replayStartTime = Date.now();
|
|
|
|
replayFlushTimer = setInterval(() => flushReplay(), REPLAY_FLUSH_INTERVAL);
|
|
|
|
replayStopFn = record({
|
|
emit(event) {
|
|
if (replayStopped) return;
|
|
|
|
if (Date.now() - replayStartTime > maxDuration) {
|
|
stopReplay();
|
|
return;
|
|
}
|
|
|
|
replayBuffer.push(event);
|
|
|
|
if (replayBuffer.length >= REPLAY_FLUSH_EVENT_COUNT) {
|
|
flushReplay();
|
|
}
|
|
},
|
|
...getMaskConfig(maskLevel),
|
|
inlineStylesheet: true,
|
|
slimDOMOptions: {
|
|
script: true,
|
|
comment: true,
|
|
headMetaDescKeywords: true,
|
|
headMetaSocial: true,
|
|
headMetaRobots: true,
|
|
headMetaHttpEquiv: true,
|
|
headMetaAuthorship: true,
|
|
headMetaVerification: true,
|
|
},
|
|
recordCanvas: false,
|
|
recordCrossOriginIframes: false,
|
|
checkoutEveryNms: 30000,
|
|
...(blockSelector && { blockSelector }),
|
|
});
|
|
};
|
|
|
|
const beginHeatmapCapture = () => {
|
|
if (heatmapStarted) return;
|
|
|
|
heatmapStarted = true;
|
|
|
|
const { computePageMetrics, computeScrollPct } = createPageMetrics();
|
|
let scrollUrl = location.href;
|
|
let maxScrollPct = 0;
|
|
let lastFlushedScrollPct = 0;
|
|
let scrollTimer = null;
|
|
|
|
const flushScroll = () => {
|
|
if (maxScrollPct <= 0 || maxScrollPct <= lastFlushedScrollPct) return;
|
|
|
|
const { pageW, pageH } = computePageMetrics({ includeBounds: true });
|
|
|
|
queueHeatmapEvent({
|
|
type: 'scroll',
|
|
url: scrollUrl,
|
|
scrollPct: maxScrollPct,
|
|
viewportW: window.innerWidth,
|
|
viewportH: window.innerHeight,
|
|
pageW,
|
|
pageH,
|
|
});
|
|
|
|
lastFlushedScrollPct = maxScrollPct;
|
|
maxScrollPct = 0;
|
|
};
|
|
|
|
const onClick = event => {
|
|
if (!event.isTrusted || event.button !== 0) return;
|
|
|
|
const {
|
|
pageW: rawPageW,
|
|
pageH: rawPageH,
|
|
scrollLeft,
|
|
scrollTop,
|
|
} = computePageMetrics({
|
|
includeBounds: true,
|
|
});
|
|
const pageX = Number.isFinite(event.pageX) ? event.pageX : event.clientX + scrollLeft;
|
|
const pageY = Number.isFinite(event.pageY) ? event.pageY : event.clientY + scrollTop;
|
|
const target = event.target;
|
|
const targetRect =
|
|
target && typeof target.getBoundingClientRect === 'function'
|
|
? target.getBoundingClientRect()
|
|
: null;
|
|
const targetRight = targetRect ? targetRect.right + scrollLeft : 0;
|
|
const targetBottom = targetRect ? targetRect.bottom + scrollTop : 0;
|
|
const pageW = Math.max(rawPageW, Math.ceil(pageX), Math.ceil(targetRight));
|
|
const pageH = Math.max(rawPageH, Math.ceil(pageY), Math.ceil(targetBottom));
|
|
|
|
queueHeatmapEvent({
|
|
type: 'click',
|
|
url: location.href,
|
|
x: Math.round(event.clientX),
|
|
y: Math.round(event.clientY),
|
|
pageX: Math.round(pageX),
|
|
pageY: Math.round(pageY),
|
|
pageW,
|
|
pageH,
|
|
viewportW: window.innerWidth,
|
|
viewportH: window.innerHeight,
|
|
});
|
|
};
|
|
|
|
const onScroll = () => {
|
|
if (scrollTimer) return;
|
|
|
|
scrollTimer = setTimeout(() => {
|
|
const { pct } = computeScrollPct();
|
|
|
|
if (pct > maxScrollPct) {
|
|
maxScrollPct = pct;
|
|
}
|
|
|
|
flushScroll();
|
|
scrollTimer = null;
|
|
}, 400);
|
|
};
|
|
|
|
const onUrlChange = () => {
|
|
if (location.href === scrollUrl) return;
|
|
|
|
flushScroll();
|
|
scrollUrl = location.href;
|
|
lastFlushedScrollPct = 0;
|
|
};
|
|
|
|
const hookHistory = method => {
|
|
const original = history[method];
|
|
|
|
history[method] = function (...args) {
|
|
const result = original.apply(this, args);
|
|
onUrlChange();
|
|
return result;
|
|
};
|
|
};
|
|
|
|
hookHistory('pushState');
|
|
hookHistory('replaceState');
|
|
window.addEventListener('popstate', onUrlChange);
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
document.addEventListener('click', onClick, { capture: true, passive: true });
|
|
|
|
{
|
|
const { pct } = computeScrollPct();
|
|
|
|
if (pct > maxScrollPct) {
|
|
maxScrollPct = pct;
|
|
}
|
|
|
|
flushScroll();
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
flushScroll();
|
|
flushHeatmap(true);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
flushScroll();
|
|
flushHeatmap(true);
|
|
});
|
|
};
|
|
|
|
const waitForSession = (callback, attempts = 0) => {
|
|
if (attempts > 50) return;
|
|
|
|
if (getSessionCache()) {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => waitForSession(callback, attempts + 1), 100);
|
|
};
|
|
|
|
const startCaptures = () => {
|
|
const shouldRecordReplay = replayEnabled && shouldSample(sampleRate);
|
|
const shouldRecordHeatmap = heatmapEnabled && shouldSample(heatmapSampleRate);
|
|
|
|
if (shouldRecordHeatmap) {
|
|
beginHeatmapCapture();
|
|
}
|
|
|
|
if (shouldRecordReplay) {
|
|
beginReplayCapture();
|
|
}
|
|
|
|
if (!shouldRecordHeatmap && !shouldRecordReplay) {
|
|
return;
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
flushReplay(true);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('pagehide', () => {
|
|
flushReplay(true);
|
|
});
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
flushReplay(true);
|
|
});
|
|
};
|
|
|
|
const bootstrap = async () => {
|
|
try {
|
|
const response = await fetch(configEndpoint, { credentials: 'omit' });
|
|
|
|
if (!response.ok) return;
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data?.enabled) return;
|
|
|
|
replayEnabled = data.replayEnabled === true;
|
|
heatmapEnabled = data.heatmapEnabled === true;
|
|
|
|
if (typeof data.sampleRate === 'number') sampleRate = data.sampleRate;
|
|
if (typeof data.heatmapSampleRate === 'number') heatmapSampleRate = data.heatmapSampleRate;
|
|
if (typeof data.maskLevel === 'string') maskLevel = data.maskLevel;
|
|
if (typeof data.maxDuration === 'number') maxDuration = data.maxDuration;
|
|
if (typeof data.blockSelector === 'string') blockSelector = data.blockSelector;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (!replayEnabled && !heatmapEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (document.readyState === 'complete') {
|
|
waitForSession(startCaptures);
|
|
} else {
|
|
document.addEventListener('readystatechange', () => {
|
|
if (document.readyState === 'complete') {
|
|
waitForSession(startCaptures);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
bootstrap();
|
|
})(window);
|