diff --git a/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts b/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts index 026007d5c..0b48b4146 100644 --- a/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts +++ b/packages/plugin-autocapture-browser/src/autocapture/track-rage-click.ts @@ -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; @@ -28,6 +22,13 @@ type EventRageClick = { '[Amplitude] Click Count': number; }; +type ClickEvent = { + event: MouseEvent | Event; + timestamp: number; + targetElementProperties: Record; + closestTrackedAncestor: Element | null; +}; + export function trackRageClicks({ amplitude, allObservables, @@ -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 }); + }); } diff --git a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts index c8e88c84d..c0059c401 100644 --- a/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts +++ b/packages/plugin-autocapture-browser/test/autocapture-plugin/track-rage-click.test.ts @@ -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'; @@ -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(), @@ -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, @@ -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, @@ -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, @@ -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({