clarity-decode
Version:
An analytics library that uses web page interactions to generate aggregated insights
184 lines (173 loc) • 8.13 kB
text/typescript
import { Constant } from "@clarity-types/data";
import { Data, Layout } from "clarity-js";
import { DomData, LayoutEvent, Interaction, RegionVisibility } from "../types/layout";
const AverageWordLength = 6;
const Space = " ";
export function decode(tokens: Data.Token[]): LayoutEvent {
let time = tokens[0] as number;
let event = tokens[1] as Data.Event;
switch (event) {
case Data.Event.Document:
let documentData: Layout.DocumentData = { width: tokens[2] as number, height: tokens[3] as number };
return { time, event, data: documentData };
case Data.Event.Region:
let regionData: Layout.RegionData[] = [];
// From 0.6.15 we send each reach update in an individual event. This allows us to include time with it.
// To keep it backward compatible (<= 0.6.14), we look for multiple regions in the same event. This works both with newer and older payloads.
// In future, we can update the logic to look deterministically for only 3 fields and remove the for loop.
let increment: number;
for (let i = 2; i < tokens.length; i += increment) {
let region: Layout.RegionData;
if (typeof(tokens[i+2]) == Constant.Number) {
region = {
id: tokens[i] as number,
interaction: tokens[i + 1] as number,
visibility: tokens[i + 2] as number,
name: tokens[i + 3] as string
};
increment = 4;
} else {
let state = tokens[i + 1] as number;
region = {
id: tokens[i] as number,
// For backward compatibility before 0.6.24 - where region states were sent as a single enum
// we convert the value into the two states tracked after 0.6.24
interaction: state >= Interaction.None ? state : Interaction.None,
visibility: state <= RegionVisibility.ScrolledToEnd ? state : RegionVisibility.Rendered,
name: tokens[i + 2] as string
};
increment = 3;
}
regionData.push(region);
}
return { time, event, data: regionData };
case Data.Event.StyleSheetAdoption:
let styleSheetAdoptionData: Layout.StyleSheetData = {
id: tokens[2] as number,
operation: tokens[3] as number,
newIds: tokens[4] as string[]
};
return { time, event, data: styleSheetAdoptionData };
case Data.Event.StyleSheetUpdate:
let styleSheetUpdateData: Layout.StyleSheetData = {
id: tokens[2] as string,
operation: tokens[3] as number,
cssRules: tokens[4] as string
}
return { time, event, data: styleSheetUpdateData };
case Data.Event.Animation:
let animationData: Layout.AnimationData = {
id: tokens[2] as string,
operation: tokens[3] as number,
keyFrames: tokens[4] as string,
timing: tokens[5] as string,
timeline: tokens[6] as string,
targetId: tokens[7] as number
}
return { time, event, data: animationData};
case Data.Event.Discover:
case Data.Event.Mutation:
case Data.Event.Snapshot:
let lastType = null;
let node = [];
let tagIndex = 0;
let domData: DomData[] = [];
for (let i = 2; i < tokens.length; i++) {
let token = tokens[i];
let type = typeof(token);
switch (type) {
case "number":
if (type !== lastType && lastType !== null) {
domData.push(process(node, tagIndex));
node = [];
tagIndex = 0;
}
node.push(token);
tagIndex++;
break;
case "string":
node.push(token);
break;
case "object":
let subtoken = token[0];
let subtype = typeof(subtoken);
switch (subtype) {
case "number":
for (let t of (token as number[])) {
node.push(tokens.length > t ? tokens[t] : null);
}
break;
}
}
lastType = type;
}
// Process last node
domData.push(process(node, tagIndex));
return { time, event, data: domData };
}
return null;
}
function process(node: any[] | number[], tagIndex: number): DomData {
// For backward compatibility, only extract the tag even if position is available as part of the tag name
// And, continue computing position in the visualization library from decoded payload.
let tag = node[tagIndex] ? node[tagIndex].split("~")[0] : node[tagIndex];
let output: DomData = {
id: Math.abs(node[0]),
parent: tagIndex > 1 ? node[1] : null,
previous: tagIndex > 2 ? node[2] : null,
tag
};
let masked = node[0] < 0;
let hasAttribute = false;
let attributes: Layout.Attributes = {};
let value = null;
for (let i = tagIndex + 1; i < node.length; i++) {
// Explicitly convert the token into a string value
let token = node[i].toString();
let keyIndex = token.indexOf("=");
let firstChar = token[0];
let lastChar = token[token.length - 1];
if (i === (node.length - 1) && output.tag === "STYLE") {
value = token;
} else if (output.tag !== Layout.Constant.TextTag && lastChar === ">" && keyIndex === -1) {
// Backward compatibility - since v0.6.25
// Ignore this conditional block since we no longer compute selectors at decode time to save on uploaded bytes
// Instead, we now compute selector and hash at visualization layer where we have access to all payloads together
} else if (output.tag !== Layout.Constant.TextTag && firstChar === Layout.Constant.Hash && keyIndex === -1) {
let parts = token.substr(1).split(Layout.Constant.Period);
if (parts.length === 2) {
output.width = num(parts[0]) / Data.Setting.BoxPrecision;
output.height = num(parts[1]) / Data.Setting.BoxPrecision;
}
} else if (output.tag !== Layout.Constant.TextTag && keyIndex > 0) {
hasAttribute = true;
let k = token.substr(0, keyIndex);
let v = token.substr(keyIndex + 1);
attributes[k] = v;
} else if (output.tag === Layout.Constant.TextTag) {
value = masked ? unmask(token) : token;
}
}
if (hasAttribute) { output.attributes = attributes; }
if (value) { output.value = value; }
return output;
}
function num(input: string): number {
return input ? parseInt(input, 36) : null;
}
function unmask(value: string): string {
let trimmed = value.trim();
if (trimmed.length > 0 && trimmed.indexOf(Space) === -1) {
let length = num(trimmed);
if (length > 0) {
let quotient = Math.floor(length / AverageWordLength);
let remainder = length % AverageWordLength;
let output = Array(remainder + 1).join(Data.Constant.Mask);
for (let i = 0; i < quotient; i++) {
output += (i === 0 && remainder === 0 ? Data.Constant.Mask : Space) + Array(AverageWordLength).join(Data.Constant.Mask);
}
return output;
}
}
return value;
}