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
+ my favourite takeaway has this order now not button in their hero image +
+ 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
+ my favourite takeaway has this order now not button in their hero image +
+ 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