Skip to content

Commit

Permalink
⚗✨ [RUMF-1209] collect rage clicks (#1488)
Browse files Browse the repository at this point in the history
* [RUMF-1209] introduce rage click chains

If we need to collect rage click, we need to buffer similar clicks
to make sure they don't need to be represented as a single "rage click"
action.

This commit introduce a concept of "click chain", grouping similar
clicks together, and flushing them when we are sure that the click chain
is "complete".

* ✨ [RUMF-1209] collect rage clicks

If the flushed click chain meet rage clicks conditions, let's generate a
single action instead of one for each click.

* 👌 rename "click action" to "potential click action"

... and other related namings

* ✅ add an E2E test

* 👌 improve RAGE_CLICK_THRESHOLD naming to be more explicit

* 👌 rename flush to finalize

* 👌 rename 'potential click action' to 'click'

* 👌 only expose `event` instead of the whole `base`

* 👌 add a test case for clicks with negative duration + ff enabled

* 👌 single loop on clicks when finalizing the rage click chain

* ✅ rename frustration_type to frustration.type in e2e tests

* 👌 remove unneeded comments

* 👌 add click.isStopped

* 👌 rename timeout and move clearTimeouts

* 👌 rename onClick to processClick

* 👌 adjust comment

* 👌 adjust comment

* 👌 rename state PENDING to ONGOING

* 👌 reorder functions in createRageClickChain
  • Loading branch information
BenoitZugmeyer authored May 9, 2022
1 parent 7b9c205 commit 7934617
Show file tree
Hide file tree
Showing 6 changed files with 508 additions and 42 deletions.
2 changes: 2 additions & 0 deletions packages/core/test/specHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ class StubXhr extends StubEventEmitter {
}
}

