-
Notifications
You must be signed in to change notification settings - Fork 142
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
7b9c205
commit 7934617
Showing
6 changed files
with
508 additions
and
42 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
221 changes: 221 additions & 0 deletions
221
packages/rum-core/src/domain/rumEventsCollection/action/rageClickChain.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}, | ||
} | ||
} |
115 changes: 115 additions & 0 deletions
115
packages/rum-core/src/domain/rumEventsCollection/action/rageClickChain.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.