services/analytics/AnalyticsSDK.jsjavascript
// Vendored from ~/Development/js/messe-tracking/analytics.js (messe-tracking repo).
// Keep this file a 1:1 mirror of the source SDK; project-specific adjustments
// (config sourcing, graceful degradation) live in analytics.js instead.
export class AnalyticsSDK {
constructor(config = {}) {
if (!config.exponatId) {
throw new Error("[Analytics] Mandatory configuration missing: 'exponatId' must be provided.");
}
if (!config.apiKey) {
throw new Error(
"[Analytics] Mandatory configuration missing: 'apiKey' (x-api-key) must be provided."
);
}
this.exponatId = config.exponatId;
this.apiKey = config.apiKey;
this.endpoint = config.endpoint || "/events";
this.batchIntervalMs = config.batchIntervalMs || 5000;
this.maxBatchSize = config.maxBatchSize || 20;
this.deviceId = config.deviceId || this._getOrCreateDeviceId();
this.queue = [];
this.db = null;
this._initIndexedDB().then(() => {
this._flushStoredQueue();
});
this._setupGlobalEventListeners();
this._setupHistoryTracking();
this._startBatchTimer();
}
// ----------------------------------------------------------
// Identity Management
// ----------------------------------------------------------
_getOrCreateDeviceId() {
let id = localStorage.getItem("analytics_device_id");
if (!id) {
id = "dev_" + crypto.randomUUID();
localStorage.setItem("analytics_device_id", id);
}
return id;
}
// ----------------------------------------------------------
// DOM Selector Path Resolver (Normal & Shadow DOM)
// ----------------------------------------------------------
_getDomPath(element) {
const path = [];
let currentEl = element;
while (currentEl) {
if (currentEl instanceof ShadowRoot) {
path.unshift(`[ShadowRoot]`);
currentEl = currentEl.host;
continue;
}
if (currentEl.nodeType !== Node.ELEMENT_NODE) {
currentEl = currentEl.parentNode;
continue;
}
let selector = currentEl.nodeName.toLowerCase();
if (currentEl.id) {
selector += `#${currentEl.id}`;
path.unshift(selector);
break;
} else {
if (
currentEl.className &&
typeof currentEl.className === "string" &&
currentEl.className.trim()
) {
selector += `.${currentEl.className.trim().replace(/\s+/g, ".")}`;
}
let siblingIndex = 1;
let sib = currentEl.previousElementSibling;
while (sib) {
if (sib.nodeName.toLowerCase() === currentEl.nodeName.toLowerCase()) {
siblingIndex++;
}
sib = sib.previousElementSibling;
}
selector += `:nth-of-type(${siblingIndex})`;
}
path.unshift(selector);
currentEl = currentEl.parentNode || currentEl.host;
}
return path.join(" > ");
}
// ----------------------------------------------------------
// Implicit Global Event Tracking
// ----------------------------------------------------------
_setupGlobalEventListeners() {
const eventsToTrack = ["click", "touchstart", "keyup"];
eventsToTrack.forEach((eventType) => {
window.addEventListener(
eventType,
(event) => {
const path = event.composedPath ? event.composedPath() : [];
const target = path[0] || event.target;
if (!target || !(target instanceof HTMLElement || target instanceof SVGElement)) return;
const elementText = target.innerText
? target.innerText.substring(0, 50).trim()
: target.tagName || null;
// SVGElement.className is an SVGAnimatedString, not a plain string
// (unlike HTMLElement.className) - IndexedDB's structured clone
// can't store it as-is, which throws DataCloneError on every click
// inside an inline <svg> (e.g. ValueInfographic's Venn diagram).
const className =
typeof target.className === "string"
? target.className
: target.className?.baseVal || null;
this.trackEvent("click", {
element: elementText,
domPath: this._getDomPath(target),
payload: {
interactionType: event.type,
id: target.id || null,
className: className,
},
});
},
true
);
});
}
// ----------------------------------------------------------
// Framework agnostic History API Router Tracking
// ----------------------------------------------------------
_setupHistoryTracking() {
let lastUrl = window.location.href;
const notifyUrlChange = () => {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
const previousUrl = lastUrl;
lastUrl = currentUrl;
this.trackPageView({ previousUrl, currentUrl });
}
};
["pushState", "replaceState"].forEach((method) => {
const original = history[method];
history[method] = (...args) => {
const result = original.apply(history, args);
notifyUrlChange();
return result;
};
});
window.addEventListener("popstate", notifyUrlChange);
this.trackPageView({ previousUrl: null, currentUrl: lastUrl });
}
// ----------------------------------------------------------
// Explicit Tracking API
// ----------------------------------------------------------
trackPageView(customData = {}) {
this.recordPayload({
eventType: "page_view",
url: customData.currentUrl || window.location.href,
payload: {
title: document.title,
previousUrl: customData.previousUrl || null,
},
});
}
trackEvent(eventType, options = {}) {
this.recordPayload({
eventType: eventType,
element: options.element || null,
domPath: options.domPath || null,
payload: options.payload || null,
});
}
trackSlideOrStory(storyId, slideIndex, extraPayload = {}) {
this.recordPayload({
eventType: "story_view",
element: `Story ${storyId} - Slide ${slideIndex}`,
payload: {
storyId,
slideIndex,
...extraPayload,
},
});
}
trackHotspot(hotspotId, extraPayload = {}) {
this.recordPayload({
eventType: "click",
element: `Hotspot ${hotspotId}`,
domPath: "Hotspot Overlay Component",
payload: {
hotspotId,
...extraPayload,
},
});
}
recordPayload({ eventType, url, element, domPath, payload }) {
const event = {
_localId: crypto.randomUUID(),
exponatId: this.exponatId,
deviceId: this.deviceId,
eventType: eventType,
url: url || window.location.href,
timestamp: new Date().toISOString(),
};
if (element) event.element = element;
if (domPath) event.domPath = domPath;
if (payload && Object.keys(payload).length > 0) event.payload = payload;
this._enqueue(event);
}
// ----------------------------------------------------------
// Persistence & Caching
// ----------------------------------------------------------
async _initIndexedDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("AnalyticsCacheDB", 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains("events")) {
db.createObjectStore("events", { keyPath: "_localId" });
}
};
request.onsuccess = (e) => {
this.db = e.target.result;
resolve();
};
request.onerror = (e) => reject(e);
});
}
async _enqueue(event) {
this.queue.push(event);
if (this.db) {
const tx = this.db.transaction("events", "readwrite");
tx.objectStore("events").put(event);
}
if (this.queue.length >= this.maxBatchSize) {
this.flush();
}
}
_startBatchTimer() {
setInterval(() => this.flush(), this.batchIntervalMs);
}
async flush() {
if (this.queue.length === 0) return;
const batchToSend = [...this.queue];
// Remove internal _localId property before sending to API
const cleanedBatch = batchToSend.map((item) => {
const eventData = { ...item };
delete eventData._localId;
return eventData;
});
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
},
body: JSON.stringify(cleanedBatch),
keepalive: true,
});
if (response.ok) {
// Clear successfully sent items from memory queue and IndexedDB
this.queue = this.queue.filter(
(item) => !batchToSend.some((sent) => sent._localId === item._localId)
);
if (this.db) {
const tx = this.db.transaction("events", "readwrite");
const store = tx.objectStore("events");
batchToSend.forEach((item) => store.delete(item._localId));
}
} else {
// eslint-disable-next-line no-console
console.warn(`[Analytics] Backend rejected batch with HTTP ${response.status}`);
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[Analytics] Network error or offline mode. Retrying on next tick.", err);
}
}
async _flushStoredQueue() {
if (!this.db) return;
const tx = this.db.transaction("events", "readonly");
const store = tx.objectStore("events");
const request = store.getAll();
request.onsuccess = () => {
if (request.result && request.result.length > 0) {
this.queue = [...request.result, ...this.queue];
this.flush();
}
};
}
}