/* * Lightweight analytics tracker. * Usage: * * * Design goals: never block page load, tolerate the collector being down, * and batch requests via sendBeacon so unload doesn't drop data. */ (function () { "use strict"; var CURRENT_SCRIPT = document.currentScript; if (!CURRENT_SCRIPT) return; var SITE_ID = CURRENT_SCRIPT.getAttribute("data-site-id"); var TOKEN = CURRENT_SCRIPT.getAttribute("data-token"); if (!SITE_ID || !TOKEN) { console.warn("[analytics] missing data-site-id or data-token, tracker not started"); return; } var API_BASE = CURRENT_SCRIPT.getAttribute("data-api") || new URL(CURRENT_SCRIPT.src).origin; var TRACK_URL = API_BASE + "/api/v1/track"; if (navigator.doNotTrack === "1" || window.doNotTrack === "1") { return; } var SESSION_TIMEOUT_MS = 30 * 60 * 1000; var FLUSH_INTERVAL_MS = 5000; var MAX_BATCH_SIZE = 10; var VISITOR_KEY = "_awa_visitor_id"; var SESSION_KEY = "_awa_session_id"; var LAST_ACTIVITY_KEY = "_awa_last_activity"; function uuid() { if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { var r = (Math.random() * 16) | 0; var v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function safeGet(storage, key) { try { return storage.getItem(key); } catch (e) { return null; } } function safeSet(storage, key, value) { try { storage.setItem(key, value); } catch (e) { /* storage unavailable (private mode, quota) — degrade silently */ } } function getOrCreateVisitorId() { var id = safeGet(localStorage, VISITOR_KEY); var isNew = false; if (!id) { id = uuid(); isNew = true; safeSet(localStorage, VISITOR_KEY, id); } return { id: id, isNew: isNew }; } function getOrCreateSessionId() { var now = Date.now(); var lastActivity = parseInt(safeGet(localStorage, LAST_ACTIVITY_KEY) || "0", 10); var sessionId = safeGet(localStorage, SESSION_KEY); var isNew = false; if (!sessionId || now - lastActivity > SESSION_TIMEOUT_MS) { sessionId = uuid(); isNew = true; safeSet(localStorage, SESSION_KEY, sessionId); } safeSet(localStorage, LAST_ACTIVITY_KEY, String(now)); return { id: sessionId, isNew: isNew }; } var visitor = getOrCreateVisitorId(); var session = getOrCreateSessionId(); function getUtmParams() { var params = new URLSearchParams(window.location.search); return { utm_source: params.get("utm_source"), utm_medium: params.get("utm_medium"), utm_campaign: params.get("utm_campaign"), utm_term: params.get("utm_term"), utm_content: params.get("utm_content"), }; } var queue = []; var flushTimer = null; function scheduleFlush() { if (flushTimer) return; flushTimer = setTimeout(function () { flushTimer = null; flush(false); }, FLUSH_INTERVAL_MS); } function buildPayload(items) { return JSON.stringify({ site_id: SITE_ID, token: TOKEN, visitor_id: visitor.id, session_id: session.id, is_new_session: session.isNew, is_new_visitor: visitor.isNew, client: { language: navigator.language || null, screen_resolution: screen.width + "x" + screen.height, viewport: window.innerWidth + "x" + window.innerHeight, browser_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null, }, items: items, }); } function flush(useBeacon) { if (queue.length === 0) return; var items = queue.splice(0, queue.length); var body = buildPayload(items); if (useBeacon && navigator.sendBeacon) { var blob = new Blob([body], { type: "application/json" }); var ok = navigator.sendBeacon(TRACK_URL, blob); if (ok) return; // fall through to fetch as a best-effort fallback } fetch(TRACK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: body, keepalive: true, }).catch(function () { /* collector unreachable — drop silently, never break the host page */ }); } function enqueue(item) { queue.push(item); if (queue.length >= MAX_BATCH_SIZE) { flush(false); } else { scheduleFlush(); } } // ---- Pageview + active-time + scroll-depth tracking ---- var activeMs = 0; var lastVisibleAt = document.visibilityState === "visible" ? Date.now() : null; var maxScrollPct = 0; function updateActiveTime() { if (lastVisibleAt !== null) { activeMs += Date.now() - lastVisibleAt; lastVisibleAt = null; } } document.addEventListener("visibilitychange", function () { if (document.visibilityState === "visible") { lastVisibleAt = Date.now(); } else { updateActiveTime(); } }); function trackScroll() { var doc = document.documentElement; var scrollableHeight = doc.scrollHeight - doc.clientHeight; if (scrollableHeight <= 0) { maxScrollPct = 100; return; } var pct = Math.min(100, Math.round((window.scrollY / scrollableHeight) * 100)); if (pct > maxScrollPct) maxScrollPct = pct; } window.addEventListener("scroll", trackScroll, { passive: true }); var CURRENT_PATH = window.location.pathname; function pageviewItem() { var utm = getUtmParams(); return { type: "pageview", url: window.location.href, path: CURRENT_PATH, title: document.title, referrer: document.referrer || null, timestamp: new Date().toISOString(), utm_source: utm.utm_source, utm_medium: utm.utm_medium, utm_campaign: utm.utm_campaign, utm_term: utm.utm_term, utm_content: utm.utm_content, time_active_seconds: 0, scroll_depth_pct: 0, }; } // Send the pageview immediately so real-time views and page counts show up // without waiting for the visitor to leave the page. enqueue(pageviewItem()); flush(false); var engagementSent = false; function sendEngagement() { if (engagementSent) return; updateActiveTime(); // Engagement (active time + scroll depth) is reported as an event rather // than a second pageview row, so a single page visit never counts twice // in page_views — the tradeoff is that it shows up under Events, not // merged back into the original page_views row. engagementSent = true; enqueue({ type: "event", name: "page_engagement", properties: { path: CURRENT_PATH, time_active_seconds: Math.round(activeMs / 1000), scroll_depth_pct: maxScrollPct, }, page_url: window.location.href, timestamp: new Date().toISOString(), }); flush(true); } document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") sendEngagement(); }); window.addEventListener("pagehide", sendEngagement); // ---- Public API for custom events ---- window.analytics = window.analytics || {}; window.analytics.track = function (name, properties) { if (!name) return; enqueue({ type: "event", name: String(name), properties: properties || {}, page_url: window.location.href, timestamp: new Date().toISOString(), }); }; })();