Skip to content

fix: use sliding window to capture rage clicks #1202

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 10, 2025
Merged
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
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { AllWindowObservables } from 'src/autocapture-plugin';
import { filter, map, bufferTime } from 'rxjs';
import { filter, map } from 'rxjs';
import { BrowserClient } from '@amplitude/analytics-core';
import { filterOutNonTrackableEvents, shouldTrackEvent } from '../helpers';
import { AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT } from '../constants';
import { DEFAULT_RAGE_CLICK_THRESHOLD, DEFAULT_RAGE_CLICK_WINDOW_MS } from '@amplitude/analytics-core';

let RAGE_CLICK_THRESHOLD = DEFAULT_RAGE_CLICK_THRESHOLD;
let RAGE_CLICK_WINDOW_MS = DEFAULT_RAGE_CLICK_WINDOW_MS;

// allow override of rage click config for testing only
export function _overrideRageClickConfig(rageClickThreshold: number, rageClickWindowMs: number) {
RAGE_CLICK_THRESHOLD = rageClickThreshold;
RAGE_CLICK_WINDOW_MS = rageClickWindowMs;
}
const RAGE_CLICK_THRESHOLD = DEFAULT_RAGE_CLICK_THRESHOLD;
const RAGE_CLICK_WINDOW_MS = DEFAULT_RAGE_CLICK_WINDOW_MS;

