Skip to content
Open
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
14 changes: 14 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,20 @@ export interface ElementInteractionsOptions {
*/
dataAttributePrefix?: string;

/**
* Whether CSS class names should be captured in the element hierarchy.
* Default is true.
*
* When set to false, each entry in the captured element-interaction
* hierarchy will omit the `classes` field entirely (the field is not
* present as `null` or an empty array). This only affects the
* hierarchy `classes` field — other captured properties that may
* contain class-like values (e.g., the top-level
* `[Amplitude] Element Class` event property) are not affected by
* this option.
*/
captureCssClasses?: boolean;

/**
* Options for integrating visual tagging selector.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ export const autocapturePlugin = (
isElementSelectable: createShouldTrackEvent(options, [...allowlist, ...actionClickAllowlist]),
cssSelectorAllowlist: allowlist,
actionClickAllowlist,
elementInteractionsOptions: options,
});
enableBackgroundCapture(messenger);
/* istanbul ignore next */
Expand Down
6 changes: 5 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 options: ElementInteractionsOptions;
diagnosticsClient?: IDiagnosticsClient;

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

const rawPatterns = options.maskTextRegex ?? [];

Expand Down Expand Up @@ -88,8 +90,10 @@ export class DataExtractor {
}
}

const captureCssClasses = this.options.captureCssClasses !== false;

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

// Search for and mask any sensitive attribute values
Expand Down
9 changes: 6 additions & 3 deletions 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,9 +71,11 @@ export function getElementProperties(
properties.id = String(id);
}

