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

feat: deadclicks in heatmaps #1510

Merged
merged 15 commits into from
Nov 12, 2024
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
Loading