type Click = {
X: number;
Expand All @@ -28,6 +22,13 @@ type EventRageClick = {
'[Amplitude] Click Count': number;
};

type ClickEvent = {
event: MouseEvent | Event;
timestamp: number;
targetElementProperties: Record<string, any>;
closestTrackedAncestor: Element | null;
};

export function trackRageClicks({
amplitude,
allObservables,
Expand All @@ -39,52 +40,74 @@ export function trackRageClicks({
}) {
const { clickObservable } = allObservables;

// Buffer clicks within a RAGE_CLICK_WINDOW_MS window and filter for rage clicks
const rageClickObservable = clickObservable.pipe(
filter(filterOutNonTrackableEvents),
filter((click) => {
return shouldTrackRageClick('click', click.closestTrackedAncestor);
}),
bufferTime(RAGE_CLICK_WINDOW_MS),
filter((clicks) => {
// filter if not enough clicks to be a rage click
if (clicks.length < RAGE_CLICK_THRESHOLD) {
return false;
}
// Keep track of all clicks within the sliding window
const clickWindow: ClickEvent[] = [];

return clickObservable
.pipe(
filter(filterOutNonTrackableEvents),
filter((click) => {
return shouldTrackRageClick('click', click.closestTrackedAncestor);
}),
map((click) => {
const now = click.timestamp;

// filter if the last RAGE_CLICK_THRESHOLD clicks were not all on the same element
let trailingIndex = clicks.length - 1;
const lastClickTarget = clicks[trailingIndex].event.target;
while (--trailingIndex >= clicks.length - RAGE_CLICK_THRESHOLD) {
if (clicks[trailingIndex].event.target !== lastClickTarget) {
return false;
// if the current click isn't on the same element as the most recent click,
// clear the sliding window and start over
if (
clickWindow.length > 0 &&
clickWindow[clickWindow.length - 1].closestTrackedAncestor !== click.closestTrackedAncestor
) {
clickWindow.splice(0, clickWindow.length);
}
}

// if we reach here that means the last RAGE_CLICK_THRESHOLD clicks were all on the same element
// and thus we have a rage click
return true;
}),
map((clicks) => {
const firstClick = clicks[0];
const lastClick = clicks[clicks.length - 1];
const rageClickEvent: EventRageClick = {
'[Amplitude] Begin Time': new Date(firstClick.timestamp).toISOString(),
'[Amplitude] End Time': new Date(lastClick.timestamp).toISOString(),
'[Amplitude] Duration': lastClick.timestamp - firstClick.timestamp,
'[Amplitude] Clicks': clicks.map((click) => ({
X: (click.event as MouseEvent).clientX,
Y: (click.event as MouseEvent).clientY,
Time: click.timestamp,
})),
'[Amplitude] Click Count': clicks.length,
...firstClick.targetElementProperties,
};
return { rageClickEvent, time: firstClick.timestamp };
}),
);
// remove past clicks that are outside the sliding window
let clickPtr = 0;
for (; clickPtr < clickWindow.length; clickPtr++) {
if (now - clickWindow[clickPtr].timestamp < RAGE_CLICK_WINDOW_MS) {
break;
}
}
clickWindow.splice(0, clickPtr);

return rageClickObservable.subscribe(({ rageClickEvent, time }) => {
amplitude.track(AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT, rageClickEvent, { time });
});
// add the current click to the window
clickWindow.push(click);

// if there's not enough clicks to be a rage click, return null
if (clickWindow.length < RAGE_CLICK_THRESHOLD) {
return null;
}

// if we've made it here, we have enough trailing clicks on the same element
// for it to be a rage click
const firstClick = clickWindow[0];
const lastClick = clickWindow[clickWindow.length - 1];

const rageClickEvent: EventRageClick = {
'[Amplitude] Begin Time': new Date(firstClick.timestamp).toISOString(),
'[Amplitude] End Time': new Date(lastClick.timestamp).toISOString(),
'[Amplitude] Duration': lastClick.timestamp - firstClick.timestamp,
'[Amplitude] Clicks': clickWindow.map((click) => ({
X: (click.event as MouseEvent).clientX,
Y: (click.event as MouseEvent).clientY,
Time: click.timestamp,
})),
'[Amplitude] Click Count': clickWindow.length,
...firstClick.targetElementProperties,
};

// restart the sliding window
clickWindow.splice(0, clickWindow.length);

return { rageClickEvent, time: firstClick.timestamp };
}),
filter((result) => result !== null),
)
.subscribe((data: { rageClickEvent: EventRageClick; time: number } | null) => {
/* istanbul ignore if */
if (data === null) {
return;
}
amplitude.track(AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT, data.rageClickEvent, { time: data.time });
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { Subject } from 'rxjs';
import { BrowserClient } from '@amplitude/analytics-core';
import { _overrideRageClickConfig, trackRageClicks } from '../../src/autocapture/track-rage-click';
import { trackRageClicks } from '../../src/autocapture/track-rage-click';
import { AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT } from '../../src/constants';
import { AllWindowObservables, ObservablesEnum } from '../../src/autocapture-plugin';

Expand All @@ -14,11 +14,6 @@ describe('trackRageClicks', () => {
let allObservables: AllWindowObservables;
let shouldTrackRageClick: jest.Mock;

beforeAll(() => {
// reduce the rage click window to 5ms to speed up the test
_overrideRageClickConfig(5, 5);
});

beforeEach(() => {
mockAmplitude = {
track: jest.fn(),
Expand Down Expand Up @@ -67,7 +62,7 @@ describe('trackRageClicks', () => {
expect(mockAmplitude.track).toHaveBeenCalledWith(
AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT,
expect.objectContaining({
'[Amplitude] Click Count': 5,
'[Amplitude] Click Count': 4,
'[Amplitude] Clicks': expect.arrayContaining([
expect.objectContaining({
X: 100,
Expand All @@ -83,7 +78,7 @@ describe('trackRageClicks', () => {
}, 100); // Wait slightly longer than the buffer window
});

it('should not track when clicks are below threshold', (done) => {
it('should track if 6 clicks but first click is outside the rage click window', (done) => {
const subscription = trackRageClicks({
amplitude: mockAmplitude,
allObservables,
Expand All @@ -93,8 +88,51 @@ describe('trackRageClicks', () => {
// Create a mock element
const mockElement = document.createElement('div');

// Simulate only 4 clicks (below threshold)
// Simulate 6 clicks
const startTime = Date.now();
clickObservable.next({
event: {
target: mockElement,
clientX: 100,
clientY: 100,
},
timestamp: startTime,
closestTrackedAncestor: mockElement,
targetElementProperties: { id: 'test-element' },
});
const rageClickWindow = 1000;
for (let i = 0; i < 4; i++) {
clickObservable.next({
event: {
target: mockElement,
clientX: 100,
clientY: 100,
},
timestamp: startTime + (rageClickWindow - 200) + i * 100,
closestTrackedAncestor: mockElement,
targetElementProperties: { id: 'test-element' },
});
}

setTimeout(() => {
expect(mockAmplitude.track).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
done();
}, 100);
});

it('should not track when clicks are below threshold', (done) => {
const subscription = trackRageClicks({
amplitude: mockAmplitude,
allObservables,
shouldTrackRageClick,
});

// Create a mock element
const mockElement = document.createElement('div');

// Simulate only 3 clicks (below threshold)
for (let i = 0; i < 3; i++) {
clickObservable.next({
event: {
target: mockElement,
Expand Down Expand Up @@ -146,7 +184,7 @@ describe('trackRageClicks', () => {
}, 100);
});

it('should not track div elements', (done) => {
it('should not track untracked elements', (done) => {
shouldTrackRageClick.mockReturnValue(false);

const subscription = trackRageClicks({
Expand Down
Loading