const classes = Array.from(element.classList);
if (classes.length) {
properties.classes = classes;
if (captureCssClasses) {
const classes = Array.from(element.classList);
if (classes.length) {
properties.classes = classes;
}
}

const attributes: Record<string, string> = {};
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin-autocapture-browser/src/libs/messenger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* istanbul ignore file */
/* eslint-disable no-restricted-globals */
import { AMPLITUDE_VISUAL_TAGGING_SELECTOR_SCRIPT_URL, AMPLITUDE_VISUAL_TAGGING_HIGHLIGHT_CLASS } from '../constants';
import type { BaseWindowMessenger } from '@amplitude/analytics-core';
import type { BaseWindowMessenger, ElementInteractionsOptions } from '@amplitude/analytics-core';
import { ActionType } from '@amplitude/analytics-core';
import { VERSION } from '../version';
import { DataExtractor } from '../data-extractor';
Expand Down Expand Up @@ -83,6 +83,7 @@ export function enableVisualTagging(
cssSelectorAllowlist?: string[];
actionClickAllowlist?: string[];
dataExtractor: DataExtractor;
elementInteractionsOptions?: ElementInteractionsOptions;
},
): void {
// Idempotency guard — works across bundle boundaries
Expand All @@ -92,7 +93,8 @@ export function enableVisualTagging(
}
branded[VISUAL_TAGGING_BRAND] = true;

const { dataExtractor, isElementSelectable, cssSelectorAllowlist, actionClickAllowlist } = options;
const { dataExtractor, isElementSelectable, cssSelectorAllowlist, actionClickAllowlist, elementInteractionsOptions } =
options;

let amplitudeVisualTaggingSelectorInstance: any = null;

Expand Down Expand Up @@ -131,6 +133,7 @@ export function enableVisualTagging(
actionClickAllowlist,
extractDataFromDataSource: dataExtractor.extractDataFromDataSource,
dataExtractor,
elementInteractionsOptions,
diagnostics: {
autocapture: {
version: VERSION,
Expand Down
113 changes: 113 additions & 0 deletions packages/plugin-autocapture-browser/test/data-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,119 @@ describe('data extractor', () => {
dataExtractorWithoutDiagnostics.getHierarchy(target);
}).not.toThrow();
});

describe('captureCssClasses option threading', () => {
const HTML_FIXTURE = `
<div id="parent2" class="grandparent gp2">
<div id="parent1" class="parent">
<div id="inner" class="leaf leaf-two">xxx</div>
</div>
</div>
`;

test('default-on (option unset): hierarchy entries with classes are populated', () => {
const extractor = new DataExtractor({});
document.getElementsByTagName('body')[0].innerHTML = HTML_FIXTURE;

const hierarchy = extractor.getHierarchy(document.getElementById('inner'));

// First three entries (inner, parent1, parent2) all have class attributes.
expect(hierarchy[0]).toHaveProperty('classes', ['leaf', 'leaf-two']);
expect(hierarchy[1]).toHaveProperty('classes', ['parent']);
expect(hierarchy[2]).toHaveProperty('classes', ['grandparent', 'gp2']);
});

test('default-on (captureCssClasses: true): hierarchy entries with classes are populated', () => {
const extractor = new DataExtractor({ captureCssClasses: true });
document.getElementsByTagName('body')[0].innerHTML = HTML_FIXTURE;

const hierarchy = extractor.getHierarchy(document.getElementById('inner'));

expect(hierarchy[0]).toHaveProperty('classes', ['leaf', 'leaf-two']);
expect(hierarchy[1]).toHaveProperty('classes', ['parent']);
expect(hierarchy[2]).toHaveProperty('classes', ['grandparent', 'gp2']);
});

test('default-on path produces byte-equivalent JSON to today', () => {
const extractorDefault = new DataExtractor({});
const extractorExplicit = new DataExtractor({ captureCssClasses: true });
document.getElementsByTagName('body')[0].innerHTML = HTML_FIXTURE;

const inner = document.getElementById('inner');
const defaultPayload = JSON.stringify(extractorDefault.getHierarchy(inner));
const explicitPayload = JSON.stringify(extractorExplicit.getHierarchy(inner));

expect(defaultPayload).toEqual(explicitPayload);
});

test('off path: every hierarchy entry omits the `classes` field entirely', () => {
const extractor = new DataExtractor({ captureCssClasses: false });
document.getElementsByTagName('body')[0].innerHTML = HTML_FIXTURE;

const hierarchy = extractor.getHierarchy(document.getElementById('inner'));

for (const node of hierarchy) {
expect(node ?? {}).not.toHaveProperty('classes');
}

// Field must be fully absent — not present as null, not present as [].
const serialized = JSON.stringify(hierarchy);
expect(serialized).not.toContain('"classes"');
});

test('off path: hierarchy preserves non-class properties unchanged', () => {
const extractor = new DataExtractor({ captureCssClasses: false });
document.getElementsByTagName('body')[0].innerHTML = HTML_FIXTURE;

const hierarchy = extractor.getHierarchy(document.getElementById('inner'));

expect(hierarchy[0]).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
tag: 'div',
});
expect(hierarchy[1]).toEqual({
id: 'parent1',
index: 0,
indexOfType: 0,
tag: 'div',
});
});

test('top-level [Amplitude] Element Class property is still populated when captureCssClasses is false (AC4)', () => {
const extractor = new DataExtractor({ captureCssClasses: false });
document.getElementsByTagName('body')[0].innerHTML = `
<div id="parent">
<button id="target" class="cta primary">Click</button>
</div>
`;

const target = document.getElementById('target') as Element;
const properties = extractor.getEventProperties('click', target, 'data-amp-track-');

// Hierarchy must not carry `classes` anywhere.
const hierarchyJson = JSON.stringify(properties[constants.AMPLITUDE_EVENT_PROP_ELEMENT_HIERARCHY]);
expect(hierarchyJson).not.toContain('"classes"');

// The explicitly out-of-scope top-level property still carries the class string.
expect(properties[constants.AMPLITUDE_EVENT_PROP_ELEMENT_CLASS]).toBe('cta primary');
});

test('top-level [Amplitude] Element Class property is unchanged when captureCssClasses is true', () => {
const extractor = new DataExtractor({ captureCssClasses: true });
document.getElementsByTagName('body')[0].innerHTML = `
<div id="parent">
<button id="target" class="cta primary">Click</button>
</div>
`;

const target = document.getElementById('target') as Element;
const properties = extractor.getEventProperties('click', target, 'data-amp-track-');

expect(properties[constants.AMPLITUDE_EVENT_PROP_ELEMENT_CLASS]).toBe('cta primary');
});
});
});

