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
60 changes: 60 additions & 0 deletions packages/rum-core/src/domain/action/clickIgnore.ts
Original file line number Diff line number Diff line change
@@ -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<Element, number>()

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
}

56 changes: 56 additions & 0 deletions packages/rum-core/src/domain/action/computeFrustration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<button target data-dd-click-ignore="dead"></button>')
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('<button target data-dd-click-ignore="error"></button>')
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('<button target></button>')
const t2 = appendElement('<button target data-dd-click-ignore="rage"></button>')
const t3 = appendElement('<button target></button>')
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('<div data-dd-click-ignore="dead"><button target></button></div>')
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('<div data-dd-click-ignore="all"><button target></button></div>')
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('<button target data-dd-click-ignore=" Rage , DEAD "></button>')
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])
}
Expand Down
28 changes: 19 additions & 9 deletions packages/rum-core/src/domain/action/computeFrustration.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Expand Down