export function createNewEvent(eventName: 'click', properties?: { [name: string]: unknown }): MouseEvent
export function createNewEvent(eventName: string, properties?: { [name: string]: unknown }): Event
export function createNewEvent(eventName: string, properties: { [name: string]: unknown } = {}) {
let event: Event
if (typeof Event === 'function') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { noop, ONE_SECOND, timeStampNow } from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test/specHelper'
import { mockClock, createNewEvent } from '@datadog/browser-core/test/specHelper'
import { FrustrationType } from '../../../rawRumEvent.types'
import type { RageClickChain } from './rageClickChain'
import {
MAX_DISTANCE_BETWEEN_CLICKS,
MAX_DURATION_BETWEEN_CLICKS,
createRageClickChain,
isRage,
} from './rageClickChain'
import type { Click } from './trackClickActions'

describe('createRageClickChain', () => {
let clickChain: RageClickChain | undefined
let clock: Clock

beforeEach(() => {
clock = mockClock()
})

afterEach(() => {
clickChain?.stop()
clock.cleanup()
})

it('creates a click chain', () => {
clickChain = createRageClickChain(createFakeClick())
expect(clickChain).toEqual({
tryAppend: jasmine.any(Function),
stop: jasmine.any(Function),
})
})

it('appends a click', () => {
clickChain = createRageClickChain(createFakeClick())
expect(clickChain.tryAppend(createFakeClick())).toBe(true)
})

describe('finalize', () => {
it('finalizes if we try to append a non-similar click', () => {
const firstClick = createFakeClick({ target: document.documentElement })
clickChain = createRageClickChain(firstClick)
firstClick.stop()
clickChain.tryAppend(createFakeClick({ target: document.body }))
expect(firstClick.validate).toHaveBeenCalled()
})

it('does not finalize until it waited long enough to ensure no other click can be appended', () => {
const firstClick = createFakeClick()
clickChain = createRageClickChain(firstClick)
firstClick.stop()
clock.tick(MAX_DURATION_BETWEEN_CLICKS - 1)
expect(firstClick.validate).not.toHaveBeenCalled()
clock.tick(1)
expect(firstClick.validate).toHaveBeenCalled()
})

it('does not finalize until all clicks are stopped', () => {
const firstClick = createFakeClick()
clickChain = createRageClickChain(firstClick)
clock.tick(MAX_DURATION_BETWEEN_CLICKS)
expect(firstClick.validate).not.toHaveBeenCalled()
firstClick.stop()
expect(firstClick.validate).toHaveBeenCalled()
})

it('finalizes when stopping the click chain', () => {
const firstClick = createFakeClick({ target: document.documentElement })
clickChain = createRageClickChain(firstClick)
firstClick.stop()
clickChain.stop()
expect(firstClick.validate).toHaveBeenCalled()
})
})

describe('clicks similarity', () => {
it('does not accept a click if its timestamp is long after the previous one', () => {
clickChain = createRageClickChain(createFakeClick())
clock.tick(MAX_DURATION_BETWEEN_CLICKS)
expect(clickChain.tryAppend(createFakeClick())).toBe(false)
})

it('does not accept a click if its target is different', () => {
clickChain = createRageClickChain(createFakeClick({ target: document.documentElement }))
expect(clickChain.tryAppend(createFakeClick({ target: document.body }))).toBe(false)
})

it('does not accept a click if its location is far from the previous one', () => {
clickChain = createRageClickChain(createFakeClick({ clientX: 100, clientY: 100 }))
expect(
clickChain.tryAppend(createFakeClick({ clientX: 100, clientY: 100 + MAX_DISTANCE_BETWEEN_CLICKS + 1 }))
).toBe(false)
})

it('considers clicks relative to the previous one', () => {
clickChain = createRageClickChain(createFakeClick())
clock.tick(MAX_DURATION_BETWEEN_CLICKS - 1)
clickChain.tryAppend(createFakeClick())
clock.tick(MAX_DURATION_BETWEEN_CLICKS - 1)
expect(clickChain.tryAppend(createFakeClick())).toBe(true)
})
})

describe('when rage is detected', () => {
it('discards individual clicks', () => {
const clicks = [createFakeClick(), createFakeClick(), createFakeClick()]
createValidatedRageClickChain(clicks)
clicks.forEach((click) => expect(click.discard).toHaveBeenCalled())
})

it('uses a clone of the first click to represent the rage click', () => {
const clicks = [createFakeClick(), createFakeClick(), createFakeClick()]
createValidatedRageClickChain(clicks)
expect(clicks[0].clonedClick).toBeTruthy()
expect(clicks[0].clonedClick?.validate).toHaveBeenCalled()
})

it('the rage click should have a "rage" frustration', () => {
const clicks = [createFakeClick(), createFakeClick(), createFakeClick()]
createValidatedRageClickChain(clicks)
const expectedFrustrations = new Set()
expectedFrustrations.add(FrustrationType.RAGE)
expect(clicks[0].clonedClick?.getFrustrations()).toEqual(expectedFrustrations)
})

it('the rage click should contains other clicks frustration', () => {
const clicks = [createFakeClick(), createFakeClick(), createFakeClick()]
clicks[1].addFrustration(FrustrationType.DEAD)
createValidatedRageClickChain(clicks)
expect(clicks[0].clonedClick?.getFrustrations().has(FrustrationType.RAGE)).toBe(true)
})

function createValidatedRageClickChain(clicks: Click[]) {
clickChain = createRageClickChain(clicks[0])
clicks.slice(1).forEach((click) => clickChain!.tryAppend(click))
clicks.forEach((click) => click.stop())
clock.tick(MAX_DURATION_BETWEEN_CLICKS)
}
})
})

describe('isRage', () => {
let clock: Clock

beforeEach(() => {
clock = mockClock()
})

afterEach(() => {
clock.cleanup()
})

it('considers as rage three clicks happening at the same time', () => {
expect(isRage([createFakeClick(), createFakeClick(), createFakeClick()])).toBe(true)
})

it('does not consider as rage two clicks happening at the same time', () => {
expect(isRage([createFakeClick(), createFakeClick()])).toBe(false)
})

it('does not consider as rage a first click long before two fast clicks', () => {
const clicks = [createFakeClick()]
clock.tick(ONE_SECOND * 2)
clicks.push(createFakeClick(), createFakeClick())

expect(isRage(clicks)).toBe(false)
})

it('considers as rage a first click long before three fast clicks', () => {
const clicks = [createFakeClick()]
clock.tick(ONE_SECOND * 2)
clicks.push(createFakeClick(), createFakeClick(), createFakeClick())

expect(isRage(clicks)).toBe(true)
})

it('considers as rage three fast clicks long before a last click', () => {
const clicks = [createFakeClick(), createFakeClick(), createFakeClick()]
clock.tick(ONE_SECOND * 2)
clicks.push(createFakeClick())

expect(isRage(clicks)).toBe(true)
})
})

function createFakeClick(eventPartial?: Partial<MouseEvent>): Click & { clonedClick?: Click } {
let onStopCallback = noop
let isStopped = false
let clonedClick: Click | undefined
const frustrations = new Set<FrustrationType>()
return {
event: createNewEvent('click', {
element: document.body,
clientX: 100,
clientY: 100,
timeStamp: timeStampNow(),
...eventPartial,
}),
onStop: (newOnStopCallback) => {
onStopCallback = newOnStopCallback
},
isStopped: () => isStopped,
stop: () => {
isStopped = true
onStopCallback()
},
clone: () => {
clonedClick = createFakeClick(eventPartial)
return clonedClick
},
discard: jasmine.createSpy(),
validate: jasmine.createSpy(),
addFrustration: (frustration) => frustrations.add(frustration),
getFrustrations: () => frustrations,

get clonedClick() {
return clonedClick
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { monitor, ONE_SECOND, timeStampNow } from '@datadog/browser-core'
import { FrustrationType } from '../../../rawRumEvent.types'
import type { Click } from './trackClickActions'

export interface RageClickChain {
tryAppend: (click: Click) => boolean
stop: () => void
}

export const MAX_DURATION_BETWEEN_CLICKS = ONE_SECOND
export const MAX_DISTANCE_BETWEEN_CLICKS = 100

const enum RageClickChainStatus {
WaitingForMoreClicks,
WaitingForClicksToStop,
Finalized,
}

export function createRageClickChain(firstClick: Click): RageClickChain {
const bufferedClicks: Click[] = []
let status = RageClickChainStatus.WaitingForMoreClicks
let maxDurationBetweenClicksTimeout: number | undefined
const rageClick = firstClick.clone()
appendClick(firstClick)

function appendClick(click: Click) {
click.onStop(tryFinalize)
bufferedClicks.push(click)
clearTimeout(maxDurationBetweenClicksTimeout)
maxDurationBetweenClicksTimeout = setTimeout(monitor(dontAcceptMoreClick), MAX_DURATION_BETWEEN_CLICKS)
}

function tryFinalize() {
if (status === RageClickChainStatus.WaitingForClicksToStop && bufferedClicks.every((click) => click.isStopped())) {
status = RageClickChainStatus.Finalized
finalizeClicks(bufferedClicks, rageClick)
}
}

function dontAcceptMoreClick() {
clearTimeout(maxDurationBetweenClicksTimeout)
if (status === RageClickChainStatus.WaitingForMoreClicks) {
status = RageClickChainStatus.WaitingForClicksToStop
tryFinalize()
}
}

return {
tryAppend: (click) => {
if (status !== RageClickChainStatus.WaitingForMoreClicks) {
return false
}

if (
bufferedClicks.length > 0 &&
!areEventsSimilar(bufferedClicks[bufferedClicks.length - 1].event, click.event)
) {
dontAcceptMoreClick()
return false
}

appendClick(click)
return true
},
stop: () => {
dontAcceptMoreClick()
},
}
}

/**
* Checks whether two events are similar by comparing their target, position and timestamp
*/
function areEventsSimilar(first: MouseEvent, second: MouseEvent) {
return (
first.target === second.target &&
mouseEventDistance(first, second) <= MAX_DISTANCE_BETWEEN_CLICKS &&
first.timeStamp - second.timeStamp <= MAX_DURATION_BETWEEN_CLICKS
)
}

function mouseEventDistance(origin: MouseEvent, other: MouseEvent) {
return Math.sqrt(Math.pow(origin.clientX - other.clientX, 2) + Math.pow(origin.clientY - other.clientY, 2))
}

function finalizeClicks(clicks: Click[], rageClick: Click) {
if (isRage(clicks)) {
clicks.forEach((click) => {
click.discard()
click.getFrustrations().forEach((frustration) => {
rageClick.addFrustration(frustration)
})
})
rageClick.addFrustration(FrustrationType.RAGE)
rageClick.validate(timeStampNow())
} else {
rageClick.discard()
clicks.forEach((click) => click.validate())
}
}

const MIN_CLICKS_PER_SECOND_TO_CONSIDER_RAGE = 3

export function isRage(clicks: Click[]) {
// TODO: this condition should be improved to avoid reporting 3-click selection as rage click
for (let i = 0; i < clicks.length - (MIN_CLICKS_PER_SECOND_TO_CONSIDER_RAGE - 1); i += 1) {
if (
clicks[i + MIN_CLICKS_PER_SECOND_TO_CONSIDER_RAGE - 1].event.timeStamp - clicks[i].event.timeStamp <=
ONE_SECOND
) {
return true
}
}
return false
}
Loading

0 comments on commit 7934617

Please sign in to comment.