describe('getEventProperties with title masking', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BrowserConfig, EnrichmentPlugin, ILogger, BrowserClient, IDiagnosticsCl
import { mockWindowLocationFromURL } from './utils';
import { VERSION } from '../src/version';
import { createMockBrowserClient } from './mock-browser-client';
import * as messengerLib from '../src/libs/messenger';

const TESTING_DEBOUNCE_TIME = 4;

Expand Down Expand Up @@ -127,6 +128,39 @@ describe('autoTrackingPlugin', () => {
expect((messengerMock as any).setup).toHaveBeenCalledTimes(1);
});

test('passes elementInteractionsOptions to enableVisualTagging', async () => {
window.opener = true;
const messengerMock = {
setup: jest.fn(),
registerActionHandler: jest.fn(),
};
jest.spyOn(AnalyticsCore, 'getOrCreateWindowMessenger').mockReturnValue(messengerMock as any);
jest.spyOn(AnalyticsCore, 'enableBackgroundCapture').mockImplementation(jest.fn());
const enableVisualTaggingSpy = jest.spyOn(messengerLib, 'enableVisualTagging').mockImplementation(jest.fn());

plugin = autocapturePlugin({
visualTaggingOptions: { enabled: true },
captureCssClasses: true,
});
const loggerProvider: Partial<ILogger> = {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
const config: Partial<BrowserConfig> = {
defaultTracking: false,
loggerProvider: loggerProvider as ILogger,
};
const amplitude: Partial<BrowserClient> = {};
await plugin?.setup?.(config as BrowserConfig, amplitude as BrowserClient);

expect(enableVisualTaggingSpy).toHaveBeenCalledTimes(1);
const callArgs = enableVisualTaggingSpy.mock.calls[0][1] as {
elementInteractionsOptions?: AnalyticsCore.ElementInteractionsOptions;
};
expect(callArgs.elementInteractionsOptions?.captureCssClasses).toBe(true);
});

test('should use custom exposureDuration from deprecated flat option', async () => {
const customDuration = 500;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
75 changes: 75 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,81 @@ describe('autocapture-plugin hierarchy', () => {
classes: ['class1', 'class2'],
});
});

describe('captureCssClasses option', () => {
test('should include classes when captureCssClasses defaults (omitted argument)', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="container">
<div id="inner" class="class1 class2">xxx</div>
</div>
`;

const inner = document.getElementById('inner');
expect(HierarchyUtil.getElementProperties(inner, new Set())).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
tag: 'div',
classes: ['class1', 'class2'],
});
});

test('should include classes when captureCssClasses is explicitly true', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="container">
<div id="inner" class="class1 class2">xxx</div>
</div>
`;

const inner = document.getElementById('inner');
expect(HierarchyUtil.getElementProperties(inner, new Set(), true)).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
tag: 'div',
classes: ['class1', 'class2'],
});
});

test('should omit `classes` field entirely when captureCssClasses is false', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="container">
<div id="inner" class="class1 class2">xxx</div>
</div>
`;

const inner = document.getElementById('inner');
const result = HierarchyUtil.getElementProperties(inner, new Set(), false);

// Field is fully absent — not null, not [].
expect(result).not.toBeNull();
expect(result).not.toHaveProperty('classes');
// Other fields are unchanged.
expect(result).toEqual({
id: 'inner',
index: 0,
indexOfType: 0,
tag: 'div',
});
});

test('should omit `classes` when captureCssClasses is false even on highly sensitive input types', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<input id="target" class="test" type="password" ok-attribute="hi"></input>
`;

const target = document.getElementById('target');
const result = HierarchyUtil.getElementProperties(target, new Set(), false);

expect(result).not.toHaveProperty('classes');
expect(result).toEqual({
id: 'target',
index: 0,
indexOfType: 0,
tag: 'input',
});
});
});
});

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