Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 2 additions & 22 deletions packages/plugin-autocapture-browser/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,26 +181,6 @@ export const removeEmptyProperties = (properties: { [key: string]: unknown }) =>
}, {});
};

export const getNearestLabel = (element: Element): string => {
const parent = element.parentElement;
if (!parent) {
return '';
}
let labelElement;
try {
labelElement = parent.querySelector(':scope>span,h1,h2,h3,h4,h5,h6');
} catch (error) {
/* istanbul ignore next */
labelElement = null;
}
if (labelElement) {
/* istanbul ignore next */
const labelText = labelElement.textContent || '';
return isNonSensitiveString(labelText) ? labelText : '';
}
return getNearestLabel(parent);
};

export const querySelectUniqueElements = (root: Element | Document, selectors: string[]): Element[] => {
if (root && 'querySelectorAll' in root && typeof root.querySelectorAll === 'function') {
const elementSet = selectors.reduce((elements: Set<Element>, selector) => {
Expand Down Expand Up @@ -298,12 +278,12 @@ export const getEventProperties = (actionType: ActionType, element: Element, dat
typeof element.getBoundingClientRect === 'function' ? element.getBoundingClientRect() : { left: null, top: null };
const ariaLabel = element.getAttribute('aria-label');
const attributes = getAttributesWithPrefix(element, dataAttributePrefix);
const nearestLabel = getNearestLabel(element);
const { hierarchy, nearestLabel } = getHierarchy(element);
/* istanbul ignore next */
const properties: Record<string, any> = {
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_ID]: element.getAttribute('id') || '',
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_CLASS]: element.getAttribute('class'),
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_HIERARCHY]: getHierarchy(element),
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_HIERARCHY]: hierarchy,
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_TAG]: tag,
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_TEXT]: getText(element),
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_LEFT]: rect.left == null ? null : Math.round(rect.left),
Expand Down
111 changes: 92 additions & 19 deletions packages/plugin-autocapture-browser/src/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { isNonSensitiveElement, JSONValue } from './helpers';
import { getGlobalScope } from '@amplitude/analytics-core';
import { isNonSensitiveElement, isNonSensitiveString, JSONValue } from './helpers';
import { Hierarchy, HierarchyNode } from './typings/autocapture';

const globalScope = getGlobalScope();

