From 838e83ad5c6e73e10eac174da3171d1d2649c3e0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 12 Nov 2024 11:24:50 +0100 Subject: [PATCH] feat: deadclicks in heatmaps (#1510) --- ...azy-loaded-dead-clicks-autocapture.test.ts | 40 ++++++++++--- .../dead-clicks-autocapture.test.ts | 4 +- src/__tests__/heatmaps.test.ts | 9 +++ src/constants.ts | 1 + src/entrypoints/dead-clicks-autocapture.ts | 58 ++++++++----------- src/extensions/dead-clicks-autocapture.ts | 41 ++++++++----- src/heatmaps.ts | 20 ++++++- src/posthog-core.ts | 4 +- src/types.ts | 23 ++++++++ src/utils/element-utils.ts | 4 +- src/utils/globals.ts | 2 +- 11 files changed, 139 insertions(+), 67 deletions(-) diff --git a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts index 7588cb264..b206acd62 100644 --- a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts +++ b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts @@ -160,7 +160,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by scroll, not a dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, scrollDelayMs: 99, }) @@ -175,7 +175,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by mutation, not a dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastMutation'] = 1000 @@ -189,7 +189,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by a selection change, not a dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastSelectionChanged'] = 999 @@ -203,7 +203,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by a selection change outside of threshold, dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastSelectionChanged'] = 1000 @@ -245,7 +245,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by a mutation after threshold, dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastMutation'] = 900 + 2501 @@ -287,7 +287,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by a scroll after threshold, dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, scrollDelayMs: 2501, }) @@ -329,7 +329,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click followed by nothing for too long, dead click', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined @@ -371,7 +371,7 @@ describe('LazyLoadedDeadClicksAutocapture', () => { it('click not followed by anything within threshold, rescheduled for next check', () => { lazyLoadedDeadClicksAutocapture['_clicks'].push({ node: document.body, - originalEvent: { type: 'click' } as Event, + originalEvent: { type: 'click' } as MouseEvent, timestamp: 900, }) lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined @@ -383,4 +383,28 @@ describe('LazyLoadedDeadClicksAutocapture', () => { expect(fakeInstance.capture).not.toHaveBeenCalled() }) }) + + it('can have alternative behaviour for onCapture', () => { + jest.setSystemTime(0) + const replacementCapture = jest.fn() + + lazyLoadedDeadClicksAutocapture = new LazyLoadedDeadClicksAutocapture(fakeInstance, { + __onCapture: replacementCapture, + }) + lazyLoadedDeadClicksAutocapture.start(document) + + lazyLoadedDeadClicksAutocapture['_clicks'].push({ + node: document.body, + originalEvent: { type: 'click' } as MouseEvent, + timestamp: 900, + }) + lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined + + jest.setSystemTime(3001 + 900) + lazyLoadedDeadClicksAutocapture['_checkClicks']() + + expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0) + expect(fakeInstance.capture).not.toHaveBeenCalled() + expect(replacementCapture).toHaveBeenCalled() + }) }) diff --git a/src/__tests__/extensions/dead-clicks-autocapture.test.ts b/src/__tests__/extensions/dead-clicks-autocapture.test.ts index 44bde3154..af1a66aab 100644 --- a/src/__tests__/extensions/dead-clicks-autocapture.test.ts +++ b/src/__tests__/extensions/dead-clicks-autocapture.test.ts @@ -51,7 +51,7 @@ describe('DeadClicksAutocapture', () => { mockLoader.mockClear() const instance = await createPosthogInstance(uuidv7(), { capture_dead_clicks: true }) - new DeadClicksAutocapture(instance).startIfEnabled() + new DeadClicksAutocapture(instance, () => true).startIfEnabled() expect(mockLoader).toHaveBeenCalledWith(instance, 'dead-clicks-autocapture', expect.any(Function)) }) @@ -100,7 +100,7 @@ describe('DeadClicksAutocapture', () => { [DEAD_CLICKS_ENABLED_SERVER_SIDE]: serverSide, }) instance.config.capture_dead_clicks = clientSide - expect(instance.deadClicksAutocapture.isEnabled).toBe(expected) + expect(instance.deadClicksAutocapture.isEnabled(instance.deadClicksAutocapture)).toBe(expected) } ) }) diff --git a/src/__tests__/heatmaps.test.ts b/src/__tests__/heatmaps.test.ts index 8865dc9ca..95a4c4fb5 100644 --- a/src/__tests__/heatmaps.test.ts +++ b/src/__tests__/heatmaps.test.ts @@ -225,4 +225,13 @@ describe('heatmaps', () => { } ) }) + + it('starts dead clicks autocapture with the correct config', () => { + const heatmapsDeadClicksInstance = posthog.heatmaps['deadClicksCapture'] + expect(heatmapsDeadClicksInstance.isEnabled(heatmapsDeadClicksInstance)).toBe(true) + // this is a little nasty but the binding to this makes the function not directly comparable + expect(JSON.stringify(heatmapsDeadClicksInstance.onCapture)).toEqual( + JSON.stringify(posthog.heatmaps['_onDeadClick'].bind(posthog.heatmaps)) + ) + }) }) diff --git a/src/constants.ts b/src/constants.ts index b13f15d58..f86c52ee4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,6 +45,7 @@ export const INITIAL_REFERRER_INFO = '$initial_referrer_info' export const INITIAL_PERSON_INFO = '$initial_person_info' export const ENABLE_PERSON_PROCESSING = '$epp' export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__' +export const TOOLBAR_CONTAINER_CLASS = 'toolbar-global-fade-container' export const WEB_EXPERIMENTS = '$web_experiments' diff --git a/src/entrypoints/dead-clicks-autocapture.ts b/src/entrypoints/dead-clicks-autocapture.ts index f60c33177..bb4c5d1a1 100644 --- a/src/entrypoints/dead-clicks-autocapture.ts +++ b/src/entrypoints/dead-clicks-autocapture.ts @@ -2,32 +2,11 @@ import { assignableWindow, LazyLoadedDeadClicksAutocaptureInterface } from '../u import { PostHog } from '../posthog-core' import { isNull, isNumber, isUndefined } from '../utils/type-utils' import { autocaptureCompatibleElements, getEventTarget } from '../autocapture-utils' -import { DeadClicksAutoCaptureConfig, Properties } from '../types' +import { DeadClickCandidate, DeadClicksAutoCaptureConfig, Properties } from '../types' import { autocapturePropertiesForElement } from '../autocapture' import { isElementInToolbar, isElementNode, isTag } from '../utils/element-utils' -const DEFAULT_CONFIG: Required = { - element_attribute_ignorelist: [], - scroll_threshold_ms: 100, - selection_change_threshold_ms: 100, - mutation_threshold_ms: 2500, -} - -interface Click { - node: Element - originalEvent: Event - timestamp: number - // time between click and the most recent scroll - scrollDelayMs?: number - // time between click and the most recent mutation - mutationDelayMs?: number - // time between click and the most recent selection changed event - selectionChangedDelayMs?: number - // if neither scroll nor mutation seen before threshold passed - absoluteDelayMs?: number -} - -function asClick(event: Event): Click | null { +function asClick(event: MouseEvent): DeadClickCandidate | null { const eventTarget = getEventTarget(event) if (eventTarget) { return { @@ -47,23 +26,35 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture private _mutationObserver: MutationObserver | undefined private _lastMutation: number | undefined private _lastSelectionChanged: number | undefined - private _clicks: Click[] = [] + private _clicks: DeadClickCandidate[] = [] private _checkClickTimer: number | undefined private _config: Required + private _onCapture: (click: DeadClickCandidate, properties: Properties) => void + + private _defaultConfig = (defaultOnCapture: (click: DeadClickCandidate, properties: Properties) => void) => ({ + element_attribute_ignorelist: [], + scroll_threshold_ms: 100, + selection_change_threshold_ms: 100, + mutation_threshold_ms: 2500, + __onCapture: defaultOnCapture, + }) private asRequiredConfig(providedConfig?: DeadClicksAutoCaptureConfig): Required { + const defaultConfig = this._defaultConfig(providedConfig?.__onCapture || this._captureDeadClick.bind(this)) return { element_attribute_ignorelist: - providedConfig?.element_attribute_ignorelist ?? DEFAULT_CONFIG.element_attribute_ignorelist, - scroll_threshold_ms: providedConfig?.scroll_threshold_ms ?? DEFAULT_CONFIG.scroll_threshold_ms, + providedConfig?.element_attribute_ignorelist ?? defaultConfig.element_attribute_ignorelist, + scroll_threshold_ms: providedConfig?.scroll_threshold_ms ?? defaultConfig.scroll_threshold_ms, selection_change_threshold_ms: - providedConfig?.selection_change_threshold_ms ?? DEFAULT_CONFIG.selection_change_threshold_ms, - mutation_threshold_ms: providedConfig?.mutation_threshold_ms ?? DEFAULT_CONFIG.mutation_threshold_ms, + providedConfig?.selection_change_threshold_ms ?? defaultConfig.selection_change_threshold_ms, + mutation_threshold_ms: providedConfig?.mutation_threshold_ms ?? defaultConfig.mutation_threshold_ms, + __onCapture: defaultConfig.__onCapture, } } constructor(readonly instance: PostHog, config?: DeadClicksAutoCaptureConfig) { this._config = this.asRequiredConfig(config) + this._onCapture = this._config.__onCapture } start(observerTarget: Node) { @@ -105,7 +96,7 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture assignableWindow.addEventListener('click', this._onClick) } - private _onClick = (event: Event): void => { + private _onClick = (event: MouseEvent): void => { const click = asClick(event) if (!isNull(click) && !this._ignoreClick(click)) { this._clicks.push(click) @@ -148,7 +139,7 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture this._lastSelectionChanged = Date.now() } - private _ignoreClick(click: Click | null): boolean { + private _ignoreClick(click: DeadClickCandidate | null): boolean { if (!click) { return true } @@ -222,7 +213,7 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture } if (scrollTimeout || mutationTimeout || absoluteTimeout || selectionChangedTimeout) { - this._captureDeadClick(click, { + this._onCapture(click, { $dead_click_last_mutation_timestamp: this._lastMutation, $dead_click_event_timestamp: click.timestamp, $dead_click_scroll_timeout: scrollTimeout, @@ -243,7 +234,7 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture } } - private _captureDeadClick(click: Click, properties: Properties) { + private _captureDeadClick(click: DeadClickCandidate, properties: Properties) { // TODO need to check safe and captur-able as with autocapture // TODO autocaputure config this.instance.capture( @@ -271,6 +262,7 @@ class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocapture } assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {} -assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = (ph) => new LazyLoadedDeadClicksAutocapture(ph) +assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = (ph, config) => + new LazyLoadedDeadClicksAutocapture(ph, config) export default LazyLoadedDeadClicksAutocapture diff --git a/src/extensions/dead-clicks-autocapture.ts b/src/extensions/dead-clicks-autocapture.ts index 05b7c8d31..43aab7b46 100644 --- a/src/extensions/dead-clicks-autocapture.ts +++ b/src/extensions/dead-clicks-autocapture.ts @@ -3,10 +3,19 @@ import { DEAD_CLICKS_ENABLED_SERVER_SIDE } from '../constants' import { isBoolean, isObject } from '../utils/type-utils' import { assignableWindow, document, LazyLoadedDeadClicksAutocaptureInterface } from '../utils/globals' import { logger } from '../utils/logger' -import { DecideResponse } from '../types' +import { DeadClicksAutoCaptureConfig, DecideResponse } from '../types' const LOGGER_PREFIX = '[Dead Clicks]' +export const isDeadClicksEnabledForHeatmaps = () => { + return true +} +export const isDeadClicksEnabledForAutocapture = (instance: DeadClicksAutocapture) => { + const isRemoteEnabled = !!instance.instance.persistence?.get_property(DEAD_CLICKS_ENABLED_SERVER_SIDE) + const clientConfig = instance.instance.config.capture_dead_clicks + return isBoolean(clientConfig) ? clientConfig : isRemoteEnabled +} + export class DeadClicksAutocapture { get lazyLoadedDeadClicksAutocapture(): LazyLoadedDeadClicksAutocaptureInterface | undefined { return this._lazyLoadedDeadClicksAutocapture @@ -14,19 +23,14 @@ export class DeadClicksAutocapture { private _lazyLoadedDeadClicksAutocapture: LazyLoadedDeadClicksAutocaptureInterface | undefined - constructor(readonly instance: PostHog) { + constructor( + readonly instance: PostHog, + readonly isEnabled: (dca: DeadClicksAutocapture) => boolean, + readonly onCapture?: DeadClicksAutoCaptureConfig['__onCapture'] + ) { this.startIfEnabled() } - public get isRemoteEnabled(): boolean { - return !!this.instance.persistence?.get_property(DEAD_CLICKS_ENABLED_SERVER_SIDE) - } - - public get isEnabled(): boolean { - const clientConfig = this.instance.config.capture_dead_clicks - return isBoolean(clientConfig) ? clientConfig : this.isRemoteEnabled - } - public afterDecideResponse(response: DecideResponse) { if (this.instance.persistence) { this.instance.persistence.register({ @@ -37,8 +41,10 @@ export class DeadClicksAutocapture { } public startIfEnabled() { - if (this.isEnabled) { - this.loadScript(this.start.bind(this)) + if (this.isEnabled(this)) { + this.loadScript(() => { + this.start() + }) } } @@ -70,11 +76,14 @@ export class DeadClicksAutocapture { !this._lazyLoadedDeadClicksAutocapture && assignableWindow.__PosthogExtensions__?.initDeadClicksAutocapture ) { + const config = isObject(this.instance.config.capture_dead_clicks) + ? this.instance.config.capture_dead_clicks + : {} + config.__onCapture = this.onCapture + this._lazyLoadedDeadClicksAutocapture = assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture( this.instance, - isObject(this.instance.config.capture_dead_clicks) - ? this.instance.config.capture_dead_clicks - : undefined + config ) this._lazyLoadedDeadClicksAutocapture.start(document) logger.info(`${LOGGER_PREFIX} starting...`) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index ca6b99d5a..5ed1a41ef 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -1,6 +1,6 @@ import { includes, registerEvent } from './utils' import RageClick from './extensions/rageclick' -import { DecideResponse, Properties } from './types' +import { DeadClickCandidate, DecideResponse, Properties } from './types' import { PostHog } from './posthog-core' import { document, window } from './utils/globals' @@ -9,6 +9,7 @@ import { HEATMAPS_ENABLED_SERVER_SIDE } from './constants' import { isEmptyObject, isObject, isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { isElementInToolbar, isElementNode, isTag } from './utils/element-utils' +import { DeadClicksAutocapture, isDeadClicksEnabledForHeatmaps } from './extensions/dead-clicks-autocapture' const DEFAULT_FLUSH_INTERVAL = 5000 const HEATMAPS = 'heatmaps' @@ -48,6 +49,7 @@ export class Heatmaps { // TODO: Periodically flush this if no other event has taken care of it private buffer: HeatmapEventBuffer private _flushInterval: ReturnType | null = null + private deadClicksCapture: DeadClicksAutocapture | undefined constructor(instance: PostHog) { this.instance = instance @@ -92,6 +94,7 @@ export class Heatmaps { this._flushInterval = setInterval(this.flush.bind(this), this.flushIntervalMilliseconds) } else { clearInterval(this._flushInterval ?? undefined) + this.deadClicksCapture?.stop() this.getAndClearBuffer() } } @@ -115,6 +118,10 @@ export class Heatmaps { return buffer } + private _onDeadClick(click: DeadClickCandidate): void { + this._onClick(click.originalEvent, 'deadclick') + } + private _setupListeners(): void { if (!window || !document) { return @@ -123,6 +130,13 @@ export class Heatmaps { registerEvent(document, 'click', (e) => this._onClick((e || window?.event) as MouseEvent), false, true) registerEvent(document, 'mousemove', (e) => this._onMouseMove((e || window?.event) as MouseEvent), false, true) + this.deadClicksCapture = new DeadClicksAutocapture( + this.instance, + isDeadClicksEnabledForHeatmaps, + this._onDeadClick.bind(this) + ) + this.deadClicksCapture.startIfEnabled() + this._initialized = true } @@ -145,11 +159,11 @@ export class Heatmaps { } } - private _onClick(e: MouseEvent): void { + private _onClick(e: MouseEvent, type: string = 'click'): void { if (isElementInToolbar(e.target as Element)) { return } - const properties = this._getProperties(e, 'click') + const properties = this._getProperties(e, type) if (this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())) { this._capture({ diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 5f5f9b04f..6e2bc3e7c 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -78,7 +78,7 @@ import { ExceptionObserver } from './extensions/exception-autocapture' import { WebVitalsAutocapture } from './extensions/web-vitals' import { WebExperiments } from './web-experiments' import { PostHogExceptions } from './posthog-exceptions' -import { DeadClicksAutocapture } from './extensions/dead-clicks-autocapture' +import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture' /* SIMPLE STYLE GUIDE: @@ -448,7 +448,7 @@ export class PostHog { this.exceptionObserver = new ExceptionObserver(this) this.exceptionObserver.startIfEnabled() - this.deadClicksAutocapture = new DeadClicksAutocapture(this) + this.deadClicksAutocapture = new DeadClicksAutocapture(this, isDeadClicksEnabledForAutocapture) this.deadClicksAutocapture.startIfEnabled() // if any instance on the page has debug = true, we set the diff --git a/src/types.ts b/src/types.ts index 16684ebcb..d54c161c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,20 @@ export interface PerformanceCaptureConfig { web_vitals_delayed_flush_ms?: number } +export interface DeadClickCandidate { + node: Element + originalEvent: MouseEvent + timestamp: number + // time between click and the most recent scroll + scrollDelayMs?: number + // time between click and the most recent mutation + mutationDelayMs?: number + // time between click and the most recent selection changed event + selectionChangedDelayMs?: number + // if neither scroll nor mutation seen before threshold passed + absoluteDelayMs?: number +} + export type DeadClicksAutoCaptureConfig = { // by default if a click is followed by a sroll within 100ms it is not a dead click scroll_threshold_ms?: number @@ -129,6 +143,15 @@ export type DeadClicksAutoCaptureConfig = { selection_change_threshold_ms?: number // by default if a click is followed by a mutation within 2500ms it is not a dead click mutation_threshold_ms?: number + /** + * Allows setting behavior for when a dead click is captured. + * For e.g. to support capture to heatmaps + * + * If not provided the default behavior is to auto-capture dead click events + * + * Only intended to be provided by the SDK + */ + __onCapture?: ((click: DeadClickCandidate, properties: Properties) => void) | undefined } & Pick export interface HeatmapConfig { diff --git a/src/utils/element-utils.ts b/src/utils/element-utils.ts index 9a9b43768..5567cd696 100644 --- a/src/utils/element-utils.ts +++ b/src/utils/element-utils.ts @@ -1,8 +1,8 @@ -import { TOOLBAR_ID } from '../constants' +import { TOOLBAR_CONTAINER_CLASS, TOOLBAR_ID } from '../constants' export function isElementInToolbar(el: Element): boolean { // NOTE: .closest is not supported in IE11 hence the operator check - return el.id === TOOLBAR_ID || !!el.closest?.('#' + TOOLBAR_ID) + return el.id === TOOLBAR_ID || !!el.closest?.('.' + TOOLBAR_CONTAINER_CLASS) } /* diff --git a/src/utils/globals.ts b/src/utils/globals.ts index e360469ca..e23e10365 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -68,7 +68,7 @@ interface PostHogExtensions { } initDeadClicksAutocapture?: ( ph: PostHog, - config?: DeadClicksAutoCaptureConfig + config: DeadClicksAutoCaptureConfig ) => LazyLoadedDeadClicksAutocaptureInterface }