Skip to content
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

Hackathon rage click #806

Open
wants to merge 11 commits into
base: v1.x
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions packages/plugin-autocapture-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"dependencies": {
"@amplitude/analytics-client-common": ">=1 <3",
"@amplitude/analytics-types": ">=1 <3",
"rxjs": "^7.8.1",
"tslib": "^2.4.1"
},
"devDependencies": {
Expand Down
38 changes: 36 additions & 2 deletions packages/plugin-autocapture-browser/src/autocapture-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-restricted-globals */
import { BrowserClient, BrowserConfig, EnrichmentPlugin, Logger } from '@amplitude/analytics-types';
import { Observable } from 'rxjs';
import * as constants from './constants';
import {
getText,
Expand All @@ -13,6 +14,9 @@ import {
} from './helpers';
import { Messenger, WindowMessenger } from './libs/messenger';
import { ActionType } from './typings/autocapture';
import { fromEvent } from 'rxjs';
import { trackErrors } from './tracking/errorTracking';
import { trackDeadClicks } from './tracking/deadClickTracking';

type BrowserEnrichmentPlugin = EnrichmentPlugin<BrowserClient, BrowserConfig>;

Expand Down Expand Up @@ -72,6 +76,12 @@ interface Options {
enabled?: boolean;
messenger?: Messenger;
};
/**
* Options for integrating frustration analytics.
*/
frustrationAnalyticsOptions?: {
enabled?: boolean;
};
}

export const autocapturePlugin = (options: Options = {}): BrowserEnrichmentPlugin => {
Expand All @@ -87,7 +97,6 @@ export const autocapturePlugin = (options: Options = {}): BrowserEnrichmentPlugi
} = options;
const name = constants.PLUGIN_NAME;
const type = 'enrichment';

let observer: MutationObserver | undefined;
let eventListeners: EventListener[] = [];
let logger: Logger | undefined = undefined;
Expand Down Expand Up @@ -216,9 +225,34 @@ export const autocapturePlugin = (options: Options = {}): BrowserEnrichmentPlugi
if (typeof document === 'undefined') {
return;
}

// Create an Observable for MutationObserver events
const mutationObservable = new Observable<MutationRecord[]>((observer) => {
const mutationObserver = new MutationObserver((mutations) => {
observer.next(mutations);
});
mutationObserver.observe(document.body, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
});
return () => mutationObserver.disconnect();
});

if (options.frustrationAnalyticsOptions?.enabled) {
// Create Observables from window events
const clickObservable = fromEvent<MouseEvent>(window, 'mousedown');
const keydownObservable = fromEvent<KeyboardEvent>(window, 'keydown');
const errorObservable = fromEvent<ErrorEvent>(window, 'error');

trackErrors({ clickObservable, keydownObservable, errorObservable }, amplitude, getEventProperties);
trackDeadClicks({ clickObservable, mutationObservable }, amplitude, getEventProperties);
}