const BLOCKED_ATTRIBUTES = [
// Already captured elsewhere in the hierarchy object
'id',
Expand Down Expand Up @@ -35,25 +38,58 @@ const HIGHLY_SENSITIVE_INPUT_TYPES = ['password', 'hidden'];
const MAX_ATTRIBUTE_LENGTH = 128;
const MAX_HIERARCHY_LENGTH = 1024;

export function getElementProperties(element: Element | null): HierarchyNode | null {
export function getElementProperties(element: Element | null, skipNearestLabel = false): {
properties: HierarchyNode | null;
nearestLabel: string;
} {
if (element === null) {
return null;
return { properties: null, nearestLabel: '' };
}

let nearestLabel = '';
const tagName = String(element.tagName).toLowerCase();
const properties: HierarchyNode = {
tag: tagName,
};

const siblings = Array.from(element.parentElement?.children ?? []);
if (siblings.length) {
properties.index = siblings.indexOf(element);
properties.indexOfType = siblings.filter((el) => el.tagName === element.tagName).indexOf(element);
// Get index of element in parent's children and index of type of element in parent's children
let indexOfType = 0,
indexOfElement = 0;
const siblings = element.parentElement?.children ?? [];
while (indexOfElement < siblings.length) {
const el = siblings[indexOfElement];
if (el === element) {
properties.index = indexOfElement;
properties.indexOfType = indexOfType;
if (skipNearestLabel) {
break;
}
}
indexOfElement++;
if (el.tagName === element.tagName) {
indexOfType++;
}
const tagName = el.tagName.toLowerCase();
let labelEl;
if (!nearestLabel && !skipNearestLabel) {
if (['span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) {
labelEl = el;
} else {
labelEl = el.querySelector('h1,h2,h3,h4,h5,h6');
}
}

// TODO: DO NOT MERGE THE IGNORE!!! ADD A TEST!!!
/* istanbul ignore next */
if (labelEl) {
nearestLabel = labelEl.textContent || '';
nearestLabel = isNonSensitiveString(nearestLabel) ? nearestLabel : '';
}
}

const prevSiblingTag = element.previousElementSibling?.tagName?.toLowerCase();
if (prevSiblingTag) {
properties.prevSib = String(prevSiblingTag);
const previousElement = element.previousElementSibling;
if (previousElement) {
properties.prevSib = String(previousElement.tagName).toLowerCase();
}

const id = element.getAttribute('id');
Expand All @@ -67,28 +103,33 @@ export function getElementProperties(element: Element | null): HierarchyNode | n
}

const attributes: Record<string, string> = {};
const attributesArray = Array.from(element.attributes);
const filteredAttributes = attributesArray.filter((attr) => !BLOCKED_ATTRIBUTES.includes(attr.name));
const isSensitiveElement = !isNonSensitiveElement(element);

// if input is hidden or password or for SVGs, skip attribute collection entirely
let hasAttributes = false;
if (!HIGHLY_SENSITIVE_INPUT_TYPES.includes(String(element.getAttribute('type'))) && !SVG_TAGS.includes(tagName)) {
for (const attr of filteredAttributes) {
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
if (BLOCKED_ATTRIBUTES.includes(attr.name)) {
continue;
}

// If sensitive element, only allow certain attributes
if (isSensitiveElement && !SENSITIVE_ELEMENT_ATTRIBUTE_ALLOWLIST.includes(attr.name)) {
continue;
}

// Finally cast attribute value to string and limit attribute value length
attributes[attr.name] = String(attr.value).substring(0, MAX_ATTRIBUTE_LENGTH);
hasAttributes = true;
}
}

if (Object.keys(attributes).length) {
if (hasAttributes) {
properties.attrs = attributes;
}

return properties;
return { properties, nearestLabel };
}

export function getAncestors(targetEl: Element | null): Element[] {
Expand All @@ -108,21 +149,53 @@ export function getAncestors(targetEl: Element | null): Element[] {
return ancestors;
}

type HierarchyResult = {
hierarchy: Hierarchy;
nearestLabel: string;
};

const hierarchyCache = new Map<Element, HierarchyResult>();

// Get the DOM hierarchy of the element, starting from the target element to the root element.
export const getHierarchy = (element: Element | null): Hierarchy => {
export const getHierarchy = (element: Element | null): HierarchyResult => {
let hierarchy: Hierarchy = [];
if (!element) {
return [];
return { hierarchy: [], nearestLabel: '' };
}

if (hierarchyCache.has(element)) {
return hierarchyCache.get(element) as HierarchyResult;
}

// Get list of ancestors including itself and get properties at each level in the hierarchy
const ancestors = getAncestors(element);
let nearestLabel = '';
hierarchy = ensureListUnderLimit(
ancestors.map((el) => getElementProperties(el)),
ancestors.map((el) => {
const skipNearestLabel = nearestLabel !== '';
const { properties, nearestLabel: currNearestLabel } = getElementProperties(el, skipNearestLabel);
if (!nearestLabel && currNearestLabel) {
nearestLabel = currNearestLabel;
}
return properties;
}),
MAX_HIERARCHY_LENGTH,
) as Hierarchy;

return hierarchy;
// memoize the results of this method so that if another handler calls this method
// on the same element and within the same event loop, we don't need to
// re-calculate the hierarchy
// (e.g.: clicking a "checkbox" will invoke a click and a change event)
// TODO: DO NOT MERGE THE IGNORE!!! ADD A TEST!!!
/* istanbul ignore next */
if (globalScope?.queueMicrotask) {
hierarchyCache.set(element, { hierarchy, nearestLabel });
globalScope.queueMicrotask(() => {
hierarchyCache.clear();
});
}

return { hierarchy, nearestLabel };
};

export function ensureListUnderLimit(list: Hierarchy | JSONValue[], bytesLimit: number): Hierarchy | JSONValue[] {
Expand Down
12 changes: 6 additions & 6 deletions packages/plugin-autocapture-browser/test/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getHierarchy } from '../src/hierarchy';
import {
isNonSensitiveString,
isTextNode,
Expand All @@ -7,7 +8,6 @@ import {
getAttributesWithPrefix,
isEmpty,
removeEmptyProperties,
getNearestLabel,
querySelectUniqueElements,
getClosestElement,
getEventTagProps,
Expand Down Expand Up @@ -299,7 +299,7 @@ describe('autocapture-plugin helpers', () => {
});
});

describe('getNearestLabel', () => {
describe('getHierarchy().nearestLabel', () => {
test('should return nearest label of the element', () => {
const div = document.createElement('div');
const span = document.createElement('span');
Expand All @@ -308,7 +308,7 @@ describe('autocapture-plugin helpers', () => {
div.appendChild(span);
div.appendChild(input);

const result = getNearestLabel(input);
const result = getHierarchy(input).nearestLabel;
expect(result).toEqual('nearest label');
});

Expand All @@ -320,7 +320,7 @@ describe('autocapture-plugin helpers', () => {
div.appendChild(span);
div.appendChild(input);

const result = getNearestLabel(input);
const result = getHierarchy(input).nearestLabel;
expect(result).toEqual('');
});

Expand All @@ -334,14 +334,14 @@ describe('autocapture-plugin helpers', () => {
const input = document.createElement('input');
innerDiv.appendChild(input);

const result = getNearestLabel(input);
const result = getHierarchy(input).nearestLabel;
expect(result).toEqual('parent label');
});

test('should return empty string when there is no parent', () => {
const input = document.createElement('input');

const result = getNearestLabel(input);
const result = getHierarchy(input).nearestLabel;
expect(result).toEqual('');
});
});
Expand Down
24 changes: 12 additions & 12 deletions packages/plugin-autocapture-browser/test/hierarchy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const nullElement = document.getElementById('null-element');
expect(HierarchyUtil.getElementProperties(nullElement)).toEqual(null);
expect(HierarchyUtil.getElementProperties(nullElement).properties).toEqual(null);
});

test('should return tag and index information if element has siblings', () => {
Expand All @@ -41,7 +41,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const inner4 = document.getElementById('inner4');
expect(HierarchyUtil.getElementProperties(inner4)).toEqual({
expect(HierarchyUtil.getElementProperties(inner4).properties).toEqual({
id: 'inner4',
index: 3,
indexOfType: 1,
Expand All @@ -63,7 +63,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const inner = document.getElementById('inner');
expect(HierarchyUtil.getElementProperties(inner)).toEqual({
expect(HierarchyUtil.getElementProperties(inner).properties).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
Expand All @@ -81,7 +81,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const inner = document.getElementById('inner');
expect(HierarchyUtil.getElementProperties(inner)).toEqual({
expect(HierarchyUtil.getElementProperties(inner).properties).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
Expand All @@ -94,7 +94,7 @@ describe('autocapture-plugin hierarchy', () => {
test('should not fail when parent element is null', () => {
const parentlessElement = document.createElement('div');

expect(HierarchyUtil.getElementProperties(parentlessElement)).toEqual({
expect(HierarchyUtil.getElementProperties(parentlessElement).properties).toEqual({
tag: 'div',
});
});
Expand All @@ -106,7 +106,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const target = document.getElementById('target');
expect(HierarchyUtil.getElementProperties(target)).toEqual({
expect(HierarchyUtil.getElementProperties(target).properties).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
Expand All @@ -123,7 +123,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const target = document.getElementById('target');
expect(HierarchyUtil.getElementProperties(target)).toEqual({
expect(HierarchyUtil.getElementProperties(target).properties).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
Expand All @@ -140,7 +140,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const target = document.getElementById('target');
expect(HierarchyUtil.getElementProperties(target)).toEqual({
expect(HierarchyUtil.getElementProperties(target).properties).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
Expand All @@ -155,7 +155,7 @@ describe('autocapture-plugin hierarchy', () => {
`;

const target = document.getElementById('target');
expect(HierarchyUtil.getElementProperties(target)).toEqual({
expect(HierarchyUtil.getElementProperties(target).properties).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
Expand Down Expand Up @@ -210,7 +210,7 @@ describe('getHierarchy', () => {

const inner2 = document.getElementById('inner2');

expect(HierarchyUtil.getHierarchy(inner2)).toEqual([
expect(HierarchyUtil.getHierarchy(inner2).hierarchy).toEqual([
{
id: 'inner2',
index: 1,
Expand Down Expand Up @@ -241,7 +241,7 @@ describe('getHierarchy', () => {

test('should not fail when element is null', () => {
const nullElement = null;
expect(HierarchyUtil.getHierarchy(nullElement)).toEqual([]);
expect(HierarchyUtil.getHierarchy(nullElement).hierarchy).toEqual([]);
});

describe('[Amplitude] Element Hierarchy property:', () => {
Expand All @@ -264,7 +264,7 @@ describe('getHierarchy', () => {
`;

const inner12345 = document.getElementById('inner12345');
const innerHierarchy = HierarchyUtil.getHierarchy(inner12345);
const innerHierarchy = HierarchyUtil.getHierarchy(inner12345).hierarchy;
// expect innerHierarchy to not have body to stay under 1024 chars
expect(innerHierarchy).toEqual([
{
Expand Down
Loading
Loading