import { getSharedInstance } from "../utils/sharedInstance.js"; import { generateUuid } from "../utils/common.js"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__"; export const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__"; export const ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY = "analytics-enable"; export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = "base44_analytics_session_id"; const defaultConfiguration = { // default to enabled // enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 30, heartBeatInterval: 60 * 1000, }; /////////////////////////////////////////////// //// shared queue for analytics events //// /////////////////////////////////////////////// const ANALYTICS_SHARED_STATE_NAME = "analytics"; // shared state// const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, () => ({ requestsQueue: [], isProcessing: false, isHeartBeatProcessing: false, wasInitializationTracked: false, sessionContext: null, sessionStartTime: null, config: { ...defaultConfiguration, ...getAnalyticsConfigFromUrlParams(), }, })); export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, }) => { var _a; // prevent overflow of events // const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; if (!((_a = analyticsSharedState.config) === null || _a === void 0 ? void 0 : _a.enabled)) { return { track: () => { }, cleanup: () => { }, }; } let clearHeartBeatProcessor = undefined; const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; const batchRequestFallback = async (events) => { await axiosClient.request({ method: "POST", url: `/apps/${appId}/analytics/track/batch`, data: { events }, }); }; // currently disabled, until fully tested // const beaconRequest = (events) => { try { const beaconPayload = JSON.stringify({ events }); const blob = new Blob([beaconPayload], { type: "application/json" }); return (typeof navigator === "undefined" || beaconPayload.length > 60000 || !navigator.sendBeacon(trackBatchUrl, blob)); } catch (_a) { return false; } }; const flush = async (eventsData, options = {}) => { if (eventsData.length === 0) return; const sessionContext_ = await getSessionContext(userAuthModule); const events = eventsData.map(transformEventDataToApiRequestData(sessionContext_)); try { if (!options.isBeacon || !beaconRequest(events)) { await batchRequestFallback(events); } } catch (_a) { // do nothing } }; const startProcessing = () => { startAnalyticsProcessor(flush, { throttleTime, batchSize, }); }; const track = (params) => { if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { return; } const intrinsicData = getEventIntrinsicData(); analyticsSharedState.requestsQueue.push({ ...params, ...intrinsicData, }); startProcessing(); }; const onDocVisible = () => { startAnalyticsProcessor(flush, { throttleTime, batchSize, }); clearHeartBeatProcessor = startHeartBeatProcessor(track); setSessionDurationTimerStart(); }; const onDocHidden = () => { stopAnalyticsProcessor(); clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor(); trackSessionDurationEvent(track); // flush entire queue on visibility change and hope for the best // const eventsData = analyticsSharedState.requestsQueue.splice(0); flush(eventsData, { isBeacon: true }); }; const onVisibilityChange = () => { if (typeof window === "undefined") return; if (document.visibilityState === "hidden") { onDocHidden(); } else if (document.visibilityState === "visible") { onDocVisible(); } }; const cleanup = () => { stopAnalyticsProcessor(); clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor(); if (typeof window !== "undefined") { window.removeEventListener("visibilitychange", onVisibilityChange); } }; // start the flusing process /// startProcessing(); // start the heart beat processor // clearHeartBeatProcessor = startHeartBeatProcessor(track); // track the referrer event // trackInitializationEvent(track); // start the visibility change listener // if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); } return { track, cleanup, }; }; function stopAnalyticsProcessor() { analyticsSharedState.isProcessing = false; } async function startAnalyticsProcessor(handleTrack, options) { if (analyticsSharedState.isProcessing) { // only one instance of the analytics processor can be running at a time // return; } analyticsSharedState.isProcessing = true; const { throttleTime = 1000, batchSize = 30 } = options !== null && options !== void 0 ? options : {}; while (analyticsSharedState.isProcessing && analyticsSharedState.requestsQueue.length > 0) { const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); requests.length && (await handleTrack(requests)); await new Promise((resolve) => setTimeout(resolve, throttleTime)); } analyticsSharedState.isProcessing = false; } function startHeartBeatProcessor(track) { var _a; if (analyticsSharedState.isHeartBeatProcessing || ((_a = analyticsSharedState.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) { return () => { }; } analyticsSharedState.isHeartBeatProcessing = true; const interval = setInterval(() => { track({ eventName: USER_HEARTBEAT_EVENT_NAME }); }, analyticsSharedState.config.heartBeatInterval); return () => { clearInterval(interval); analyticsSharedState.isHeartBeatProcessing = false; }; } function trackInitializationEvent(track) { if (typeof window === "undefined" || analyticsSharedState.wasInitializationTracked) { return; } analyticsSharedState.wasInitializationTracked = true; track({ eventName: ANALYTICS_INITIALIZATION_EVENT_NAME, properties: { referrer: document === null || document === void 0 ? void 0 : document.referrer, }, }); } function setSessionDurationTimerStart() { if (typeof window === "undefined" || analyticsSharedState.sessionStartTime !== null) { return; } analyticsSharedState.sessionStartTime = new Date().toISOString(); } function trackSessionDurationEvent(track) { if (typeof window === "undefined" || analyticsSharedState.sessionStartTime === null) return; const sessionDuration = new Date().getTime() - new Date(analyticsSharedState.sessionStartTime).getTime(); analyticsSharedState.sessionStartTime = null; track({ eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME, properties: { sessionDuration }, }); } function getEventIntrinsicData() { return { timestamp: new Date().toISOString(), pageUrl: typeof window !== "undefined" ? window.location.pathname : null, }; } function transformEventDataToApiRequestData(sessionContext) { return (eventData) => ({ event_name: eventData.eventName, properties: eventData.properties, timestamp: eventData.timestamp, page_url: eventData.pageUrl, ...sessionContext, }); } let sessionContextPromise = null; async function getSessionContext(userAuthModule) { if (!analyticsSharedState.sessionContext) { if (!sessionContextPromise) { const sessionId = getAnalyticsSessionId(); sessionContextPromise = userAuthModule .me() .then((user) => ({ user_id: user.id, session_id: sessionId, })) .catch(() => ({ user_id: null, session_id: sessionId, })); } analyticsSharedState.sessionContext = await sessionContextPromise; } return analyticsSharedState.sessionContext; } export function getAnalyticsConfigFromUrlParams() { if (typeof window === "undefined") return undefined; const urlParams = new URLSearchParams(window.location.search); const analyticsEnable = urlParams.get(ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY); // if the url param is not set, return undefined // if (analyticsEnable == null || !analyticsEnable.length) return undefined; // remove the url param from the url // const newUrlParams = new URLSearchParams(window.location.search); newUrlParams.delete(ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY); const newUrl = window.location.pathname + (newUrlParams.toString() ? "?" + newUrlParams.toString() : ""); window.history.replaceState({}, "", newUrl); // return the config object // return { enabled: analyticsEnable === "true" }; } export function getAnalyticsSessionId() { if (typeof window === "undefined") { return generateUuid(); } try { const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY); if (!sessionId) { const newSessionId = generateUuid(); localStorage.setItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY, newSessionId); return newSessionId; } return sessionId; } catch (_a) { return generateUuid(); } }