@dotcms/analytics
Version:
Official JavaScript library for Content Analytics with DotCMS.
203 lines (202 loc) • 9.51 kB
JavaScript
import { getUVEState as m } from "../../../../uve/src/lib/core/core.utils.js";
import "../../../../uve/src/internal/constants.js";
import { getViewportMetrics as d, isElementMeetingVisibilityThreshold as u } from "./dot-analytics.impression.utils.js";
import { DEFAULT_IMPRESSION_CONFIG as l, IMPRESSION_EVENT_TYPE as g } from "../../shared/constants/dot-analytics.constants.js";
import { createPluginLogger as p, isBrowser as a, INITIAL_SCAN_DELAY_MS as f, findContentlets as b, extractContentletIdentifier as c, createContentletObserver as v, extractContentletData as I } from "../../shared/utils/dot-analytics.utils.js";
class E {
constructor(e) {
this.observer = null, this.mutationObserver = null, this.elementImpressionStates = /* @__PURE__ */ new Map(), this.sessionTrackedImpressions = /* @__PURE__ */ new Set(), this.currentPagePath = "", this.subscribers = /* @__PURE__ */ new Set(), this.logger = p("Impression", e), this.impressionConfig = this.resolveImpressionConfig(e.impressions);
}
/**
* Subscribe to impression events
* @param callback - Function called when impression is detected
* @returns Subscription object with unsubscribe method
*/
onImpression(e) {
return this.subscribers.add(e), {
unsubscribe: () => {
this.subscribers.delete(e);
}
};
}
/** Notifies all subscribers of an impression */
notifySubscribers(e, i) {
this.subscribers.forEach((t) => {
try {
t(e, i);
} catch (s) {
this.logger.error("Error in impression subscriber:", s);
}
});
}
/** Merges user config with defaults */
resolveImpressionConfig(e) {
return typeof e != "object" || e === null ? { ...l } : {
...l,
...e
};
}
/** Initializes tracking: sets up observers, finds contentlets, handles visibility/navigation */
initialize() {
if (a()) {
if (m()) {
this.logger.warn("Impression tracking disabled in editor mode");
return;
}
this.initializeIntersectionObserver(), typeof window < "u" && setTimeout(() => {
this.logger.debug("Running initial scan after timeout..."), this.findAndObserveContentletElements();
}, f), this.initializeDynamicContentDetector(), this.initializePageVisibilityHandler(), this.initializePageNavigationHandler(), this.logger.info("Impression tracking initialized with config:", this.impressionConfig);
}
}
/** Sets up IntersectionObserver with configured visibility threshold */
initializeIntersectionObserver() {
const e = {
root: null,
// Use viewport as root
rootMargin: "0px",
threshold: this.impressionConfig.visibilityThreshold
};
this.observer = new IntersectionObserver((i) => {
this.processIntersectionChanges(i);
}, e);
}
/** Finds contentlets in DOM, validates them, and starts observing (respects maxNodes limit) */
findAndObserveContentletElements() {
if (!this.observer) return;
const e = b();
if (e.length === 0) {
this.logger.warn("No contentlets found to track");
return;
}
const i = Math.min(e.length, this.impressionConfig.maxNodes);
let t = 0;
for (let s = 0; s < i; s++) {
const n = e[s], r = c(n);
if (r) {
const o = this.shouldSkipElement(n);
if (o) {
this.logger.debug(`Skipping element ${r} (${o})`);
continue;
}
this.elementImpressionStates.has(r) || (n.dataset.dotAnalyticsDomIndex || (n.dataset.dotAnalyticsDomIndex = String(s)), this.observer.observe(n), this.elementImpressionStates.set(r, {
timer: null,
visibleSince: null,
tracked: this.hasBeenTrackedInSession(r),
element: n
}), t++);
}
}
this.logger.info(`Observing ${t} contentlets`), e.length > i && this.logger.warn(
`${e.length - i} contentlets not tracked (maxNodes limit: ${this.impressionConfig.maxNodes})`
);
}
/** Watches for new contentlets added to DOM (debounced for performance) */
initializeDynamicContentDetector() {
a() && (this.mutationObserver = v(() => {
this.findAndObserveContentletElements();
}), this.logger.info("MutationObserver enabled for dynamic content detection"));
}
/** Cancels all timers when page is hidden (prevents false impressions) */
initializePageVisibilityHandler() {
document.addEventListener("visibilitychange", () => {
document.visibilityState === "hidden" && (this.elementImpressionStates.forEach((e) => {
e.timer !== null && (window.clearTimeout(e.timer), e.timer = null, e.visibleSince = null);
}), this.logger.warn("Page hidden, all impression timers cancelled"));
});
}
/** Resets tracking on SPA navigation (listens to pushState, replaceState, popstate) */
initializePageNavigationHandler() {
this.currentPagePath = window.location.pathname;
const e = () => {
const s = window.location.pathname;
s !== this.currentPagePath && (this.logger.warn(
`Navigation detected (${this.currentPagePath} → ${s}), resetting impression tracking`
), this.currentPagePath = s, this.sessionTrackedImpressions.clear(), this.elementImpressionStates.forEach((n) => {
n.timer !== null && (window.clearTimeout(n.timer), n.timer = null, n.visibleSince = null);
}), this.elementImpressionStates.clear());
};
window.addEventListener("popstate", e);
const i = history.pushState, t = history.replaceState;
history.pushState = function(...s) {
i.apply(this, s), e();
}, history.replaceState = function(...s) {
t.apply(this, s), e();
};
}
/** Handles visibility changes: starts timer on enter, cancels on exit */
processIntersectionChanges(e) {
document.visibilityState === "visible" && e.forEach((i) => {
const t = i.target, s = c(t);
s && (i.isIntersecting ? this.startImpressionDwellTimer(s, t) : this.cancelImpressionDwellTimer(s));
});
}
/** Starts dwell timer; fires impression if element still visible when timer expires */
startImpressionDwellTimer(e, i) {
const t = this.elementImpressionStates.get(e);
t && (t.tracked || this.hasBeenTrackedInSession(e) || t.timer === null && document.visibilityState === "visible" && (t.visibleSince = Date.now(), t.element = i, t.timer = window.setTimeout(() => {
this.isElementStillVisible(i) ? this.trackAndSendImpression(e, i) : (this.logger.warn(
`Dwell timer expired for ${e} but element no longer visible, skipping impression`
), t.timer = null, t.visibleSince = null);
}, this.impressionConfig.dwellMs), this.logger.debug(
`Started dwell timer for ${e} (${this.impressionConfig.dwellMs}ms)`
)));
}
/** Cancels active dwell timer (element left viewport before dwell time) */
cancelImpressionDwellTimer(e) {
const i = this.elementImpressionStates.get(e);
!i || i.timer === null || (window.clearTimeout(i.timer), i.timer = null, i.visibleSince = null, this.logger.debug(`Cancelled dwell timer for ${e}`));
}
/** Fires impression event with content & position data (page data added by enricher plugin) */
trackAndSendImpression(e, i) {
const t = this.elementImpressionStates.get(e);
if (!t) return;
const s = t.visibleSince ? Date.now() - t.visibleSince : 0, n = I(i), r = d(i), o = parseInt(i.dataset.dotAnalyticsDomIndex || "-1", 10), h = {
content: {
identifier: n.identifier,
inode: n.inode,
title: n.title,
content_type: n.contentType
},
position: {
viewport_offset_pct: r.offsetPercentage,
dom_index: o
}
};
this.notifySubscribers(g, h), this.markImpressionAsTracked(e), t.timer = null, t.visibleSince = null, t.tracked = !0, this.observer && this.observer.unobserve(i), this.logger.info(
`Fired impression for ${e} (dwell: ${s}ms) - element unobserved`,
n
);
}
/** Returns skip reason if element is hidden/too small, null if trackable */
shouldSkipElement(e) {
const i = e.getBoundingClientRect(), t = window.getComputedStyle(e);
if (i.height === 0 || i.width === 0)
return `zero dimensions: ${i.width}x${i.height}`;
const s = 10;
return i.height < s || i.width < s ? `too small: ${i.width}x${i.height} (minimum: ${s}px)` : t.visibility === "hidden" ? "visibility: hidden" : parseFloat(t.opacity) === 0 ? "opacity: 0" : t.display === "none" ? "display: none" : null;
}
/** Post-dwell check: verifies element still meets visibility threshold */
isElementStillVisible(e) {
return document.visibilityState !== "visible" ? !1 : u(
e,
this.impressionConfig.visibilityThreshold
);
}
/** Checks if impression already fired in current page session */
hasBeenTrackedInSession(e) {
return this.sessionTrackedImpressions.has(e);
}
/** Marks impression as tracked (prevents duplicates in same page session) */
markImpressionAsTracked(e) {
this.sessionTrackedImpressions.add(e);
}
/** Cleanup: disconnects observers, clears timers and state */
cleanup() {
this.observer && (this.observer.disconnect(), this.observer = null), this.mutationObserver && (this.mutationObserver.disconnect(), this.mutationObserver = null), this.elementImpressionStates.forEach((e) => {
e.timer !== null && window.clearTimeout(e.timer);
}), this.elementImpressionStates.clear(), this.subscribers.clear(), this.logger.info("Impression tracking cleaned up");
}
}
export {
E as DotCMSImpressionTracker
};