clarity-js
Version:
An analytics library that uses web page interactions to generate aggregated insights
420 lines (372 loc) • 15.7 kB
text/typescript
import { Priority, Task, type Timer } from "@clarity-types/core";
import { Code, Event, Metric, Severity } from "@clarity-types/data";
import {
Constant,
type IWindowWithOverrides,
type MutationHistory,
type MutationQueue,
type MutationRecordWithTime,
Setting,
Source,
} from "@clarity-types/layout";
import { FunctionNames } from "@clarity-types/performance";
import * as core from "@src/core";
import api from "@src/core/api";
import config from "@src/core/config";
import * as event from "@src/core/event";
import measure from "@src/core/measure";
import * as task from "@src/core/task";
import { time } from "@src/core/time";
import { clearTimeout, setTimeout } from "@src/core/timeout";
import { id } from "@src/data/metadata";
import * as metric from "@src/data/metric";
import * as summary from "@src/data/summary";
import * as internal from "@src/diagnostic/internal";
import * as doc from "@src/layout/document";
import * as dom from "@src/layout/dom";
import encode from "@src/layout/encode";
import * as region from "@src/layout/region";
import traverse from "@src/layout/traverse";
import processNode from "./node";
let observers: Set<MutationObserver> = new Set();
let mutations: MutationQueue[] = [];
let throttledMutations: { [key: number]: MutationRecordWithTime } = {};
let queue: Node[] = [];
let timeout: number = null;
let throttleDelay: number = null;
let activePeriod = null;
let history: MutationHistory = {};
let observedNodes: WeakMap<Node, MutationObserver> = new WeakMap<Node, MutationObserver>();
// We ignore mutations if these attributes are updated
const IGNORED_ATTRIBUTES = ["data-google-query-id", "data-load-complete", "data-google-container-id"];
export function start(): void {
start.dn = FunctionNames.MutationStart;
observers = new Set();
queue = [];
timeout = null;
activePeriod = 0;
history = {};
observedNodes = new WeakMap<Node, MutationObserver>();
proxyStyleRules(window);
}
export function observe(node: Document | ShadowRoot): void {
// Create a new observer for every time a new DOM tree (e.g. root document or shadowdom root) is discovered on the page
// In the case of shadow dom, any mutations that happen within the shadow dom are not bubbled up to the host document
// For this reason, we need to wire up mutations every time we see a new shadow dom.
// Also, wrap it inside a try / catch. In certain browsers (e.g. legacy Edge), observer on shadow dom can throw errors
try {
const m = api(Constant.MutationObserver);
const observer = m in window ? new window[m](measure(handle) as MutationCallback) : null;
if (observer) {
observer.observe(node, { attributes: true, childList: true, characterData: true, subtree: true });
observedNodes.set(node, observer);
observers.add(observer);
}
if (node instanceof Document && node.defaultView) {
proxyStyleRules(node.defaultView);
}
} catch (e) {
internal.log(Code.MutationObserver, Severity.Info, e ? e.name : null);
}
}
export function monitor(frame: HTMLIFrameElement): void {
// Bind to iframe's onload event so we get notified anytime there's an update to iframe content.
// This includes cases where iframe location is updated without explicitly updating src attribute
// E.g. iframe.contentWindow.location.href = "new-location";
if (dom.has(frame) === false) {
event.bind(frame, Constant.LoadEvent, generate.bind(this, frame, Constant.ChildList), true);
}
}
export function stop(): void {
for (const observer of Array.from(observers)) {
if (observer) {
observer.disconnect();
}
}
observers = new Set();
history = {};
mutations = [];
throttledMutations = {};
queue = [];
activePeriod = 0;
timeout = null;
observedNodes = new WeakMap();
}
export function active(): void {
activePeriod = time() + Setting.MutationActivePeriod;
}
export function disconnect(n: Node): void {
const ob = observedNodes.get(n);
if (ob) {
ob.disconnect();
observers.delete(ob);
observedNodes.delete(n);
}
}
function handle(m: MutationRecord[]): void {
handle.dn = FunctionNames.MutationHandle;
// Queue up mutation records for asynchronous processing
const now = time();
summary.track(Event.Mutation, now);
mutations.push({ time: now, mutations: m });
task.schedule(process, Priority.High).then((): void => {
setTimeout(doc.compute);
measure(region.compute)();
});
}
async function processMutation(timer: Timer, mutation: MutationRecord, instance: number, timestamp: number): Promise<void> {
let state = task.state(timer);
if (state === Task.Wait) {
state = await task.suspend(timer);
}
if (state === Task.Stop) {
return;
}
const target = mutation.target;
const type = config.throttleDom ? track(mutation, timer, instance, timestamp) : mutation.type;
if (type && target && target.ownerDocument) {
dom.parse(target.ownerDocument);
}
if (type && target && target.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (target as ShadowRoot).host) {
dom.parse(target as ShadowRoot);
}
switch (type) {
case Constant.Attributes:
if (IGNORED_ATTRIBUTES.indexOf(mutation.attributeName) < 0) {
processNode(target, Source.Attributes, timestamp);
}
break;
case Constant.CharacterData:
processNode(target, Source.CharacterData, timestamp);
break;
case Constant.ChildList:
processNodeList(mutation.addedNodes, Source.ChildListAdd, timer, timestamp);
processNodeList(mutation.removedNodes, Source.ChildListRemove, timer, timestamp);
break;
default:
break;
}
}
async function process(): Promise<void> {
const timer: Timer = { id: id(), cost: Metric.LayoutCost };
task.start(timer);
while (mutations.length > 0) {
const record = mutations.shift();
const instance = time();
for (const mutation of record.mutations) {
await processMutation(timer, mutation, instance, record.time);
}
await encode(Event.Mutation, timer, record.time);
}
let processedMutations = false;
for (const key of Object.keys(throttledMutations)) {
const throttledMutationToProcess: MutationRecordWithTime = throttledMutations[key];
delete throttledMutations[key];
await processMutation(timer, throttledMutationToProcess.mutation, time(), throttledMutationToProcess.timestamp);
processedMutations = true;
}
if (Object.keys(throttledMutations).length > 0) {
processThrottledMutations();
}
// ensure we encode the previously throttled mutations once we have finished them
if (Object.keys(throttledMutations).length === 0 && processedMutations) {
await encode(Event.Mutation, timer, time());
}
cleanHistory();
task.stop(timer);
}
function cleanHistory(): void {
const now = time();
if (Object.keys(history).length > Setting.MaxMutationHistoryCount) {
history = {};
metric.count(Metric.HistoryClear);
}
for (const key of Object.keys(history)) {
const h = history[key];
if (now > h[1] + Setting.MaxMutationHistoryTime) {
delete history[key];
}
}
}
function track(m: MutationRecord, timer: Timer, instance: number, timestamp: number): string {
const value = m.target ? dom.get(m.target.parentNode) : null;
// Check if the parent is already discovered and that the parent is not the document root
if (value && value.data.tag !== Constant.HTML) {
// calculate inactive period based on the timestamp of the mutation not when the mutation is processed
const inactive = timestamp > activePeriod;
// Calculate critical period based on when mutation is processed
const target = dom.get(m.target);
const element = target?.selector ? target.selector.join() : m.target.nodeName;
const parent = value.selector ? value.selector.join() : Constant.Empty;
// We use selector, instead of id, to determine the key (signature for the mutation) because in some cases
// repeated mutations can cause elements to be destroyed and then recreated as new DOM nodes
// In those cases, IDs will change however the selector (which is relative to DOM xPath) remains the same
const key = [parent, element, m.attributeName, names(m.addedNodes), names(m.removedNodes)].join();
// Initialize an entry if it doesn't already exist
history[key] = key in history ? history[key] : [0, instance];
const h = history[key];
// Lookup any pending nodes queued up for removal, and process them now if we suspended a mutation before
if (inactive === false && h[0] >= Setting.MutationSuspendThreshold) {
processNodeList(h[2], Source.ChildListRemove, timer, timestamp);
}
// Update the counter, do not reset counter if its critical period
h[0] = inactive ? (h[1] === instance ? h[0] : h[0] + 1) : 1;
h[1] = instance;
// Return updated mutation type based on,
// 1. if we have already hit the threshold or not
// 2. if its a low priority mutation happening during critical time period
if (h[0] >= Setting.MutationSuspendThreshold) {
// Store a reference to removedNodes so we can process them later
// when we resume mutations again on user interactions
h[2] = m.removedNodes;
if (instance > timestamp + Setting.MutationActivePeriod) {
return m.type;
}
// we only store the most recent mutation for a given key if it is being throttled
throttledMutations[key] = { mutation: m, timestamp };
return Constant.Throttle;
}
}
return m.type;
}
function names(nodes: NodeList): string {
const output: string[] = [];
for (let i = 0; nodes && i < nodes.length; i++) {
output.push(nodes[i].nodeName);
}
return output.join();
}
async function processNodeList(list: NodeList, source: Source, timer: Timer, timestamp: number): Promise<void> {
const length = list ? list.length : 0;
for (let i = 0; i < length; i++) {
const node = list[i];
if (source === Source.ChildListAdd) {
traverse(node, timer, source, timestamp);
} else {
let state = task.state(timer);
if (state === Task.Wait) {
state = await task.suspend(timer);
}
if (state === Task.Stop) {
break;
}
processNode(node, source, timestamp);
}
}
}
function processThrottledMutations(): void {
if (throttleDelay) {
clearTimeout(throttleDelay);
}
throttleDelay = setTimeout(() => {
task.schedule(process, Priority.High);
}, Setting.LookAhead);
}
export function schedule(node: Node): Node {
// Only schedule manual trigger for this node if it's not already in the queue
if (queue.indexOf(node) < 0) {
queue.push(node);
}
// Cancel any previous trigger before scheduling a new one.
// It's common for a webpage to call multiple synchronous "insertRule" / "deleteRule" calls.
// And in those cases we do not wish to monitor changes multiple times for the same node.
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
trigger();
}, Setting.LookAhead);
return node;
}
function trigger(): void {
for (const node of queue) {
// Generate a mutation for this node only if it still exists
if (node) {
const shadowRoot = node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
// Skip re-processing shadowRoot if it was already discovered
if (shadowRoot && dom.has(node)) {
continue;
}
generate(node, shadowRoot ? Constant.ChildList : Constant.CharacterData);
}
}
queue = [];
}
function generate(target: Node, type: MutationRecordType): void {
generate.dn = FunctionNames.MutationGenerate;
measure(handle)([
{
addedNodes: [target],
attributeName: null,
attributeNamespace: null,
nextSibling: null,
oldValue: null,
previousSibling: null,
removedNodes: [],
target,
type,
},
]);
}
function proxyStyleRules(win: IWindowWithOverrides): void {
if (win === null || win === undefined) {
return;
}
win.clarityOverrides = win.clarityOverrides || {};
// Some popular open source libraries, like styled-components, optimize performance
// by injecting CSS using insertRule API vs. appending text node. A side effect of
// using javascript API is that it doesn't trigger DOM mutation and therefore we
// need to override the insertRule API and listen for changes manually.
if (win.clarityOverrides.InsertRule === undefined) {
win.clarityOverrides.InsertRule = win.CSSStyleSheet.prototype.insertRule;
win.CSSStyleSheet.prototype.insertRule = function (...args): number {
if (core.active()) {
schedule(this.ownerNode);
}
return win.clarityOverrides.InsertRule.apply(this, args);
};
}
if ("CSSMediaRule" in win && win.clarityOverrides.MediaInsertRule === undefined) {
win.clarityOverrides.MediaInsertRule = win.CSSMediaRule.prototype.insertRule;
win.CSSMediaRule.prototype.insertRule = function (...args): number {
if (core.active()) {
schedule(this.parentStyleSheet.ownerNode);
}
return win.clarityOverrides.MediaInsertRule.apply(this, args);
};
}
if (win.clarityOverrides.DeleteRule === undefined) {
win.clarityOverrides.DeleteRule = win.CSSStyleSheet.prototype.deleteRule;
win.CSSStyleSheet.prototype.deleteRule = function (...args): void {
if (core.active()) {
schedule(this.ownerNode);
}
win.clarityOverrides.DeleteRule.apply(this, args);
};
}
if ("CSSMediaRule" in win && win.clarityOverrides.MediaDeleteRule === undefined) {
win.clarityOverrides.MediaDeleteRule = win.CSSMediaRule.prototype.deleteRule;
win.CSSMediaRule.prototype.deleteRule = function (...args): void {
if (core.active()) {
schedule(this.parentStyleSheet.ownerNode);
}
win.clarityOverrides.MediaDeleteRule.apply(this, args);
};
}
// Add a hook to attachShadow API calls
// In case we are unable to add a hook and browser throws an exception,
// reset attachShadow variable and resume processing like before
if (win.clarityOverrides.AttachShadow === undefined) {
win.clarityOverrides.AttachShadow = win.Element.prototype.attachShadow;
try {
win.Element.prototype.attachShadow = function (...args): ShadowRoot {
if (core.active()) {
return schedule(win.clarityOverrides.AttachShadow.apply(this, args)) as ShadowRoot;
}
return win.clarityOverrides.AttachShadow.apply(this, args);
};
} catch {
win.clarityOverrides.AttachShadow = null;
}
}
}