const addListener = (el: Element) => {
if (shouldTrackEvent('click', el)) {
addEventListener(el, 'click', (event: Event) => {
addEventListener(el, 'click', () => {
// Limit to only the innermost element that matches the selectors, avoiding all propagated event after matching.
/* istanbul ignore next */
if (
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin-autocapture-browser/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const PLUGIN_NAME = '@amplitude/plugin-autocapture-browser';

export const AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT = '[Amplitude] Rage Click';
export const AMPLITUDE_ELEMENT_ERROR_CLICKED_EVENT = '[Amplitude] Error Click';
export const AMPLITUDE_ELEMENT_DEAD_CLICKED_EVENT = '[Amplitude] Dead Click';
export const AMPLITUDE_ELEMENT_CLICKED_EVENT = '[Amplitude] Element Clicked';
export const AMPLITUDE_ELEMENT_CHANGED_EVENT = '[Amplitude] Element Changed';

Expand Down
58 changes: 58 additions & 0 deletions packages/plugin-autocapture-browser/src/frustration-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable no-restricted-globals */
import { BrowserClient } from '@amplitude/analytics-types';

// import debounce from 'lodash.debounce';

import * as constants from './constants';

export type QueuedEvent = {
timestamp: number;
type: 'click';
element: Element;
event: Record<string, unknown>;
shouldTrackEvent: boolean;
};

// const debouncedProcess = debounce((amplitude: BrowserClient) => processQueue(amplitude), 1000);
const debouncedProcess = (amplitude: BrowserClient) => processQueue(amplitude);
let eventQueue: QueuedEvent[] = [];
export const addToQueue = (event: QueuedEvent, amplitude: BrowserClient) => {
// if new event is not the same as the ones in queue
if (eventQueue.length && event.element !== eventQueue[0].element) {
console.log(event.element !== eventQueue[0].element, event.element, eventQueue[0].element);
console.log('process immediate');
// Cancel the debounce and process everything that we have
// debouncedProcess?.cancel();
processQueue(amplitude);

// Add the current event to the queue and start the debounce again
eventQueue.push(event);
debouncedProcess(amplitude);
} else {
console.log('debounce process');
eventQueue.push(event);
debouncedProcess(amplitude);
}
};
export const processQueue = (amplitude: BrowserClient) => {
const rageThreshold = 5;
console.log('processQueue', eventQueue);
// If length is greater than the rageThreshold, send rage click
if (eventQueue.length >= rageThreshold) {
/* istanbul ignore next */
amplitude?.track(
constants.AMPLITUDE_ELEMENT_RAGE_CLICKED_EVENT,
{ ...eventQueue[0].event, '[Amplitude] Number of clicks': eventQueue.length },
{ time: eventQueue[0].timestamp },
);
} else {
for (const ev of eventQueue) {
if (ev.shouldTrackEvent) {
amplitude?.track(constants.AMPLITUDE_ELEMENT_CLICKED_EVENT, ev.event, { time: ev.timestamp });
}
}
}
eventQueue = [];
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Observable, timer, map } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
import * as constants from '../constants';
import { ActionType } from 'src/typings/autocapture';
export function trackDeadClicks(
{
clickObservable,
mutationObservable,
}: {
clickObservable: Observable<MouseEvent>;
mutationObservable: Observable<MutationRecord[]>;
},
amplitude: any,
getEventProperties: (actionType: ActionType, element: Element) => Record<string, any>,
) {
// Track clicks not followed by mutations
const clicksWithoutMutations = clickObservable.pipe(
switchMap((click) =>
timer(1500).pipe(
takeUntil(mutationObservable),
map(() => click),
),
),
);

clicksWithoutMutations.subscribe({
next(click) {
const target = click.target as Element;
console.log(`No mutation detected within ${1500}ms after click at (${click}) }`);

if (['A', 'BUTTON'].includes(String(target?.tagName))) {
amplitude?.track(constants.AMPLITUDE_ELEMENT_DEAD_CLICKED_EVENT, getEventProperties('click', target));
}
},
});
}
61 changes: 61 additions & 0 deletions packages/plugin-autocapture-browser/src/tracking/errorTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { merge, timer, Observable } from 'rxjs';
import { buffer, filter, map, switchMap } from 'rxjs/operators';
import * as constants from '../constants';
import { ActionType } from 'src/typings/autocapture';
import { BrowserClient } from '@amplitude/analytics-types';
export function trackErrors(
{
clickObservable,
keydownObservable,
errorObservable,
}: {
clickObservable: Observable<MouseEvent>;
keydownObservable: Observable<KeyboardEvent>;
errorObservable: Observable<ErrorEvent>;
},
amplitude: any,
getEventProperties: (actionType: ActionType, element: Element) => Record<string, any>,
) {
// Combine all events
const allEventsObservable: Observable<Event> = merge(clickObservable, keydownObservable, errorObservable);

// Create an Observable that emits after 500ms of each event
const timeWindowObservable = allEventsObservable.pipe(switchMap(() => timer(500)));

// Buffer all events that occur within 500ms
const bufferedEvents = allEventsObservable.pipe(
buffer(timeWindowObservable),
filter((events) => events.some((event) => event.type === 'error')),
map((events) => {
const errorIndex = events.findIndex((event) => event.type === 'error');
// Only include events before the error
const eventsBeforeError = events.slice(0, errorIndex);
return {
events: eventsBeforeError,
bufferStartTime: eventsBeforeError[0].timeStamp,
bufferEndTime: eventsBeforeError[eventsBeforeError.length - 1].timeStamp,
};
}),
);

// Subscribe to the buffered events and log them
bufferedEvents.subscribe({
next({ events }) {
const triggeringEvent = events[events.length - 1];
if (!amplitude) {
return;
}
/* istanbul ignore next */
(amplitude as BrowserClient).track(
constants.AMPLITUDE_ELEMENT_ERROR_CLICKED_EVENT as string,
getEventProperties(
triggeringEvent.type === 'mousedown' ? 'click' : 'keydown',
triggeringEvent.target as Element,
),
);
},
error(err) {
console.error('Error in buffered events subscription:', err);
},
});
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export type ActionType = 'click' | 'change';
export type ActionType = 'click' | 'change' | 'keydown';
Loading