map / src /analytics.js
PH70248's picture
Deploy bookmark loading and analytics exclusions
cd96fd5 verified
Raw
History Blame Contribute Delete
9.04 kB
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]),
);
}