Merge pull request #53 from ciphera-net/staging
Slim tracking script: send raw browser state, let server handle normalization
This commit is contained in:
@@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Improved
|
||||
|
||||
- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything.
|
||||
- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end.
|
||||
- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors.
|
||||
- **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster.
|
||||
- **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value.
|
||||
|
||||
121
public/script.js
121
public/script.js
@@ -189,40 +189,17 @@
|
||||
return cachedSessionId;
|
||||
}
|
||||
|
||||
// * Normalize path: strip trailing slash and all query params except UTM/attribution.
|
||||
// * Allowlist approach — only UTM params pass through because the backend extracts
|
||||
// * them for attribution before cleaning the stored path. Everything else (cache-busters,
|
||||
// * ad click IDs, filter params, etc.) is stripped to prevent path fragmentation.
|
||||
var KEEP_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'source', 'ref'];
|
||||
// * Normalize path: strip trailing slash, return pathname only.
|
||||
// * UTM extraction and query handling moved server-side.
|
||||
function cleanPath() {
|
||||
var pathname = window.location.pathname;
|
||||
// * Strip trailing slash (but keep root /)
|
||||
if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
|
||||
pathname = pathname.slice(0, -1);
|
||||
}
|
||||
// * Only keep allowlisted params, strip everything else
|
||||
var search = window.location.search;
|
||||
if (search) {
|
||||
try {
|
||||
var params = new URLSearchParams(search);
|
||||
var kept = new URLSearchParams();
|
||||
for (var i = 0; i < KEEP_PARAMS.length; i++) {
|
||||
if (params.has(KEEP_PARAMS[i])) {
|
||||
kept.set(KEEP_PARAMS[i], params.get(KEEP_PARAMS[i]));
|
||||
}
|
||||
}
|
||||
var remaining = kept.toString();
|
||||
if (remaining) pathname += '?' + remaining;
|
||||
} catch (e) {
|
||||
// * URLSearchParams not supported — send path without query
|
||||
}
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
// * SPA referrer: only attribute external referrer to the landing page
|
||||
var firstPageviewSent = false;
|
||||
|
||||
// * Refresh dedup: skip pageview if the same path was tracked within 5 seconds
|
||||
// * Prevents inflated pageview counts from F5/refresh while allowing genuine revisits
|
||||
var REFRESH_DEDUP_WINDOW = 5000;
|
||||
@@ -263,22 +240,7 @@
|
||||
|
||||
currentEventId = null;
|
||||
pageStartTime = 0;
|
||||
// * Only send external referrer on the first pageview (landing page).
|
||||
// * SPA navigations keep document.referrer stale, so clear it after first hit
|
||||
// * to avoid inflating traffic source attribution.
|
||||
var referrer = '';
|
||||
if (!firstPageviewSent) {
|
||||
var rawReferrer = document.referrer || '';
|
||||
if (rawReferrer) {
|
||||
try {
|
||||
var refHost = new URL(rawReferrer).hostname.replace(/^www\./, '');
|
||||
var siteHost = domain.replace(/^www\./, '');
|
||||
if (refHost !== siteHost) referrer = rawReferrer;
|
||||
} catch (e) {
|
||||
referrer = rawReferrer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const screenSize = {
|
||||
width: window.innerWidth || window.screen.width,
|
||||
height: window.innerHeight || window.screen.height,
|
||||
@@ -286,8 +248,9 @@
|
||||
|
||||
const payload = {
|
||||
domain: domain,
|
||||
path: path,
|
||||
referrer: referrer,
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
referrer: document.referrer || '',
|
||||
screen: screenSize,
|
||||
session_id: getSessionId(),
|
||||
};
|
||||
@@ -303,7 +266,6 @@
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
recordPageview(path);
|
||||
firstPageviewSent = true;
|
||||
if (data && data.id) {
|
||||
currentEventId = data.id;
|
||||
pageStartTime = Date.now();
|
||||
@@ -331,8 +293,6 @@
|
||||
if (url !== lastUrl) {
|
||||
lastUrl = url;
|
||||
trackPageview();
|
||||
// * Check for 404 after SPA navigation (deferred so title updates first)
|
||||
setTimeout(check404, 100);
|
||||
// * Reset scroll depth tracking for the new page
|
||||
if (trackScroll) scrollFired = {};
|
||||
}
|
||||
@@ -349,58 +309,23 @@
|
||||
if (url === lastUrl) return;
|
||||
lastUrl = url;
|
||||
trackPageview();
|
||||
setTimeout(check404, 100);
|
||||
if (trackScroll) scrollFired = {};
|
||||
});
|
||||
|
||||
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
|
||||
var EVENT_NAME_MAX = 64;
|
||||
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
// * Custom events / goals
|
||||
function trackCustomEvent(eventName, props) {
|
||||
if (typeof eventName !== 'string' || !eventName.trim()) return;
|
||||
var name = eventName.trim().toLowerCase();
|
||||
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
console.warn('Pulse: event name must contain only letters, numbers, and underscores (max ' + EVENT_NAME_MAX + ' chars).');
|
||||
}
|
||||
return;
|
||||
}
|
||||
var path = cleanPath();
|
||||
// * Custom events use same referrer logic: only on first pageview, empty after
|
||||
var referrer = '';
|
||||
if (!firstPageviewSent) {
|
||||
var rawRef = document.referrer || '';
|
||||
if (rawRef) {
|
||||
try {
|
||||
var rh = new URL(rawRef).hostname.replace(/^www\./, '');
|
||||
var sh = domain.replace(/^www\./, '');
|
||||
if (rh !== sh) referrer = rawRef;
|
||||
} catch (e) { referrer = rawRef; }
|
||||
}
|
||||
}
|
||||
var screenSize = { width: window.innerWidth || 0, height: window.innerHeight || 0 };
|
||||
var payload = {
|
||||
domain: domain,
|
||||
path: path,
|
||||
referrer: referrer,
|
||||
screen: screenSize,
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
referrer: document.referrer || '',
|
||||
screen: { width: window.innerWidth || 0, height: window.innerHeight || 0 },
|
||||
session_id: getSessionId(),
|
||||
name: name,
|
||||
name: eventName.trim().toLowerCase(),
|
||||
};
|
||||
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
|
||||
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
||||
var sanitized = {};
|
||||
var count = 0;
|
||||
for (var key in props) {
|
||||
if (!props.hasOwnProperty(key)) continue;
|
||||
if (count >= 30) break;
|
||||
var k = String(key).substring(0, 200);
|
||||
var v = String(props[key]).substring(0, 2000);
|
||||
sanitized[k] = v;
|
||||
count++;
|
||||
}
|
||||
if (count > 0) payload.props = sanitized;
|
||||
payload.props = props;
|
||||
}
|
||||
fetch(apiUrl + '/api/v1/events', {
|
||||
method: 'POST',
|
||||
@@ -415,26 +340,6 @@
|
||||
window.pulse.track = trackCustomEvent;
|
||||
window.pulse.cleanPath = cleanPath;
|
||||
|
||||
// * Auto-track 404 error pages (on by default)
|
||||
// * Detects pages where document.title contains "404" or "not found"
|
||||
// * Opt-out: add data-no-404 to the script tag
|
||||
var track404 = !script.hasAttribute('data-no-404');
|
||||
var sent404ForUrl = '';
|
||||
|
||||
function check404() {
|
||||
if (!track404) return;
|
||||
// * Only fire once per URL
|
||||
var currentUrl = location.href;
|
||||
if (sent404ForUrl === currentUrl) return;
|
||||
if (/404|not found/i.test(document.title)) {
|
||||
sent404ForUrl = currentUrl;
|
||||
trackCustomEvent('404');
|
||||
}
|
||||
}
|
||||
|
||||
// * Check on initial load (deferred so SPAs can set title)
|
||||
setTimeout(check404, 0);
|
||||
|
||||
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
|
||||
// * Each threshold fires once per pageview; resets on SPA navigation
|
||||
// * Opt-out: add data-no-scroll to the script tag
|
||||
|
||||
Reference in New Issue
Block a user