export const ANALYTICS_CONFIG_URL = "public/config/analytics.json"; export const ANALYTICS_SCHEMA_VERSION = 1; export const ANALYTICS_USER_ID_STORAGE_KEY = "world-citybook-anonymous-user-id"; export const ANALYTICS_SESSION_STORAGE_KEY = "world-citybook-session-id"; export const ANALYTICS_OPT_OUT_STORAGE_KEY = "world-citybook-analytics-opt-out"; export const DEFAULT_ANALYTICS_API_HOST = "https://us.i.posthog.com"; let analyticsState = { initialized: false, config: normalizeAnalyticsConfig(), anonymousUserId: "", sessionId: "", appVersion: "", pageUrl: "", referrer: "", send: null, }; export async function initAnalytics(options = {}) { const win = options.window || globalThis.window; const doc = options.document || win?.document || globalThis.document; const optOut = resolveAnalyticsOptOut(win); const config = normalizeAnalyticsConfig(options.config || await loadAnalyticsConfig(win, options.configUrl)); const anonymousUserId = getOrCreateAnonymousUserId({ localStorage: win?.localStorage, randomUUID: win?.crypto?.randomUUID?.bind(win.crypto), }); const sessionId = getOrCreateSessionId({ sessionStorage: win?.sessionStorage, randomUUID: win?.crypto?.randomUUID?.bind(win.crypto), }); const identityOptOut = await isExcludedAnalyticsId( anonymousUserId, config.excludedDistinctIdHashes, win?.crypto || globalThis.crypto, ); config.optOut = optOut || isLocalAnalyticsLocation(win?.location) || identityOptOut; analyticsState = { initialized: true, config, anonymousUserId, sessionId, appVersion: options.appVersion || appVersionFromDocument(doc), pageUrl: String(win?.location?.href || ""), referrer: String(doc?.referrer || ""), send: typeof options.send === "function" ? options.send : null, }; trackEvent("site_entered", { path: String(win?.location?.pathname || ""), search: String(win?.location?.search || ""), title: String(doc?.title || ""), }); return { anonymousUserId, sessionId, enabled: shouldTrackAnalytics(config), }; } export function trackEvent(name, props = {}) { if (!analyticsState.initialized) return null; const event = normalizeAnalyticsEvent(name, { anonymousUserId: analyticsState.anonymousUserId, sessionId: analyticsState.sessionId, appVersion: analyticsState.appVersion, pageUrl: analyticsState.pageUrl, referrer: analyticsState.referrer, props, }); if (!shouldTrackAnalytics(analyticsState.config)) return event; if (analyticsState.send) { analyticsState.send(event, analyticsState.config); } else if (analyticsState.config.provider === "posthog") { sendPostHogEvent(event, analyticsState.config); } return event; } export function getAnalyticsIdentity() { return { anonymousUserId: analyticsState.anonymousUserId, sessionId: analyticsState.sessionId, }; } export function getOrCreateAnonymousUserId(options = {}) { return getOrCreateStoredId({ storage: options.localStorage, storageKey: ANALYTICS_USER_ID_STORAGE_KEY, prefix: "wcb", randomUUID: options.randomUUID, }); } export function getOrCreateSessionId(options = {}) { return getOrCreateStoredId({ storage: options.sessionStorage, storageKey: ANALYTICS_SESSION_STORAGE_KEY, prefix: "wcbs", randomUUID: options.randomUUID, }); } export function normalizeAnalyticsConfig(value = {}) { const provider = String(value.provider || "posthog").trim().toLowerCase(); return { enabled: value.enabled === true, provider, posthogKey: String(value.posthogKey || value.apiKey || "").trim(), apiHost: stripTrailingSlash(value.apiHost || DEFAULT_ANALYTICS_API_HOST), debug: value.debug === true, optOut: value.optOut === true || value.optedOut === true, excludedDistinctIdHashes: normalizeDistinctIdHashes(value.excludedDistinctIdHashes), }; } export async function isExcludedAnalyticsId(value, hashes, cryptoProvider = globalThis.crypto) { const normalizedHashes = normalizeDistinctIdHashes(hashes); if (!value || !normalizedHashes.length || !cryptoProvider?.subtle?.digest) return false; const bytes = new TextEncoder().encode(String(value)); const digest = await cryptoProvider.subtle.digest("SHA-256", bytes); const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); return normalizedHashes.includes(hash); } export function isLocalAnalyticsLocation(location) { let hostname = String(location?.hostname || "").trim().toLowerCase(); if (!hostname && location?.href) { try { hostname = new URL(String(location.href)).hostname.toLowerCase(); } catch { return false; } } return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } export function normalizeAnalyticsEvent(name, options = {}) { return { name: String(name || "").trim(), properties: sanitizeProperties({ anonymous_user_id: options.anonymousUserId, session_id: options.sessionId, schema_version: ANALYTICS_SCHEMA_VERSION, app_version: options.appVersion, page_url: options.pageUrl, referrer: options.referrer, ...options.props, }), }; } export function shouldTrackAnalytics(config = analyticsState.config) { return config?.enabled === true && config.provider === "posthog" && Boolean(config.posthogKey) && config.optOut !== true; } async function loadAnalyticsConfig(win, configUrl = ANALYTICS_CONFIG_URL) { const fetcher = win?.fetch || globalThis.fetch; if (typeof fetcher !== "function") return {}; try { const response = await fetcher(configUrl, { cache: "no-store" }); if (!response?.ok) return {}; return await response.json(); } catch { return {}; } } function sendPostHogEvent(event, config) { const body = JSON.stringify({ api_key: config.posthogKey, event: event.name, distinct_id: event.properties.anonymous_user_id, properties: event.properties, }); const url = `${config.apiHost}/capture/`; const nav = globalThis.navigator; if (nav?.sendBeacon && globalThis.Blob) { const blob = new Blob([body], { type: "application/json" }); if (nav.sendBeacon(url, blob)) return; } globalThis.fetch?.(url, { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true, mode: "no-cors", }).catch(() => {}); } function getOrCreateStoredId({ storage, storageKey, prefix, randomUUID }) { const existing = safeStorageGet(storage, storageKey); if (isValidAnalyticsId(existing, prefix)) return existing; const id = `${prefix}_${createRandomId(randomUUID)}`; safeStorageSet(storage, storageKey, id); return id; } function createRandomId(randomUUID) { if (typeof randomUUID === "function") return String(randomUUID()); const random = Math.random().toString(36).slice(2, 12); const time = Date.now().toString(36); return `${time}-${random}`; } function isValidAnalyticsId(value, prefix) { return typeof value === "string" && value.startsWith(`${prefix}_`) && value.length > prefix.length + 4; } function safeStorageGet(storage, key) { try { return storage?.getItem?.(key) || ""; } catch { return ""; } } function safeStorageSet(storage, key, value) { try { storage?.setItem?.(key, value); } catch { // Analytics must never break the product experience. } } function safeStorageRemove(storage, key) { try { storage?.removeItem?.(key); } catch { // Analytics must never break the product experience. } } function resolveAnalyticsOptOut(win) { const storage = win?.localStorage; const params = new URLSearchParams(String(win?.location?.search || "")); if (truthyParam(params.get("analytics_opt_in"))) { safeStorageRemove(storage, ANALYTICS_OPT_OUT_STORAGE_KEY); } if (truthyParam(params.get("analytics_opt_out"))) { safeStorageSet(storage, ANALYTICS_OPT_OUT_STORAGE_KEY, "1"); return true; } return safeStorageGet(storage, ANALYTICS_OPT_OUT_STORAGE_KEY) === "1"; } function truthyParam(value) { return ["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase()); } function appVersionFromDocument(doc) { const script = doc?.querySelector?.('script[src*="src/app.js"]'); const src = script?.getAttribute("src") || ""; return src.match(/[?&]v=([^&]+)/)?.[1] || ""; } function stripTrailingSlash(value) { return String(value || "").trim().replace(/\/+$/u, ""); } function normalizeDistinctIdHashes(value) { const items = Array.isArray(value) ? value : String(value || "").split(/[\s,;]+/u); return [...new Set(items .map((item) => String(item || "").trim().toLowerCase()) .filter((item) => /^[a-f0-9]{64}$/u.test(item)))]; } function sanitizeProperties(value) { return Object.fromEntries( Object.entries(value) .filter(([, item]) => item !== undefined && item !== null && item !== "") .map(([key, item]) => [key, item instanceof Date ? item.toISOString() : item]), ); }