UNPKG

clarity-js

Version:

An analytics library that uses web page interactions to generate aggregated insights

384 lines (345 loc) 17.3 kB
import { Time } from "@clarity-types/core"; import { BooleanFlag, Constant, Dimension, type Metadata, type MetadataCallback, type MetadataCallbackOptions, Metric, type Session, Setting, type User, } from "@clarity-types/data"; import * as clarity from "@src/clarity"; import * as core from "@src/core"; import config from "@src/core/config"; import hash from "@src/core/hash"; import * as scrub from "@src/core/scrub"; import * as trackConsent from "@src/data/consent"; import * as dimension from "@src/data/dimension"; import * as metric from "@src/data/metric"; import { set } from "@src/data/variable"; export let data: Metadata = null; export const callbacks: MetadataCallbackOptions[] = []; export let electron = BooleanFlag.False; let rootDomain = null; export function start(): void { rootDomain = null; const ua = navigator && "userAgent" in navigator ? navigator.userAgent : Constant.Empty; const timezone = Intl?.DateTimeFormat()?.resolvedOptions()?.timeZone ?? ""; const timezoneOffset = new Date().getTimezoneOffset().toString(); const ancestorOrigins = window.location.ancestorOrigins ? Array.from(window.location.ancestorOrigins).toString() : ""; const title = document?.title ? document.title : Constant.Empty; electron = ua.indexOf(Constant.Electron) > 0 ? BooleanFlag.True : BooleanFlag.False; // Populate ids for this page const s = session(); const u = user(); const projectId = config.projectId || hash(location.host); data = { projectId, userId: u.id, sessionId: s.session, pageNum: s.count }; // Override configuration based on what's in the session storage, unless it is blank (e.g. using upload callback, like in devtools) config.lean = config.track && s.upgrade !== null ? s.upgrade === BooleanFlag.False : config.lean; config.upload = config.track && typeof config.upload === "string" && s.upload && s.upload.length > Constant.HTTPS.length ? s.upload : config.upload; // Log page metadata as dimensions dimension.log(Dimension.UserAgent, ua); dimension.log(Dimension.PageTitle, title); dimension.log(Dimension.Url, scrub.url(location.href, !!electron)); dimension.log(Dimension.Referrer, document.referrer); dimension.log(Dimension.TabId, tab()); dimension.log(Dimension.PageLanguage, document.documentElement.lang); dimension.log(Dimension.DocumentDirection, document.dir); dimension.log(Dimension.DevicePixelRatio, `${window.devicePixelRatio}`); dimension.log(Dimension.Dob, u.dob.toString()); dimension.log(Dimension.CookieVersion, u.version.toString()); dimension.log(Dimension.AncestorOrigins, ancestorOrigins); dimension.log(Dimension.Timezone, timezone); dimension.log(Dimension.TimezoneOffset, timezoneOffset); // Capture additional metadata as metrics metric.max(Metric.ClientTimestamp, s.ts); metric.max(Metric.Playback, BooleanFlag.False); metric.max(Metric.Electron, electron); // Capture navigator specific dimensions if (navigator) { dimension.log(Dimension.Language, navigator.language); metric.max(Metric.HardwareConcurrency, navigator.hardwareConcurrency); metric.max(Metric.MaxTouchPoints, navigator.maxTouchPoints); // biome-ignore lint/suspicious/noExplicitAny: not all browsers support navigator.deviceMemory metric.max(Metric.DeviceMemory, Math.round((<any>navigator).deviceMemory)); userAgentData(); } if (screen) { metric.max(Metric.ScreenWidth, Math.round(screen.width)); metric.max(Metric.ScreenHeight, Math.round(screen.height)); metric.max(Metric.ColorDepth, Math.round(screen.colorDepth)); } // Read cookies specified in configuration for (const key of config.cookies) { const value = getCookie(key); if (value) { set(key, value); } } // Track consent config trackConsent.config(config.track); // Track ids using a cookie if configuration allows it track(u); } function userAgentData(): void { // biome-ignore lint/suspicious/noExplicitAny: not all browsers support navigator.userAgentData const uaData = (<any>navigator).userAgentData; if (uaData?.getHighEntropyValues) { uaData.getHighEntropyValues(["model", "platform", "platformVersion", "uaFullVersion"]).then((ua) => { dimension.log(Dimension.Platform, ua.platform); dimension.log(Dimension.PlatformVersion, ua.platformVersion); if (ua.brands) { for (const brand of ua.brands) { dimension.log(Dimension.Brand, brand.name + Constant.Tilde + brand.version); } } dimension.log(Dimension.Model, ua.model); metric.max(Metric.Mobile, ua.mobile ? BooleanFlag.True : BooleanFlag.False); }); } else { dimension.log(Dimension.Platform, navigator.platform); } } export function stop(): void { rootDomain = null; data = null; for (const cb of callbacks) { cb.called = false; } } export function metadata(cb: MetadataCallback, wait = true, recall = false): void { const upgraded = config.lean ? BooleanFlag.False : BooleanFlag.True; let called = false; // if caller hasn't specified that they want to skip waiting for upgrade but we've already upgraded, we need to // directly execute the callback in addition to adding to our list as we only process callbacks at the moment // we go through the upgrading flow. if (data && (upgraded || wait === false)) { // Immediately invoke the callback if the caller explicitly doesn't want to wait for the upgrade confirmation cb(data, !config.lean); called = true; } if (recall || !called) { callbacks.push({ callback: cb, wait, recall, called }); } } export function id(): string { return data ? [data.userId, data.sessionId, data.pageNum].join(Constant.Dot) : Constant.Empty; } export function consent(status = true): void { if (!status) { config.track = false; setCookie(Constant.SessionKey, Constant.Empty, -Number.MAX_VALUE); setCookie(Constant.CookieKey, Constant.Empty, -Number.MAX_VALUE); clarity.stop(); window.setTimeout(clarity.start, Setting.RestartDelay); return; } if (core.active()) { config.track = true; track(user(), BooleanFlag.True); save(); trackConsent.consent(); } } export function clear(): void { // Clear any stored information in the cookie that tracks session information so we can restart fresh the next time setCookie(Constant.SessionKey, Constant.Empty, 0); } function tab(): string { let id = shortid(); if (config.track && supported(window, Constant.SessionStorage)) { const value = sessionStorage.getItem(Constant.TabKey); id = value ? value : id; sessionStorage.setItem(Constant.TabKey, id); } return id; } export function callback(): void { const upgrade = config.lean ? BooleanFlag.False : BooleanFlag.True; processCallback(upgrade); } export function save(): void { if (!data || !config.track) return; const ts = Math.round(Date.now()); const upload = config.upload && typeof config.upload === "string" ? (config.upload as string).replace(Constant.HTTPS, Constant.Empty) : Constant.Empty; const upgrade = config.lean ? BooleanFlag.False : BooleanFlag.True; setCookie(Constant.SessionKey, [data.sessionId, ts, data.pageNum, upgrade, upload].join(Constant.Pipe), Setting.SessionExpire); } function processCallback(upgrade: BooleanFlag) { if (callbacks.length > 0) { for (let i = 0; i < callbacks.length; i++) { const cb = callbacks[i]; if (cb.callback && !cb.called && (!cb.wait || upgrade)) { cb.callback(data, !config.lean); cb.called = true; if (!cb.recall) { callbacks.splice(i, 1); i--; } } } } } function supported(target: Window | Document, api: string): boolean { try { return !!target[api]; } catch { return false; } } function track(u: User, consentInput: BooleanFlag = null): void { // If consent is not explicitly specified, infer it from the user object const consent = consentInput === null ? u.consent : consentInput; // Convert time precision into days to reduce number of bytes we have to write in a cookie // E.g. Math.ceil(1628735962643 / (24*60*60*1000)) => 18852 (days) => ejo in base36 (13 bytes => 3 bytes) const end = Math.ceil((Date.now() + Setting.Expire * Time.Day) / Time.Day); // If DOB is not set in the user object, use the date set in the config as a DOB const dob = u.dob === 0 ? (config.dob === null ? 0 : config.dob) : u.dob; // To avoid cookie churn, write user id cookie only once every day if (u.expiry === null || Math.abs(end - u.expiry) >= Setting.CookieInterval || u.consent !== consent || u.dob !== dob) { const cookieParts = [data.userId, Setting.CookieVersion, end.toString(36), consent, dob]; setCookie(Constant.CookieKey, cookieParts.join(Constant.Pipe), Setting.Expire); } } export function shortid(): string { let id = Math.floor(Math.random() * 2 ** 32); if (window?.crypto?.getRandomValues && Uint32Array) { id = window.crypto.getRandomValues(new Uint32Array(1))[0]; } return id.toString(36); } function session(): Session { const output: Session = { session: shortid(), ts: Math.round(Date.now()), count: 1, upgrade: null, upload: Constant.Empty }; const value = getCookie(Constant.SessionKey, !config.includeSubdomains); if (value) { // Maintaining support for pipe separator for backward compatibility, this can be removed in future releases const parts = value.includes(Constant.Caret) ? value.split(Constant.Caret) : value.split(Constant.Pipe); // Making it backward & forward compatible by using greater than comparison (v0.6.21) // In future version, we can reduce the parts length to be 5 where the last part contains the full upload URL if (parts.length >= 5 && output.ts - num(parts[1]) < Setting.SessionTimeout) { output.session = parts[0]; output.count = num(parts[2]) + 1; output.upgrade = num(parts[3]); output.upload = parts.length >= 6 ? `${Constant.HTTPS}${parts[5]}/${parts[4]}` : `${Constant.HTTPS}${parts[4]}`; } } return output; } function num(string: string, base = 10): number { return Number.parseInt(string, base); } function user(): User { const output: User = { id: shortid(), version: 0, expiry: null, consent: BooleanFlag.False, dob: 0 }; const cookie = getCookie(Constant.CookieKey, !config.includeSubdomains); if (cookie && cookie.length > 0) { // Splitting and looking up first part for forward compatibility, in case we wish to store additional information in a cookie // Maintaining support for pipe separator for backward compatibility, this can be removed in future releases const parts = cookie.includes(Constant.Caret) ? cookie.split(Constant.Caret) : cookie.split(Constant.Pipe); // Read version information and timestamp from cookie, if available if (parts.length > 1) { output.version = num(parts[1]); } if (parts.length > 2) { output.expiry = num(parts[2], 36); } // Check if we have explicit consent to track this user if (parts.length > 3 && num(parts[3]) === 1) { output.consent = BooleanFlag.True; } if (parts.length > 4 && num(parts[1]) > 1) { output.dob = num(parts[4]); } // Set track configuration to true for this user if we have explicit consent, regardless of project setting config.track = config.track || output.consent === BooleanFlag.True; // Get user id from cookie only if we tracking is enabled, otherwise fallback to a random id output.id = config.track ? parts[0] : output.id; } return output; } function getCookie(key: string, limit = false): string { if (supported(document, Constant.Cookie)) { const cookies: string[] = document.cookie.split(Constant.Semicolon); if (cookies) { for (let i = 0; i < cookies.length; i++) { const pair: string[] = cookies[i].split(Constant.Equals); if (pair.length > 1 && pair[0] && pair[0].trim() === key) { // Some browsers automatically url encode cookie values if they are not url encoded. // We therefore encode and decode cookie values ourselves. // For backwards compatability we need to consider 3 cases: // * Cookie was previously not encoded by Clarity and browser did not encode it // * Cookie was previously not encoded by Clarity and browser encoded it once or more // * Cookie was previously encoded by Clarity and browser did not encode it let [isEncoded, decodedValue] = decodeCookieValue(pair[1]); while (isEncoded) { [isEncoded, decodedValue] = decodeCookieValue(decodedValue); } // If we are limiting cookies, check if the cookie value is limited if (limit) { return decodedValue.endsWith(`${Constant.Tilde}1`) ? decodedValue.substring(0, decodedValue.length - 2) : null; } return decodedValue; } } } } return null; } function decodeCookieValue(value: string): [boolean, string] { try { const decodedValue = decodeURIComponent(value); return [decodedValue !== value, decodedValue]; } catch {} return [false, value]; } function encodeCookieValue(value: string): string { return encodeURIComponent(value); } function setCookie(key: string, value: string, time: number): void { // only write cookies if we are currently in a cookie writing mode (and they are supported) // OR if we are trying to write an empty cookie (i.e. clear the cookie value out) if ((config.track || value === Constant.Empty) && (navigator?.cookieEnabled || supported(document, Constant.Cookie))) { // Some browsers automatically url encode cookie values if they are not url encoded. // We therefore encode and decode cookie values ourselves. const encodedValue = encodeCookieValue(value); const expiry = new Date(); expiry.setDate(expiry.getDate() + time); const expires = expiry ? Constant.Expires + expiry.toUTCString() : Constant.Empty; const cookie = `${key}=${encodedValue}${Constant.Semicolon}${expires}${Constant.Path}`; try { // Attempt to get the root domain only once and fall back to writing cookie on the current domain. if (rootDomain === null) { const hostname = location.hostname ? location.hostname.split(Constant.Dot) : []; // Walk backwards on a domain and attempt to set a cookie, until successful for (let i = hostname.length - 1; i >= 0; i--) { rootDomain = `.${hostname[i]}${rootDomain ? rootDomain : Constant.Empty}`; // We do not wish to attempt writing a cookie on the absolute last part of the domain, e.g. .com or .net. // So we start attempting after second-last part, e.g. .domain.com (PASS) or .co.uk (FAIL) if (i < hostname.length - 1) { // Write the cookie on the current computed top level domain document.cookie = `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}`; // Once written, check if the cookie exists and its value matches exactly with what we intended to set // Checking for exact value match helps us eliminate a corner case where the cookie may already be present with a different value // If the check is successful, no more action is required and we can return from the function since rootDomain cookie is already set // If the check fails, continue with the for loop until we can successfully set and verify the cookie if (getCookie(key) === value) { return; } } } // Finally, if we were not successful and gone through all the options, play it safe and reset rootDomain to be empty // This forces our code to fall back to always writing cookie to the current domain rootDomain = Constant.Empty; } } catch { rootDomain = Constant.Empty; } document.cookie = rootDomain ? `${cookie}${Constant.Semicolon}${Constant.Domain}${rootDomain}` : cookie; } }