diff --git a/cypress/e2e/capture.cy.ts b/cypress/e2e/capture.cy.ts
index 816af49a2..3516e35e7 100644
--- a/cypress/e2e/capture.cy.ts
+++ b/cypress/e2e/capture.cy.ts
@@ -4,6 +4,7 @@ import { version } from '../../package.json'
import { getBase64EncodedPayload, getGzipEncodedPayload, getPayload } from '../support/compression'
import { start } from '../support/setup'
+import { pollPhCaptures } from '../support/assertions'
const urlWithVersion = new RegExp(`&ver=${version}`)
@@ -286,7 +287,9 @@ describe('Event capture', () => {
it('does not autocapture anything when /decide is disabled', () => {
start({ options: { autocapture: false, advanced_disable_decide: true }, waitForDecide: false })
- cy.get('body').click(100, 100).click(98, 102).click(101, 103)
+ cy.get('body').click(100, 100)
+ cy.get('body').click(98, 102)
+ cy.get('body').click(101, 103)
cy.get('[data-cy-custom-event-button]').click()
// No autocapture events, still captures custom events
@@ -375,4 +378,55 @@ describe('Event capture', () => {
})
})
})
+
+ it('capture dead clicks when configured to', () => {
+ start({
+ options: {
+ capture_dead_clicks: true,
+ },
+ })
+
+ cy.get('[data-cy-not-an-order-button]').click()
+
+ pollPhCaptures('$dead_click').then(() => {
+ cy.phCaptures({ full: true }).then((captures) => {
+ const deadClicks = captures.filter((capture) => capture.event === '$dead_click')
+ expect(deadClicks.length).to.eq(1)
+ const deadClick = deadClicks[0]
+ expect(deadClick.properties.$dead_click_last_mutation_timestamp).to.be.a('number')
+ expect(deadClick.properties.$dead_click_event_timestamp).to.be.a('number')
+ expect(deadClick.properties.$dead_click_absolute_delay_ms).to.be.greaterThan(2500)
+ expect(deadClick.properties.$dead_click_scroll_timeout).to.eq(false)
+ expect(deadClick.properties.$dead_click_mutation_timeout).to.eq(false)
+ expect(deadClick.properties.$dead_click_absolute_timeout).to.eq(true)
+ })
+ })
+ })
+
+ it('does not capture dead click for selected text', () => {
+ start({
+ options: {
+ capture_dead_clicks: true,
+ },
+ })
+
+ cy.get('[data-cy-dead-click-text]').then(($el) => {
+ const text = $el.text()
+ const wordToSelect = text.split(' ')[0]
+ const position = text.indexOf(wordToSelect)
+
+ // click the text to make a selection
+ cy.get('[data-cy-dead-click-text]')
+ .trigger('mousedown', position, 0)
+ .trigger('mousemove', position + wordToSelect.length, 0)
+ .trigger('mouseup')
+ .trigger('dblclick', position, 0)
+ })
+
+ cy.wait(1000)
+ cy.phCaptures({ full: true }).then((captures) => {
+ const deadClicks = captures.filter((capture) => capture.event === '$dead_click')
+ expect(deadClicks.length).to.eq(0)
+ })
+ })
})
diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts
index fece3496d..cc80fb0bd 100644
--- a/cypress/e2e/surveys.cy.ts
+++ b/cypress/e2e/surveys.cy.ts
@@ -3,6 +3,10 @@ import { getPayload } from '../support/compression'
import 'cypress-localstorage-commands'
function onPageLoad(options = {}) {
+ cy.posthog().then((ph) => {
+ ph.persistence?.properties().clear()
+ })
+
cy.posthogInit(options)
cy.wait('@decide')
cy.wait('@surveys')
@@ -335,8 +339,13 @@ describe('Surveys', () => {
onPageLoad()
cy.wait('@capture-assertion')
cy.get('.PostHogSurvey12345').shadow().find('.survey-form').should('be.visible')
- cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice3').click()
- cy.get('.PostHogSurvey12345').shadow().find('input[type=text]').type('Product engineer')
+ cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice3').and('not.be.disabled').click()
+ // TODO: you have to click on the input to activate it, really clicking on the parent should select the input
+ cy.get('.PostHogSurvey12345').shadow().find('#surveyQuestion0Choice3Open').and('not.be.disabled').click()
+ cy.get('.PostHogSurvey12345')
+ .shadow()
+ .find('input[type=text]#surveyQuestion0Choice3Open')
+ .type('Product engineer')
cy.get('.PostHogSurvey12345').shadow().find('.form-submit').click()
cy.wait('@capture-assertion').then(async ({ request }) => {
const captures = await getPayload(request)
@@ -590,7 +599,7 @@ describe('Surveys', () => {
cy.phCaptures().should('include', 'survey sent')
})
- it('wigetType is custom selector', () => {
+ it('widgetType is custom selector', () => {
cy.intercept('GET', '**/surveys/*', {
surveys: [
{
@@ -615,6 +624,7 @@ describe('Surveys', () => {
onPageLoad()
cy.get('.PostHogWidget123').shadow().find('.ph-survey-widget-tab').should('not.exist')
cy.get('.test-surveys').click()
+ cy.wait(5000)
cy.get('.PostHogWidget123').shadow().find('.survey-form').should('be.visible')
cy.get('.PostHogWidget123').shadow().find('.survey-question').should('have.text', 'Feedback for us?')
cy.get('.PostHogWidget123')
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
index 4754515dc..813d60cef 100644
--- a/cypress/support/e2e.ts
+++ b/cypress/support/e2e.ts
@@ -23,6 +23,7 @@ beforeEach(() => {
'exception-autocapture',
'tracing-headers',
'web-vitals',
+ 'dead-clicks-autocapture',
]
lazyLoadedJSFiles.forEach((key: string) => {
cy.readFile(`dist/${key}.js`).then((body) => {
diff --git a/playground/cypress-full/index.html b/playground/cypress-full/index.html
index 6e06796a5..ee788682c 100644
--- a/playground/cypress-full/index.html
+++ b/playground/cypress-full/index.html
@@ -46,6 +46,12 @@
+
+
my favourite takeaway has this order now not button in their hero image
+
it's a great example of why you need dead click tracking
+
+
+
diff --git a/playground/cypress/index.html b/playground/cypress/index.html
index 9ae94c58f..06830c7c9 100644
--- a/playground/cypress/index.html
+++ b/playground/cypress/index.html
@@ -56,6 +56,12 @@
Capture as string
+
+
my favourite takeaway has this order now not button in their hero image
+
it's a great example of why you need dead click tracking
+
+
+
diff --git a/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts
new file mode 100644
index 000000000..95edc40f1
--- /dev/null
+++ b/src/__tests__/entrypoints/lazy-loaded-dead-clicks-autocapture.test.ts
@@ -0,0 +1,374 @@
+import { PostHog } from '../../posthog-core'
+import LazyLoadedDeadClicksAutocapture from '../../entrypoints/dead-clicks-autocapture'
+import { assignableWindow, document } from '../../utils/globals'
+import { autocaptureCompatibleElements } from '../../autocapture-utils'
+
+// need to fake the timer before jsdom inits
+jest.useFakeTimers()
+jest.setSystemTime(1000)
+
+const triggerMouseEvent = function (node: Node, eventType: string) {
+ node.dispatchEvent(
+ new MouseEvent(eventType, {
+ bubbles: true,
+ cancelable: true,
+ })
+ )
+}
+
+describe('LazyLoadedDeadClicksAutocapture', () => {
+ let fakeInstance: PostHog
+ let lazyLoadedDeadClicksAutocapture: LazyLoadedDeadClicksAutocapture
+
+ beforeEach(async () => {
+ jest.setSystemTime(1000)
+
+ assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
+ assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
+ .fn()
+ .mockImplementation(() => (_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
+ cb()
+ })
+
+ fakeInstance = {
+ config: {
+ capture_dead_clicks: true,
+ },
+ persistence: {
+ props: {},
+ },
+ capture: jest.fn(),
+ } as unknown as Partial as PostHog
+
+ lazyLoadedDeadClicksAutocapture = new LazyLoadedDeadClicksAutocapture(fakeInstance)
+ lazyLoadedDeadClicksAutocapture.start(document)
+ })
+
+ describe('defaults', () => {
+ it('starts without scroll time', () => {
+ expect(lazyLoadedDeadClicksAutocapture['_lastScroll']).toBe(undefined)
+ })
+
+ it('starts without mutation', () => {
+ expect(lazyLoadedDeadClicksAutocapture['_lastMutation']).toBe(undefined)
+ })
+
+ it('starts without clicks', () => {
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(0)
+ })
+
+ it('stores clicks', () => {
+ lazyLoadedDeadClicksAutocapture.start(document)
+
+ triggerMouseEvent(document.body, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(1)
+ })
+
+ it('does not store clicks after stop', () => {
+ lazyLoadedDeadClicksAutocapture.start(document)
+ lazyLoadedDeadClicksAutocapture.stop()
+
+ triggerMouseEvent(document.body, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(0)
+ })
+
+ it('sets timer when detecting clicks', () => {
+ expect(lazyLoadedDeadClicksAutocapture['_checkClickTimer']).toBe(undefined)
+
+ triggerMouseEvent(document.body, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_checkClickTimer']).not.toBe(undefined)
+ })
+ })
+
+ it('tracks last scroll', () => {
+ jest.setSystemTime(1000)
+ triggerMouseEvent(document.body, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'][0].scrollDelayMs).not.toBeDefined()
+
+ jest.setSystemTime(1050)
+ triggerMouseEvent(document.body, 'scroll')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'][0].scrollDelayMs).toBe(50)
+ })
+
+ // i think there's some kind of jsdom fangling happening where the mutation observer
+ // started by the detector isn't passed details of mutations made in the tests
+ // js-dom supports mutation observer since v13.x but 🤷
+ it.skip('tracks last mutation', () => {
+ expect(lazyLoadedDeadClicksAutocapture['_lastMutation']).not.toBeDefined()
+
+ document.body.append(document.createElement('div'))
+
+ expect(lazyLoadedDeadClicksAutocapture['_lastMutation']).toBeDefined()
+ })
+
+ describe('click ignore', () => {
+ it('ignores clicks on same node within one second', () => {
+ jest.setSystemTime(1000)
+ triggerMouseEvent(document.body, 'click')
+
+ jest.setSystemTime(1999)
+ triggerMouseEvent(document.body, 'click')
+
+ jest.setSystemTime(2000)
+ triggerMouseEvent(document.body, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(2)
+ })
+
+ it('ignores clicks on html node', () => {
+ const fakeHTML = document.createElement('html')
+ document.body.append(fakeHTML)
+
+ triggerMouseEvent(fakeHTML, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(0)
+ })
+
+ it('ignores clicks on non element nodes', () => {
+ // TODO: should we detect dead clicks on text nodes?
+ const nonElementNode = document.createTextNode('text')
+ document.body.append(nonElementNode)
+
+ triggerMouseEvent(nonElementNode, 'click')
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks'].length).toBe(0)
+ })
+
+ it.each(autocaptureCompatibleElements)('click on %s node is never a deadclick', (element) => {
+ const el = document.createElement(element)
+ document.body.append(el)
+ triggerMouseEvent(el, 'click')
+ jest.setSystemTime(4000)
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('dead click detection', () => {
+ beforeEach(() => {
+ jest.setSystemTime(0)
+ })
+
+ it('click followed by scroll, not a dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ scrollDelayMs: 99,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).not.toHaveBeenCalled()
+ })
+
+ it('click followed by mutation, not a dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = 1000
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).not.toHaveBeenCalled()
+ })
+
+ it('click followed by a selection change, not a dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastSelectionChanged'] = 999
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).not.toHaveBeenCalled()
+ })
+
+ it('click followed by a selection change outside of threshold, dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastSelectionChanged'] = 1000
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).toHaveBeenCalledWith('$dead_click', {
+ // faked system timestamp isn't moving so this is negative
+ $dead_click_absolute_delay_ms: -900,
+ $dead_click_absolute_timeout: false,
+ $dead_click_event_timestamp: 900,
+ $dead_click_last_mutation_timestamp: undefined,
+ $dead_click_last_scroll_timestamp: undefined,
+ $dead_click_mutation_delay_ms: undefined,
+ $dead_click_mutation_timeout: false,
+ $dead_click_scroll_delay_ms: undefined,
+ $dead_click_scroll_timeout: false,
+ $dead_click_selection_changed_delay_ms: 100,
+ $dead_click_selection_changed_timeout: true,
+ timestamp: 900,
+ $ce_version: 1,
+ $el_text: 'text',
+ $elements: [
+ {
+ $el_text: 'text',
+ nth_child: 2,
+ nth_of_type: 1,
+ tag_name: 'body',
+ },
+ ],
+ $event_type: 'click',
+ })
+ })
+
+ it('click followed by a mutation after threshold, dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = 900 + 2501
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).toHaveBeenCalledWith('$dead_click', {
+ // faked system timestamp isn't moving so this is negative
+ $dead_click_absolute_delay_ms: -900,
+ $dead_click_absolute_timeout: false,
+ $dead_click_event_timestamp: 900,
+ $dead_click_last_mutation_timestamp: 3401,
+ $dead_click_last_scroll_timestamp: undefined,
+ $dead_click_mutation_delay_ms: 2501,
+ $dead_click_mutation_timeout: true,
+ $dead_click_scroll_delay_ms: undefined,
+ $dead_click_scroll_timeout: false,
+ $dead_click_selection_changed_delay_ms: undefined,
+ $dead_click_selection_changed_timeout: false,
+ timestamp: 900,
+ $ce_version: 1,
+ $el_text: 'text',
+ $elements: [
+ {
+ $el_text: 'text',
+ nth_child: 2,
+ nth_of_type: 1,
+ tag_name: 'body',
+ },
+ ],
+ $event_type: 'click',
+ })
+ })
+
+ it('click followed by a scroll after threshold, dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ scrollDelayMs: 2501,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined
+
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).toHaveBeenCalledWith('$dead_click', {
+ // faked system timestamp isn't moving so this is negative
+ $dead_click_absolute_delay_ms: -900,
+ $dead_click_absolute_timeout: false,
+ $dead_click_event_timestamp: 900,
+ $dead_click_last_mutation_timestamp: undefined,
+ $dead_click_mutation_delay_ms: undefined,
+ $dead_click_mutation_timeout: false,
+ $dead_click_scroll_delay_ms: 2501,
+ $dead_click_scroll_timeout: true,
+ $dead_click_selection_changed_delay_ms: undefined,
+ $dead_click_selection_changed_timeout: false,
+ $ce_version: 1,
+ $el_text: 'text',
+ $elements: [
+ {
+ $el_text: 'text',
+ nth_child: 2,
+ nth_of_type: 1,
+ tag_name: 'body',
+ },
+ ],
+ $event_type: 'click',
+ timestamp: 900,
+ })
+ })
+
+ it('click followed by nothing for too long, dead click', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined
+
+ jest.setSystemTime(2501 + 900)
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(0)
+ expect(fakeInstance.capture).toHaveBeenCalledWith('$dead_click', {
+ $dead_click_absolute_delay_ms: 2501,
+ $dead_click_absolute_timeout: true,
+ $dead_click_event_timestamp: 900,
+ $dead_click_last_mutation_timestamp: undefined,
+ $dead_click_last_scroll_timestamp: undefined,
+ $dead_click_mutation_delay_ms: undefined,
+ $dead_click_mutation_timeout: false,
+ $dead_click_scroll_delay_ms: undefined,
+ $dead_click_scroll_timeout: false,
+ $dead_click_selection_changed_delay_ms: undefined,
+ $dead_click_selection_changed_timeout: false,
+ $ce_version: 1,
+ $el_text: 'text',
+ $elements: [
+ {
+ $el_text: 'text',
+ nth_child: 2,
+ nth_of_type: 1,
+ tag_name: 'body',
+ },
+ ],
+ $event_type: 'click',
+ timestamp: 900,
+ })
+ })
+
+ it('click not followed by anything within threshold, rescheduled for next check', () => {
+ lazyLoadedDeadClicksAutocapture['_clicks'].push({
+ node: document.body,
+ originalEvent: { type: 'click' } as Event,
+ timestamp: 900,
+ })
+ lazyLoadedDeadClicksAutocapture['_lastMutation'] = undefined
+
+ jest.setSystemTime(25 + 900)
+ lazyLoadedDeadClicksAutocapture['_checkClicks']()
+
+ expect(lazyLoadedDeadClicksAutocapture['_clicks']).toHaveLength(1)
+ expect(fakeInstance.capture).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/__tests__/extensions/dead-clicks-autocapture.test.ts b/src/__tests__/extensions/dead-clicks-autocapture.test.ts
new file mode 100644
index 000000000..44bde3154
--- /dev/null
+++ b/src/__tests__/extensions/dead-clicks-autocapture.test.ts
@@ -0,0 +1,107 @@
+import { PostHog } from '../../posthog-core'
+import { assignableWindow } from '../../utils/globals'
+import { createPosthogInstance } from '../helpers/posthog-instance'
+import { uuidv7 } from '../../uuidv7'
+import { DeadClicksAutocapture } from '../../extensions/dead-clicks-autocapture'
+import { DEAD_CLICKS_ENABLED_SERVER_SIDE } from '../../constants'
+
+describe('DeadClicksAutocapture', () => {
+ let mockStart: jest.Mock
+
+ beforeEach(() => {
+ mockStart = jest.fn()
+ assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
+ assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = () => ({
+ start: mockStart,
+ stop: jest.fn(),
+ })
+ assignableWindow.__PosthogExtensions__.loadExternalDependency = jest
+ .fn()
+ .mockImplementation(() => (_ph: PostHog, _name: string, cb: (err?: Error) => void) => {
+ cb()
+ })
+ })
+
+ it('should call initDeadClicksAutocapture if isEnabled is true', async () => {
+ await createPosthogInstance(uuidv7(), {
+ api_host: 'https://test.com',
+ token: 'testtoken',
+ autocapture: true,
+ capture_dead_clicks: true,
+ })
+
+ expect(mockStart).toHaveBeenCalled()
+ })
+
+ it('should not call initDeadClicksAutocapture if isEnabled is false', async () => {
+ await createPosthogInstance(uuidv7(), {
+ api_host: 'https://test.com',
+ token: 'testtoken',
+ autocapture: true,
+ capture_dead_clicks: false,
+ })
+
+ expect(mockStart).not.toHaveBeenCalled()
+ })
+
+ it('should call loadExternalDependency if script is not already loaded', async () => {
+ assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = undefined
+
+ const mockLoader = assignableWindow.__PosthogExtensions__.loadExternalDependency as jest.Mock
+ mockLoader.mockClear()
+
+ const instance = await createPosthogInstance(uuidv7(), { capture_dead_clicks: true })
+ new DeadClicksAutocapture(instance).startIfEnabled()
+
+ expect(mockLoader).toHaveBeenCalledWith(instance, 'dead-clicks-autocapture', expect.any(Function))
+ })
+
+ it('should call lazy loaded stop when stopping', async () => {
+ const instance = await createPosthogInstance(uuidv7(), {
+ api_host: 'https://test.com',
+ token: 'testtoken',
+ autocapture: true,
+ capture_dead_clicks: true,
+ })
+
+ const mockLazyStop = instance.deadClicksAutocapture.lazyLoadedDeadClicksAutocapture?.stop
+ instance.deadClicksAutocapture.stop()
+
+ expect(mockLazyStop).toHaveBeenCalled()
+ expect(instance.deadClicksAutocapture.lazyLoadedDeadClicksAutocapture).toBeUndefined()
+ })
+
+ describe('config', () => {
+ let instance: PostHog
+
+ beforeEach(async () => {
+ instance = await createPosthogInstance(uuidv7(), {
+ api_host: 'https://test.com',
+ token: 'testtoken',
+ autocapture: true,
+ capture_dead_clicks: true,
+ })
+ })
+
+ it.each([
+ ['enabled when both enabled', true, true, true],
+ ['uses client side setting when set to false', true, false, false],
+ ['uses client side setting when set to true', false, true, true],
+ ['disabled when both disabled', false, false, false],
+ ['uses client side setting (disabled) if server side setting is not set', undefined, false, false],
+ ['uses client side setting (enabled) if server side setting is not set', undefined, true, true],
+ ['is disabled when nothing is set', undefined, undefined, false],
+ ['uses server side setting (disabled) if client side setting is not set', undefined, false, false],
+ ['uses server side setting (enabled) if client side setting is not set', undefined, true, true],
+ ])(
+ '%s',
+ (_name: string, serverSide: boolean | undefined, clientSide: boolean | undefined, expected: boolean) => {
+ instance.persistence?.register({
+ [DEAD_CLICKS_ENABLED_SERVER_SIDE]: serverSide,
+ })
+ instance.config.capture_dead_clicks = clientSide
+ expect(instance.deadClicksAutocapture.isEnabled).toBe(expected)
+ }
+ )
+ })
+})
diff --git a/src/constants.ts b/src/constants.ts
index ac85e6936..b13f15d58 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -15,6 +15,7 @@ export const HEATMAPS_ENABLED_SERVER_SIDE = '$heatmaps_enabled_server_side'
export const EXCEPTION_CAPTURE_ENABLED_SERVER_SIDE = '$exception_capture_enabled_server_side'
export const EXCEPTION_CAPTURE_ENDPOINT_SUFFIX = '$exception_capture_endpoint_suffix'
export const WEB_VITALS_ENABLED_SERVER_SIDE = '$web_vitals_enabled_server_side'
+export const DEAD_CLICKS_ENABLED_SERVER_SIDE = '$dead_clicks_enabled_server_side'
export const WEB_VITALS_ALLOWED_METRICS = '$web_vitals_allowed_metrics'
export const SESSION_RECORDING_ENABLED_SERVER_SIDE = '$session_recording_enabled_server_side'
export const CONSOLE_LOG_RECORDING_ENABLED_SERVER_SIDE = '$console_log_recording_enabled_server_side'
diff --git a/src/entrypoints/all-external-dependencies.ts b/src/entrypoints/all-external-dependencies.ts
index 65a06bf92..218d89851 100644
--- a/src/entrypoints/all-external-dependencies.ts
+++ b/src/entrypoints/all-external-dependencies.ts
@@ -3,3 +3,4 @@ import './surveys'
import './exception-autocapture'
import './tracing-headers'
import './web-vitals'
+import './dead-clicks-autocapture'
diff --git a/src/entrypoints/dead-clicks-autocapture.ts b/src/entrypoints/dead-clicks-autocapture.ts
new file mode 100644
index 000000000..80da3d6c7
--- /dev/null
+++ b/src/entrypoints/dead-clicks-autocapture.ts
@@ -0,0 +1,269 @@
+import { assignableWindow, LazyLoadedDeadClicksAutocaptureInterface } from '../utils/globals'
+import { PostHog } from '../posthog-core'
+import { isNull, isNumber, isUndefined } from '../utils/type-utils'
+import { autocaptureCompatibleElements, getEventTarget, isElementNode, isTag } from '../autocapture-utils'
+import { DeadClicksAutoCaptureConfig, Properties } from '../types'
+import { autocapturePropertiesForElement } from '../autocapture'
+import { isElementInToolbar } 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 {
+ const eventTarget = getEventTarget(event)
+ if (eventTarget) {
+ return {
+ node: eventTarget,
+ originalEvent: event,
+ timestamp: Date.now(),
+ }
+ }
+ return null
+}
+
+function checkTimeout(value: number | undefined, thresholdMs: number) {
+ return isNumber(value) && value >= thresholdMs
+}
+
+class LazyLoadedDeadClicksAutocapture implements LazyLoadedDeadClicksAutocaptureInterface {
+ private _mutationObserver: MutationObserver | undefined
+ private _lastMutation: number | undefined
+ private _lastSelectionChanged: number | undefined
+ private _clicks: Click[] = []
+ private _checkClickTimer: number | undefined
+ private _config: Required
+
+ private asRequiredConfig(providedConfig?: DeadClicksAutoCaptureConfig): Required {
+ 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,
+ 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,
+ }
+ }
+
+ constructor(readonly instance: PostHog, config?: DeadClicksAutoCaptureConfig) {
+ this._config = this.asRequiredConfig(config)
+ }
+
+ start(observerTarget: Node) {
+ this._startClickObserver()
+ this._startScrollObserver()
+ this._startSelectionChangedObserver()
+ this._startMutationObserver(observerTarget)
+ }
+
+ private _startMutationObserver(observerTarget: Node) {
+ if (!this._mutationObserver) {
+ this._mutationObserver = new MutationObserver((mutations) => {
+ this.onMutation(mutations)
+ })
+ this._mutationObserver.observe(observerTarget, {
+ attributes: true,
+ characterData: true,
+ childList: true,
+ subtree: true,
+ })
+ }
+ }
+
+ stop() {
+ this._mutationObserver?.disconnect()
+ this._mutationObserver = undefined
+ assignableWindow.removeEventListener('click', this._onClick)
+ assignableWindow.removeEventListener('scroll', this._onScroll, true)
+ assignableWindow.removeEventListener('selectionchange', this._onSelectionChange)
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ private onMutation(_mutations: MutationRecord[]): void {
+ // we don't actually care about the content of the mutations, right now
+ this._lastMutation = Date.now()
+ }
+
+ private _startClickObserver() {
+ assignableWindow.addEventListener('click', this._onClick)
+ }
+
+ private _onClick = (event: Event): void => {
+ const click = asClick(event)
+ if (!isNull(click) && !this._ignoreClick(click)) {
+ this._clicks.push(click)
+ }
+
+ if (this._clicks.length && isUndefined(this._checkClickTimer)) {
+ this._checkClickTimer = assignableWindow.setTimeout(() => {
+ this._checkClicks()
+ }, 1000)
+ }
+ }
+
+ private _startScrollObserver() {
+ // setting the third argument to `true` means that we will receive scroll events for other scrollable elements
+ // on the page, not just the window
+ // see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture
+ assignableWindow.addEventListener('scroll', this._onScroll, true)
+ }
+
+ private _onScroll = (): void => {
+ const candidateNow = Date.now()
+ // very naive throttle
+ if (candidateNow % 50 === 0) {
+ // we can see many scrolls between scheduled checks,
+ // so we update scroll delay as we see them
+ // to avoid false positives
+ this._clicks.forEach((click) => {
+ if (isUndefined(click.scrollDelayMs)) {
+ click.scrollDelayMs = candidateNow - click.timestamp
+ }
+ })
+ }
+ }
+
+ private _startSelectionChangedObserver() {
+ assignableWindow.addEventListener('selectionchange', this._onSelectionChange)
+ }
+
+ private _onSelectionChange = (): void => {
+ this._lastSelectionChanged = Date.now()
+ }
+
+ private _ignoreClick(click: Click | null): boolean {
+ if (!click) {
+ return true
+ }
+
+ if (isElementInToolbar(click.node)) {
+ return true
+ }
+
+ const alreadyClickedInLastSecond = this._clicks.some((c) => {
+ return c.node === click.node && Math.abs(c.timestamp - click.timestamp) < 1000
+ })
+
+ if (alreadyClickedInLastSecond) {
+ return true
+ }
+
+ if (
+ isTag(click.node, 'html') ||
+ !isElementNode(click.node) ||
+ autocaptureCompatibleElements.includes(click.node.tagName.toLowerCase())
+ ) {
+ return true
+ }
+
+ return false
+ }
+
+ private _checkClicks() {
+ if (!this._clicks.length) {
+ return
+ }
+
+ clearTimeout(this._checkClickTimer)
+ this._checkClickTimer = undefined
+
+ const clicksToCheck = this._clicks
+ this._clicks = []
+
+ for (const click of clicksToCheck) {
+ click.mutationDelayMs =
+ click.mutationDelayMs ??
+ (this._lastMutation && click.timestamp <= this._lastMutation
+ ? this._lastMutation - click.timestamp
+ : undefined)
+ click.absoluteDelayMs = Date.now() - click.timestamp
+ click.selectionChangedDelayMs =
+ this._lastSelectionChanged && click.timestamp <= this._lastSelectionChanged
+ ? this._lastSelectionChanged - click.timestamp
+ : undefined
+
+ const scrollTimeout = checkTimeout(click.scrollDelayMs, this._config.scroll_threshold_ms)
+ const selectionChangedTimeout = checkTimeout(
+ click.selectionChangedDelayMs,
+ this._config.selection_change_threshold_ms
+ )
+ const mutationTimeout = checkTimeout(click.mutationDelayMs, this._config.mutation_threshold_ms)
+ const absoluteTimeout = checkTimeout(click.absoluteDelayMs, this._config.mutation_threshold_ms)
+
+ const hadScroll = isNumber(click.scrollDelayMs) && click.scrollDelayMs < this._config.scroll_threshold_ms
+ const hadMutation =
+ isNumber(click.mutationDelayMs) && click.mutationDelayMs < this._config.mutation_threshold_ms
+ const hadSelectionChange =
+ isNumber(click.selectionChangedDelayMs) &&
+ click.selectionChangedDelayMs < this._config.selection_change_threshold_ms
+
+ if (hadScroll || hadMutation || hadSelectionChange) {
+ // ignore clicks that had a scroll or mutation
+ continue
+ }
+
+ if (scrollTimeout || mutationTimeout || absoluteTimeout || selectionChangedTimeout) {
+ this._captureDeadClick(click, {
+ $dead_click_last_mutation_timestamp: this._lastMutation,
+ $dead_click_event_timestamp: click.timestamp,
+ $dead_click_scroll_timeout: scrollTimeout,
+ $dead_click_mutation_timeout: mutationTimeout,
+ $dead_click_absolute_timeout: absoluteTimeout,
+ $dead_click_selection_changed_timeout: selectionChangedTimeout,
+ })
+ } else if (click.absoluteDelayMs < this._config.mutation_threshold_ms) {
+ // keep waiting until next check
+ this._clicks.push(click)
+ }
+ }
+
+ if (this._clicks.length && isUndefined(this._checkClickTimer)) {
+ this._checkClickTimer = assignableWindow.setTimeout(() => {
+ this._checkClicks()
+ }, 1000)
+ }
+ }
+
+ private _captureDeadClick(click: Click, properties: Properties) {
+ // TODO need to check safe and captur-able as with autocapture
+ // TODO autocaputure config
+ this.instance.capture('$dead_click', {
+ ...properties,
+ ...autocapturePropertiesForElement(click.node, {
+ e: click.originalEvent,
+ maskAllElementAttributes: this.instance.config.mask_all_element_attributes,
+ maskAllText: this.instance.config.mask_all_text,
+ elementAttributeIgnoreList: this._config.element_attribute_ignorelist,
+ // TRICKY: it appears that we were moving to elementsChainAsString, but the UI still depends on elements, so :shrug:
+ elementsChainAsString: false,
+ }).props,
+ $dead_click_scroll_delay_ms: click.scrollDelayMs,
+ $dead_click_mutation_delay_ms: click.mutationDelayMs,
+ $dead_click_absolute_delay_ms: click.absoluteDelayMs,
+ $dead_click_selection_changed_delay_ms: click.selectionChangedDelayMs,
+ timestamp: click.timestamp,
+ })
+ }
+}
+
+assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
+assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture = (ph) => new LazyLoadedDeadClicksAutocapture(ph)
+
+export default LazyLoadedDeadClicksAutocapture
diff --git a/src/extensions/dead-clicks-autocapture.ts b/src/extensions/dead-clicks-autocapture.ts
new file mode 100644
index 000000000..05b7c8d31
--- /dev/null
+++ b/src/extensions/dead-clicks-autocapture.ts
@@ -0,0 +1,91 @@
+import { PostHog } from '../posthog-core'
+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'
+
+const LOGGER_PREFIX = '[Dead Clicks]'
+
+export class DeadClicksAutocapture {
+ get lazyLoadedDeadClicksAutocapture(): LazyLoadedDeadClicksAutocaptureInterface | undefined {
+ return this._lazyLoadedDeadClicksAutocapture
+ }
+
+ private _lazyLoadedDeadClicksAutocapture: LazyLoadedDeadClicksAutocaptureInterface | undefined
+
+ constructor(readonly instance: PostHog) {
+ 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({
+ [DEAD_CLICKS_ENABLED_SERVER_SIDE]: response?.captureDeadClicks,
+ })
+ }
+ this.startIfEnabled()
+ }
+
+ public startIfEnabled() {
+ if (this.isEnabled) {
+ this.loadScript(this.start.bind(this))
+ }
+ }
+
+ private loadScript(cb: () => void): void {
+ if (assignableWindow.__PosthogExtensions__?.initDeadClicksAutocapture) {
+ // already loaded
+ cb()
+ }
+ assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(
+ this.instance,
+ 'dead-clicks-autocapture',
+ (err) => {
+ if (err) {
+ logger.error(LOGGER_PREFIX + ' failed to load script', err)
+ return
+ }
+ cb()
+ }
+ )
+ }
+
+ private start() {
+ if (!document) {
+ logger.error(LOGGER_PREFIX + ' `document` not found. Cannot start.')
+ return
+ }
+
+ if (
+ !this._lazyLoadedDeadClicksAutocapture &&
+ assignableWindow.__PosthogExtensions__?.initDeadClicksAutocapture
+ ) {
+ this._lazyLoadedDeadClicksAutocapture = assignableWindow.__PosthogExtensions__.initDeadClicksAutocapture(
+ this.instance,
+ isObject(this.instance.config.capture_dead_clicks)
+ ? this.instance.config.capture_dead_clicks
+ : undefined
+ )
+ this._lazyLoadedDeadClicksAutocapture.start(document)
+ logger.info(`${LOGGER_PREFIX} starting...`)
+ }
+ }
+
+ stop() {
+ if (this._lazyLoadedDeadClicksAutocapture) {
+ this._lazyLoadedDeadClicksAutocapture.stop()
+ this._lazyLoadedDeadClicksAutocapture = undefined
+ logger.info(`${LOGGER_PREFIX} stopping...`)
+ }
+ }
+}
diff --git a/src/posthog-core.ts b/src/posthog-core.ts
index 2946b0a62..5f5f9b04f 100644
--- a/src/posthog-core.ts
+++ b/src/posthog-core.ts
@@ -78,6 +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'
/*
SIMPLE STYLE GUIDE:
@@ -258,6 +259,7 @@ export class PostHog {
heatmaps?: Heatmaps
webVitalsAutocapture?: WebVitalsAutocapture
exceptionObserver?: ExceptionObserver
+ deadClicksAutocapture?: DeadClicksAutocapture
_requestQueue?: RequestQueue
_retryQueue?: RetryQueue
@@ -446,6 +448,9 @@ export class PostHog {
this.exceptionObserver = new ExceptionObserver(this)
this.exceptionObserver.startIfEnabled()
+ this.deadClicksAutocapture = new DeadClicksAutocapture(this)
+ this.deadClicksAutocapture.startIfEnabled()
+
// if any instance on the page has debug = true, we set the
// global debug to be true
Config.DEBUG = Config.DEBUG || this.config.debug
@@ -516,7 +521,7 @@ export class PostHog {
this.toolbar.maybeLoadToolbar()
- // We wan't to avoid promises for IE11 compatibility, so we use callbacks here
+ // We want to avoid promises for IE11 compatibility, so we use callbacks here
if (config.segment) {
setupSegmentIntegration(this, () => this._loaded())
} else {
@@ -561,12 +566,12 @@ export class PostHog {
this.webVitalsAutocapture?.afterDecideResponse(response)
this.exceptions?.afterDecideResponse(response)
this.exceptionObserver?.afterDecideResponse(response)
+ this.deadClicksAutocapture?.afterDecideResponse(response)
}
_loaded(): void {
// Pause `reloadFeatureFlags` calls in config.loaded callback.
- // These feature flags are loaded in the decide call made right
- // afterwards
+ // These feature flags are loaded in the decide call made right after
const disableDecide = this.config.advanced_disable_decide
if (!disableDecide) {
this.featureFlags.setReloadingPaused(true)
diff --git a/src/types.ts b/src/types.ts
index 8c5058a5f..ea0674763 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -122,6 +122,15 @@ export interface PerformanceCaptureConfig {
web_vitals_delayed_flush_ms?: 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
+ // by default if a click is followed by a selection change within 100ms it is not a dead click
+ 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
+} & Pick
+
export interface HeatmapConfig {
/*
* how often to send batched data in $$heatmap_data events
@@ -215,6 +224,7 @@ export interface PostHogConfig {
/* @deprecated - use `capture_heatmaps` instead */
enable_heatmaps?: boolean
capture_heatmaps?: boolean | HeatmapConfig
+ capture_dead_clicks?: boolean | DeadClicksAutoCaptureConfig
disable_scroll_properties?: boolean
// Let the pageview scroll stats use a custom css selector for the root element, e.g. `main`
scroll_root_selector?: string | string[]
@@ -413,6 +423,7 @@ export interface DecideResponse {
siteApps: { id: number; url: string }[]
heatmaps?: boolean
defaultIdentifiedOnly?: boolean
+ captureDeadClicks?: boolean
}
export type FeatureFlagsCallback = (
diff --git a/src/utils/globals.ts b/src/utils/globals.ts
index d1fb123f5..912b27abd 100644
--- a/src/utils/globals.ts
+++ b/src/utils/globals.ts
@@ -1,7 +1,7 @@
import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion'
import type { PostHog } from '../posthog-core'
import { SessionIdManager } from '../sessionid'
-import { ErrorEventArgs, ErrorMetadata, Properties } from '../types'
+import { DeadClicksAutoCaptureConfig, ErrorEventArgs, ErrorMetadata, Properties } from '../types'
/*
* Global helpers to protect access to browser globals in a way that is safer for different targets
@@ -28,6 +28,12 @@ export type PostHogExtensionKind =
| 'recorder'
| 'tracing-headers'
| 'surveys'
+ | 'dead-clicks-autocapture'
+
+export interface LazyLoadedDeadClicksAutocaptureInterface {
+ start: (observerTarget: Node) => void
+ stop: () => void
+}
interface PostHogExtensions {
loadExternalDependency?: (
@@ -60,6 +66,10 @@ interface PostHogExtensions {
_patchFetch: (sessionManager: SessionIdManager) => () => void
_patchXHR: (sessionManager: any) => () => void
}
+ initDeadClicksAutocapture?: (
+ ph: PostHog,
+ config?: DeadClicksAutoCaptureConfig
+ ) => LazyLoadedDeadClicksAutocaptureInterface
}
const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win