diff --git a/packages/rum-core/src/domain/action/clickIgnore.ts b/packages/rum-core/src/domain/action/clickIgnore.ts new file mode 100644 index 0000000000..9e5ce29613 --- /dev/null +++ b/packages/rum-core/src/domain/action/clickIgnore.ts @@ -0,0 +1,60 @@ +import { getParentNode, isElementNode } from '../../browser/htmlDomUtils' + +export const CLICK_IGNORE_ATTR_NAME = 'data-dd-click-ignore' + +export const ClickIgnoreFlag = { + RAGE: 1, + DEAD: 2, + ERROR: 4, +} as const + +export const CLICK_IGNORE_ALL = ClickIgnoreFlag.RAGE | ClickIgnoreFlag.DEAD | ClickIgnoreFlag.ERROR + +const cache = new WeakMap() + +function parseTokens(value: string): number { + let mask = 0 + const tokens = value + .split(/[\s,]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean) + + for (const token of tokens) { + if (token === 'all') { + return CLICK_IGNORE_ALL + } + if (token === 'rage') { + mask |= ClickIgnoreFlag.RAGE + } else if (token === 'dead') { + mask |= ClickIgnoreFlag.DEAD + } else if (token === 'error') { + mask |= ClickIgnoreFlag.ERROR + } + } + return mask +} + +export function getIgnoredForElement(element: Element): number { + const cached = cache.get(element) + if (cached !== undefined) { + return cached + } + let mask = 0 + let node: Node | null = element + while (node) { + if (isElementNode(node)) { + const value = node.getAttribute(CLICK_IGNORE_ATTR_NAME) + if (value) { + const parsed = parseTokens(value) + mask |= parsed + if ((mask & CLICK_IGNORE_ALL) === CLICK_IGNORE_ALL) { + break + } + } + } + node = getParentNode(node) + } + cache.set(element, mask) + return mask +} + diff --git a/packages/rum-core/src/domain/action/computeFrustration.spec.ts b/packages/rum-core/src/domain/action/computeFrustration.spec.ts index bc2c844777..b71c7a0f6a 100644 --- a/packages/rum-core/src/domain/action/computeFrustration.spec.ts +++ b/packages/rum-core/src/domain/action/computeFrustration.spec.ts @@ -75,6 +75,62 @@ describe('computeFrustration', () => { }) }) + describe('click-ignore attribute', () => { + it('suppresses dead_click when target has data-dd-click-ignore="dead"', () => { + const target = appendElement('') + clicks[1] = createFakeClick({ hasPageActivity: false, event: { target } }) + computeFrustration(clicks, rageClick) + expect(getFrustrations(clicks[1])).toEqual([]) + }) + + it('suppresses error_click when target has data-dd-click-ignore="error"', () => { + const target = appendElement('') + clicks[1] = createFakeClick({ hasError: true, event: { target } }) + computeFrustration(clicks, rageClick) + expect(getFrustrations(clicks[1])).toEqual([]) + }) + + it('suppresses rage_click for the chain when any click target has data-dd-click-ignore="rage"', () => { + const t1 = appendElement('') + const t2 = appendElement('') + const t3 = appendElement('') + clicksConsideredAsRage = [ + createFakeClick({ event: { target: t1 } }), + createFakeClick({ event: { target: t2 } }), + createFakeClick({ event: { target: t3 } }), + ] + computeFrustration(clicksConsideredAsRage, rageClick) + expect(getFrustrations(rageClick)).toEqual([]) + }) + + it('inherits from ancestor element', () => { + const parent = appendElement('
') + const child = parent.querySelector('button') as HTMLElement + clicks[1] = createFakeClick({ hasPageActivity: false, event: { target: child } }) + computeFrustration(clicks, rageClick) + expect(getFrustrations(clicks[1])).toEqual([]) + }) + + it('all token suppresses dead and error', () => { + const parent = appendElement('
') + const child = parent.querySelector('button') as HTMLElement + clicks[0] = createFakeClick({ hasError: true, event: { target: child } }) + clicks[1] = createFakeClick({ hasPageActivity: false, event: { target: child } }) + computeFrustration(clicks, rageClick) + expect(getFrustrations(clicks[0])).toEqual([]) + expect(getFrustrations(clicks[1])).toEqual([]) + }) + + it('parses mixed case and spacing', () => { + const target = appendElement('') + clicks[0] = createFakeClick({ hasPageActivity: false, event: { target } }) + clicks[1] = createFakeClick({ hasError: true, event: { target } }) + computeFrustration(clicks, rageClick) + expect(getFrustrations(clicks[0])).toEqual([]) // dead suppressed + expect(getFrustrations(clicks[1])).toEqual([FrustrationType.ERROR_CLICK]) // error not suppressed + }) + }) + function getFrustrations(click: FakeClick) { return click.addFrustration.calls.allArgs().map((args) => args[0]) } diff --git a/packages/rum-core/src/domain/action/computeFrustration.ts b/packages/rum-core/src/domain/action/computeFrustration.ts index 302638c2c2..bcb7593d64 100644 --- a/packages/rum-core/src/domain/action/computeFrustration.ts +++ b/packages/rum-core/src/domain/action/computeFrustration.ts @@ -1,30 +1,40 @@ import { ONE_SECOND } from '@datadog/browser-core' import { FrustrationType } from '../../rawRumEvent.types' import type { Click } from './trackClickActions' +import { ClickIgnoreFlag, getIgnoredForElement } from './clickIgnore' const MIN_CLICKS_PER_SECOND_TO_CONSIDER_RAGE = 3 export function computeFrustration(clicks: Click[], rageClick: Click) { if (isRage(clicks)) { - rageClick.addFrustration(FrustrationType.RAGE_CLICK) - if (clicks.some(isDead)) { - rageClick.addFrustration(FrustrationType.DEAD_CLICK) + const chainIgnoredMask = clicks.reduce((acc, c) => acc | getIgnoredForElement(c.event.target), 0) + const rageIgnored = (chainIgnoredMask & ClickIgnoreFlag.RAGE) !== 0 + if (!rageIgnored) { + rageClick.addFrustration(FrustrationType.RAGE_CLICK) + const hasDeadNotIgnored = clicks.some((c) => isDead(c) && (getIgnoredForElement(c.event.target) & ClickIgnoreFlag.DEAD) === 0) + if (hasDeadNotIgnored) { + rageClick.addFrustration(FrustrationType.DEAD_CLICK) + } + const errorIgnored = (getIgnoredForElement(rageClick.event.target) & ClickIgnoreFlag.ERROR) !== 0 + if (rageClick.hasError && !errorIgnored) { + rageClick.addFrustration(FrustrationType.ERROR_CLICK) + } + return { isRage: true } } - if (rageClick.hasError) { - rageClick.addFrustration(FrustrationType.ERROR_CLICK) - } - return { isRage: true } } const hasSelectionChanged = clicks.some((click) => click.getUserActivity().selection) clicks.forEach((click) => { if (click.hasError) { - click.addFrustration(FrustrationType.ERROR_CLICK) + if ((getIgnoredForElement(click.event.target) & ClickIgnoreFlag.ERROR) === 0) { + click.addFrustration(FrustrationType.ERROR_CLICK) + } } if ( isDead(click) && // Avoid considering clicks part of a double-click or triple-click selections as dead clicks - !hasSelectionChanged + !hasSelectionChanged && + (getIgnoredForElement(click.event.target) & ClickIgnoreFlag.DEAD) === 0 ) { click.addFrustration(FrustrationType.DEAD_CLICK) }