Skip to content

Commit e22ad41

Browse files
feat(plugin-frustration-browser): add rage click
1 parent e1cc3f3 commit e22ad41

File tree

7 files changed

+498
-6
lines changed

7 files changed

+498
-6
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const PLUGIN_NAME = 'frustration-browser';
2+
export const RAGE_CLICK_EVENT_NAME = '[Amplitude] Rage Click';

packages/plugin-frustration-browser/src/frustration-plugin.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
11
/* eslint-disable no-restricted-globals */
2-
import { BrowserClient, BrowserConfig, EnrichmentPlugin } from '@amplitude/analytics-core';
3-
import { PLUGIN_NAME } from './constants';
2+
import { BrowserClient, BrowserConfig, EnrichmentPlugin, getGlobalScope } from '@amplitude/analytics-core';
3+
import { PLUGIN_NAME, RAGE_CLICK_EVENT_NAME } from './constants';
4+
import * as rageClick from './rage-click';
5+
import { RageClickEventPayload } from './types';
46

57
export type BrowserEnrichmentPlugin = EnrichmentPlugin<BrowserClient, BrowserConfig>;
68

79
export const frustrationPlugin = (): BrowserEnrichmentPlugin => {
8-
const setup: BrowserEnrichmentPlugin['setup'] = async (/*config, amplitude*/) => {
9-
// add logic here to setup any resources
10+
let clickHandler: ((event: MouseEvent) => void) | null = null;
11+
12+
const setup: BrowserEnrichmentPlugin['setup'] = async (_, amplitude) => {
13+
const { document } = getGlobalScope() as typeof globalThis;
14+
rageClick.init({
15+
timeout: 3000, // TODO: make this configurable
16+
threshold: 3, // TODO: make this configurable
17+
ignoreSelector: '#ignore-rage-click', // TODO: make this configurable
18+
onRageClick(clickEvent, element) {
19+
const payload: RageClickEventPayload = {
20+
'[Amplitude] Begin Time': clickEvent.begin,
21+
'[Amplitude] End Time': clickEvent.end!,
22+
'[Amplitude] Duration': clickEvent.end! - clickEvent.begin,
23+
'[Amplitude] Element Text': element.innerText || element.textContent || '',
24+
'[Amplitude] Element Tag': element.tagName.toLowerCase(),
25+
'[Amplitude] Clicks': clickEvent.clicks,
26+
};
27+
amplitude.track(RAGE_CLICK_EVENT_NAME, payload);
28+
},
29+
});
30+
31+
document.addEventListener(
32+
'click',
33+
(clickHandler = (event: MouseEvent) => {
34+
const { target: clickedEl } = event;
35+
if (clickedEl instanceof HTMLElement) {
36+
rageClick.registerClick(clickedEl, event);
37+
}
38+
}),
39+
);
1040
};
1141

1242
/* istanbul ignore next */
@@ -15,7 +45,12 @@ export const frustrationPlugin = (): BrowserEnrichmentPlugin => {
1545
};
1646

1747
const teardown = async () => {
18-
// add logic here to clean up any resources
48+
const { document } = getGlobalScope() as typeof globalThis;
49+
if (clickHandler) {
50+
document.removeEventListener('click', clickHandler);
51+
clickHandler = null;
52+
}
53+
rageClick.clear();
1954
};
2055

2156
return {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ClickEvent, RageClickOptions } from './types';
2+
3+
const CLICKABLE_ELEMENT_SELECTOR = 'a,button,input';
4+
5+
const rageClickedElements = new Map<HTMLElement, ClickEvent>();
6+
let timeout: number;
7+
let threshold: number;
8+
let ignoreSelector: string;
9+
let onRageClick: (event: ClickEvent, element: HTMLElement) => void;
10+
11+
export function init(options: RageClickOptions): void {
12+
timeout = options.timeout;
13+
threshold = options.threshold;
14+
ignoreSelector = options.ignoreSelector;
15+
onRageClick = options.onRageClick;
16+
}
17+
18+
export function registerClick(clickedEl: HTMLElement, event: MouseEvent): void {
19+
const target: HTMLElement | null = clickedEl.closest(CLICKABLE_ELEMENT_SELECTOR);
20+
if (!target) {
21+
return;
22+
}
23+
if (ignoreSelector && target.matches(ignoreSelector)) {
24+
return;
25+
}
26+
27+
let clickEvent = rageClickedElements.get(target);
28+
29+
// create a new click event if it doesn't exist
30+
const eventTime = Math.floor(performance.now() + performance.timeOrigin);
31+
if (!clickEvent) {
32+
const timer = setTimeout(() => {
33+
rageClickedElements.delete(target);
34+
}, timeout);
35+
clickEvent = {
36+
begin: eventTime,
37+
count: 0,
38+
timer,
39+
clicks: [],
40+
};
41+
rageClickedElements.set(target, clickEvent);
42+
}
43+
44+
const elapsedTime = eventTime - clickEvent.begin;
45+
if (elapsedTime < timeout) {
46+
clickEvent.count += 1;
47+
clickEvent.clicks.push({
48+
x: event.clientX,
49+
y: event.clientY,
50+
Time: new Date(performance.now() + performance.timeOrigin).toISOString(),
51+
});
52+
if (clickEvent.count >= threshold) {
53+
clickEvent.end = Math.floor(performance.now() + performance.timeOrigin);
54+
onRageClick(clickEvent, target);
55+
rageClickedElements.delete(target);
56+
clearTimeout(clickEvent.timer);
57+
}
58+
}
59+
}
60+
61+
export function clear(): void {
62+
rageClickedElements.clear();
63+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export type Click = {
2+
x: number;
3+
y: number;
4+
Time: string;
5+
};
6+
7+
export type ClickEvent = {
8+
begin: number;
9+
end?: number;
10+
count: number;
11+
timer: ReturnType<typeof setTimeout>;
12+
clicks: Click[];
13+
};
14+
15+
export type RageClickOptions = {
16+
timeout: number;
17+
threshold: number;
18+
ignoreSelector: string;
19+
onRageClick: (event: ClickEvent, element: HTMLElement) => void;
20+
};
21+
22+
export type RageClickEventPayload = {
23+
'[Amplitude] Begin Time': number;
24+
'[Amplitude] End Time': number;
25+
'[Amplitude] Duration': number;
26+
'[Amplitude] Element Text': string;
27+
'[Amplitude] Element Tag': string;
28+
'[Amplitude] Clicks': Click[];
29+
};

0 commit comments

Comments
 (0)