Spaces:
Running
Running
| 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]), | |
| ); | |
| } | |