Skip to content

Commit

Permalink
feat: deadclicks in heatmaps (#1510)
Browse files Browse the repository at this point in the history
  • Loading branch information
pauldambra authored Nov 12, 2024
1 parent ac18b9a commit 838e83a
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
})
})
4 changes: 2 additions & 2 deletions src/__tests__/extensions/dead-clicks-autocapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down Expand Up @@ -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)
}
)
})
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/heatmaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
})
})
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
58 changes: 25 additions & 33 deletions src/entrypoints/dead-clicks-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeadClicksAutoCaptureConfig> = {
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 {
Expand All @@ -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<DeadClicksAutoCaptureConfig>
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<DeadClicksAutoCaptureConfig> {
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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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
41 changes: 25 additions & 16 deletions src/extensions/dead-clicks-autocapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,34 @@ 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
}

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({
Expand All @@ -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()
})
}
}

Expand Down Expand Up @@ -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...`)
Expand Down
Loading

0 comments on commit 838e83a

Please sign in to comment.