From 7d63b154ee07204e30f3daf065421cc49a68f241 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Thu, 3 Jul 2025 20:37:21 -0700 Subject: [PATCH 01/10] fix: reduce redundant array creation in getHierarchy --- .../src/hierarchy.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 5c534ba07..03180c932 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -45,15 +45,26 @@ export function getElementProperties(element: Element | null): HierarchyNode | n 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; + break; + } + indexOfElement++; + if (el.tagName === element.tagName) { + indexOfType++; + } } - 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 +78,16 @@ 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 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; From 28a4e6fcd019f2acb81550b4551e18731268b5dc Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Mon, 23 Jun 2025 09:28:06 -0700 Subject: [PATCH 02/10] chore: autocapture performance test --- test-server/autocapture/performance-test.html | 529 ++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 test-server/autocapture/performance-test.html 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 From ff323609987fa1ea9b03dff2784bccbe340774ab Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Fri, 4 Jul 2025 13:47:32 -0700 Subject: [PATCH 03/10] fix: optimize attributes check --- packages/plugin-autocapture-browser/src/hierarchy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 03180c932..c257c9a50 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -81,6 +81,7 @@ export function getElementProperties(element: Element | null): HierarchyNode | n 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 (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; @@ -95,10 +96,11 @@ 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; } From ee8865f379cf075bf07e5df375aff8138058f010 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 19:56:42 -0700 Subject: [PATCH 04/10] fix: add caching --- .../src/hierarchy.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index c257c9a50..62a81b6c3 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -1,6 +1,9 @@ +import { getGlobalScope } from '@amplitude/analytics-core'; import { isNonSensitiveElement, JSONValue } from './helpers'; import { Hierarchy, HierarchyNode } from './typings/autocapture'; +const globalScope = getGlobalScope(); + const BLOCKED_ATTRIBUTES = [ // Already captured elsewhere in the hierarchy object 'id', @@ -124,6 +127,8 @@ export function getAncestors(targetEl: Element | null): Element[] { return ancestors; } +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 => { let hierarchy: Hierarchy = []; @@ -131,6 +136,10 @@ export const getHierarchy = (element: Element | null): Hierarchy => { return []; } + if (hierarchyCache.has(element)) { + return hierarchyCache.get(element) as Hierarchy; + } + // Get list of ancestors including itself and get properties at each level in the hierarchy const ancestors = getAncestors(element); hierarchy = ensureListUnderLimit( @@ -138,6 +147,17 @@ export const getHierarchy = (element: Element | null): Hierarchy => { MAX_HIERARCHY_LENGTH, ) as 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) + if (globalScope?.queueMicrotask) { + hierarchyCache.set(element, hierarchy); + globalScope.queueMicrotask(() => { + hierarchyCache.clear(); + }); + } + return hierarchy; }; From 8531d4977ed8fb8c2044600516057eedb90d1348 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 19:58:46 -0700 Subject: [PATCH 05/10] fix: add caching --- packages/plugin-autocapture-browser/src/hierarchy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 62a81b6c3..e98180167 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -151,6 +151,8 @@ export const getHierarchy = (element: Element | null): Hierarchy => { // 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); globalScope.queueMicrotask(() => { From 22971e39e8056d1c08fc311e50bf005ebf67966d Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 20:47:40 -0700 Subject: [PATCH 06/10] refactor: make hierarchy return an object instead --- packages/plugin-autocapture-browser/src/helpers.ts | 3 ++- packages/plugin-autocapture-browser/src/hierarchy.ts | 8 ++++---- .../plugin-autocapture-browser/test/hierarchy.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts index a94ebf447..090cfd59d 100644 --- a/packages/plugin-autocapture-browser/src/helpers.ts +++ b/packages/plugin-autocapture-browser/src/helpers.ts @@ -299,11 +299,12 @@ export const getEventProperties = (actionType: ActionType, element: Element, dat const ariaLabel = element.getAttribute('aria-label'); const attributes = getAttributesWithPrefix(element, dataAttributePrefix); const nearestLabel = getNearestLabel(element); + const { hierarchy } = 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 e98180167..fa0eabd9c 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -130,14 +130,14 @@ export function getAncestors(targetEl: Element | null): Element[] { 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): { hierarchy: Hierarchy } => { let hierarchy: Hierarchy = []; if (!element) { - return []; + return { hierarchy: [] }; } if (hierarchyCache.has(element)) { - return hierarchyCache.get(element) as Hierarchy; + return { hierarchy: hierarchyCache.get(element) as Hierarchy }; } // Get list of ancestors including itself and get properties at each level in the hierarchy @@ -160,7 +160,7 @@ export const getHierarchy = (element: Element | null): Hierarchy => { }); } - return hierarchy; + return { hierarchy }; }; export function ensureListUnderLimit(list: Hierarchy | JSONValue[], bytesLimit: number): Hierarchy | JSONValue[] { diff --git a/packages/plugin-autocapture-browser/test/hierarchy.test.ts b/packages/plugin-autocapture-browser/test/hierarchy.test.ts index 90ce15572..e817ebefc 100644 --- a/packages/plugin-autocapture-browser/test/hierarchy.test.ts +++ b/packages/plugin-autocapture-browser/test/hierarchy.test.ts @@ -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([ { From 5db86826a4dbf7d659361052125d569645da74a4 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 20:51:35 -0700 Subject: [PATCH 07/10] refactor: make getElementProperties return an object --- .../src/hierarchy.ts | 8 ++++---- .../test/hierarchy.test.ts | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index fa0eabd9c..3a4b27605 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -38,9 +38,9 @@ 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): { properties: HierarchyNode | null } { if (element === null) { - return null; + return { properties: null }; } const tagName = String(element.tagName).toLowerCase(); @@ -107,7 +107,7 @@ export function getElementProperties(element: Element | null): HierarchyNode | n properties.attrs = attributes; } - return properties; + return { properties }; } export function getAncestors(targetEl: Element | null): Element[] { @@ -143,7 +143,7 @@ export const getHierarchy = (element: Element | null): { hierarchy: Hierarchy } // Get list of ancestors including itself and get properties at each level in the hierarchy const ancestors = getAncestors(element); hierarchy = ensureListUnderLimit( - ancestors.map((el) => getElementProperties(el)), + ancestors.map((el) => getElementProperties(el).properties), MAX_HIERARCHY_LENGTH, ) as Hierarchy; diff --git a/packages/plugin-autocapture-browser/test/hierarchy.test.ts b/packages/plugin-autocapture-browser/test/hierarchy.test.ts index e817ebefc..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, From fb4c41e87934f362021a8f38691a1341d2ef94b9 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 21:07:33 -0700 Subject: [PATCH 08/10] refactor: get nearest label in hierarchy --- .../src/hierarchy.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index 3a4b27605..bb6850fd7 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -1,5 +1,5 @@ import { getGlobalScope } from '@amplitude/analytics-core'; -import { isNonSensitiveElement, JSONValue } from './helpers'; +import { isNonSensitiveElement, isNonSensitiveString, JSONValue } from './helpers'; import { Hierarchy, HierarchyNode } from './typings/autocapture'; const globalScope = getGlobalScope(); @@ -38,11 +38,12 @@ const HIGHLY_SENSITIVE_INPUT_TYPES = ['password', 'hidden']; const MAX_ATTRIBUTE_LENGTH = 128; const MAX_HIERARCHY_LENGTH = 1024; -export function getElementProperties(element: Element | null): { properties: HierarchyNode | null } { +export function getElementProperties(element: Element | null): { properties: HierarchyNode | null; nearestLabel: string } { if (element === null) { - return { properties: null }; + return { properties: null, nearestLabel: '' }; } + let nearestLabel = ''; const tagName = String(element.tagName).toLowerCase(); const properties: HierarchyNode = { tag: tagName, @@ -63,6 +64,10 @@ export function getElementProperties(element: Element | null): { properties: Hie if (el.tagName === element.tagName) { indexOfType++; } + if (['span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(el.tagName)) { + nearestLabel = el.textContent || ''; + nearestLabel = isNonSensitiveString(nearestLabel) ? nearestLabel : ''; + } } const previousElement = element.previousElementSibling; @@ -107,7 +112,7 @@ export function getElementProperties(element: Element | null): { properties: Hie properties.attrs = attributes; } - return { properties }; + return { properties, nearestLabel }; } export function getAncestors(targetEl: Element | null): Element[] { @@ -127,23 +132,35 @@ export function getAncestors(targetEl: Element | null): Element[] { return ancestors; } -const hierarchyCache = new Map(); +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: Hierarchy } => { +export const getHierarchy = (element: Element | null): HierarchyResult => { let hierarchy: Hierarchy = []; if (!element) { - return { hierarchy: [] }; + return { hierarchy: [], nearestLabel: '' }; } if (hierarchyCache.has(element)) { - return { hierarchy: hierarchyCache.get(element) as Hierarchy }; + 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).properties), + ancestors.map((el) => { + const { properties, nearestLabel: currNearestLabel } = getElementProperties(el); + if (!nearestLabel && currNearestLabel) { + nearestLabel = currNearestLabel; + } + return properties; + }), MAX_HIERARCHY_LENGTH, ) as Hierarchy; @@ -154,13 +171,13 @@ export const getHierarchy = (element: Element | null): { hierarchy: Hierarchy } // TODO: DO NOT MERGE THE IGNORE!!! ADD A TEST!!! /* istanbul ignore next */ if (globalScope?.queueMicrotask) { - hierarchyCache.set(element, hierarchy); + hierarchyCache.set(element, { hierarchy, nearestLabel }); globalScope.queueMicrotask(() => { hierarchyCache.clear(); }); } - return { hierarchy }; + return { hierarchy, nearestLabel }; }; export function ensureListUnderLimit(list: Hierarchy | JSONValue[], bytesLimit: number): Hierarchy | JSONValue[] { From e1b53d1369eae218550462ddb5d90762908e7e2d Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 21:37:36 -0700 Subject: [PATCH 09/10] fix: capture nearestLabel inside getHierarchy --- .../plugin-autocapture-browser/src/helpers.ts | 23 +------------------ .../src/hierarchy.ts | 22 ++++++++++++++---- .../test/helpers.test.ts | 12 +++++----- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/helpers.ts b/packages/plugin-autocapture-browser/src/helpers.ts index 090cfd59d..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,8 +278,7 @@ 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 } = getHierarchy(element); + const { hierarchy, nearestLabel } = getHierarchy(element); /* istanbul ignore next */ const properties: Record = { [constants.AMPLITUDE_EVENT_PROP_ELEMENT_ID]: element.getAttribute('id') || '', diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index bb6850fd7..aa6e2defc 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -38,7 +38,10 @@ const HIGHLY_SENSITIVE_INPUT_TYPES = ['password', 'hidden']; const MAX_ATTRIBUTE_LENGTH = 128; const MAX_HIERARCHY_LENGTH = 1024; -export function getElementProperties(element: Element | null): { properties: HierarchyNode | null; nearestLabel: string } { +export function getElementProperties(element: Element | null): { + properties: HierarchyNode | null; + nearestLabel: string; +} { if (element === null) { return { properties: null, nearestLabel: '' }; } @@ -58,14 +61,25 @@ export function getElementProperties(element: Element | null): { properties: Hie if (el === element) { properties.index = indexOfElement; properties.indexOfType = indexOfType; - break; } indexOfElement++; if (el.tagName === element.tagName) { indexOfType++; } - if (['span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(el.tagName)) { - nearestLabel = el.textContent || ''; + const tagName = el.tagName.toLowerCase(); + let labelEl; + if (!nearestLabel) { + 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 : ''; } } 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(''); }); }); From 0b59b86e387ca17b39bf7e897a335cfc3575f4f6 Mon Sep 17 00:00:00 2001 From: Daniel Graham Date: Tue, 15 Jul 2025 21:50:51 -0700 Subject: [PATCH 10/10] again --- packages/plugin-autocapture-browser/src/hierarchy.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts index aa6e2defc..e8eea7322 100644 --- a/packages/plugin-autocapture-browser/src/hierarchy.ts +++ b/packages/plugin-autocapture-browser/src/hierarchy.ts @@ -38,7 +38,7 @@ const HIGHLY_SENSITIVE_INPUT_TYPES = ['password', 'hidden']; const MAX_ATTRIBUTE_LENGTH = 128; const MAX_HIERARCHY_LENGTH = 1024; -export function getElementProperties(element: Element | null): { +export function getElementProperties(element: Element | null, skipNearestLabel = false): { properties: HierarchyNode | null; nearestLabel: string; } { @@ -61,6 +61,9 @@ export function getElementProperties(element: Element | null): { if (el === element) { properties.index = indexOfElement; properties.indexOfType = indexOfType; + if (skipNearestLabel) { + break; + } } indexOfElement++; if (el.tagName === element.tagName) { @@ -68,7 +71,7 @@ export function getElementProperties(element: Element | null): { } const tagName = el.tagName.toLowerCase(); let labelEl; - if (!nearestLabel) { + if (!nearestLabel && !skipNearestLabel) { if (['span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) { labelEl = el; } else { @@ -169,7 +172,8 @@ export const getHierarchy = (element: Element | null): HierarchyResult => { let nearestLabel = ''; hierarchy = ensureListUnderLimit( ancestors.map((el) => { - const { properties, nearestLabel: currNearestLabel } = getElementProperties(el); + const skipNearestLabel = nearestLabel !== ''; + const { properties, nearestLabel: currNearestLabel } = getElementProperties(el, skipNearestLabel); if (!nearestLabel && currNearestLabel) { nearestLabel = currNearestLabel; }