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
3 changes: 3 additions & 0 deletions packages/analytics-browser/test/default-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,13 +752,15 @@ describe('getElementInteractionsConfig', () => {
const testPageUrlAllowlist = ['example.com'];
const mockedShouldTrackEventResolver = jest.fn(() => true);
const testDataAttributePrefix = 'data-amp-track';
const testCaptureCssClasses = false;
const config = getElementInteractionsConfig({
autocapture: {
elementInteractions: {
cssSelectorAllowlist: testCssSelectorAllowlist,
pageUrlAllowlist: testPageUrlAllowlist,
shouldTrackEventResolver: mockedShouldTrackEventResolver,
dataAttributePrefix: testDataAttributePrefix,
captureCssClasses: testCaptureCssClasses,
},
},
});
Expand All @@ -767,6 +769,7 @@ describe('getElementInteractionsConfig', () => {
expect(config?.pageUrlAllowlist).toBe(testPageUrlAllowlist);
expect(config?.shouldTrackEventResolver).toBe(mockedShouldTrackEventResolver);
expect(config?.dataAttributePrefix).toBe(testDataAttributePrefix);
expect(config?.captureCssClasses).toBe(testCaptureCssClasses);
});
});

Expand Down
6 changes: 6 additions & 0 deletions packages/analytics-core/src/types/element-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface ElementInteractionsOptions {
*/
dataAttributePrefix?: string;

/**
* Whether CSS class names should be captured in the element hierarchy.
* Default is true.
*/
captureCssClasses?: boolean;

/**
* Options for integrating visual tagging selector.
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-autocapture-browser/src/data-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import { cssPath } from './libs/element-path';

export class DataExtractor {
private readonly additionalMaskTextPatterns: RegExp[];
private readonly captureCssClasses: boolean;
diagnosticsClient?: IDiagnosticsClient;

constructor(options: ElementInteractionsOptions, context?: { diagnosticsClient: IDiagnosticsClient }) {
this.diagnosticsClient = context?.diagnosticsClient;
this.captureCssClasses = options.captureCssClasses ?? true;

const rawPatterns = options.maskTextRegex ?? [];

Expand Down Expand Up @@ -89,7 +91,7 @@ export class DataExtractor {
}

hierarchy = ancestors.map((el) =>
getElementProperties(el, elementToAttributesToMaskMap.get(el) ?? new Set<string>()),
getElementProperties(el, elementToAttributesToMaskMap.get(el) ?? new Set<string>(), this.captureCssClasses),
);

// Search for and mask any sensitive attribute values
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-autocapture-browser/src/hierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const MAX_HIERARCHY_LENGTH = 1024;
export function getElementProperties(
element: Element | null,
userMaskedAttributeNames: Set<string>,
captureCssClasses = true,
): HierarchyNode | null {
if (element === null) {
return null;
Expand All @@ -70,7 +71,7 @@ export function getElementProperties(
properties.id = String(id);
}

const classes = Array.from(element.classList);
const classes = captureCssClasses ? Array.from(element.classList) : [];
if (classes.length) {
properties.classes = classes;
}
Expand Down
124 changes: 124 additions & 0 deletions packages/plugin-autocapture-browser/test/data-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,22 @@ describe('data extractor', () => {

expect(result).toEqual('button#test-button');
});

test('should return the same CSS path when CSS class capture is disabled', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div>
<button class="secondary-button">Other</button>
<button class="primary-button">Click me</button>
</div>
`;

const button = document.getElementsByClassName('primary-button')[0];
const defaultResult = dataExtractor.getElementPath(button);
const resultWithoutHierarchyClasses = new DataExtractor({ captureCssClasses: false }).getElementPath(button);

expect(resultWithoutHierarchyClasses).toEqual(defaultResult);
expect(resultWithoutHierarchyClasses).toContain('.primary-button');
});
});

describe('getEventTagProps', () => {
Expand Down Expand Up @@ -765,6 +781,83 @@ describe('data extractor', () => {
expect(dataExtractor.getHierarchy(nullElement)).toEqual([]);
});

test('should include classes by default', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="parent" class="parent-class">
<button id="target" class="target-class primary" data-test-id="save-button">
Save
</button>
</div>
`;

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

expect(dataExtractor.getHierarchy(target)[0]).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
tag: 'button',
classes: ['target-class', 'primary'],
attrs: {
'data-test-id': 'save-button',
},
});
});

test('should exclude classes from hierarchy when captureCssClasses is false', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="parent" class="parent-class">
<button id="target" class="target-class primary" data-test-id="save-button">
Save
</button>
</div>
`;

const extractor = new DataExtractor({ captureCssClasses: false });
const target = document.getElementById('target');

expect(extractor.getHierarchy(target)[0]).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
tag: 'button',
attrs: {
'data-test-id': 'save-button',
},
});
});

test('should keep direct element class event property when captureCssClasses is false', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<button id="target" class="target-class primary" data-amp-track-role="save">
Save
</button>
`;

const extractor = new DataExtractor({ captureCssClasses: false });
const target = document.getElementById('target') as HTMLElement;
const result = extractor.getEventProperties('click', target, 'data-amp-track-');

expect(result[constants.AMPLITUDE_EVENT_PROP_ELEMENT_CLASS]).toBe('target-class primary');
expect(result[constants.AMPLITUDE_EVENT_PROP_ELEMENT_HIERARCHY]).toEqual([
{
id: 'target',
index: 0,
indexOfType: 0,
tag: 'button',
attrs: {
'data-amp-track-role': 'save',
},
},
{
index: 1,
indexOfType: 0,
prevSib: 'head',
tag: 'body',
},
]);
});

test('should handle null elements in hierarchy when getElementProperties returns null', () => {
// Create a valid element
const element = document.createElement('div');
Expand Down Expand Up @@ -828,6 +921,37 @@ describe('data extractor', () => {
});

describe('[Amplitude] Element Hierarchy property:', () => {
test('should reduce class-heavy hierarchy payload while preserving stable ancestor attributes', () => {
const classList = Array.from({ length: 8 }, (_, index) => `very-long-generated-class-name-${index}`).join(' ');
document.getElementsByTagName('body')[0].innerHTML = `
<section class="${classList}" data-test-section="billing">
<div class="${classList}" data-test-panel="payment-methods">
<div class="${classList}" data-test-row="primary-card">
<button id="target" class="${classList}" data-test-action="save-card">
Save card
</button>
</div>
</div>
</section>
`;

const target = document.getElementById('target');
const defaultHierarchy = dataExtractor.getHierarchy(target);
const hierarchyWithoutClasses = new DataExtractor({ captureCssClasses: false }).getHierarchy(target);

expect(JSON.stringify(defaultHierarchy).length).toBeGreaterThan(1024);
expect(JSON.stringify(hierarchyWithoutClasses).length).toBeLessThanOrEqual(1024);
expect(hierarchyWithoutClasses).toEqual(
expect.arrayContaining([
expect.objectContaining({ attrs: { 'data-test-action': 'save-card' } }),
expect.objectContaining({ attrs: { 'data-test-row': 'primary-card' } }),
expect.objectContaining({ attrs: { 'data-test-panel': 'payment-methods' } }),
expect.objectContaining({ attrs: { 'data-test-section': 'billing' } }),
]),
);
expect(hierarchyWithoutClasses.every((node) => !node?.classes)).toBe(true);
});

test('should cut off hierarchy output nodes to stay less than or equal to 1024 chars', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="parent2">
Expand Down
22 changes: 22 additions & 0 deletions packages/plugin-autocapture-browser/test/hierarchy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ describe('autocapture-plugin hierarchy', () => {
classes: ['class1', 'class2'],
});
});

test('should not return class list when CSS class capture is disabled', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="container">
<div id="inner" class="class1 class2" data-test-id="stable-target" aria-label="Stable label">
xxx
</div>
</div>
`;

const inner = document.getElementById('inner');
expect(HierarchyUtil.getElementProperties(inner, new Set(), false)).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
tag: 'div',
attrs: {
'data-test-id': 'stable-target',
'aria-label': 'Stable label',
},
});
});
});

test('should not fail when parent element is null', () => {
Expand Down
Loading