diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts index a94ebf447..00283d38a 100644 --- a/packages/plugin-autocapture-browser/src/helpers.ts +++ b/packages/plugin-autocapture-browser/src/helpers.ts @@ -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, selector) => { @@ -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 = { [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), diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 5c534ba07..e8eea7322 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -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', @@ -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'); @@ -67,13 +103,17 @@ export function getElementProperties(element: Element | null): HierarchyNode | n } const attributes: Record = {}; - 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; @@ -81,14 +121,15 @@ export function getElementProperties(element: Element | null): HierarchyNode | n // 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[] { @@ -108,21 +149,53 @@ export function getAncestors(targetEl: Element | null): Element[] { return ancestors; } +type HierarchyResult = { + hierarchy: Hierarchy; + nearestLabel: string; +}; + +const hierarchyCache = new Map(); + // 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[] { diff --git a/packages/plugin-autocapture-browser/test/helpers.test.ts b/packages/plugin-autocapture-browser/test/helpers.test.ts index 0abfd8392..19927c4ff 100644 --- a/packages/plugin-autocapture-browser/test/helpers.test.ts +++ b/packages/plugin-autocapture-browser/test/helpers.test.ts @@ -1,3 +1,4 @@ +import { getHierarchy } from '../src/hierarchy'; import { isNonSensitiveString, isTextNode, @@ -7,7 +8,6 @@ import { getAttributesWithPrefix, isEmpty, removeEmptyProperties, - getNearestLabel, querySelectUniqueElements, getClosestElement, getEventTagProps, @@ -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'); @@ -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'); }); @@ -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(''); }); @@ -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(''); }); }); diff --git a/packages/plugin-autocapture-browser/test/hierarchy.test.ts b/packages/plugin-autocapture-browser/test/hierarchy.test.ts index 90ce15572..f4534c655 100644 --- a/packages/plugin-autocapture-browser/test/hierarchy.test.ts +++ b/packages/plugin-autocapture-browser/test/hierarchy.test.ts @@ -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', () => { @@ -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, @@ -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, @@ -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, @@ -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', }); }); @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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:', () => { @@ -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([ { diff --git a/test-server/autocapture/performance-test.html b/test-server/autocapture/performance-test.html new file mode 100644 index 000000000..be8c6167a --- /dev/null +++ b/test-server/autocapture/performance-test.html @@ -0,0 +1,529 @@ + + + + + + + Performance Test - Deep DOM Hierarchy + + + +
+

Performance Test - Deep DOM Hierarchy

+ +
+

Test Description

+

This page creates a deeply nested DOM structure with many attributes, classes, and siblings to test the performance of the getHierarchy function. The target checkbox is nested 15 levels deep with 12 siblings at each level.

+
    +
  • DOM Depth: 15 levels deep
  • +
  • Siblings per level: 12 elements
  • +
  • Attributes per element: 5-10 attributes
  • +
  • Classes per element: 3-5 classes
  • +
  • Total DOM operations: ~1000+ operations per hierarchy calculation
  • +
+
+ +
+

Performance Metrics

+
+

Click the checkbox below to trigger the performance test...

+
+
+ +
+

Target Checkbox (Deeply Nested)

+

This checkbox is nested 15 levels deep in the DOM. Clicking it will trigger getHierarchy to process the entire ancestor chain.

+ +
+ +
+
+ +
+

Sibling Elements (Level 1)

+
+ +
+
+ +
+

Additional Sibling Elements (Level 2)

+
+ +
+
+ +
+

Control Buttons

+ + + +
+
+ + + + \ No newline at end of file