From f898d51d5d7b71c6b15d6e85db5cd33c9085e9e3 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 11:31:47 +0200 Subject: [PATCH 01/41] Refactoring of autocapture things --- src/__tests__/autocapture-v2.test.ts | 1194 ++++++++++++++++++++++++++ src/__tests__/autocapture.test.ts | 15 - src/__tests__/posthog-core.js | 7 - src/autocapture-v2.ts | 358 ++++++++ src/autocapture.ts | 47 +- src/extensions/rageclick.ts | 8 +- src/posthog-core.ts | 17 +- src/types.ts | 9 +- src/utils/index.ts | 16 - 9 files changed, 1576 insertions(+), 95 deletions(-) create mode 100644 src/__tests__/autocapture-v2.test.ts create mode 100644 src/autocapture-v2.ts diff --git a/src/__tests__/autocapture-v2.test.ts b/src/__tests__/autocapture-v2.test.ts new file mode 100644 index 000000000..b1055d664 --- /dev/null +++ b/src/__tests__/autocapture-v2.test.ts @@ -0,0 +1,1194 @@ +/// +/* eslint-disable compat/compat */ + +import { Autocapture } from '../autocapture-v2' +import { shouldCaptureDomEvent } from '../autocapture-utils' +import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants' +import { AutocaptureConfig, DecideResponse, PostHogConfig } from '../types' +import { PostHog } from '../posthog-core' +import { PostHogPersistence } from '../posthog-persistence' +import { window } from '../utils/globals' + +// JS DOM doesn't have ClipboardEvent, so we need to mock it +// see https://github.com/jsdom/jsdom/issues/1568 +class MockClipboardEvent extends Event implements ClipboardEvent { + clipboardData: DataTransfer | null = null + type: 'copy' | 'cut' | 'paste' = 'copy' +} +window!.ClipboardEvent = MockClipboardEvent + +const triggerMouseEvent = function (node: Node, eventType: string) { + node.dispatchEvent( + new MouseEvent(eventType, { + bubbles: true, + cancelable: true, + }) + ) +} + +const simulateClick = function (el: Node) { + triggerMouseEvent(el, 'click') +} + +function makePostHog(ph: Partial): PostHog { + return { + get_distinct_id() { + return 'distinctid' + }, + ...ph, + } as unknown as PostHog +} + +export function makeMouseEvent(partialEvent: Partial) { + return { type: 'click', ...partialEvent } as unknown as MouseEvent +} + +export function makeCopyEvent(partialEvent: Partial) { + return { type: 'copy', ...partialEvent } as unknown as ClipboardEvent +} + +export function makeCutEvent(partialEvent: Partial) { + return { type: 'cut', ...partialEvent } as unknown as ClipboardEvent +} + +function setWindowTextSelection(s: string): void { + window!.getSelection = () => { + return { + toString: () => s, + } as Selection + } +} + +describe('Autocapture system', () => { + const originalWindowLocation = window!.location + + let $autocapture_disabled_server_side: boolean + let autocapture: Autocapture + let posthog: PostHog + let captureMock: jest.Mock + let persistence: PostHogPersistence + + beforeEach(() => { + jest.spyOn(window!.console, 'log').mockImplementation() + + Object.defineProperty(window, 'location', { + configurable: true, + enumerable: true, + writable: true, + // eslint-disable-next-line compat/compat + value: new URL('https://example.com'), + }) + + captureMock = jest.fn() + persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence + posthog = makePostHog({ + config: { + api_host: 'https://test.com', + token: 'testtoken', + autocapture: true, + } as PostHogConfig, + capture: captureMock, + get_property: (property_key: string) => + property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, + persistence: persistence, + }) + + autocapture = new Autocapture(posthog) + }) + + afterEach(() => { + document.getElementsByTagName('html')[0].innerHTML = '' + + Object.defineProperty(window, 'location', { + configurable: true, + enumerable: true, + value: originalWindowLocation, + }) + }) + + describe('_getPropertiesFromElement', () => { + let div: HTMLDivElement + let div2: HTMLDivElement + let input: HTMLInputElement + let sensitiveInput: HTMLInputElement + let hidden: HTMLInputElement + let password: HTMLInputElement + + beforeEach(() => { + div = document.createElement('div') + div.className = 'class1 class2 class3 ' // Lots of spaces might mess things up + div.innerHTML = 'my sweet inner text' + + input = document.createElement('input') + input.value = 'test val' + + sensitiveInput = document.createElement('input') + sensitiveInput.value = 'test val' + sensitiveInput.className = 'ph-sensitive' + + hidden = document.createElement('input') + hidden.setAttribute('type', 'hidden') + hidden.value = 'hidden val' + + password = document.createElement('input') + password.setAttribute('type', 'password') + password.value = 'password val' + + const divSibling = document.createElement('div') + const divSibling2 = document.createElement('span') + + div2 = document.createElement('div') + div2.className = 'parent' + div2.appendChild(divSibling) + div2.appendChild(divSibling2) + div2.appendChild(div) + div2.appendChild(input) + div2.appendChild(sensitiveInput) + div2.appendChild(hidden) + div2.appendChild(password) + }) + + it('should contain the proper tag name', () => { + const props = autocapture['_getPropertiesFromElement'](div, false, false) + expect(props['tag_name']).toBe('div') + }) + + it('should contain class list', () => { + const props = autocapture['_getPropertiesFromElement'](div, false, false) + expect(props['classes']).toEqual(['class1', 'class2', 'class3']) + }) + + it('should not collect input value', () => { + const props = autocapture['_getPropertiesFromElement'](input, false, false) + expect(props['value']).toBeUndefined() + }) + + it('should strip element value with class "ph-sensitive"', () => { + const props = autocapture['_getPropertiesFromElement'](sensitiveInput, false, false) + expect(props['value']).toBeUndefined() + }) + + it('should strip hidden element value', () => { + const props = autocapture['_getPropertiesFromElement'](hidden, false, false) + expect(props['value']).toBeUndefined() + }) + + it('should strip password element value', () => { + const props = autocapture['_getPropertiesFromElement'](password, false, false) + expect(props['value']).toBeUndefined() + }) + + it('should contain nth-of-type', () => { + const props = autocapture['_getPropertiesFromElement'](div, false, false) + expect(props['nth_of_type']).toBe(2) + }) + + it('should contain nth-child', () => { + const props = autocapture['_getPropertiesFromElement'](password, false, false) + expect(props['nth_child']).toBe(7) + }) + + it('should filter out Angular content attributes', () => { + const angularDiv = document.createElement('div') + angularDiv.setAttribute('_ngcontent-dpm-c448', '') + angularDiv.setAttribute('_nghost-dpm-c448', '') + const props = autocapture['_getPropertiesFromElement'](angularDiv, false, false) + expect(props['_ngcontent-dpm-c448']).toBeUndefined() + expect(props['_nghost-dpm-c448']).toBeUndefined() + }) + + it('should filter element attributes based on the ignorelist', () => { + posthog.config.autocapture = { + element_attribute_ignorelist: ['data-attr', 'data-attr-2'], + } + div.setAttribute('data-attr', 'value') + div.setAttribute('data-attr-2', 'value') + div.setAttribute('data-attr-3', 'value') + const props = autocapture['_getPropertiesFromElement'](div, false, false) + expect(props['attr__data-attr']).toBeUndefined() + expect(props['attr__data-attr-2']).toBeUndefined() + expect(props['attr__data-attr-3']).toBe('value') + }) + + it('an empty ignorelist does nothing', () => { + posthog.config.autocapture = { + element_attribute_ignorelist: [], + } + div.setAttribute('data-attr', 'value') + div.setAttribute('data-attr-2', 'value') + div.setAttribute('data-attr-3', 'value') + const props = autocapture['_getPropertiesFromElement'](div, false, false) + expect(props['attr__data-attr']).toBe('value') + expect(props['attr__data-attr-2']).toBe('value') + expect(props['attr__data-attr-3']).toBe('value') + }) + }) + + describe('_getAugmentPropertiesFromElement', () => { + let div: HTMLDivElement + let div2: HTMLDivElement + let input: HTMLInputElement + let sensitiveInput: HTMLInputElement + let hidden: HTMLInputElement + let password: HTMLInputElement + + beforeEach(() => { + div = document.createElement('div') + div.className = 'class1 class2 class3 ' // Lots of spaces might mess things up + div.innerHTML = 'my sweet inner text' + div.setAttribute('data-ph-capture-attribute-one-on-the-div', 'one') + div.setAttribute('data-ph-capture-attribute-two-on-the-div', 'two') + div.setAttribute('data-ph-capture-attribute-falsey-on-the-div', '0') + div.setAttribute('data-ph-capture-attribute-false-on-the-div', 'false') + + input = document.createElement('input') + input.setAttribute('data-ph-capture-attribute-on-the-input', 'is on the input') + input.value = 'test val' + + sensitiveInput = document.createElement('input') + sensitiveInput.value = 'test val' + sensitiveInput.setAttribute('data-ph-capture-attribute-on-the-sensitive-input', 'is on the sensitive-input') + sensitiveInput.className = 'ph-sensitive' + + hidden = document.createElement('input') + hidden.setAttribute('type', 'hidden') + hidden.setAttribute('data-ph-capture-attribute-on-the-hidden', 'is on the hidden') + hidden.value = 'hidden val' + + password = document.createElement('input') + password.setAttribute('type', 'password') + password.setAttribute('data-ph-capture-attribute-on-the-password', 'is on the password') + password.value = 'password val' + + const divSibling = document.createElement('div') + const divSibling2 = document.createElement('span') + + div2 = document.createElement('div') + div2.className = 'parent' + div2.appendChild(divSibling) + div2.appendChild(divSibling2) + div2.appendChild(div) + div2.appendChild(input) + div2.appendChild(sensitiveInput) + div2.appendChild(hidden) + div2.appendChild(password) + }) + + it('should collect multiple augments from elements', () => { + const props = autocapture['_getAugmentPropertiesFromElement'](div) + expect(props['one-on-the-div']).toBe('one') + expect(props['two-on-the-div']).toBe('two') + expect(props['falsey-on-the-div']).toBe('0') + expect(props['false-on-the-div']).toBe('false') + }) + + it('should collect augment from input value', () => { + const props = autocapture['_getAugmentPropertiesFromElement'](input) + expect(props['on-the-input']).toBe('is on the input') + }) + + it('should collect augment from input with class "ph-sensitive"', () => { + const props = autocapture['_getAugmentPropertiesFromElement'](sensitiveInput) + expect(props['on-the-sensitive-input']).toBeUndefined() + }) + + it('should not collect augment from the hidden element value', () => { + const props = autocapture['_getAugmentPropertiesFromElement'](hidden) + expect(props).toStrictEqual({}) + }) + + it('should collect augment from the password element value', () => { + const props = autocapture['_getAugmentPropertiesFromElement'](password) + expect(props).toStrictEqual({}) + }) + }) + + describe('isBrowserSupported', () => { + let orig: typeof document.querySelectorAll + + beforeEach(() => { + orig = document.querySelectorAll + }) + + afterEach(() => { + document.querySelectorAll = orig + }) + + it('should return true if document.querySelectorAll is a function', () => { + document.querySelectorAll = function () { + return [] as unknown as NodeListOf + } + expect(autocapture.isBrowserSupported()).toBe(true) + }) + + it('should return false if document.querySelectorAll is not a function', () => { + document.querySelectorAll = undefined as unknown as typeof document.querySelectorAll + expect(autocapture.isBrowserSupported()).toBe(false) + }) + }) + + describe('_previousElementSibling', () => { + it('should return the adjacent sibling', () => { + const div = document.createElement('div') + const sibling = document.createElement('div') + const child = document.createElement('div') + div.appendChild(sibling) + div.appendChild(child) + expect(autocapture['_previousElementSibling'](child)).toBe(sibling) + }) + + it('should return the first child and not the immediately previous sibling (text)', () => { + const div = document.createElement('div') + const sibling = document.createElement('div') + const child = document.createElement('div') + div.appendChild(sibling) + div.appendChild(document.createTextNode('some text')) + div.appendChild(child) + expect(autocapture['_previousElementSibling'](child)).toBe(sibling) + }) + + it('should return null when the previous sibling is a text node', () => { + const div = document.createElement('div') + const child = document.createElement('div') + div.appendChild(document.createTextNode('some text')) + div.appendChild(child) + expect(autocapture['_previousElementSibling'](child)).toBeNull() + }) + }) + + describe('_getDefaultProperties', () => { + it('should return the default properties', () => { + expect(autocapture['_getDefaultProperties']('test')).toEqual({ + $event_type: 'test', + $ce_version: 1, + }) + }) + }) + + describe('_captureEvent', () => { + beforeEach(() => { + posthog.config.rageclick = true + }) + + it('should add the custom property when an element matching any of the event selectors is clicked', () => { + posthog.config.mask_all_element_attributes = false + $autocapture_disabled_server_side = false + autocapture.afterDecideResponse({} as DecideResponse) + + const eventElement1 = document.createElement('div') + const eventElement2 = document.createElement('div') + const propertyElement = document.createElement('div') + eventElement1.className = 'event-element-1' + eventElement1.style.cursor = 'pointer' + eventElement2.className = 'event-element-2' + eventElement2.style.cursor = 'pointer' + propertyElement.className = 'property-element' + propertyElement.textContent = 'my property value' + document.body.appendChild(eventElement1) + document.body.appendChild(eventElement2) + document.body.appendChild(propertyElement) + + expect(captureMock).toHaveBeenCalledTimes(0) + simulateClick(eventElement1) + simulateClick(eventElement2) + expect(captureMock).toHaveBeenCalledTimes(2) + const captureArgs1 = captureMock.mock.calls[0] + const captureArgs2 = captureMock.mock.calls[1] + const eventType1 = captureArgs1[1]['my property name'] + const eventType2 = captureArgs2[1]['my property name'] + expect(eventType1).toBe('my property value') + expect(eventType2).toBe('my property value') + }) + + it('should capture rageclick', () => { + const elTarget = document.createElement('img') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.setAttribute('href', 'https://test.com') + elGrandparent.appendChild(elParent) + const fakeEvent = makeMouseEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) + autocapture['_captureEvent'](fakeEvent) + autocapture['_captureEvent'](fakeEvent) + autocapture['_captureEvent'](fakeEvent) + + expect(captureMock).toHaveBeenCalledTimes(4) + expect(captureMock.mock.calls.map((args) => args[0])).toEqual([ + '$autocapture', + '$autocapture', + '$rageclick', + '$autocapture', + ]) + }) + + describe('clipboard autocapture', () => { + let elTarget: HTMLDivElement + + beforeEach(() => { + elTarget = document.createElement('div') + elTarget.innerText = 'test' + const elParent = document.createElement('div') + elParent.appendChild(elTarget) + }) + + it('should capture copy', () => { + const fakeEvent = makeCopyEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + + setWindowTextSelection('copy this test') + + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') + + expect(captureMock).toHaveBeenCalledTimes(1) + expect(captureMock.mock.calls[0][0]).toEqual('$copy_autocapture') + expect(captureMock.mock.calls[0][1]).toHaveProperty('$selected_content', 'copy this test') + expect(captureMock.mock.calls[0][1]).toHaveProperty('$copy_type', 'copy') + }) + + it('should capture cut', () => { + const fakeEvent = makeCutEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + + setWindowTextSelection('cut this test') + + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') + + const spyArgs = captureMock.mock.calls + expect(spyArgs.length).toBe(1) + expect(spyArgs[0][0]).toEqual('$copy_autocapture') + expect(spyArgs[0][1]).toHaveProperty('$selected_content', 'cut this test') + expect(spyArgs[0][1]).toHaveProperty('$copy_type', 'cut') + }) + + it('ignores empty selection', () => { + const fakeEvent = makeCopyEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + + setWindowTextSelection('') + + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') + + const spyArgs = captureMock.mock.calls + expect(spyArgs.length).toBe(0) + }) + + it('runs selection through the safe text before capture', () => { + const fakeEvent = makeCopyEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + + // oh no, a social security number! + setWindowTextSelection('123-45-6789') + + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') + + const spyArgs = captureMock.mock.calls + expect(spyArgs.length).toBe(0) + }) + }) + + it('should capture augment properties', () => { + const elTarget = document.createElement('img') + elTarget.setAttribute('data-ph-capture-attribute-target-augment', 'the target') + const elParent = document.createElement('span') + elParent.setAttribute('data-ph-capture-attribute-parent-augment', 'the parent') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.setAttribute('href', 'https://test.com') + elGrandparent.appendChild(elParent) + const fakeEvent = makeMouseEvent({ + target: elTarget, + clientX: 5, + clientY: 5, + }) + Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) + autocapture['_captureEvent'](fakeEvent) + + const captureProperties = captureMock.mock.calls[0][1] + expect(captureProperties).toHaveProperty('target-augment', 'the target') + expect(captureProperties).toHaveProperty('parent-augment', 'the parent') + }) + + it('should not capture events when config returns false, when an element matching any of the event selectors is clicked', () => { + posthog.config.autocapture = false + autocapture.afterDecideResponse({} as DecideResponse) + + const eventElement1 = document.createElement('div') + const eventElement2 = document.createElement('div') + const propertyElement = document.createElement('div') + eventElement1.className = 'event-element-1' + eventElement1.style.cursor = 'pointer' + eventElement2.className = 'event-element-2' + eventElement2.style.cursor = 'pointer' + propertyElement.className = 'property-element' + propertyElement.textContent = 'my property value' + document.body.appendChild(eventElement1) + document.body.appendChild(eventElement2) + document.body.appendChild(propertyElement) + + expect(captureMock).toHaveBeenCalledTimes(0) + simulateClick(eventElement1) + simulateClick(eventElement2) + expect(captureMock).toHaveBeenCalledTimes(0) + }) + + it('should not capture events when config returns true but server setting is disabled', () => { + autocapture.afterDecideResponse({ + autocapture_opt_out: true, + } as DecideResponse) + + const eventElement = document.createElement('a') + document.body.appendChild(eventElement) + + expect(captureMock).toHaveBeenCalledTimes(0) + simulateClick(eventElement) + expect(captureMock).toHaveBeenCalledTimes(0) + }) + + it('includes necessary metadata as properties when capturing an event', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'https://test.com') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('div') + elGrandparent.appendChild(elParent) + const elGreatGrandparent = document.createElement('table') + elGreatGrandparent.appendChild(elGrandparent) + document.body.appendChild(elGreatGrandparent) + const e = makeMouseEvent({ + target: elTarget, + }) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const captureArgs = captureMock.mock.calls[0] + const event = captureArgs[0] + const props = captureArgs[1] + expect(event).toBe('$autocapture') + expect(props['$event_type']).toBe('click') + expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') + expect(props['$elements'][1]).toHaveProperty('tag_name', 'span') + expect(props['$elements'][2]).toHaveProperty('tag_name', 'div') + expect(props['$elements'][props['$elements'].length - 1]).toHaveProperty('tag_name', 'body') + }) + + it('truncate any element property value to 1024 bytes', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'https://test.com') + const longString = 'prop'.repeat(400) + elTarget.dataset.props = longString + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('div') + elGrandparent.appendChild(elParent) + const elGreatGrandparent = document.createElement('table') + elGreatGrandparent.appendChild(elGrandparent) + document.body.appendChild(elGreatGrandparent) + const e = makeMouseEvent({ + target: elTarget, + }) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const captureArgs = captureMock.mock.calls[0] + const props = captureArgs[1] + expect(longString).toBe('prop'.repeat(400)) + expect(props['$elements'][0]).toHaveProperty('attr__data-props', 'prop'.repeat(256) + '...') + }) + + it('gets the href attribute from parent anchor tags', () => { + const elTarget = document.createElement('img') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.setAttribute('href', 'https://test.com') + elGrandparent.appendChild(elParent) + autocapture['_captureEvent']( + makeMouseEvent({ + target: elTarget, + }) + ) + expect(captureMock.mock.calls[0][1]['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') + }) + + it('does not capture href attribute values from password elements', () => { + const elTarget = document.createElement('span') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('input') + elGrandparent.appendChild(elParent) + elGrandparent.setAttribute('type', 'password') + autocapture['_captureEvent']( + makeMouseEvent({ + target: elTarget, + }) + ) + expect(captureMock.mock.calls[0][1]).not.toHaveProperty('attr__href') + }) + + it('does not capture href attribute values from hidden elements', () => { + const elTarget = document.createElement('span') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.appendChild(elParent) + elGrandparent.setAttribute('type', 'hidden') + autocapture['_captureEvent']( + makeMouseEvent({ + target: elTarget, + }) + ) + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') + }) + + it('does not capture href attribute values that look like credit card numbers', () => { + const elTarget = document.createElement('span') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.appendChild(elParent) + elGrandparent.setAttribute('href', '4111111111111111') + autocapture['_captureEvent']( + makeMouseEvent({ + target: elTarget, + }) + ) + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') + }) + + it('does not capture href attribute values that look like social-security numbers', () => { + const elTarget = document.createElement('span') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + const elGrandparent = document.createElement('a') + elGrandparent.appendChild(elParent) + elGrandparent.setAttribute('href', '123-58-1321') + autocapture['_captureEvent']( + makeMouseEvent({ + target: elTarget, + }) + ) + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') + }) + + it('correctly identifies and formats text content', () => { + document.body.innerHTML = ` +
+ +
+
+
+ + +
+
+
+
+ + + ` + const span1 = document.getElementById('span1') + const span2 = document.getElementById('span2') + const img2 = document.getElementById('img2') + + const e1 = makeMouseEvent({ + target: span2, + }) + captureMock.mockClear() + autocapture['_captureEvent'](e1) + + const props1 = captureMock.mock.calls[0][1] + const text1 = + "Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d" + expect(props1['$elements'][0]).toHaveProperty('$el_text', text1) + expect(props1['$el_text']).toEqual(text1) + + const e2 = makeMouseEvent({ + target: span1, + }) + captureMock.mockClear() + autocapture['_captureEvent'](e2) + const props2 = captureMock.mock.calls[0][1] + expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text') + expect(props2['$el_text']).toEqual('Some text') + + const e3 = makeMouseEvent({ + target: img2, + }) + captureMock.mockClear() + autocapture['_captureEvent'](e3) + const props3 = captureMock.mock.calls[0][1] + expect(props3['$elements'][0]).toHaveProperty('$el_text', '') + expect(props3).not.toHaveProperty('$el_text') + }) + + it('does not capture sensitive text content', () => { + // ^ valid credit card and social security numbers + document.body.innerHTML = ` +
+ +
+ + + ` + const button1 = document.getElementById('button1') + const button2 = document.getElementById('button2') + const button3 = document.getElementById('button3') + + const e1 = makeMouseEvent({ + target: button1, + }) + autocapture['_captureEvent'](e1) + const props1 = captureMock.mock.calls[0][1] + expect(props1['$elements'][0]).toHaveProperty('$el_text') + expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) + + const e2 = makeMouseEvent({ + target: button2, + }) + autocapture['_captureEvent'](e2) + const props2 = captureMock.mock.calls[0][1] + expect(props2['$elements'][0]).toHaveProperty('$el_text') + expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) + + const e3 = makeMouseEvent({ + target: button3, + }) + autocapture['_captureEvent'](e3) + const props3 = captureMock.mock.calls[0][1] + expect(props3['$elements'][0]).toHaveProperty('$el_text') + expect(props3['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) + }) + + it('should capture a submit event with form field props', () => { + const e = { + target: document.createElement('form'), + type: 'submit', + } as unknown as FormDataEvent + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] + expect(props['$event_type']).toBe('submit') + }) + + it('should capture a click event inside a form with form field props', () => { + const form = document.createElement('form') + const link = document.createElement('a') + const input = document.createElement('input') + input.name = 'test input' + input.value = 'test val' + form.appendChild(link) + form.appendChild(input) + const e = makeMouseEvent({ + target: link, + }) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] + expect(props['$event_type']).toBe('click') + }) + + it('should capture a click event inside a shadowroot', () => { + const main_el = document.createElement('some-element') + const shadowRoot = main_el.attachShadow({ mode: 'open' }) + const button = document.createElement('a') + button.innerHTML = 'bla' + shadowRoot.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] + expect(props['$event_type']).toBe('click') + }) + + it('should never capture an element with `ph-no-capture` class', () => { + const a = document.createElement('a') + const span = document.createElement('span') + a.appendChild(span) + autocapture['_captureEvent'](makeMouseEvent({ target: a })) + expect(captureMock).toHaveBeenCalledTimes(1) + + autocapture['_captureEvent'](makeMouseEvent({ target: span })) + expect(captureMock).toHaveBeenCalledTimes(2) + + captureMock.mockClear() + a.className = 'test1 ph-no-capture test2' + autocapture['_captureEvent'](makeMouseEvent({ target: a })) + expect(captureMock).toHaveBeenCalledTimes(0) + + autocapture['_captureEvent'](makeMouseEvent({ target: span })) + expect(captureMock).toHaveBeenCalledTimes(0) + }) + + it('does not capture any element attributes if mask_all_element_attributes is set', () => { + const dom = ` + + ` + + posthog.config.mask_all_element_attributes = true + + document.body.innerHTML = dom + const button1 = document.getElementById('button1') + + const e1 = makeMouseEvent({ + target: button1, + }) + autocapture['_captureEvent'](e1) + + const props1 = captureMock.mock.calls[0][1] + expect('attr__formmethod' in props1['$elements'][0]).toEqual(false) + }) + + it('does not capture any textContent if mask_all_text is set', () => { + const dom = ` + + Dont capture me! + + ` + posthog.config.mask_all_text = true + + document.body.innerHTML = dom + const a = document.getElementById('a1') + const e1 = makeMouseEvent({ + target: a, + }) + + autocapture['_captureEvent'](e1) + const props1 = captureMock.mock.calls[0][1] + + expect(props1['$elements'][0]).not.toHaveProperty('$el_text') + }) + + it('returns elementsChain instead of elements when set', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'http://test.com') + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + + const e = { + target: elTarget, + type: 'click', + } as unknown as MouseEvent + + autocapture.afterDecideResponse({ + elementsChainAsString: true, + } as DecideResponse) + + autocapture['_captureEvent'](e) + const props1 = captureMock.mock.calls[0][1] + + expect(props1['$elements_chain']).toBeDefined() + expect(props1['$elements']).toBeUndefined() + }) + + it('returns elementsChain correctly with newlines in css', () => { + const elTarget = document.createElement('a') + elTarget.setAttribute('href', 'http://test.com') + elTarget.setAttribute( + 'class', + '\ftest-class\n test-class2\ttest-class3 test-class4 \r\n test-class5' + ) + const elParent = document.createElement('span') + elParent.appendChild(elTarget) + + const e = { + target: elTarget, + type: 'click', + } as unknown as MouseEvent + + autocapture['_elementsChainAsString'] = true + autocapture['_captureEvent'](e) + const props1 = captureMock.mock.calls[0][1] + + expect(props1['$elements_chain']).toBe( + 'a.test-class.test-class2.test-class3.test-class4.test-class5:nth-child="1"nth-of-type="1"href="http://test.com"attr__href="http://test.com"attr__class="test-class test-class2 test-class3 test-class4 test-class5";span:nth-child="1"nth-of-type="1"' + ) + }) + }) + + describe('_addDomEventHandlers', () => { + beforeEach(() => { + document.title = 'test page' + posthog.config.mask_all_element_attributes = false + }) + + it('should capture click events', () => { + autocapture['_addDomEventHandlers']() + const button = document.createElement('button') + document.body.appendChild(button) + simulateClick(button) + simulateClick(button) + expect(captureMock).toHaveBeenCalledTimes(2) + expect(captureMock.mock.calls[0][0]).toBe('$autocapture') + expect(captureMock.mock.calls[0][1]['$event_type']).toBe('click') + expect(captureMock.mock.calls[1][0]).toBe('$autocapture') + expect(captureMock.mock.calls[1][1]['$event_type']).toBe('click') + }) + }) + + describe('afterDecideResponse()', () => { + beforeEach(() => { + document.title = 'test page' + + jest.spyOn(autocapture, '_addDomEventHandlers') + }) + + it('should be enabled before the decide response', () => { + expect(autocapture.isEnabled).toBe(true) + }) + + it('should be disabled before the decide response if opt out is in persistence', () => { + persistence.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] = true + expect(autocapture.isEnabled).toBe(false) + }) + + it('should be disabled before the decide response if client side opted out', () => { + posthog.config.autocapture = false + expect(autocapture.isEnabled).toBe(false) + }) + + it.each([ + // when client side is opted out, it is always off + [false, true, false], + [false, false, false], + // when client side is opted in, it is only on, if the remote does not opt out + [true, true, false], + [true, false, true], + ])( + 'when client side config is %p and remote opt out is %p - autocapture enabled should be %p', + (clientSideOptIn, serverSideOptOut, expected) => { + posthog.config.autocapture = clientSideOptIn + autocapture.afterDecideResponse({ + autocapture_opt_out: serverSideOptOut, + } as DecideResponse) + expect(autocapture.isEnabled).toBe(expected) + } + ) + + it('should call _addDomEventHandlders if autocapture is true', () => { + $autocapture_disabled_server_side = false + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled() + }) + + it('should not call _addDomEventHandlders if autocapture is disabled', () => { + expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + autocapture: false, + } as PostHogConfig + $autocapture_disabled_server_side = true + + autocapture.afterDecideResponse({} as DecideResponse) + + expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() + }) + + it('should NOT call _addDomEventHandlders if the decide request fails', () => { + autocapture.afterDecideResponse({ + status: 0, + error: 'Bad HTTP status: 400 Bad Request', + } as unknown as DecideResponse) + + expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() + }) + + it('should NOT call _addDomEventHandlders when the token has already been initialized', () => { + $autocapture_disabled_server_side = false + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) + + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) + }) + }) + + describe('shouldCaptureDomEvent autocapture config', () => { + it('only capture urls which match the url regex allowlist', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('a') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config = { + url_allowlist: ['https://posthog.com/test/*'], + } + + window!.location = new URL('https://posthog.com/test/captured') as unknown as Location + + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) + + window!.location = new URL('https://posthog.com/docs/not-captured') as unknown as Location + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) + }) + + it('an empty url regex allowlist does not match any url', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('a') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + url_allowlist: [], + } + + window!.location = new URL('https://posthog.com/test/captured') as unknown as Location + + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) + }) + + it('only capture event types which match the allowlist', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + dom_event_allowlist: ['click'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) + + const autocapture_config_change: AutocaptureConfig = { + dom_event_allowlist: ['change'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) + }) + + it('an empty event type allowlist matches no events', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config = { + dom_event_allowlist: [], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) + }) + + it('only capture elements which match the allowlist', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + element_allowlist: ['button'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) + + const autocapture_config_change: AutocaptureConfig = { + element_allowlist: ['a'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) + }) + + it('an empty event allowlist means we capture no elements', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + element_allowlist: [], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) + }) + + it('only capture elements which match the css allowlist', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.setAttribute('data-track', 'yes') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + css_selector_allowlist: ['[data-track="yes"]'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) + + const autocapture_config_change = { + css_selector_allowlist: ['[data-track="no"]'], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) + }) + + it('an empty css selector list captures no elements', () => { + const main_el = document.createElement('some-element') + const button = document.createElement('button') + button.setAttribute('data-track', 'yes') + button.innerHTML = 'bla' + main_el.appendChild(button) + const e = makeMouseEvent({ + target: main_el, + composedPath: () => [button, main_el], + }) + const autocapture_config: AutocaptureConfig = { + css_selector_allowlist: [], + } + expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) + }) + }) +}) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index b57b0d876..c1baf512a 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -83,14 +83,6 @@ describe('Autocapture system', () => { config: { enable_collect_everything: true, }, - // TODO: delete custom_properties after changeless typescript refactor - custom_properties: [ - { - event_selectors: ['.event-element-1', '.event-element-2'], - css_selector: '.property-element', - name: 'my property name', - }, - ], } as DecideResponse }) @@ -1237,19 +1229,12 @@ describe('Autocapture system', () => { } as PostHogConfig, }) - let navigateSpy: sinon.SinonSpy - beforeEach(() => { document.title = 'test page' autocapture._addDomEventHandlers(lib) - navigateSpy = sinon.spy(autocapture, '_navigate') ;(lib.capture as sinon.SinonSpy).resetHistory() }) - afterAll(() => { - navigateSpy.restore() - }) - it('should capture click events', () => { const button = document.createElement('button') document.body.appendChild(button) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index e1f6d8d74..88ab393e1 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -348,13 +348,6 @@ describe('posthog core', () => { expect(given.lib.analyticsDefaultEndpoint).toEqual('/i/v0/e/') }) - - it('enables elementsChainAsString if given', () => { - given('decideResponse', () => ({ elementsChainAsString: true })) - given.subject() - - expect(given.lib.elementsChainAsString).toBe(true) - }) }) describe('_calculate_event_properties()', () => { diff --git a/src/autocapture-v2.ts b/src/autocapture-v2.ts new file mode 100644 index 000000000..893cb5773 --- /dev/null +++ b/src/autocapture-v2.ts @@ -0,0 +1,358 @@ +import { _each, _extend, _includes, _register_event } from './utils' +import { + autocaptureCompatibleElements, + getClassNames, + getDirectAndNestedSpanText, + getElementsChainString, + getSafeText, + isAngularStyleAttr, + isDocumentFragment, + isElementNode, + isSensitiveElement, + isTag, + isTextNode, + makeSafeText, + shouldCaptureDomEvent, + shouldCaptureElement, + shouldCaptureValue, + splitClassString, +} from './autocapture-utils' +import RageClick from './extensions/rageclick' +import { AutocaptureConfig, DecideResponse, Properties } from './types' +import { PostHog } from './posthog-core' +import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' + +import { _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' +import { logger } from './utils/logger' +import { document, window } from './utils/globals' + +const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture' + +function limitText(length: number, text: string): string { + if (text.length > length) { + return text.slice(0, length) + '...' + } + return text +} + +export class Autocapture { + instance: PostHog + _initialized: boolean = false + _isDisabledServerSide: boolean | null = null + rageclicks = new RageClick() + _elementsChainAsString = false + + constructor(instance: PostHog) { + this.instance = instance + + // precompile the regex + // TODO: This doesn't work - we can't override the config like this + if (this.config?.url_allowlist) { + this.config.url_allowlist = this.config.url_allowlist.map((url) => new RegExp(url)) + } + + // if (autocapture._isAutocaptureEnabled) { + // this.__autocapture = this.config.autocapture + // if (!autocapture.isBrowserSupported()) { + // this.__autocapture = false + // logger.info('Disabling Automatic Event Collection because this browser is not supported') + // } else { + // autocapture.init(this) + // } + // } + } + + private get config(): AutocaptureConfig { + return _isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {} + } + + private _addDomEventHandlers(): void { + if (!window || !document) { + return + } + const handler = (e: Event) => { + e = e || window?.event + this._captureEvent(e) + } + + const copiedTextHandler = (e: Event) => { + e = e || window?.event + this._captureEvent(e, COPY_AUTOCAPTURE_EVENT) + } + + _register_event(document, 'submit', handler, false, true) + _register_event(document, 'change', handler, false, true) + _register_event(document, 'click', handler, false, true) + + if (this.config.capture_copied_text) { + _register_event(document, 'copy', copiedTextHandler, false, true) + _register_event(document, 'cut', copiedTextHandler, false, true) + } + } + + public afterDecideResponse(response: DecideResponse) { + if (this._initialized) { + logger.info('autocapture already initialized') + return + } + + if (this.instance.persistence) { + this.instance.persistence.register({ + [AUTOCAPTURE_DISABLED_SERVER_SIDE]: !!response['autocapture_opt_out'], + }) + } + // store this in-memory incase persistence is disabled + this._isDisabledServerSide = !!response['autocapture_opt_out'] + + if (response.elementsChainAsString) { + this._elementsChainAsString = response.elementsChainAsString + } + + if (this.isEnabled) { + this._addDomEventHandlers() + this._initialized = true + } + } + + public get isEnabled(): boolean { + const disabled_server_side = _isNull(this._isDisabledServerSide) + ? !!this.instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] + : this._isDisabledServerSide + const enabled_client_side = !!this.instance.config.autocapture + return enabled_client_side && !disabled_server_side + } + + private _previousElementSibling(el: Element): Element | null { + if (el.previousElementSibling) { + return el.previousElementSibling + } + let _el: Element | null = el + do { + _el = _el.previousSibling as Element | null // resolves to ChildNode->Node, which is Element's parent class + } while (_el && !isElementNode(_el)) + return _el + } + + private _getAugmentPropertiesFromElement(elem: Element): Properties { + const shouldCaptureEl = shouldCaptureElement(elem) + if (!shouldCaptureEl) { + return {} + } + + const props: Properties = {} + + _each(elem.attributes, function (attr: Attr) { + if (attr.name.indexOf('data-ph-capture-attribute') === 0) { + const propertyKey = attr.name.replace('data-ph-capture-attribute-', '') + const propertyValue = attr.value + if (propertyKey && propertyValue && shouldCaptureValue(propertyValue)) { + props[propertyKey] = propertyValue + } + } + }) + return props + } + + private _getPropertiesFromElement(elem: Element, maskInputs: boolean, maskText: boolean): Properties { + const tag_name = elem.tagName.toLowerCase() + const props: Properties = { + tag_name: tag_name, + } + if (autocaptureCompatibleElements.indexOf(tag_name) > -1 && !maskText) { + if (tag_name.toLowerCase() === 'a' || tag_name.toLowerCase() === 'button') { + props['$el_text'] = limitText(1024, getDirectAndNestedSpanText(elem)) + } else { + props['$el_text'] = limitText(1024, getSafeText(elem)) + } + } + + const classes = getClassNames(elem) + if (classes.length > 0) + props['classes'] = classes.filter(function (c) { + return c !== '' + }) + + // capture the deny list here because this not-a-class class makes it tricky to use this.config in the function below + const elementAttributeIgnorelist = this.config?.element_attribute_ignorelist + _each(elem.attributes, function (attr: Attr) { + // Only capture attributes we know are safe + if (isSensitiveElement(elem) && ['name', 'id', 'class'].indexOf(attr.name) === -1) return + + if (elementAttributeIgnorelist?.includes(attr.name)) return + + if (!maskInputs && shouldCaptureValue(attr.value) && !isAngularStyleAttr(attr.name)) { + let value = attr.value + if (attr.name === 'class') { + // html attributes can _technically_ contain linebreaks, + // but we're very intolerant of them in the class string, + // so we strip them. + value = splitClassString(value).join(' ') + } + props['attr__' + attr.name] = limitText(1024, value) + } + }) + + let nthChild = 1 + let nthOfType = 1 + let currentElem: Element | null = elem + while ((currentElem = this._previousElementSibling(currentElem))) { + // eslint-disable-line no-cond-assign + nthChild++ + if (currentElem.tagName === elem.tagName) { + nthOfType++ + } + } + props['nth_child'] = nthChild + props['nth_of_type'] = nthOfType + + return props + } + + private _getDefaultProperties(eventType: string): Properties { + return { + $event_type: eventType, + $ce_version: 1, + } + } + + private _getEventTarget(e: Event): Element | null { + // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes + if (_isUndefined(e.target)) { + return (e.srcElement as Element) || null + } else { + if ((e.target as HTMLElement)?.shadowRoot) { + return (e.composedPath()[0] as Element) || null + } + return (e.target as Element) || null + } + } + + private _captureEvent(e: Event, eventName = '$autocapture', extraProps?: Properties): boolean | void { + /*** Don't mess with this code without running IE8 tests on it ***/ + let target = this._getEventTarget(e) + if (isTextNode(target)) { + // defeat Safari bug (see: http://www.quirksmode.org/js/events_properties.html) + target = (target.parentNode || null) as Element | null + } + + if (eventName === '$autocapture' && e.type === 'click' && e instanceof MouseEvent) { + if ( + this.instance.config.rageclick && + this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime()) + ) { + this._captureEvent(e, '$rageclick') + } + } + + const isCopyAutocapture = eventName === COPY_AUTOCAPTURE_EVENT + if ( + target && + shouldCaptureDomEvent( + target, + e, + this.config, + // mostly this method cares about the target element, but in the case of copy events, + // we want some of the work this check does without insisting on the target element's type + isCopyAutocapture, + // we also don't want to restrict copy checks to clicks, + // so we pass that knowledge in here, rather than add the logic inside the check + isCopyAutocapture ? ['copy', 'cut'] : undefined + ) + ) { + const targetElementList = [target] + let curEl = target + while (curEl.parentNode && !isTag(curEl, 'body')) { + if (isDocumentFragment(curEl.parentNode)) { + targetElementList.push((curEl.parentNode as any).host) + curEl = (curEl.parentNode as any).host + continue + } + targetElementList.push(curEl.parentNode as Element) + curEl = curEl.parentNode as Element + } + + const elementsJson: Properties[] = [] + const autocaptureAugmentProperties: Properties = {} + let href, + explicitNoCapture = false + _each(targetElementList, (el) => { + const shouldCaptureEl = shouldCaptureElement(el) + + // if the element or a parent element is an anchor tag + // include the href as a property + if (el.tagName.toLowerCase() === 'a') { + href = el.getAttribute('href') + href = shouldCaptureEl && shouldCaptureValue(href) && href + } + + // allow users to programmatically prevent capturing of elements by adding class 'ph-no-capture' + const classes = getClassNames(el) + if (_includes(classes, 'ph-no-capture')) { + explicitNoCapture = true + } + + elementsJson.push( + this._getPropertiesFromElement( + el, + this.instance.config.mask_all_element_attributes, + this.instance.config.mask_all_text + ) + ) + + const augmentProperties = this._getAugmentPropertiesFromElement(el) + _extend(autocaptureAugmentProperties, augmentProperties) + }) + + if (!this.instance.config.mask_all_text) { + // if the element is a button or anchor tag get the span text from any + // children and include it as/with the text property on the parent element + if (target.tagName.toLowerCase() === 'a' || target.tagName.toLowerCase() === 'button') { + elementsJson[0]['$el_text'] = getDirectAndNestedSpanText(target) + } else { + elementsJson[0]['$el_text'] = getSafeText(target) + } + } + + if (href) { + elementsJson[0]['attr__href'] = href + } + + if (explicitNoCapture) { + return false + } + + const props = _extend( + this._getDefaultProperties(e.type), + this._elementsChainAsString + ? { + $elements_chain: getElementsChainString(elementsJson), + } + : { + $elements: elementsJson, + }, + elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, + autocaptureAugmentProperties, + extraProps || {} + ) + + if (eventName === COPY_AUTOCAPTURE_EVENT) { + // you can't read the data from the clipboard event, + // but you can guess that you can read it from the window's current selection + const selectedContent = makeSafeText(window?.getSelection()?.toString()) + const clipType = (e as ClipboardEvent).type || 'clipboard' + if (!selectedContent) { + return false + } + props['$selected_content'] = selectedContent + props['$copy_type'] = clipType + } + + this.instance.capture(eventName, props) + return true + } + } + + isBrowserSupported(): boolean { + return _isFunction(document?.querySelectorAll) + } +} diff --git a/src/autocapture.ts b/src/autocapture.ts index ec912ebe5..f354e9f8f 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -1,4 +1,4 @@ -import { _bind_instance_methods, _each, _extend, _includes, _register_event, _safewrap_instance_methods } from './utils' +import { _each, _extend, _includes, _register_event, _safewrap } from './utils' import { autocaptureCompatibleElements, getClassNames, @@ -35,6 +35,22 @@ function limitText(length: number, text: string): string { return text } +const _bind_instance_methods = function (obj: Record): void { + for (const func in obj) { + if (_isFunction(obj[func])) { + obj[func] = obj[func].bind(obj) + } + } +} + +const _safewrap_instance_methods = function (obj: Record): void { + for (const func in obj) { + if (_isFunction(obj[func])) { + obj[func] = _safewrap(obj[func]) + } + } +} + const autocapture = { _initializedTokens: [] as string[], _isDisabledServerSide: null as boolean | null, @@ -160,22 +176,6 @@ const autocapture = { return propValues.join(', ') }, - // TODO: delete custom_properties after changeless typescript refactor - _getCustomProperties: function (targetElementList: Element[]): Properties { - const props: Properties = {} // will be deleted - _each(this._customProperties, (customProperty) => { - _each(customProperty['event_selectors'], (eventSelector) => { - const eventElements = document?.querySelectorAll(eventSelector) - _each(eventElements, (eventElement) => { - if (_includes(targetElementList, eventElement) && shouldCaptureElement(eventElement)) { - props[customProperty['name']] = this._extractCustomPropertyValue(customProperty) - } - }) - }) - }) - return props - }, - _getEventTarget: function (e: Event): Element | null { // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes if (_isUndefined(e.target)) { @@ -316,15 +316,6 @@ const autocapture = { } }, - // only reason is to stub for unit tests - // since you can't override window.location props - _navigate: function (href: string): void { - if (!window) { - return - } - window.location.href = href - }, - _addDomEventHandlers: function (instance: PostHog): void { if (!window || !document) { return @@ -391,10 +382,6 @@ const autocapture = { response['config']['enable_collect_everything'] && this._isAutocaptureEnabled ) { - // TODO: delete custom_properties after changeless typescript refactor - if (response['custom_properties']) { - this._customProperties = response['custom_properties'] - } this._addDomEventHandlers(instance) } else { instance['__autocapture'] = false diff --git a/src/extensions/rageclick.ts b/src/extensions/rageclick.ts index 3084aa88c..ad92e7a41 100644 --- a/src/extensions/rageclick.ts +++ b/src/extensions/rageclick.ts @@ -8,18 +8,12 @@ const RAGE_CLICK_CLICK_COUNT = 3 export default class RageClick { clicks: { x: number; y: number; timestamp: number }[] - enabled: boolean - constructor(enabled: boolean) { + constructor() { this.clicks = [] - this.enabled = enabled } isRageClick(x: number, y: number, timestamp: number): boolean { - if (!this.enabled) { - return false - } - const lastClick = this.clicks[this.clicks.length - 1] if ( lastClick && diff --git a/src/posthog-core.ts b/src/posthog-core.ts index cf32fd9c4..1382ec9f0 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -72,6 +72,7 @@ import { logger } from './utils/logger' import { SessionPropsManager } from './session-props' import { _isBlockedUA } from './utils/blocked-uas' import { extendURLParams, request, SUPPORTS_REQUEST } from './request' +import { Autocapture } from './autocapture-v2' /* SIMPLE STYLE GUIDE: @@ -213,6 +214,7 @@ export class PostHog { sessionManager?: SessionIdManager sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter + autocapture: Autocapture _requestQueue?: RequestQueue _retryQueue?: RetryQueue @@ -226,7 +228,6 @@ export class PostHog { __autocapture: boolean | AutocaptureConfig | undefined decideEndpointWasHit: boolean analyticsDefaultEndpoint: string - elementsChainAsString: boolean SentryIntegration: typeof SentryIntegration segmentIntegration: () => any @@ -247,7 +248,6 @@ export class PostHog { this.__loaded = false this.__autocapture = undefined this.analyticsDefaultEndpoint = '/e/' - this.elementsChainAsString = false this.featureFlags = new PostHogFeatureFlags(this) this.toolbar = new Toolbar(this) @@ -370,16 +370,13 @@ export class PostHog { this.pageViewManager.startMeasuringScrollPosition() } + this.autocapture = new Autocapture(this) + this.__autocapture = this.config.autocapture autocapture._setIsAutocaptureEnabled(this) if (autocapture._isAutocaptureEnabled) { this.__autocapture = this.config.autocapture - const num_buckets = 100 - const num_enabled_buckets = 100 - if (!autocapture.enabledForProject(this.config.token, num_buckets, num_enabled_buckets)) { - this.__autocapture = false - logger.info('Not in active bucket: disabling Automatic Event Collection.') - } else if (!autocapture.isBrowserSupported()) { + if (!autocapture.isBrowserSupported()) { this.__autocapture = false logger.info('Disabling Automatic Event Collection because this browser is not supported') } else { @@ -487,10 +484,6 @@ export class PostHog { if (response.analytics?.endpoint) { this.analyticsDefaultEndpoint = response.analytics.endpoint } - - if (response.elementsChainAsString) { - this.elementsChainAsString = response.elementsChainAsString - } } _loaded(): void { diff --git a/src/types.ts b/src/types.ts index 34cd30966..679479b5b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -252,10 +252,10 @@ export type FlagVariant = { flag: string; variant: string } export interface DecideResponse { supportedCompression: Compression[] + // NOTE: Remove this entirely as it is never used config: { enable_collect_everything: boolean } - custom_properties: AutoCaptureCustomProperty[] // TODO: delete, not sent featureFlags: Record featureFlagPayloads: Record errorsWhileComputingFlags: boolean @@ -301,13 +301,6 @@ export type FeatureFlagsCallback = ( } ) => void -// TODO: delete custom_properties after changeless typescript refactor -export interface AutoCaptureCustomProperty { - name: string - css_selector: string - event_selectors: string[] -} - export interface GDPROptions { capture?: ( event: string, diff --git a/src/utils/index.ts b/src/utils/index.ts index 2e558b9cb..92ac5d01e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -21,14 +21,6 @@ export const _trim = function (str: string): string { return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '') } -export const _bind_instance_methods = function (obj: Record): void { - for (const func in obj) { - if (_isFunction(obj[func])) { - obj[func] = obj[func].bind(obj) - } - } -} - export function _eachArray( obj: E[] | null | undefined, iterator: (value: E, key: number) => void | Breaker, @@ -205,14 +197,6 @@ export const _safewrap_class = function (klass: Function, functions: string[]): } } -export const _safewrap_instance_methods = function (obj: Record): void { - for (const func in obj) { - if (_isFunction(obj[func])) { - obj[func] = _safewrap(obj[func]) - } - } -} - export const _strip_empty_properties = function (p: Properties): Properties { const ret: Properties = {} _each(p, function (v, k) { From a7f44a563674bc1063b72e4827480ce8afff977f Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 11:42:24 +0200 Subject: [PATCH 02/41] Fixes --- src/__tests__/autocapture-v2.test.ts | 1194 -------------------- src/__tests__/autocapture.test.ts | 668 +++-------- src/__tests__/extensions/rageclick.test.ts | 12 +- 3 files changed, 166 insertions(+), 1708 deletions(-) delete mode 100644 src/__tests__/autocapture-v2.test.ts diff --git a/src/__tests__/autocapture-v2.test.ts b/src/__tests__/autocapture-v2.test.ts deleted file mode 100644 index b1055d664..000000000 --- a/src/__tests__/autocapture-v2.test.ts +++ /dev/null @@ -1,1194 +0,0 @@ -/// -/* eslint-disable compat/compat */ - -import { Autocapture } from '../autocapture-v2' -import { shouldCaptureDomEvent } from '../autocapture-utils' -import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants' -import { AutocaptureConfig, DecideResponse, PostHogConfig } from '../types' -import { PostHog } from '../posthog-core' -import { PostHogPersistence } from '../posthog-persistence' -import { window } from '../utils/globals' - -// JS DOM doesn't have ClipboardEvent, so we need to mock it -// see https://github.com/jsdom/jsdom/issues/1568 -class MockClipboardEvent extends Event implements ClipboardEvent { - clipboardData: DataTransfer | null = null - type: 'copy' | 'cut' | 'paste' = 'copy' -} -window!.ClipboardEvent = MockClipboardEvent - -const triggerMouseEvent = function (node: Node, eventType: string) { - node.dispatchEvent( - new MouseEvent(eventType, { - bubbles: true, - cancelable: true, - }) - ) -} - -const simulateClick = function (el: Node) { - triggerMouseEvent(el, 'click') -} - -function makePostHog(ph: Partial): PostHog { - return { - get_distinct_id() { - return 'distinctid' - }, - ...ph, - } as unknown as PostHog -} - -export function makeMouseEvent(partialEvent: Partial) { - return { type: 'click', ...partialEvent } as unknown as MouseEvent -} - -export function makeCopyEvent(partialEvent: Partial) { - return { type: 'copy', ...partialEvent } as unknown as ClipboardEvent -} - -export function makeCutEvent(partialEvent: Partial) { - return { type: 'cut', ...partialEvent } as unknown as ClipboardEvent -} - -function setWindowTextSelection(s: string): void { - window!.getSelection = () => { - return { - toString: () => s, - } as Selection - } -} - -describe('Autocapture system', () => { - const originalWindowLocation = window!.location - - let $autocapture_disabled_server_side: boolean - let autocapture: Autocapture - let posthog: PostHog - let captureMock: jest.Mock - let persistence: PostHogPersistence - - beforeEach(() => { - jest.spyOn(window!.console, 'log').mockImplementation() - - Object.defineProperty(window, 'location', { - configurable: true, - enumerable: true, - writable: true, - // eslint-disable-next-line compat/compat - value: new URL('https://example.com'), - }) - - captureMock = jest.fn() - persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence - posthog = makePostHog({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - autocapture: true, - } as PostHogConfig, - capture: captureMock, - get_property: (property_key: string) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, - persistence: persistence, - }) - - autocapture = new Autocapture(posthog) - }) - - afterEach(() => { - document.getElementsByTagName('html')[0].innerHTML = '' - - Object.defineProperty(window, 'location', { - configurable: true, - enumerable: true, - value: originalWindowLocation, - }) - }) - - describe('_getPropertiesFromElement', () => { - let div: HTMLDivElement - let div2: HTMLDivElement - let input: HTMLInputElement - let sensitiveInput: HTMLInputElement - let hidden: HTMLInputElement - let password: HTMLInputElement - - beforeEach(() => { - div = document.createElement('div') - div.className = 'class1 class2 class3 ' // Lots of spaces might mess things up - div.innerHTML = 'my sweet inner text' - - input = document.createElement('input') - input.value = 'test val' - - sensitiveInput = document.createElement('input') - sensitiveInput.value = 'test val' - sensitiveInput.className = 'ph-sensitive' - - hidden = document.createElement('input') - hidden.setAttribute('type', 'hidden') - hidden.value = 'hidden val' - - password = document.createElement('input') - password.setAttribute('type', 'password') - password.value = 'password val' - - const divSibling = document.createElement('div') - const divSibling2 = document.createElement('span') - - div2 = document.createElement('div') - div2.className = 'parent' - div2.appendChild(divSibling) - div2.appendChild(divSibling2) - div2.appendChild(div) - div2.appendChild(input) - div2.appendChild(sensitiveInput) - div2.appendChild(hidden) - div2.appendChild(password) - }) - - it('should contain the proper tag name', () => { - const props = autocapture['_getPropertiesFromElement'](div, false, false) - expect(props['tag_name']).toBe('div') - }) - - it('should contain class list', () => { - const props = autocapture['_getPropertiesFromElement'](div, false, false) - expect(props['classes']).toEqual(['class1', 'class2', 'class3']) - }) - - it('should not collect input value', () => { - const props = autocapture['_getPropertiesFromElement'](input, false, false) - expect(props['value']).toBeUndefined() - }) - - it('should strip element value with class "ph-sensitive"', () => { - const props = autocapture['_getPropertiesFromElement'](sensitiveInput, false, false) - expect(props['value']).toBeUndefined() - }) - - it('should strip hidden element value', () => { - const props = autocapture['_getPropertiesFromElement'](hidden, false, false) - expect(props['value']).toBeUndefined() - }) - - it('should strip password element value', () => { - const props = autocapture['_getPropertiesFromElement'](password, false, false) - expect(props['value']).toBeUndefined() - }) - - it('should contain nth-of-type', () => { - const props = autocapture['_getPropertiesFromElement'](div, false, false) - expect(props['nth_of_type']).toBe(2) - }) - - it('should contain nth-child', () => { - const props = autocapture['_getPropertiesFromElement'](password, false, false) - expect(props['nth_child']).toBe(7) - }) - - it('should filter out Angular content attributes', () => { - const angularDiv = document.createElement('div') - angularDiv.setAttribute('_ngcontent-dpm-c448', '') - angularDiv.setAttribute('_nghost-dpm-c448', '') - const props = autocapture['_getPropertiesFromElement'](angularDiv, false, false) - expect(props['_ngcontent-dpm-c448']).toBeUndefined() - expect(props['_nghost-dpm-c448']).toBeUndefined() - }) - - it('should filter element attributes based on the ignorelist', () => { - posthog.config.autocapture = { - element_attribute_ignorelist: ['data-attr', 'data-attr-2'], - } - div.setAttribute('data-attr', 'value') - div.setAttribute('data-attr-2', 'value') - div.setAttribute('data-attr-3', 'value') - const props = autocapture['_getPropertiesFromElement'](div, false, false) - expect(props['attr__data-attr']).toBeUndefined() - expect(props['attr__data-attr-2']).toBeUndefined() - expect(props['attr__data-attr-3']).toBe('value') - }) - - it('an empty ignorelist does nothing', () => { - posthog.config.autocapture = { - element_attribute_ignorelist: [], - } - div.setAttribute('data-attr', 'value') - div.setAttribute('data-attr-2', 'value') - div.setAttribute('data-attr-3', 'value') - const props = autocapture['_getPropertiesFromElement'](div, false, false) - expect(props['attr__data-attr']).toBe('value') - expect(props['attr__data-attr-2']).toBe('value') - expect(props['attr__data-attr-3']).toBe('value') - }) - }) - - describe('_getAugmentPropertiesFromElement', () => { - let div: HTMLDivElement - let div2: HTMLDivElement - let input: HTMLInputElement - let sensitiveInput: HTMLInputElement - let hidden: HTMLInputElement - let password: HTMLInputElement - - beforeEach(() => { - div = document.createElement('div') - div.className = 'class1 class2 class3 ' // Lots of spaces might mess things up - div.innerHTML = 'my sweet inner text' - div.setAttribute('data-ph-capture-attribute-one-on-the-div', 'one') - div.setAttribute('data-ph-capture-attribute-two-on-the-div', 'two') - div.setAttribute('data-ph-capture-attribute-falsey-on-the-div', '0') - div.setAttribute('data-ph-capture-attribute-false-on-the-div', 'false') - - input = document.createElement('input') - input.setAttribute('data-ph-capture-attribute-on-the-input', 'is on the input') - input.value = 'test val' - - sensitiveInput = document.createElement('input') - sensitiveInput.value = 'test val' - sensitiveInput.setAttribute('data-ph-capture-attribute-on-the-sensitive-input', 'is on the sensitive-input') - sensitiveInput.className = 'ph-sensitive' - - hidden = document.createElement('input') - hidden.setAttribute('type', 'hidden') - hidden.setAttribute('data-ph-capture-attribute-on-the-hidden', 'is on the hidden') - hidden.value = 'hidden val' - - password = document.createElement('input') - password.setAttribute('type', 'password') - password.setAttribute('data-ph-capture-attribute-on-the-password', 'is on the password') - password.value = 'password val' - - const divSibling = document.createElement('div') - const divSibling2 = document.createElement('span') - - div2 = document.createElement('div') - div2.className = 'parent' - div2.appendChild(divSibling) - div2.appendChild(divSibling2) - div2.appendChild(div) - div2.appendChild(input) - div2.appendChild(sensitiveInput) - div2.appendChild(hidden) - div2.appendChild(password) - }) - - it('should collect multiple augments from elements', () => { - const props = autocapture['_getAugmentPropertiesFromElement'](div) - expect(props['one-on-the-div']).toBe('one') - expect(props['two-on-the-div']).toBe('two') - expect(props['falsey-on-the-div']).toBe('0') - expect(props['false-on-the-div']).toBe('false') - }) - - it('should collect augment from input value', () => { - const props = autocapture['_getAugmentPropertiesFromElement'](input) - expect(props['on-the-input']).toBe('is on the input') - }) - - it('should collect augment from input with class "ph-sensitive"', () => { - const props = autocapture['_getAugmentPropertiesFromElement'](sensitiveInput) - expect(props['on-the-sensitive-input']).toBeUndefined() - }) - - it('should not collect augment from the hidden element value', () => { - const props = autocapture['_getAugmentPropertiesFromElement'](hidden) - expect(props).toStrictEqual({}) - }) - - it('should collect augment from the password element value', () => { - const props = autocapture['_getAugmentPropertiesFromElement'](password) - expect(props).toStrictEqual({}) - }) - }) - - describe('isBrowserSupported', () => { - let orig: typeof document.querySelectorAll - - beforeEach(() => { - orig = document.querySelectorAll - }) - - afterEach(() => { - document.querySelectorAll = orig - }) - - it('should return true if document.querySelectorAll is a function', () => { - document.querySelectorAll = function () { - return [] as unknown as NodeListOf - } - expect(autocapture.isBrowserSupported()).toBe(true) - }) - - it('should return false if document.querySelectorAll is not a function', () => { - document.querySelectorAll = undefined as unknown as typeof document.querySelectorAll - expect(autocapture.isBrowserSupported()).toBe(false) - }) - }) - - describe('_previousElementSibling', () => { - it('should return the adjacent sibling', () => { - const div = document.createElement('div') - const sibling = document.createElement('div') - const child = document.createElement('div') - div.appendChild(sibling) - div.appendChild(child) - expect(autocapture['_previousElementSibling'](child)).toBe(sibling) - }) - - it('should return the first child and not the immediately previous sibling (text)', () => { - const div = document.createElement('div') - const sibling = document.createElement('div') - const child = document.createElement('div') - div.appendChild(sibling) - div.appendChild(document.createTextNode('some text')) - div.appendChild(child) - expect(autocapture['_previousElementSibling'](child)).toBe(sibling) - }) - - it('should return null when the previous sibling is a text node', () => { - const div = document.createElement('div') - const child = document.createElement('div') - div.appendChild(document.createTextNode('some text')) - div.appendChild(child) - expect(autocapture['_previousElementSibling'](child)).toBeNull() - }) - }) - - describe('_getDefaultProperties', () => { - it('should return the default properties', () => { - expect(autocapture['_getDefaultProperties']('test')).toEqual({ - $event_type: 'test', - $ce_version: 1, - }) - }) - }) - - describe('_captureEvent', () => { - beforeEach(() => { - posthog.config.rageclick = true - }) - - it('should add the custom property when an element matching any of the event selectors is clicked', () => { - posthog.config.mask_all_element_attributes = false - $autocapture_disabled_server_side = false - autocapture.afterDecideResponse({} as DecideResponse) - - const eventElement1 = document.createElement('div') - const eventElement2 = document.createElement('div') - const propertyElement = document.createElement('div') - eventElement1.className = 'event-element-1' - eventElement1.style.cursor = 'pointer' - eventElement2.className = 'event-element-2' - eventElement2.style.cursor = 'pointer' - propertyElement.className = 'property-element' - propertyElement.textContent = 'my property value' - document.body.appendChild(eventElement1) - document.body.appendChild(eventElement2) - document.body.appendChild(propertyElement) - - expect(captureMock).toHaveBeenCalledTimes(0) - simulateClick(eventElement1) - simulateClick(eventElement2) - expect(captureMock).toHaveBeenCalledTimes(2) - const captureArgs1 = captureMock.mock.calls[0] - const captureArgs2 = captureMock.mock.calls[1] - const eventType1 = captureArgs1[1]['my property name'] - const eventType2 = captureArgs2[1]['my property name'] - expect(eventType1).toBe('my property value') - expect(eventType2).toBe('my property value') - }) - - it('should capture rageclick', () => { - const elTarget = document.createElement('img') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.setAttribute('href', 'https://test.com') - elGrandparent.appendChild(elParent) - const fakeEvent = makeMouseEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) - autocapture['_captureEvent'](fakeEvent) - autocapture['_captureEvent'](fakeEvent) - autocapture['_captureEvent'](fakeEvent) - - expect(captureMock).toHaveBeenCalledTimes(4) - expect(captureMock.mock.calls.map((args) => args[0])).toEqual([ - '$autocapture', - '$autocapture', - '$rageclick', - '$autocapture', - ]) - }) - - describe('clipboard autocapture', () => { - let elTarget: HTMLDivElement - - beforeEach(() => { - elTarget = document.createElement('div') - elTarget.innerText = 'test' - const elParent = document.createElement('div') - elParent.appendChild(elTarget) - }) - - it('should capture copy', () => { - const fakeEvent = makeCopyEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - - setWindowTextSelection('copy this test') - - autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - - expect(captureMock).toHaveBeenCalledTimes(1) - expect(captureMock.mock.calls[0][0]).toEqual('$copy_autocapture') - expect(captureMock.mock.calls[0][1]).toHaveProperty('$selected_content', 'copy this test') - expect(captureMock.mock.calls[0][1]).toHaveProperty('$copy_type', 'copy') - }) - - it('should capture cut', () => { - const fakeEvent = makeCutEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - - setWindowTextSelection('cut this test') - - autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - - const spyArgs = captureMock.mock.calls - expect(spyArgs.length).toBe(1) - expect(spyArgs[0][0]).toEqual('$copy_autocapture') - expect(spyArgs[0][1]).toHaveProperty('$selected_content', 'cut this test') - expect(spyArgs[0][1]).toHaveProperty('$copy_type', 'cut') - }) - - it('ignores empty selection', () => { - const fakeEvent = makeCopyEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - - setWindowTextSelection('') - - autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - - const spyArgs = captureMock.mock.calls - expect(spyArgs.length).toBe(0) - }) - - it('runs selection through the safe text before capture', () => { - const fakeEvent = makeCopyEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - - // oh no, a social security number! - setWindowTextSelection('123-45-6789') - - autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - - const spyArgs = captureMock.mock.calls - expect(spyArgs.length).toBe(0) - }) - }) - - it('should capture augment properties', () => { - const elTarget = document.createElement('img') - elTarget.setAttribute('data-ph-capture-attribute-target-augment', 'the target') - const elParent = document.createElement('span') - elParent.setAttribute('data-ph-capture-attribute-parent-augment', 'the parent') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.setAttribute('href', 'https://test.com') - elGrandparent.appendChild(elParent) - const fakeEvent = makeMouseEvent({ - target: elTarget, - clientX: 5, - clientY: 5, - }) - Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) - autocapture['_captureEvent'](fakeEvent) - - const captureProperties = captureMock.mock.calls[0][1] - expect(captureProperties).toHaveProperty('target-augment', 'the target') - expect(captureProperties).toHaveProperty('parent-augment', 'the parent') - }) - - it('should not capture events when config returns false, when an element matching any of the event selectors is clicked', () => { - posthog.config.autocapture = false - autocapture.afterDecideResponse({} as DecideResponse) - - const eventElement1 = document.createElement('div') - const eventElement2 = document.createElement('div') - const propertyElement = document.createElement('div') - eventElement1.className = 'event-element-1' - eventElement1.style.cursor = 'pointer' - eventElement2.className = 'event-element-2' - eventElement2.style.cursor = 'pointer' - propertyElement.className = 'property-element' - propertyElement.textContent = 'my property value' - document.body.appendChild(eventElement1) - document.body.appendChild(eventElement2) - document.body.appendChild(propertyElement) - - expect(captureMock).toHaveBeenCalledTimes(0) - simulateClick(eventElement1) - simulateClick(eventElement2) - expect(captureMock).toHaveBeenCalledTimes(0) - }) - - it('should not capture events when config returns true but server setting is disabled', () => { - autocapture.afterDecideResponse({ - autocapture_opt_out: true, - } as DecideResponse) - - const eventElement = document.createElement('a') - document.body.appendChild(eventElement) - - expect(captureMock).toHaveBeenCalledTimes(0) - simulateClick(eventElement) - expect(captureMock).toHaveBeenCalledTimes(0) - }) - - it('includes necessary metadata as properties when capturing an event', () => { - const elTarget = document.createElement('a') - elTarget.setAttribute('href', 'https://test.com') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('div') - elGrandparent.appendChild(elParent) - const elGreatGrandparent = document.createElement('table') - elGreatGrandparent.appendChild(elGrandparent) - document.body.appendChild(elGreatGrandparent) - const e = makeMouseEvent({ - target: elTarget, - }) - autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const captureArgs = captureMock.mock.calls[0] - const event = captureArgs[0] - const props = captureArgs[1] - expect(event).toBe('$autocapture') - expect(props['$event_type']).toBe('click') - expect(props['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') - expect(props['$elements'][1]).toHaveProperty('tag_name', 'span') - expect(props['$elements'][2]).toHaveProperty('tag_name', 'div') - expect(props['$elements'][props['$elements'].length - 1]).toHaveProperty('tag_name', 'body') - }) - - it('truncate any element property value to 1024 bytes', () => { - const elTarget = document.createElement('a') - elTarget.setAttribute('href', 'https://test.com') - const longString = 'prop'.repeat(400) - elTarget.dataset.props = longString - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('div') - elGrandparent.appendChild(elParent) - const elGreatGrandparent = document.createElement('table') - elGreatGrandparent.appendChild(elGrandparent) - document.body.appendChild(elGreatGrandparent) - const e = makeMouseEvent({ - target: elTarget, - }) - autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const captureArgs = captureMock.mock.calls[0] - const props = captureArgs[1] - expect(longString).toBe('prop'.repeat(400)) - expect(props['$elements'][0]).toHaveProperty('attr__data-props', 'prop'.repeat(256) + '...') - }) - - it('gets the href attribute from parent anchor tags', () => { - const elTarget = document.createElement('img') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.setAttribute('href', 'https://test.com') - elGrandparent.appendChild(elParent) - autocapture['_captureEvent']( - makeMouseEvent({ - target: elTarget, - }) - ) - expect(captureMock.mock.calls[0][1]['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') - }) - - it('does not capture href attribute values from password elements', () => { - const elTarget = document.createElement('span') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('input') - elGrandparent.appendChild(elParent) - elGrandparent.setAttribute('type', 'password') - autocapture['_captureEvent']( - makeMouseEvent({ - target: elTarget, - }) - ) - expect(captureMock.mock.calls[0][1]).not.toHaveProperty('attr__href') - }) - - it('does not capture href attribute values from hidden elements', () => { - const elTarget = document.createElement('span') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.appendChild(elParent) - elGrandparent.setAttribute('type', 'hidden') - autocapture['_captureEvent']( - makeMouseEvent({ - target: elTarget, - }) - ) - expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') - }) - - it('does not capture href attribute values that look like credit card numbers', () => { - const elTarget = document.createElement('span') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.appendChild(elParent) - elGrandparent.setAttribute('href', '4111111111111111') - autocapture['_captureEvent']( - makeMouseEvent({ - target: elTarget, - }) - ) - expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') - }) - - it('does not capture href attribute values that look like social-security numbers', () => { - const elTarget = document.createElement('span') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - const elGrandparent = document.createElement('a') - elGrandparent.appendChild(elParent) - elGrandparent.setAttribute('href', '123-58-1321') - autocapture['_captureEvent']( - makeMouseEvent({ - target: elTarget, - }) - ) - expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') - }) - - it('correctly identifies and formats text content', () => { - document.body.innerHTML = ` -
- -
-
-
- - -
-
-
-
- - - ` - const span1 = document.getElementById('span1') - const span2 = document.getElementById('span2') - const img2 = document.getElementById('img2') - - const e1 = makeMouseEvent({ - target: span2, - }) - captureMock.mockClear() - autocapture['_captureEvent'](e1) - - const props1 = captureMock.mock.calls[0][1] - const text1 = - "Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d" - expect(props1['$elements'][0]).toHaveProperty('$el_text', text1) - expect(props1['$el_text']).toEqual(text1) - - const e2 = makeMouseEvent({ - target: span1, - }) - captureMock.mockClear() - autocapture['_captureEvent'](e2) - const props2 = captureMock.mock.calls[0][1] - expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text') - expect(props2['$el_text']).toEqual('Some text') - - const e3 = makeMouseEvent({ - target: img2, - }) - captureMock.mockClear() - autocapture['_captureEvent'](e3) - const props3 = captureMock.mock.calls[0][1] - expect(props3['$elements'][0]).toHaveProperty('$el_text', '') - expect(props3).not.toHaveProperty('$el_text') - }) - - it('does not capture sensitive text content', () => { - // ^ valid credit card and social security numbers - document.body.innerHTML = ` -
- -
- - - ` - const button1 = document.getElementById('button1') - const button2 = document.getElementById('button2') - const button3 = document.getElementById('button3') - - const e1 = makeMouseEvent({ - target: button1, - }) - autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1] - expect(props1['$elements'][0]).toHaveProperty('$el_text') - expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - - const e2 = makeMouseEvent({ - target: button2, - }) - autocapture['_captureEvent'](e2) - const props2 = captureMock.mock.calls[0][1] - expect(props2['$elements'][0]).toHaveProperty('$el_text') - expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - - const e3 = makeMouseEvent({ - target: button3, - }) - autocapture['_captureEvent'](e3) - const props3 = captureMock.mock.calls[0][1] - expect(props3['$elements'][0]).toHaveProperty('$el_text') - expect(props3['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - }) - - it('should capture a submit event with form field props', () => { - const e = { - target: document.createElement('form'), - type: 'submit', - } as unknown as FormDataEvent - autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1] - expect(props['$event_type']).toBe('submit') - }) - - it('should capture a click event inside a form with form field props', () => { - const form = document.createElement('form') - const link = document.createElement('a') - const input = document.createElement('input') - input.name = 'test input' - input.value = 'test val' - form.appendChild(link) - form.appendChild(input) - const e = makeMouseEvent({ - target: link, - }) - autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1] - expect(props['$event_type']).toBe('click') - }) - - it('should capture a click event inside a shadowroot', () => { - const main_el = document.createElement('some-element') - const shadowRoot = main_el.attachShadow({ mode: 'open' }) - const button = document.createElement('a') - button.innerHTML = 'bla' - shadowRoot.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - autocapture['_captureEvent'](e) - expect(captureMock).toHaveBeenCalledTimes(1) - const props = captureMock.mock.calls[0][1] - expect(props['$event_type']).toBe('click') - }) - - it('should never capture an element with `ph-no-capture` class', () => { - const a = document.createElement('a') - const span = document.createElement('span') - a.appendChild(span) - autocapture['_captureEvent'](makeMouseEvent({ target: a })) - expect(captureMock).toHaveBeenCalledTimes(1) - - autocapture['_captureEvent'](makeMouseEvent({ target: span })) - expect(captureMock).toHaveBeenCalledTimes(2) - - captureMock.mockClear() - a.className = 'test1 ph-no-capture test2' - autocapture['_captureEvent'](makeMouseEvent({ target: a })) - expect(captureMock).toHaveBeenCalledTimes(0) - - autocapture['_captureEvent'](makeMouseEvent({ target: span })) - expect(captureMock).toHaveBeenCalledTimes(0) - }) - - it('does not capture any element attributes if mask_all_element_attributes is set', () => { - const dom = ` - - ` - - posthog.config.mask_all_element_attributes = true - - document.body.innerHTML = dom - const button1 = document.getElementById('button1') - - const e1 = makeMouseEvent({ - target: button1, - }) - autocapture['_captureEvent'](e1) - - const props1 = captureMock.mock.calls[0][1] - expect('attr__formmethod' in props1['$elements'][0]).toEqual(false) - }) - - it('does not capture any textContent if mask_all_text is set', () => { - const dom = ` - - Dont capture me! - - ` - posthog.config.mask_all_text = true - - document.body.innerHTML = dom - const a = document.getElementById('a1') - const e1 = makeMouseEvent({ - target: a, - }) - - autocapture['_captureEvent'](e1) - const props1 = captureMock.mock.calls[0][1] - - expect(props1['$elements'][0]).not.toHaveProperty('$el_text') - }) - - it('returns elementsChain instead of elements when set', () => { - const elTarget = document.createElement('a') - elTarget.setAttribute('href', 'http://test.com') - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - - const e = { - target: elTarget, - type: 'click', - } as unknown as MouseEvent - - autocapture.afterDecideResponse({ - elementsChainAsString: true, - } as DecideResponse) - - autocapture['_captureEvent'](e) - const props1 = captureMock.mock.calls[0][1] - - expect(props1['$elements_chain']).toBeDefined() - expect(props1['$elements']).toBeUndefined() - }) - - it('returns elementsChain correctly with newlines in css', () => { - const elTarget = document.createElement('a') - elTarget.setAttribute('href', 'http://test.com') - elTarget.setAttribute( - 'class', - '\ftest-class\n test-class2\ttest-class3 test-class4 \r\n test-class5' - ) - const elParent = document.createElement('span') - elParent.appendChild(elTarget) - - const e = { - target: elTarget, - type: 'click', - } as unknown as MouseEvent - - autocapture['_elementsChainAsString'] = true - autocapture['_captureEvent'](e) - const props1 = captureMock.mock.calls[0][1] - - expect(props1['$elements_chain']).toBe( - 'a.test-class.test-class2.test-class3.test-class4.test-class5:nth-child="1"nth-of-type="1"href="http://test.com"attr__href="http://test.com"attr__class="test-class test-class2 test-class3 test-class4 test-class5";span:nth-child="1"nth-of-type="1"' - ) - }) - }) - - describe('_addDomEventHandlers', () => { - beforeEach(() => { - document.title = 'test page' - posthog.config.mask_all_element_attributes = false - }) - - it('should capture click events', () => { - autocapture['_addDomEventHandlers']() - const button = document.createElement('button') - document.body.appendChild(button) - simulateClick(button) - simulateClick(button) - expect(captureMock).toHaveBeenCalledTimes(2) - expect(captureMock.mock.calls[0][0]).toBe('$autocapture') - expect(captureMock.mock.calls[0][1]['$event_type']).toBe('click') - expect(captureMock.mock.calls[1][0]).toBe('$autocapture') - expect(captureMock.mock.calls[1][1]['$event_type']).toBe('click') - }) - }) - - describe('afterDecideResponse()', () => { - beforeEach(() => { - document.title = 'test page' - - jest.spyOn(autocapture, '_addDomEventHandlers') - }) - - it('should be enabled before the decide response', () => { - expect(autocapture.isEnabled).toBe(true) - }) - - it('should be disabled before the decide response if opt out is in persistence', () => { - persistence.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] = true - expect(autocapture.isEnabled).toBe(false) - }) - - it('should be disabled before the decide response if client side opted out', () => { - posthog.config.autocapture = false - expect(autocapture.isEnabled).toBe(false) - }) - - it.each([ - // when client side is opted out, it is always off - [false, true, false], - [false, false, false], - // when client side is opted in, it is only on, if the remote does not opt out - [true, true, false], - [true, false, true], - ])( - 'when client side config is %p and remote opt out is %p - autocapture enabled should be %p', - (clientSideOptIn, serverSideOptOut, expected) => { - posthog.config.autocapture = clientSideOptIn - autocapture.afterDecideResponse({ - autocapture_opt_out: serverSideOptOut, - } as DecideResponse) - expect(autocapture.isEnabled).toBe(expected) - } - ) - - it('should call _addDomEventHandlders if autocapture is true', () => { - $autocapture_disabled_server_side = false - autocapture.afterDecideResponse({} as DecideResponse) - expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled() - }) - - it('should not call _addDomEventHandlders if autocapture is disabled', () => { - expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() - posthog.config = { - api_host: 'https://test.com', - token: 'testtoken', - autocapture: false, - } as PostHogConfig - $autocapture_disabled_server_side = true - - autocapture.afterDecideResponse({} as DecideResponse) - - expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() - }) - - it('should NOT call _addDomEventHandlders if the decide request fails', () => { - autocapture.afterDecideResponse({ - status: 0, - error: 'Bad HTTP status: 400 Bad Request', - } as unknown as DecideResponse) - - expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() - }) - - it('should NOT call _addDomEventHandlders when the token has already been initialized', () => { - $autocapture_disabled_server_side = false - autocapture.afterDecideResponse({} as DecideResponse) - expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) - - autocapture.afterDecideResponse({} as DecideResponse) - expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) - }) - }) - - describe('shouldCaptureDomEvent autocapture config', () => { - it('only capture urls which match the url regex allowlist', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('a') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config = { - url_allowlist: ['https://posthog.com/test/*'], - } - - window!.location = new URL('https://posthog.com/test/captured') as unknown as Location - - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - - window!.location = new URL('https://posthog.com/docs/not-captured') as unknown as Location - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) - }) - - it('an empty url regex allowlist does not match any url', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('a') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - url_allowlist: [], - } - - window!.location = new URL('https://posthog.com/test/captured') as unknown as Location - - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) - }) - - it('only capture event types which match the allowlist', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - dom_event_allowlist: ['click'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - - const autocapture_config_change: AutocaptureConfig = { - dom_event_allowlist: ['change'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) - }) - - it('an empty event type allowlist matches no events', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config = { - dom_event_allowlist: [], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) - }) - - it('only capture elements which match the allowlist', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - element_allowlist: ['button'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - - const autocapture_config_change: AutocaptureConfig = { - element_allowlist: ['a'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) - }) - - it('an empty event allowlist means we capture no elements', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - element_allowlist: [], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) - }) - - it('only capture elements which match the css allowlist', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.setAttribute('data-track', 'yes') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - css_selector_allowlist: ['[data-track="yes"]'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(true) - - const autocapture_config_change = { - css_selector_allowlist: ['[data-track="no"]'], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config_change)).toBe(false) - }) - - it('an empty css selector list captures no elements', () => { - const main_el = document.createElement('some-element') - const button = document.createElement('button') - button.setAttribute('data-track', 'yes') - button.innerHTML = 'bla' - main_el.appendChild(button) - const e = makeMouseEvent({ - target: main_el, - composedPath: () => [button, main_el], - }) - const autocapture_config: AutocaptureConfig = { - css_selector_allowlist: [], - } - expect(shouldCaptureDomEvent(button, e, autocapture_config)).toBe(false) - }) - }) -}) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index c1baf512a..a547aaebc 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -1,8 +1,7 @@ /// /* eslint-disable compat/compat */ -import sinon from 'sinon' -import { autocapture } from '../autocapture' +import { Autocapture } from '../autocapture-v2' import { shouldCaptureDomEvent } from '../autocapture-utils' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants' import { AutocaptureConfig, DecideResponse, PostHogConfig } from '../types' @@ -63,8 +62,11 @@ function setWindowTextSelection(s: string): void { describe('Autocapture system', () => { const originalWindowLocation = window!.location - let decideResponse: DecideResponse let $autocapture_disabled_server_side: boolean + let autocapture: Autocapture + let posthog: PostHog + let captureMock: jest.Mock + let persistence: PostHogPersistence beforeEach(() => { jest.spyOn(window!.console, 'log').mockImplementation() @@ -77,13 +79,21 @@ describe('Autocapture system', () => { value: new URL('https://example.com'), }) - autocapture._isDisabledServerSide = null - $autocapture_disabled_server_side = false - decideResponse = { + captureMock = jest.fn() + persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence + posthog = makePostHog({ config: { - enable_collect_everything: true, - }, - } as DecideResponse + api_host: 'https://test.com', + token: 'testtoken', + autocapture: true, + } as PostHogConfig, + capture: captureMock, + get_property: (property_key: string) => + property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, + persistence: persistence, + }) + + autocapture = new Autocapture(posthog) }) afterEach(() => { @@ -139,42 +149,42 @@ describe('Autocapture system', () => { }) it('should contain the proper tag name', () => { - const props = autocapture._getPropertiesFromElement(div, false, false) + const props = autocapture['_getPropertiesFromElement'](div, false, false) expect(props['tag_name']).toBe('div') }) it('should contain class list', () => { - const props = autocapture._getPropertiesFromElement(div, false, false) + const props = autocapture['_getPropertiesFromElement'](div, false, false) expect(props['classes']).toEqual(['class1', 'class2', 'class3']) }) it('should not collect input value', () => { - const props = autocapture._getPropertiesFromElement(input, false, false) + const props = autocapture['_getPropertiesFromElement'](input, false, false) expect(props['value']).toBeUndefined() }) it('should strip element value with class "ph-sensitive"', () => { - const props = autocapture._getPropertiesFromElement(sensitiveInput, false, false) + const props = autocapture['_getPropertiesFromElement'](sensitiveInput, false, false) expect(props['value']).toBeUndefined() }) it('should strip hidden element value', () => { - const props = autocapture._getPropertiesFromElement(hidden, false, false) + const props = autocapture['_getPropertiesFromElement'](hidden, false, false) expect(props['value']).toBeUndefined() }) it('should strip password element value', () => { - const props = autocapture._getPropertiesFromElement(password, false, false) + const props = autocapture['_getPropertiesFromElement'](password, false, false) expect(props['value']).toBeUndefined() }) it('should contain nth-of-type', () => { - const props = autocapture._getPropertiesFromElement(div, false, false) + const props = autocapture['_getPropertiesFromElement'](div, false, false) expect(props['nth_of_type']).toBe(2) }) it('should contain nth-child', () => { - const props = autocapture._getPropertiesFromElement(password, false, false) + const props = autocapture['_getPropertiesFromElement'](password, false, false) expect(props['nth_child']).toBe(7) }) @@ -182,32 +192,32 @@ describe('Autocapture system', () => { const angularDiv = document.createElement('div') angularDiv.setAttribute('_ngcontent-dpm-c448', '') angularDiv.setAttribute('_nghost-dpm-c448', '') - const props = autocapture._getPropertiesFromElement(angularDiv, false, false) + const props = autocapture['_getPropertiesFromElement'](angularDiv, false, false) expect(props['_ngcontent-dpm-c448']).toBeUndefined() expect(props['_nghost-dpm-c448']).toBeUndefined() }) it('should filter element attributes based on the ignorelist', () => { - autocapture.config = { + posthog.config.autocapture = { element_attribute_ignorelist: ['data-attr', 'data-attr-2'], } div.setAttribute('data-attr', 'value') div.setAttribute('data-attr-2', 'value') div.setAttribute('data-attr-3', 'value') - const props = autocapture._getPropertiesFromElement(div, false, false) + const props = autocapture['_getPropertiesFromElement'](div, false, false) expect(props['attr__data-attr']).toBeUndefined() expect(props['attr__data-attr-2']).toBeUndefined() expect(props['attr__data-attr-3']).toBe('value') }) it('an empty ignorelist does nothing', () => { - autocapture.config = { + posthog.config.autocapture = { element_attribute_ignorelist: [], } div.setAttribute('data-attr', 'value') div.setAttribute('data-attr-2', 'value') div.setAttribute('data-attr-3', 'value') - const props = autocapture._getPropertiesFromElement(div, false, false) + const props = autocapture['_getPropertiesFromElement'](div, false, false) expect(props['attr__data-attr']).toBe('value') expect(props['attr__data-attr-2']).toBe('value') expect(props['attr__data-attr-3']).toBe('value') @@ -265,7 +275,7 @@ describe('Autocapture system', () => { }) it('should collect multiple augments from elements', () => { - const props = autocapture._getAugmentPropertiesFromElement(div) + const props = autocapture['_getAugmentPropertiesFromElement'](div) expect(props['one-on-the-div']).toBe('one') expect(props['two-on-the-div']).toBe('two') expect(props['falsey-on-the-div']).toBe('0') @@ -273,22 +283,22 @@ describe('Autocapture system', () => { }) it('should collect augment from input value', () => { - const props = autocapture._getAugmentPropertiesFromElement(input) + const props = autocapture['_getAugmentPropertiesFromElement'](input) expect(props['on-the-input']).toBe('is on the input') }) it('should collect augment from input with class "ph-sensitive"', () => { - const props = autocapture._getAugmentPropertiesFromElement(sensitiveInput) + const props = autocapture['_getAugmentPropertiesFromElement'](sensitiveInput) expect(props['on-the-sensitive-input']).toBeUndefined() }) it('should not collect augment from the hidden element value', () => { - const props = autocapture._getAugmentPropertiesFromElement(hidden) + const props = autocapture['_getAugmentPropertiesFromElement'](hidden) expect(props).toStrictEqual({}) }) it('should collect augment from the password element value', () => { - const props = autocapture._getAugmentPropertiesFromElement(password) + const props = autocapture['_getAugmentPropertiesFromElement'](password) expect(props).toStrictEqual({}) }) }) @@ -317,15 +327,6 @@ describe('Autocapture system', () => { }) }) - describe('enabledForProject', () => { - it('should enable ce for the project with token "d" when 5 buckets are enabled out of 10', () => { - expect(autocapture.enabledForProject('d', 10, 5)).toBe(true) - }) - it('should NOT enable ce for the project with token "a" when 5 buckets are enabled out of 10', () => { - expect(autocapture.enabledForProject('a', 10, 5)).toBe(false) - }) - }) - describe('_previousElementSibling', () => { it('should return the adjacent sibling', () => { const div = document.createElement('div') @@ -333,7 +334,7 @@ describe('Autocapture system', () => { const child = document.createElement('div') div.appendChild(sibling) div.appendChild(child) - expect(autocapture._previousElementSibling(child)).toBe(sibling) + expect(autocapture['_previousElementSibling'](child)).toBe(sibling) }) it('should return the first child and not the immediately previous sibling (text)', () => { @@ -343,7 +344,7 @@ describe('Autocapture system', () => { div.appendChild(sibling) div.appendChild(document.createTextNode('some text')) div.appendChild(child) - expect(autocapture._previousElementSibling(child)).toBe(sibling) + expect(autocapture['_previousElementSibling'](child)).toBe(sibling) }) it('should return null when the previous sibling is a text node', () => { @@ -351,238 +352,28 @@ describe('Autocapture system', () => { const child = document.createElement('div') div.appendChild(document.createTextNode('some text')) div.appendChild(child) - expect(autocapture._previousElementSibling(child)).toBeNull() + expect(autocapture['_previousElementSibling'](child)).toBeNull() }) }) describe('_getDefaultProperties', () => { it('should return the default properties', () => { - expect(autocapture._getDefaultProperties('test')).toEqual({ + expect(autocapture['_getDefaultProperties']('test')).toEqual({ $event_type: 'test', $ce_version: 1, }) }) }) - describe('_getCustomProperties', () => { - let customProps - let noCustomProps - let capturedElem: HTMLDivElement - let capturedElemChild - let uncapturedElem: HTMLDivElement - let sensitiveInput: HTMLInputElement - let sensitiveDiv: HTMLDivElement - let prop1 - let prop2 - let prop3: HTMLDivElement - - beforeEach(() => { - capturedElem = document.createElement('div') - capturedElem.className = 'ce_event' - - capturedElemChild = document.createElement('span') - capturedElem.appendChild(capturedElemChild) - - uncapturedElem = document.createElement('div') - uncapturedElem.className = 'uncaptured_event' - - sensitiveInput = document.createElement('input') - sensitiveInput.className = 'sensitive_event' - - sensitiveDiv = document.createElement('div') - sensitiveDiv.className = 'sensitive_event' - - prop1 = document.createElement('div') - prop1.className = '_mp_test_property_1' - prop1.innerHTML = 'Test prop 1' - - prop2 = document.createElement('div') - prop2.className = '_mp_test_property_2' - prop2.innerHTML = 'Test prop 2' - - prop3 = document.createElement('div') - prop3.className = '_mp_test_property_3' - prop3.innerHTML = 'Test prop 3' - - document.body.appendChild(uncapturedElem) - document.body.appendChild(capturedElem) - document.body.appendChild(sensitiveInput) - document.body.appendChild(sensitiveDiv) - document.body.appendChild(prop1) - document.body.appendChild(prop2) - document.body.appendChild(prop3) - - autocapture._customProperties = [ - { - name: 'Custom Property 1', - css_selector: 'div._mp_test_property_1', - event_selectors: ['.ce_event'], - }, - { - name: 'Custom Property 2', - css_selector: 'div._mp_test_property_2', - event_selectors: ['.event_with_no_element'], - }, - { - name: 'Custom Property 3', - css_selector: 'div._mp_test_property_3', - event_selectors: ['.sensitive_event'], - }, - ] - }) - - it('should return custom properties for only matching element selectors', () => { - customProps = autocapture._getCustomProperties([capturedElem]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - }) - }) - - it('should return no custom properties for elements that do not match an event selector', () => { - noCustomProps = autocapture._getCustomProperties([uncapturedElem]) - expect(noCustomProps).toEqual({}) - }) - - it('should return no custom properties for sensitive elements', () => { - // test password field - sensitiveInput.setAttribute('type', 'password') - noCustomProps = autocapture._getCustomProperties([sensitiveInput]) - expect(noCustomProps).toEqual({}) - // verify that capturing the sensitive element along with another element only collects - // the non-sensitive element's custom properties - customProps = autocapture._getCustomProperties([capturedElem, sensitiveInput]) - expect(customProps).toEqual({ 'Custom Property 1': 'Test prop 1' }) - - // test hidden field - sensitiveInput.setAttribute('type', 'hidden') - noCustomProps = autocapture._getCustomProperties([sensitiveInput]) - expect(noCustomProps).toEqual({}) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveInput]) - expect(customProps).toEqual({ 'Custom Property 1': 'Test prop 1' }) - - // test field with sensitive-looking name - sensitiveInput.setAttribute('type', '') - sensitiveInput.setAttribute('name', 'cc') // cc assumed to indicate credit card field - noCustomProps = autocapture._getCustomProperties([sensitiveInput]) - expect(noCustomProps).toEqual({}) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveInput]) - expect(customProps).toEqual({ 'Custom Property 1': 'Test prop 1' }) - - // test field with sensitive-looking id - sensitiveInput.setAttribute('name', '') - sensitiveInput.setAttribute('id', 'cc') // cc assumed to indicate credit card field - noCustomProps = autocapture._getCustomProperties([sensitiveInput]) - expect(noCustomProps).toEqual({}) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveInput]) - expect(customProps).toEqual({ 'Custom Property 1': 'Test prop 1' }) - - // clean up - sensitiveInput.setAttribute('type', '') - sensitiveInput.setAttribute('name', '') - sensitiveInput.setAttribute('id', '') - }) - - it('should return no custom properties for element with sensitive values', () => { - // verify the base case DOES capture the custom property - customProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(customProps).toEqual({ 'Custom Property 3': 'Test prop 3' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': 'Test prop 3', - }) - - // test values that look like credit card numbers - prop3.innerHTML = '4111111111111111' // valid credit card number - noCustomProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(noCustomProps).toEqual({ 'Custom Property 3': '' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': '', - }) - prop3.innerHTML = '5105-1051-0510-5100' // valid credit card number - noCustomProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(noCustomProps).toEqual({ 'Custom Property 3': '' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': '', - }) - prop3.innerHTML = '1235-8132-1345-5891' // invalid credit card number - noCustomProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(noCustomProps).toEqual({ 'Custom Property 3': '1235-8132-1345-5891' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': '1235-8132-1345-5891', - }) - - // test values that look like social-security numbers - prop3.innerHTML = '123-58-1321' // valid SSN - noCustomProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(noCustomProps).toEqual({ 'Custom Property 3': '' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': '', - }) - prop3.innerHTML = '1235-81-321' // invalid SSN - noCustomProps = autocapture._getCustomProperties([sensitiveDiv]) - expect(noCustomProps).toEqual({ 'Custom Property 3': '1235-81-321' }) - customProps = autocapture._getCustomProperties([capturedElem, sensitiveDiv]) - expect(customProps).toEqual({ - 'Custom Property 1': 'Test prop 1', - 'Custom Property 3': '1235-81-321', - }) - - // clean up - prop3.innerHTML = 'Test prop 3' - }) - }) - describe('_captureEvent', () => { - let lib: PostHog - let sandbox: sinon.SinonSandbox - - const getCapturedProps = function (captureSpy: unknown) { - const captureArgs = (captureSpy as sinon.SinonSpy).args[0] - return captureArgs[1] - } - beforeEach(() => { - sandbox = sinon.createSandbox() - lib = makePostHog({ - capture: sandbox.spy(), - config: { - mask_all_element_attributes: false, - rageclick: true, - } as PostHogConfig, - }) - }) - - afterEach(() => { - sandbox.restore() + posthog.config.rageclick = true }) it('should add the custom property when an element matching any of the event selectors is clicked', () => { - lib = makePostHog({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - mask_all_element_attributes: false, - autocapture: true, - } as PostHogConfig, - capture: sandbox.spy(), - toolbar: { - maybeLoadToolbar: jest.fn(), - } as unknown as PostHog['toolbar'], - get_property: (property_key: string) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, - }) + posthog.config.mask_all_element_attributes = false $autocapture_disabled_server_side = false - autocapture.init(lib) - autocapture.afterDecideResponse(decideResponse, lib) + autocapture.afterDecideResponse({} as DecideResponse) const eventElement1 = document.createElement('div') const eventElement2 = document.createElement('div') @@ -597,22 +388,20 @@ describe('Autocapture system', () => { document.body.appendChild(eventElement2) document.body.appendChild(propertyElement) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) + expect(captureMock).toHaveBeenCalledTimes(0) simulateClick(eventElement1) simulateClick(eventElement2) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(2) - const captureArgs1 = (lib.capture as sinon.SinonSpy).args[0] - const captureArgs2 = (lib.capture as sinon.SinonSpy).args[1] + expect(captureMock).toHaveBeenCalledTimes(2) + const captureArgs1 = captureMock.mock.calls[0] + const captureArgs2 = captureMock.mock.calls[1] const eventType1 = captureArgs1[1]['my property name'] const eventType2 = captureArgs2[1]['my property name'] + console.warn(JSON.stringify(captureArgs1[1])) expect(eventType1).toBe('my property value') expect(eventType2).toBe('my property value') - ;(lib.capture as sinon.SinonSpy).resetHistory() }) it('should capture rageclick', () => { - autocapture.init(lib) - const elTarget = document.createElement('img') const elParent = document.createElement('span') elParent.appendChild(elTarget) @@ -625,11 +414,12 @@ describe('Autocapture system', () => { clientY: 5, }) Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) - autocapture._captureEvent(fakeEvent, lib) - autocapture._captureEvent(fakeEvent, lib) - autocapture._captureEvent(fakeEvent, lib) + autocapture['_captureEvent'](fakeEvent) + autocapture['_captureEvent'](fakeEvent) + autocapture['_captureEvent'](fakeEvent) - expect((lib.capture as sinon.SinonSpy).args.map((args) => args[0])).toEqual([ + expect(captureMock).toHaveBeenCalledTimes(4) + expect(captureMock.mock.calls.map((args) => args[0])).toEqual([ '$autocapture', '$autocapture', '$rageclick', @@ -641,8 +431,6 @@ describe('Autocapture system', () => { let elTarget: HTMLDivElement beforeEach(() => { - autocapture.init(lib) - elTarget = document.createElement('div') elTarget.innerText = 'test' const elParent = document.createElement('div') @@ -658,13 +446,12 @@ describe('Autocapture system', () => { setWindowTextSelection('copy this test') - autocapture._captureEvent(fakeEvent, lib, '$copy_autocapture') + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = (lib.capture as sinon.SinonSpy).args - expect(spyArgs.length).toBe(1) - expect(spyArgs[0][0]).toEqual('$copy_autocapture') - expect(spyArgs[0][1]).toHaveProperty('$selected_content', 'copy this test') - expect(spyArgs[0][1]).toHaveProperty('$copy_type', 'copy') + expect(captureMock).toHaveBeenCalledTimes(1) + expect(captureMock.mock.calls[0][0]).toEqual('$copy_autocapture') + expect(captureMock.mock.calls[0][1]).toHaveProperty('$selected_content', 'copy this test') + expect(captureMock.mock.calls[0][1]).toHaveProperty('$copy_type', 'copy') }) it('should capture cut', () => { @@ -676,9 +463,9 @@ describe('Autocapture system', () => { setWindowTextSelection('cut this test') - autocapture._captureEvent(fakeEvent, lib, '$copy_autocapture') + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = (lib.capture as sinon.SinonSpy).args + const spyArgs = captureMock.mock.calls expect(spyArgs.length).toBe(1) expect(spyArgs[0][0]).toEqual('$copy_autocapture') expect(spyArgs[0][1]).toHaveProperty('$selected_content', 'cut this test') @@ -694,9 +481,9 @@ describe('Autocapture system', () => { setWindowTextSelection('') - autocapture._captureEvent(fakeEvent, lib, '$copy_autocapture') + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = (lib.capture as sinon.SinonSpy).args + const spyArgs = captureMock.mock.calls expect(spyArgs.length).toBe(0) }) @@ -710,16 +497,14 @@ describe('Autocapture system', () => { // oh no, a social security number! setWindowTextSelection('123-45-6789') - autocapture._captureEvent(fakeEvent, lib, '$copy_autocapture') + autocapture['_captureEvent'](fakeEvent, '$copy_autocapture') - const spyArgs = (lib.capture as sinon.SinonSpy).args + const spyArgs = captureMock.mock.calls expect(spyArgs.length).toBe(0) }) }) it('should capture augment properties', () => { - autocapture.init(lib) - const elTarget = document.createElement('img') elTarget.setAttribute('data-ph-capture-attribute-target-augment', 'the target') const elParent = document.createElement('span') @@ -734,31 +519,16 @@ describe('Autocapture system', () => { clientY: 5, }) Object.setPrototypeOf(fakeEvent, MouseEvent.prototype) - autocapture._captureEvent(fakeEvent, lib) + autocapture['_captureEvent'](fakeEvent) - const captureProperties = (lib.capture as sinon.SinonSpy).args[0][1] + const captureProperties = captureMock.mock.calls[0][1] expect(captureProperties).toHaveProperty('target-augment', 'the target') expect(captureProperties).toHaveProperty('parent-augment', 'the parent') }) it('should not capture events when config returns false, when an element matching any of the event selectors is clicked', () => { - lib = makePostHog({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - mask_all_element_attributes: false, - autocapture: false, - } as PostHogConfig, - capture: sandbox.spy(), - toolbar: { - maybeLoadToolbar: jest.fn(), - } as unknown as PostHog['toolbar'], - get_property: (property_key: string) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, - }) - - autocapture.init(lib) - autocapture.afterDecideResponse(decideResponse, lib) + posthog.config.autocapture = false + autocapture.afterDecideResponse({} as DecideResponse) const eventElement1 = document.createElement('div') const eventElement2 = document.createElement('div') @@ -773,41 +543,23 @@ describe('Autocapture system', () => { document.body.appendChild(eventElement2) document.body.appendChild(propertyElement) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) + expect(captureMock).toHaveBeenCalledTimes(0) simulateClick(eventElement1) simulateClick(eventElement2) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) - ;(lib.capture as sinon.SinonSpy).resetHistory() + expect(captureMock).toHaveBeenCalledTimes(0) }) it('should not capture events when config returns true but server setting is disabled', () => { - lib = makePostHog({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - mask_all_element_attributes: false, - autocapture: true, - } as PostHogConfig, - capture: sandbox.spy(), - toolbar: { - maybeLoadToolbar: jest.fn(), - } as unknown as PostHog['toolbar'], - get_property: (property_key: string) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, - }) - - // TODO this appears to have no effect on the test 🤷 - $autocapture_disabled_server_side = true - autocapture.init(lib) - autocapture.afterDecideResponse(decideResponse, lib) + autocapture.afterDecideResponse({ + autocapture_opt_out: true, + } as DecideResponse) const eventElement = document.createElement('a') document.body.appendChild(eventElement) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) + expect(captureMock).toHaveBeenCalledTimes(0) simulateClick(eventElement) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) - ;(lib.capture as sinon.SinonSpy).resetHistory() + expect(captureMock).toHaveBeenCalledTimes(0) }) it('includes necessary metadata as properties when capturing an event', () => { @@ -823,9 +575,9 @@ describe('Autocapture system', () => { const e = makeMouseEvent({ target: elTarget, }) - autocapture._captureEvent(e, lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - const captureArgs = (lib.capture as sinon.SinonSpy).args[0] + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const captureArgs = captureMock.mock.calls[0] const event = captureArgs[0] const props = captureArgs[1] expect(event).toBe('$autocapture') @@ -851,9 +603,9 @@ describe('Autocapture system', () => { const e = makeMouseEvent({ target: elTarget, }) - autocapture._captureEvent(e, lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - const captureArgs = (lib.capture as sinon.SinonSpy).args[0] + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const captureArgs = captureMock.mock.calls[0] const props = captureArgs[1] expect(longString).toBe('prop'.repeat(400)) expect(props['$elements'][0]).toHaveProperty('attr__data-props', 'prop'.repeat(256) + '...') @@ -866,13 +618,12 @@ describe('Autocapture system', () => { const elGrandparent = document.createElement('a') elGrandparent.setAttribute('href', 'https://test.com') elGrandparent.appendChild(elParent) - autocapture._captureEvent( + autocapture['_captureEvent']( makeMouseEvent({ target: elTarget, - }), - lib + }) ) - expect(getCapturedProps(lib.capture)['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') + expect(captureMock.mock.calls[0][1]['$elements'][0]).toHaveProperty('attr__href', 'https://test.com') }) it('does not capture href attribute values from password elements', () => { @@ -882,13 +633,12 @@ describe('Autocapture system', () => { const elGrandparent = document.createElement('input') elGrandparent.appendChild(elParent) elGrandparent.setAttribute('type', 'password') - autocapture._captureEvent( + autocapture['_captureEvent']( makeMouseEvent({ target: elTarget, - }), - lib + }) ) - expect(getCapturedProps(lib.capture)).not.toHaveProperty('attr__href') + expect(captureMock.mock.calls[0][1]).not.toHaveProperty('attr__href') }) it('does not capture href attribute values from hidden elements', () => { @@ -898,13 +648,12 @@ describe('Autocapture system', () => { const elGrandparent = document.createElement('a') elGrandparent.appendChild(elParent) elGrandparent.setAttribute('type', 'hidden') - autocapture._captureEvent( + autocapture['_captureEvent']( makeMouseEvent({ target: elTarget, - }), - lib + }) ) - expect(getCapturedProps(lib.capture)['$elements'][0]).not.toHaveProperty('attr__href') + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') }) it('does not capture href attribute values that look like credit card numbers', () => { @@ -914,13 +663,12 @@ describe('Autocapture system', () => { const elGrandparent = document.createElement('a') elGrandparent.appendChild(elParent) elGrandparent.setAttribute('href', '4111111111111111') - autocapture._captureEvent( + autocapture['_captureEvent']( makeMouseEvent({ target: elTarget, - }), - lib + }) ) - expect(getCapturedProps(lib.capture)['$elements'][0]).not.toHaveProperty('attr__href') + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') }) it('does not capture href attribute values that look like social-security numbers', () => { @@ -930,13 +678,12 @@ describe('Autocapture system', () => { const elGrandparent = document.createElement('a') elGrandparent.appendChild(elParent) elGrandparent.setAttribute('href', '123-58-1321') - autocapture._captureEvent( + autocapture['_captureEvent']( makeMouseEvent({ target: elTarget, - }), - lib + }) ) - expect(getCapturedProps(lib.capture)['$elements'][0]).not.toHaveProperty('attr__href') + expect(captureMock.mock.calls[0][1]['$elements'][0]).not.toHaveProperty('attr__href') }) it('correctly identifies and formats text content', () => { @@ -977,29 +724,30 @@ describe('Autocapture system', () => { const e1 = makeMouseEvent({ target: span2, }) - autocapture._captureEvent(e1, lib) + captureMock.mockClear() + autocapture['_captureEvent'](e1) - const props1 = getCapturedProps(lib.capture) + const props1 = captureMock.mock.calls[0][1] const text1 = "Some super duper really long Text with new lines that we'll strip out and also we will want to make this text shorter since it's not likely people really care about text content that's super long and it also takes up more space and bandwidth. Some super d" expect(props1['$elements'][0]).toHaveProperty('$el_text', text1) expect(props1['$el_text']).toEqual(text1) - ;(lib.capture as sinon.SinonSpy).resetHistory() const e2 = makeMouseEvent({ target: span1, }) - autocapture._captureEvent(e2, lib) - const props2 = getCapturedProps(lib.capture) + captureMock.mockClear() + autocapture['_captureEvent'](e2) + const props2 = captureMock.mock.calls[0][1] expect(props2['$elements'][0]).toHaveProperty('$el_text', 'Some text') expect(props2['$el_text']).toEqual('Some text') - ;(lib.capture as sinon.SinonSpy).resetHistory() const e3 = makeMouseEvent({ target: img2, }) - autocapture._captureEvent(e3, lib) - const props3 = getCapturedProps(lib.capture) + captureMock.mockClear() + autocapture['_captureEvent'](e3) + const props3 = captureMock.mock.calls[0][1] expect(props3['$elements'][0]).toHaveProperty('$el_text', '') expect(props3).not.toHaveProperty('$el_text') }) @@ -1026,26 +774,24 @@ describe('Autocapture system', () => { const e1 = makeMouseEvent({ target: button1, }) - autocapture._captureEvent(e1, lib) - const props1 = getCapturedProps(lib.capture) + autocapture['_captureEvent'](e1) + const props1 = captureMock.mock.calls[0][1] expect(props1['$elements'][0]).toHaveProperty('$el_text') expect(props1['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - ;(lib.capture as sinon.SinonSpy).resetHistory() const e2 = makeMouseEvent({ target: button2, }) - autocapture._captureEvent(e2, lib) - const props2 = getCapturedProps(lib.capture) + autocapture['_captureEvent'](e2) + const props2 = captureMock.mock.calls[0][1] expect(props2['$elements'][0]).toHaveProperty('$el_text') expect(props2['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) - ;(lib.capture as sinon.SinonSpy).resetHistory() const e3 = makeMouseEvent({ target: button3, }) - autocapture._captureEvent(e3, lib) - const props3 = getCapturedProps(lib.capture) + autocapture['_captureEvent'](e3) + const props3 = captureMock.mock.calls[0][1] expect(props3['$elements'][0]).toHaveProperty('$el_text') expect(props3['$elements'][0]['$el_text']).toMatch(/Why\s+hello\s+there/) }) @@ -1055,9 +801,9 @@ describe('Autocapture system', () => { target: document.createElement('form'), type: 'submit', } as unknown as FormDataEvent - autocapture._captureEvent(e, lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - const props = getCapturedProps(lib.capture) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] expect(props['$event_type']).toBe('submit') }) @@ -1072,9 +818,9 @@ describe('Autocapture system', () => { const e = makeMouseEvent({ target: link, }) - autocapture._captureEvent(e, lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - const props = getCapturedProps(lib.capture as sinon.SinonSpy) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] expect(props['$event_type']).toBe('click') }) @@ -1088,9 +834,9 @@ describe('Autocapture system', () => { target: main_el, composedPath: () => [button, main_el], }) - autocapture._captureEvent(e, lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - const props = getCapturedProps(lib.capture) + autocapture['_captureEvent'](e) + expect(captureMock).toHaveBeenCalledTimes(1) + const props = captureMock.mock.calls[0][1] expect(props['$event_type']).toBe('click') }) @@ -1098,20 +844,19 @@ describe('Autocapture system', () => { const a = document.createElement('a') const span = document.createElement('span') a.appendChild(span) - autocapture._captureEvent(makeMouseEvent({ target: a }), lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - ;(lib.capture as sinon.SinonSpy).resetHistory() + autocapture['_captureEvent'](makeMouseEvent({ target: a })) + expect(captureMock).toHaveBeenCalledTimes(1) - autocapture._captureEvent(makeMouseEvent({ target: span }), lib) - expect((lib.capture as sinon.SinonSpy).calledOnce).toBe(true) - ;(lib.capture as sinon.SinonSpy).resetHistory() + autocapture['_captureEvent'](makeMouseEvent({ target: span })) + expect(captureMock).toHaveBeenCalledTimes(2) + captureMock.mockClear() a.className = 'test1 ph-no-capture test2' - autocapture._captureEvent(makeMouseEvent({ target: a }), lib) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) + autocapture['_captureEvent'](makeMouseEvent({ target: a })) + expect(captureMock).toHaveBeenCalledTimes(0) - autocapture._captureEvent(makeMouseEvent({ target: span }), lib) - expect((lib.capture as sinon.SinonSpy).callCount).toBe(0) + autocapture['_captureEvent'](makeMouseEvent({ target: span })) + expect(captureMock).toHaveBeenCalledTimes(0) }) it('does not capture any element attributes if mask_all_element_attributes is set', () => { @@ -1121,13 +866,7 @@ describe('Autocapture system', () => { ` - const newLib = makePostHog({ - ...lib, - config: { - ...lib.config, - mask_all_element_attributes: true, - }, - }) + posthog.config.mask_all_element_attributes = true document.body.innerHTML = dom const button1 = document.getElementById('button1') @@ -1135,9 +874,9 @@ describe('Autocapture system', () => { const e1 = makeMouseEvent({ target: button1, }) - autocapture._captureEvent(e1, newLib) + autocapture['_captureEvent'](e1) - const props1 = getCapturedProps(newLib.capture) + const props1 = captureMock.mock.calls[0][1] expect('attr__formmethod' in props1['$elements'][0]).toEqual(false) }) @@ -1147,24 +886,16 @@ describe('Autocapture system', () => { Dont capture me! ` - - const newLib = makePostHog({ - ...lib, - config: { - ...lib.config, - mask_all_text: true, - }, - }) + posthog.config.mask_all_text = true document.body.innerHTML = dom const a = document.getElementById('a1') - const e1 = makeMouseEvent({ target: a, }) - autocapture._captureEvent(e1, newLib) - const props1 = getCapturedProps(newLib.capture) + autocapture['_captureEvent'](e1) + const props1 = captureMock.mock.calls[0][1] expect(props1['$elements'][0]).not.toHaveProperty('$el_text') }) @@ -1180,13 +911,12 @@ describe('Autocapture system', () => { type: 'click', } as unknown as MouseEvent - const newLib = { - ...lib, + autocapture.afterDecideResponse({ elementsChainAsString: true, - } as PostHog + } as DecideResponse) - autocapture._captureEvent(e, newLib) - const props1 = getCapturedProps(newLib.capture) + autocapture['_captureEvent'](e) + const props1 = captureMock.mock.calls[0][1] expect(props1['$elements_chain']).toBeDefined() expect(props1['$elements']).toBeUndefined() @@ -1207,13 +937,9 @@ describe('Autocapture system', () => { type: 'click', } as unknown as MouseEvent - const newLib = { - ...lib, - elementsChainAsString: true, - } as PostHog - - autocapture._captureEvent(e, newLib) - const props1 = getCapturedProps(newLib.capture) + autocapture['_elementsChainAsString'] = true + autocapture['_captureEvent'](e) + const props1 = captureMock.mock.calls[0][1] expect(props1['$elements_chain']).toBe( 'a.test-class.test-class2.test-class3.test-class4.test-class5:nth-child="1"nth-of-type="1"href="http://test.com"attr__href="http://test.com"attr__class="test-class test-class2 test-class3 test-class4 test-class5";span:nth-child="1"nth-of-type="1"' @@ -1222,81 +948,44 @@ describe('Autocapture system', () => { }) describe('_addDomEventHandlers', () => { - const lib = makePostHog({ - capture: sinon.spy(), - config: { - mask_all_element_attributes: false, - } as PostHogConfig, - }) - beforeEach(() => { document.title = 'test page' - autocapture._addDomEventHandlers(lib) - ;(lib.capture as sinon.SinonSpy).resetHistory() + posthog.config.mask_all_element_attributes = false }) it('should capture click events', () => { + autocapture['_addDomEventHandlers']() const button = document.createElement('button') document.body.appendChild(button) simulateClick(button) simulateClick(button) - expect(true).toBe((lib.capture as sinon.SinonSpy).calledTwice) - const captureArgs1 = (lib.capture as sinon.SinonSpy).args[0] - const captureArgs2 = (lib.capture as sinon.SinonSpy).args[1] - const eventType1 = captureArgs1[1]['$event_type'] - const eventType2 = captureArgs2[1]['$event_type'] - expect(eventType1).toBe('click') - expect(eventType2).toBe('click') - ;(lib.capture as sinon.SinonSpy).resetHistory() + expect(captureMock).toHaveBeenCalledTimes(2) + expect(captureMock.mock.calls[0][0]).toBe('$autocapture') + expect(captureMock.mock.calls[0][1]['$event_type']).toBe('click') + expect(captureMock.mock.calls[1][0]).toBe('$autocapture') + expect(captureMock.mock.calls[1][1]['$event_type']).toBe('click') }) }) describe('afterDecideResponse()', () => { - let posthog: PostHog - let persistence: PostHogPersistence - beforeEach(() => { document.title = 'test page' - autocapture._initializedTokens = [] - - persistence = { props: {}, register: jest.fn() } as unknown as PostHogPersistence - decideResponse = { config: { enable_collect_everything: true } } as DecideResponse - - posthog = makePostHog({ - config: { - api_host: 'https://test.com', - token: 'testtoken', - autocapture: true, - } as PostHogConfig, - capture: jest.fn(), - get_property: (property_key: string) => - property_key === AUTOCAPTURE_DISABLED_SERVER_SIDE ? $autocapture_disabled_server_side : undefined, - persistence: persistence, - }) jest.spyOn(autocapture, '_addDomEventHandlers') }) it('should be enabled before the decide response', () => { - // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(posthog) - expect(autocapture._isAutocaptureEnabled).toBe(true) + expect(autocapture.isEnabled).toBe(true) }) it('should be disabled before the decide response if opt out is in persistence', () => { persistence.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] = true - - // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(posthog) - expect(autocapture._isAutocaptureEnabled).toBe(false) + expect(autocapture.isEnabled).toBe(false) }) it('should be disabled before the decide response if client side opted out', () => { posthog.config.autocapture = false - - // _setIsAutocaptureEnabled is called during init - autocapture._setIsAutocaptureEnabled(posthog) - expect(autocapture._isAutocaptureEnabled).toBe(false) + expect(autocapture.isEnabled).toBe(false) }) it.each([ @@ -1310,24 +999,21 @@ describe('Autocapture system', () => { 'when client side config is %p and remote opt out is %p - autocapture enabled should be %p', (clientSideOptIn, serverSideOptOut, expected) => { posthog.config.autocapture = clientSideOptIn - decideResponse = { - config: { enable_collect_everything: true }, + autocapture.afterDecideResponse({ autocapture_opt_out: serverSideOptOut, - } as DecideResponse - autocapture.afterDecideResponse(decideResponse, posthog) - expect(autocapture._isAutocaptureEnabled).toBe(expected) + } as DecideResponse) + expect(autocapture.isEnabled).toBe(expected) } ) it('should call _addDomEventHandlders if autocapture is true', () => { $autocapture_disabled_server_side = false - - autocapture.afterDecideResponse(decideResponse, posthog) - - expect(autocapture._addDomEventHandlers).toHaveBeenCalled() + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalled() }) it('should not call _addDomEventHandlders if autocapture is disabled', () => { + expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() posthog.config = { api_host: 'https://test.com', token: 'testtoken', @@ -1335,42 +1021,18 @@ describe('Autocapture system', () => { } as PostHogConfig $autocapture_disabled_server_side = true - autocapture.afterDecideResponse(decideResponse, posthog) + autocapture.afterDecideResponse({} as DecideResponse) - expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() - }) - - it('should NOT call _addDomEventHandlders if the decide request fails', () => { - decideResponse = { status: 0, error: 'Bad HTTP status: 400 Bad Request' } as unknown as DecideResponse - - autocapture.afterDecideResponse(decideResponse, posthog) - - expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() - }) - - it('should NOT call _addDomEventHandlders when enable_collect_everything is "false"', () => { - decideResponse = { config: { enable_collect_everything: false } } as DecideResponse - - autocapture.afterDecideResponse(decideResponse, posthog) - - expect(autocapture._addDomEventHandlers).not.toHaveBeenCalled() + expect(autocapture['_addDomEventHandlers']).not.toHaveBeenCalled() }) it('should NOT call _addDomEventHandlders when the token has already been initialized', () => { $autocapture_disabled_server_side = false - autocapture.afterDecideResponse(decideResponse, posthog) - expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(1) + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) - autocapture.afterDecideResponse(decideResponse, posthog) - expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(1) - - posthog.config = { - api_host: 'https://test.com', - token: 'anotherproject', - autocapture: true, - } as PostHogConfig - autocapture.afterDecideResponse(decideResponse, posthog) - expect(autocapture._addDomEventHandlers).toHaveBeenCalledTimes(2) + autocapture.afterDecideResponse({} as DecideResponse) + expect(autocapture['_addDomEventHandlers']).toHaveBeenCalledTimes(1) }) }) diff --git a/src/__tests__/extensions/rageclick.test.ts b/src/__tests__/extensions/rageclick.test.ts index e1c71c819..de5014079 100644 --- a/src/__tests__/extensions/rageclick.test.ts +++ b/src/__tests__/extensions/rageclick.test.ts @@ -5,7 +5,7 @@ describe('RageClick()', () => { describe('when enabled', () => { beforeEach(() => { - instance = new RageClick(true) + instance = new RageClick() }) it('identifies some rage clicking', () => { @@ -73,14 +73,4 @@ describe('RageClick()', () => { expect(rageClickDetected).toBeFalsy() }) }) - - test('does not capture rage clicks when disabled', () => { - instance = new RageClick(false) - - instance.isRageClick(5, 5, 10) - instance.isRageClick(5, 5, 20) - const rageClickDetected = instance.isRageClick(5, 5, 40) - - expect(rageClickDetected).toBeFalsy() - }) }) From fcc41fdcda8fdd975b03cac22da4ca8f2be0355c Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 11:54:38 +0200 Subject: [PATCH 03/41] Fixes --- src/__tests__/autocapture.test.ts | 2 +- src/autocapture-v2.ts | 358 ------------------------------ src/autocapture.ts | 278 +++++++++-------------- src/posthog-core.ts | 4 +- 4 files changed, 108 insertions(+), 534 deletions(-) delete mode 100644 src/autocapture-v2.ts diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index a547aaebc..7f0fb48c4 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -1,7 +1,7 @@ /// /* eslint-disable compat/compat */ -import { Autocapture } from '../autocapture-v2' +import { Autocapture } from '../autocapture' import { shouldCaptureDomEvent } from '../autocapture-utils' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from '../constants' import { AutocaptureConfig, DecideResponse, PostHogConfig } from '../types' diff --git a/src/autocapture-v2.ts b/src/autocapture-v2.ts deleted file mode 100644 index 893cb5773..000000000 --- a/src/autocapture-v2.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { _each, _extend, _includes, _register_event } from './utils' -import { - autocaptureCompatibleElements, - getClassNames, - getDirectAndNestedSpanText, - getElementsChainString, - getSafeText, - isAngularStyleAttr, - isDocumentFragment, - isElementNode, - isSensitiveElement, - isTag, - isTextNode, - makeSafeText, - shouldCaptureDomEvent, - shouldCaptureElement, - shouldCaptureValue, - splitClassString, -} from './autocapture-utils' -import RageClick from './extensions/rageclick' -import { AutocaptureConfig, DecideResponse, Properties } from './types' -import { PostHog } from './posthog-core' -import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' - -import { _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' -import { logger } from './utils/logger' -import { document, window } from './utils/globals' - -const COPY_AUTOCAPTURE_EVENT = '$copy_autocapture' - -function limitText(length: number, text: string): string { - if (text.length > length) { - return text.slice(0, length) + '...' - } - return text -} - -export class Autocapture { - instance: PostHog - _initialized: boolean = false - _isDisabledServerSide: boolean | null = null - rageclicks = new RageClick() - _elementsChainAsString = false - - constructor(instance: PostHog) { - this.instance = instance - - // precompile the regex - // TODO: This doesn't work - we can't override the config like this - if (this.config?.url_allowlist) { - this.config.url_allowlist = this.config.url_allowlist.map((url) => new RegExp(url)) - } - - // if (autocapture._isAutocaptureEnabled) { - // this.__autocapture = this.config.autocapture - // if (!autocapture.isBrowserSupported()) { - // this.__autocapture = false - // logger.info('Disabling Automatic Event Collection because this browser is not supported') - // } else { - // autocapture.init(this) - // } - // } - } - - private get config(): AutocaptureConfig { - return _isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {} - } - - private _addDomEventHandlers(): void { - if (!window || !document) { - return - } - const handler = (e: Event) => { - e = e || window?.event - this._captureEvent(e) - } - - const copiedTextHandler = (e: Event) => { - e = e || window?.event - this._captureEvent(e, COPY_AUTOCAPTURE_EVENT) - } - - _register_event(document, 'submit', handler, false, true) - _register_event(document, 'change', handler, false, true) - _register_event(document, 'click', handler, false, true) - - if (this.config.capture_copied_text) { - _register_event(document, 'copy', copiedTextHandler, false, true) - _register_event(document, 'cut', copiedTextHandler, false, true) - } - } - - public afterDecideResponse(response: DecideResponse) { - if (this._initialized) { - logger.info('autocapture already initialized') - return - } - - if (this.instance.persistence) { - this.instance.persistence.register({ - [AUTOCAPTURE_DISABLED_SERVER_SIDE]: !!response['autocapture_opt_out'], - }) - } - // store this in-memory incase persistence is disabled - this._isDisabledServerSide = !!response['autocapture_opt_out'] - - if (response.elementsChainAsString) { - this._elementsChainAsString = response.elementsChainAsString - } - - if (this.isEnabled) { - this._addDomEventHandlers() - this._initialized = true - } - } - - public get isEnabled(): boolean { - const disabled_server_side = _isNull(this._isDisabledServerSide) - ? !!this.instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] - : this._isDisabledServerSide - const enabled_client_side = !!this.instance.config.autocapture - return enabled_client_side && !disabled_server_side - } - - private _previousElementSibling(el: Element): Element | null { - if (el.previousElementSibling) { - return el.previousElementSibling - } - let _el: Element | null = el - do { - _el = _el.previousSibling as Element | null // resolves to ChildNode->Node, which is Element's parent class - } while (_el && !isElementNode(_el)) - return _el - } - - private _getAugmentPropertiesFromElement(elem: Element): Properties { - const shouldCaptureEl = shouldCaptureElement(elem) - if (!shouldCaptureEl) { - return {} - } - - const props: Properties = {} - - _each(elem.attributes, function (attr: Attr) { - if (attr.name.indexOf('data-ph-capture-attribute') === 0) { - const propertyKey = attr.name.replace('data-ph-capture-attribute-', '') - const propertyValue = attr.value - if (propertyKey && propertyValue && shouldCaptureValue(propertyValue)) { - props[propertyKey] = propertyValue - } - } - }) - return props - } - - private _getPropertiesFromElement(elem: Element, maskInputs: boolean, maskText: boolean): Properties { - const tag_name = elem.tagName.toLowerCase() - const props: Properties = { - tag_name: tag_name, - } - if (autocaptureCompatibleElements.indexOf(tag_name) > -1 && !maskText) { - if (tag_name.toLowerCase() === 'a' || tag_name.toLowerCase() === 'button') { - props['$el_text'] = limitText(1024, getDirectAndNestedSpanText(elem)) - } else { - props['$el_text'] = limitText(1024, getSafeText(elem)) - } - } - - const classes = getClassNames(elem) - if (classes.length > 0) - props['classes'] = classes.filter(function (c) { - return c !== '' - }) - - // capture the deny list here because this not-a-class class makes it tricky to use this.config in the function below - const elementAttributeIgnorelist = this.config?.element_attribute_ignorelist - _each(elem.attributes, function (attr: Attr) { - // Only capture attributes we know are safe - if (isSensitiveElement(elem) && ['name', 'id', 'class'].indexOf(attr.name) === -1) return - - if (elementAttributeIgnorelist?.includes(attr.name)) return - - if (!maskInputs && shouldCaptureValue(attr.value) && !isAngularStyleAttr(attr.name)) { - let value = attr.value - if (attr.name === 'class') { - // html attributes can _technically_ contain linebreaks, - // but we're very intolerant of them in the class string, - // so we strip them. - value = splitClassString(value).join(' ') - } - props['attr__' + attr.name] = limitText(1024, value) - } - }) - - let nthChild = 1 - let nthOfType = 1 - let currentElem: Element | null = elem - while ((currentElem = this._previousElementSibling(currentElem))) { - // eslint-disable-line no-cond-assign - nthChild++ - if (currentElem.tagName === elem.tagName) { - nthOfType++ - } - } - props['nth_child'] = nthChild - props['nth_of_type'] = nthOfType - - return props - } - - private _getDefaultProperties(eventType: string): Properties { - return { - $event_type: eventType, - $ce_version: 1, - } - } - - private _getEventTarget(e: Event): Element | null { - // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes - if (_isUndefined(e.target)) { - return (e.srcElement as Element) || null - } else { - if ((e.target as HTMLElement)?.shadowRoot) { - return (e.composedPath()[0] as Element) || null - } - return (e.target as Element) || null - } - } - - private _captureEvent(e: Event, eventName = '$autocapture', extraProps?: Properties): boolean | void { - /*** Don't mess with this code without running IE8 tests on it ***/ - let target = this._getEventTarget(e) - if (isTextNode(target)) { - // defeat Safari bug (see: http://www.quirksmode.org/js/events_properties.html) - target = (target.parentNode || null) as Element | null - } - - if (eventName === '$autocapture' && e.type === 'click' && e instanceof MouseEvent) { - if ( - this.instance.config.rageclick && - this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime()) - ) { - this._captureEvent(e, '$rageclick') - } - } - - const isCopyAutocapture = eventName === COPY_AUTOCAPTURE_EVENT - if ( - target && - shouldCaptureDomEvent( - target, - e, - this.config, - // mostly this method cares about the target element, but in the case of copy events, - // we want some of the work this check does without insisting on the target element's type - isCopyAutocapture, - // we also don't want to restrict copy checks to clicks, - // so we pass that knowledge in here, rather than add the logic inside the check - isCopyAutocapture ? ['copy', 'cut'] : undefined - ) - ) { - const targetElementList = [target] - let curEl = target - while (curEl.parentNode && !isTag(curEl, 'body')) { - if (isDocumentFragment(curEl.parentNode)) { - targetElementList.push((curEl.parentNode as any).host) - curEl = (curEl.parentNode as any).host - continue - } - targetElementList.push(curEl.parentNode as Element) - curEl = curEl.parentNode as Element - } - - const elementsJson: Properties[] = [] - const autocaptureAugmentProperties: Properties = {} - let href, - explicitNoCapture = false - _each(targetElementList, (el) => { - const shouldCaptureEl = shouldCaptureElement(el) - - // if the element or a parent element is an anchor tag - // include the href as a property - if (el.tagName.toLowerCase() === 'a') { - href = el.getAttribute('href') - href = shouldCaptureEl && shouldCaptureValue(href) && href - } - - // allow users to programmatically prevent capturing of elements by adding class 'ph-no-capture' - const classes = getClassNames(el) - if (_includes(classes, 'ph-no-capture')) { - explicitNoCapture = true - } - - elementsJson.push( - this._getPropertiesFromElement( - el, - this.instance.config.mask_all_element_attributes, - this.instance.config.mask_all_text - ) - ) - - const augmentProperties = this._getAugmentPropertiesFromElement(el) - _extend(autocaptureAugmentProperties, augmentProperties) - }) - - if (!this.instance.config.mask_all_text) { - // if the element is a button or anchor tag get the span text from any - // children and include it as/with the text property on the parent element - if (target.tagName.toLowerCase() === 'a' || target.tagName.toLowerCase() === 'button') { - elementsJson[0]['$el_text'] = getDirectAndNestedSpanText(target) - } else { - elementsJson[0]['$el_text'] = getSafeText(target) - } - } - - if (href) { - elementsJson[0]['attr__href'] = href - } - - if (explicitNoCapture) { - return false - } - - const props = _extend( - this._getDefaultProperties(e.type), - this._elementsChainAsString - ? { - $elements_chain: getElementsChainString(elementsJson), - } - : { - $elements: elementsJson, - }, - elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, - autocaptureAugmentProperties, - extraProps || {} - ) - - if (eventName === COPY_AUTOCAPTURE_EVENT) { - // you can't read the data from the clipboard event, - // but you can guess that you can read it from the window's current selection - const selectedContent = makeSafeText(window?.getSelection()?.toString()) - const clipType = (e as ClipboardEvent).type || 'clipboard' - if (!selectedContent) { - return false - } - props['$selected_content'] = selectedContent - props['$copy_type'] = clipType - } - - this.instance.capture(eventName, props) - return true - } - } - - isBrowserSupported(): boolean { - return _isFunction(document?.querySelectorAll) - } -} diff --git a/src/autocapture.ts b/src/autocapture.ts index f354e9f8f..eb59d2ded 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -1,4 +1,4 @@ -import { _each, _extend, _includes, _register_event, _safewrap } from './utils' +import { _each, _extend, _includes, _register_event } from './utils' import { autocaptureCompatibleElements, getClassNames, @@ -18,11 +18,11 @@ import { splitClassString, } from './autocapture-utils' import RageClick from './extensions/rageclick' -import { AutocaptureConfig, AutoCaptureCustomProperty, DecideResponse, Properties } from './types' +import { AutocaptureConfig, DecideResponse, Properties } from './types' import { PostHog } from './posthog-core' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' -import { _isBoolean, _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' +import { _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { document, window } from './utils/globals' @@ -35,48 +35,100 @@ function limitText(length: number, text: string): string { return text } -const _bind_instance_methods = function (obj: Record): void { - for (const func in obj) { - if (_isFunction(obj[func])) { - obj[func] = obj[func].bind(obj) +export class Autocapture { + instance: PostHog + _initialized: boolean = false + _isDisabledServerSide: boolean | null = null + rageclicks = new RageClick() + _elementsChainAsString = false + + constructor(instance: PostHog) { + this.instance = instance + + // precompile the regex + // TODO: This doesn't work - we can't override the config like this + if (this.config?.url_allowlist) { + this.config.url_allowlist = this.config.url_allowlist.map((url) => new RegExp(url)) } } -} -const _safewrap_instance_methods = function (obj: Record): void { - for (const func in obj) { - if (_isFunction(obj[func])) { - obj[func] = _safewrap(obj[func]) + private get config(): AutocaptureConfig { + return _isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {} + } + + private _addDomEventHandlers(): void { + if (!this.isBrowserSupported()) { + logger.info('Disabling Automatic Event Collection because this browser is not supported') + return + } + + if (!window || !document) { + return + } + const handler = (e: Event) => { + e = e || window?.event + this._captureEvent(e) + } + + const copiedTextHandler = (e: Event) => { + e = e || window?.event + this._captureEvent(e, COPY_AUTOCAPTURE_EVENT) + } + + _register_event(document, 'submit', handler, false, true) + _register_event(document, 'change', handler, false, true) + _register_event(document, 'click', handler, false, true) + + if (this.config.capture_copied_text) { + _register_event(document, 'copy', copiedTextHandler, false, true) + _register_event(document, 'cut', copiedTextHandler, false, true) } } -} -const autocapture = { - _initializedTokens: [] as string[], - _isDisabledServerSide: null as boolean | null, - _isAutocaptureEnabled: false as boolean, + public afterDecideResponse(response: DecideResponse) { + if (this._initialized) { + logger.info('autocapture already initialized') + return + } + + if (this.instance.persistence) { + this.instance.persistence.register({ + [AUTOCAPTURE_DISABLED_SERVER_SIDE]: !!response['autocapture_opt_out'], + }) + } + // store this in-memory incase persistence is disabled + this._isDisabledServerSide = !!response['autocapture_opt_out'] + + if (response.elementsChainAsString) { + this._elementsChainAsString = response.elementsChainAsString + } - _setIsAutocaptureEnabled: function (instance: PostHog): void { + if (this.isEnabled) { + this._addDomEventHandlers() + this._initialized = true + } + } + + public get isEnabled(): boolean { const disabled_server_side = _isNull(this._isDisabledServerSide) - ? !!instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] + ? !!this.instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] : this._isDisabledServerSide - const enabled_client_side = !!instance.config.autocapture - this._isAutocaptureEnabled = enabled_client_side && !disabled_server_side - }, + const enabled_client_side = !!this.instance.config.autocapture + return enabled_client_side && !disabled_server_side + } - _previousElementSibling: function (el: Element): Element | null { + private _previousElementSibling(el: Element): Element | null { if (el.previousElementSibling) { return el.previousElementSibling - } else { - let _el: Element | null = el - do { - _el = _el.previousSibling as Element | null // resolves to ChildNode->Node, which is Element's parent class - } while (_el && !isElementNode(_el)) - return _el } - }, + let _el: Element | null = el + do { + _el = _el.previousSibling as Element | null // resolves to ChildNode->Node, which is Element's parent class + } while (_el && !isElementNode(_el)) + return _el + } - _getAugmentPropertiesFromElement: function (elem: Element): Properties { + private _getAugmentPropertiesFromElement(elem: Element): Properties { const shouldCaptureEl = shouldCaptureElement(elem) if (!shouldCaptureEl) { return {} @@ -94,9 +146,9 @@ const autocapture = { } }) return props - }, + } - _getPropertiesFromElement: function (elem: Element, maskInputs: boolean, maskText: boolean): Properties { + private _getPropertiesFromElement(elem: Element, maskInputs: boolean, maskText: boolean): Properties { const tag_name = elem.tagName.toLowerCase() const props: Properties = { tag_name: tag_name, @@ -149,34 +201,16 @@ const autocapture = { props['nth_of_type'] = nthOfType return props - }, + } - _getDefaultProperties: function (eventType: string): Properties { + private _getDefaultProperties(eventType: string): Properties { return { $event_type: eventType, $ce_version: 1, } - }, - - _extractCustomPropertyValue: function (customProperty: AutoCaptureCustomProperty): string { - const propValues: string[] = [] - _each(document?.querySelectorAll(customProperty['css_selector']), function (matchedElem) { - let value - - if (['input', 'select'].indexOf(matchedElem.tagName.toLowerCase()) > -1) { - value = matchedElem['value'] - } else if (matchedElem['textContent']) { - value = matchedElem['textContent'] - } - - if (shouldCaptureValue(value)) { - propValues.push(value) - } - }) - return propValues.join(', ') - }, + } - _getEventTarget: function (e: Event): Element | null { + private _getEventTarget(e: Event): Element | null { // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes if (_isUndefined(e.target)) { return (e.srcElement as Element) || null @@ -186,14 +220,9 @@ const autocapture = { } return (e.target as Element) || null } - }, - - _captureEvent: function ( - e: Event, - instance: PostHog, - eventName = '$autocapture', - extraProps?: Properties - ): boolean | void { + } + + private _captureEvent(e: Event, eventName = '$autocapture', extraProps?: Properties): boolean | void { /*** Don't mess with this code without running IE8 tests on it ***/ let target = this._getEventTarget(e) if (isTextNode(target)) { @@ -202,8 +231,11 @@ const autocapture = { } if (eventName === '$autocapture' && e.type === 'click' && e instanceof MouseEvent) { - if (this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())) { - this._captureEvent(e, instance, '$rageclick') + if ( + this.instance.config.rageclick && + this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime()) + ) { + this._captureEvent(e, '$rageclick') } } @@ -257,8 +289,8 @@ const autocapture = { elementsJson.push( this._getPropertiesFromElement( el, - instance.config.mask_all_element_attributes, - instance.config.mask_all_text + this.instance.config.mask_all_element_attributes, + this.instance.config.mask_all_text ) ) @@ -266,7 +298,7 @@ const autocapture = { _extend(autocaptureAugmentProperties, augmentProperties) }) - if (!instance.config.mask_all_text) { + if (!this.instance.config.mask_all_text) { // if the element is a button or anchor tag get the span text from any // children and include it as/with the text property on the parent element if (target.tagName.toLowerCase() === 'a' || target.tagName.toLowerCase() === 'button') { @@ -286,7 +318,7 @@ const autocapture = { const props = _extend( this._getDefaultProperties(e.type), - instance.elementsChainAsString + this._elementsChainAsString ? { $elements_chain: getElementsChainString(elementsJson), } @@ -294,7 +326,6 @@ const autocapture = { $elements: elementsJson, }, elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, - this._getCustomProperties(targetElementList), autocaptureAugmentProperties, extraProps || {} ) @@ -311,111 +342,12 @@ const autocapture = { props['$copy_type'] = clipType } - instance.capture(eventName, props) + this.instance.capture(eventName, props) return true } - }, - - _addDomEventHandlers: function (instance: PostHog): void { - if (!window || !document) { - return - } - const handler = (e: Event) => { - e = e || window?.event - this._captureEvent(e, instance) - } - - const copiedTextHandler = (e: Event) => { - e = e || window?.event - this._captureEvent(e, instance, COPY_AUTOCAPTURE_EVENT) - } - - _register_event(document, 'submit', handler, false, true) - _register_event(document, 'change', handler, false, true) - _register_event(document, 'click', handler, false, true) - - if (_isObject(instance.config.autocapture) && instance.config.autocapture.capture_copied_text) { - _register_event(document, 'copy', copiedTextHandler, false, true) - _register_event(document, 'cut', copiedTextHandler, false, true) - } - }, - - _customProperties: [] as AutoCaptureCustomProperty[], - rageclicks: null as RageClick | null, - config: undefined as AutocaptureConfig | undefined, - - init: function (instance: PostHog): void { - if (!_isBoolean(instance.__autocapture)) { - this.config = instance.__autocapture - } - - // precompile the regex - if (this.config?.url_allowlist) { - this.config.url_allowlist = this.config.url_allowlist.map((url) => new RegExp(url)) - } - - this.rageclicks = new RageClick(instance.config.rageclick) - }, - - afterDecideResponse: function (response: DecideResponse, instance: PostHog): void { - const token = instance.config.token - if (this._initializedTokens.indexOf(token) > -1) { - logger.info('autocapture already initialized for token "' + token + '"') - return - } - - if (instance.persistence) { - instance.persistence.register({ - [AUTOCAPTURE_DISABLED_SERVER_SIDE]: !!response['autocapture_opt_out'], - }) - } - // store this in-memory incase persistence is disabled - this._isDisabledServerSide = !!response['autocapture_opt_out'] - - this._setIsAutocaptureEnabled(instance) - - this._initializedTokens.push(token) - - if ( - response && - response['config'] && - response['config']['enable_collect_everything'] && - this._isAutocaptureEnabled - ) { - this._addDomEventHandlers(instance) - } else { - instance['__autocapture'] = false - } - }, - - // this is a mechanism to ramp up CE with no server-side interaction. - // when CE is active, every page load results in a decide request. we - // need to gently ramp this up, so we don't overload decide. this decides - // deterministically if CE is enabled for this project by modding the char - // value of the project token. - enabledForProject: function ( - token: string | null | undefined, - numBuckets: number, - numEnabledBuckets: number - ): boolean { - if (!token) { - return true - } - numBuckets = !_isUndefined(numBuckets) ? numBuckets : 10 - numEnabledBuckets = !_isUndefined(numEnabledBuckets) ? numEnabledBuckets : 10 - let charCodeSum = 0 - for (let i = 0; i < token.length; i++) { - charCodeSum += token.charCodeAt(i) - } - return charCodeSum % numBuckets < numEnabledBuckets - }, + } - isBrowserSupported: function (): boolean { + isBrowserSupported(): boolean { return _isFunction(document?.querySelectorAll) - }, + } } - -_bind_instance_methods(autocapture) -_safewrap_instance_methods(autocapture) - -export { autocapture } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 1382ec9f0..37e322195 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -72,7 +72,7 @@ import { logger } from './utils/logger' import { SessionPropsManager } from './session-props' import { _isBlockedUA } from './utils/blocked-uas' import { extendURLParams, request, SUPPORTS_REQUEST } from './request' -import { Autocapture } from './autocapture-v2' +import { Autocapture } from './autocapture' /* SIMPLE STYLE GUIDE: @@ -214,7 +214,7 @@ export class PostHog { sessionManager?: SessionIdManager sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter - autocapture: Autocapture + autocapture?: Autocapture _requestQueue?: RequestQueue _retryQueue?: RetryQueue From 0ac01752e5950775821f77c14b57efcde39725be Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 11:56:15 +0200 Subject: [PATCH 04/41] Fixed up imports --- src/decide.ts | 3 +-- src/posthog-core.ts | 15 --------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/decide.ts b/src/decide.ts index c85ee3453..27d64ef32 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,4 +1,3 @@ -import { autocapture } from './autocapture' import { loadScript } from './utils' import { PostHog } from './posthog-core' import { Compression, DecideResponse } from './types' @@ -71,7 +70,7 @@ export class Decide { this.instance.toolbar.afterDecideResponse(response) this.instance.sessionRecording?.afterDecideResponse(response) - autocapture.afterDecideResponse(response, this.instance) + this.instance.autocapture?.afterDecideResponse(response) this.instance._afterDecideResponse(response) // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 37e322195..47bf5ed7b 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -11,7 +11,6 @@ import { isDistinctIdStringLike, } from './utils' import { assignableWindow, document, location, userAgent, window } from './utils/globals' -import { autocapture } from './autocapture' import { PostHogFeatureFlags } from './posthog-featureflags' import { PostHogPersistence } from './posthog-persistence' import { @@ -225,7 +224,6 @@ export class PostHog { compression?: Compression __captureHooks: ((eventName: string) => void)[] __request_queue: QueuedRequestOptions[] - __autocapture: boolean | AutocaptureConfig | undefined decideEndpointWasHit: boolean analyticsDefaultEndpoint: string @@ -246,7 +244,6 @@ export class PostHog { this.__captureHooks = [] this.__request_queue = [] this.__loaded = false - this.__autocapture = undefined this.analyticsDefaultEndpoint = '/e/' this.featureFlags = new PostHogFeatureFlags(this) @@ -372,18 +369,6 @@ export class PostHog { this.autocapture = new Autocapture(this) - this.__autocapture = this.config.autocapture - autocapture._setIsAutocaptureEnabled(this) - if (autocapture._isAutocaptureEnabled) { - this.__autocapture = this.config.autocapture - if (!autocapture.isBrowserSupported()) { - this.__autocapture = false - logger.info('Disabling Automatic Event Collection because this browser is not supported') - } else { - autocapture.init(this) - } - } - // if any instance on the page has debug = true, we set the // global debug to be true Config.DEBUG = Config.DEBUG || this.config.debug From ec77ae19552f8b765518183a06c918cc7257dc08 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:06:32 +0200 Subject: [PATCH 05/41] Fixes --- src/__tests__/decide.js | 13 ++++++------- src/__tests__/posthog-core.js | 8 +------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/__tests__/decide.js b/src/__tests__/decide.js index ca1ab56f3..c520844e5 100644 --- a/src/__tests__/decide.js +++ b/src/__tests__/decide.js @@ -40,6 +40,9 @@ describe('Decide', () => { sessionRecording: { afterDecideResponse: jest.fn(), }, + autocapture: { + afterDecideResponse: jest.fn(), + }, featureFlags: { receivedFeatureFlags: jest.fn(), setReloadingPaused: jest.fn(), @@ -54,10 +57,6 @@ describe('Decide', () => { given('config', () => ({ api_host: 'https://test.com', persistence: 'memory' })) - beforeEach(() => { - jest.spyOn(autocapture, 'afterDecideResponse').mockImplementation() - }) - describe('constructor', () => { given('subject', () => () => given.decide.call()) @@ -180,7 +179,7 @@ describe('Decide', () => { expect(given.posthog.toolbar.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) expect(given.posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith(given.decideResponse, false) expect(given.posthog._afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) - expect(autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse, given.posthog) + expect(given.posthog.autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) }) it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { @@ -208,7 +207,7 @@ describe('Decide', () => { given.subject() - expect(autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse, given.posthog) + expect(given.posthog.autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) expect(given.posthog.sessionRecording.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) expect(given.posthog.toolbar.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) @@ -229,7 +228,7 @@ describe('Decide', () => { given.subject() - expect(autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse, given.posthog) + expect(given.posthog.autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) expect(given.posthog.sessionRecording.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) expect(given.posthog.toolbar.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 88ab393e1..f6f4771af 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -1,7 +1,6 @@ import _posthog from '../loader-module' import { PostHogPersistence } from '../posthog-persistence' import { Decide } from '../decide' -import { autocapture } from '../autocapture' import { _info } from '../utils/event-utils' import { document, window } from '../utils/globals' @@ -744,8 +743,6 @@ describe('posthog core', () => { beforeEach(() => { jest.spyOn(window.console, 'warn').mockImplementation() jest.spyOn(window.console, 'error').mockImplementation() - jest.spyOn(autocapture, 'init').mockImplementation() - jest.spyOn(autocapture, 'afterDecideResponse').mockImplementation() }) given('advanced_disable_decide', () => true) @@ -769,7 +766,7 @@ describe('posthog core', () => { expect(given.overrides._send_request.mock.calls.length).toBe(0) // No outgoing requests }) - it('does not load autocapture, feature flags, toolbar, session recording', () => { + it('does not load feature flags, toolbar, session recording', () => { given('overrides', () => ({ sessionRecording: { afterDecideResponse: jest.fn(), @@ -789,9 +786,6 @@ describe('posthog core', () => { jest.spyOn(given.lib.sessionRecording, 'afterDecideResponse').mockImplementation() jest.spyOn(given.lib.persistence, 'register').mockImplementation() - // Autocapture - expect(autocapture.afterDecideResponse).not.toHaveBeenCalled() - // Feature flags expect(given.lib.persistence.register).not.toHaveBeenCalled() // FFs are saved this way From 5637e27e706dc6f94e1b8e80578005a8aa6994d6 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:08:11 +0200 Subject: [PATCH 06/41] Fixe --- src/posthog-core.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 47bf5ed7b..8728ef617 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -30,7 +30,6 @@ import { RetryQueue } from './retry-queue' import { SessionIdManager } from './sessionid' import { RequestRouter, RequestRouterRegion } from './utils/request-router' import { - AutocaptureConfig, CaptureOptions, CaptureResult, Compression, From 65f5344c6db4d85f7c53d3e061f0319bc37cc6df Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:12:12 +0200 Subject: [PATCH 07/41] Fixes --- src/__tests__/autocapture.test.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index 7f0fb48c4..7e28b2f8c 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -370,37 +370,6 @@ describe('Autocapture system', () => { posthog.config.rageclick = true }) - it('should add the custom property when an element matching any of the event selectors is clicked', () => { - posthog.config.mask_all_element_attributes = false - $autocapture_disabled_server_side = false - autocapture.afterDecideResponse({} as DecideResponse) - - const eventElement1 = document.createElement('div') - const eventElement2 = document.createElement('div') - const propertyElement = document.createElement('div') - eventElement1.className = 'event-element-1' - eventElement1.style.cursor = 'pointer' - eventElement2.className = 'event-element-2' - eventElement2.style.cursor = 'pointer' - propertyElement.className = 'property-element' - propertyElement.textContent = 'my property value' - document.body.appendChild(eventElement1) - document.body.appendChild(eventElement2) - document.body.appendChild(propertyElement) - - expect(captureMock).toHaveBeenCalledTimes(0) - simulateClick(eventElement1) - simulateClick(eventElement2) - expect(captureMock).toHaveBeenCalledTimes(2) - const captureArgs1 = captureMock.mock.calls[0] - const captureArgs2 = captureMock.mock.calls[1] - const eventType1 = captureArgs1[1]['my property name'] - const eventType2 = captureArgs2[1]['my property name'] - console.warn(JSON.stringify(captureArgs1[1])) - expect(eventType1).toBe('my property value') - expect(eventType2).toBe('my property value') - }) - it('should capture rageclick', () => { const elTarget = document.createElement('img') const elParent = document.createElement('span') From c31de30cc55bc65ddc717f686f13f0cee8fcd3be Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:14:58 +0200 Subject: [PATCH 08/41] Remove "enable_collect_everything" --- cypress/e2e/opting-out.cy.ts | 2 -- cypress/e2e/session-recording.cy.ts | 6 ------ cypress/e2e/surveys.cy.ts | 1 - cypress/support/setup.ts | 3 +-- playground/session-recordings/server.js | 1 - src/__tests__/decide.js | 12 ++---------- src/types.ts | 4 ---- 7 files changed, 3 insertions(+), 26 deletions(-) diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts index 57103bbd8..0f2a1897c 100644 --- a/cypress/e2e/opting-out.cy.ts +++ b/cypress/e2e/opting-out.cy.ts @@ -15,7 +15,6 @@ describe('opting out', () => { describe('session recording', () => { beforeEach(() => { cy.intercept('POST', '/decide/*', { - config: { enable_collect_everything: false }, editorParams: {}, featureFlags: ['session-recording-player'], isAuthenticated: false, @@ -114,7 +113,6 @@ describe('opting out', () => { it('can override sampling when starting session recording', () => { cy.intercept('POST', '/decide/*', { - config: { enable_collect_everything: false }, editorParams: {}, isAuthenticated: false, sessionRecording: { diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index d2949b9fe..de1e2b8c7 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -46,7 +46,6 @@ describe('Session recording', () => { it('captures session events', () => { start({ decideResponseOverrides: { - config: { enable_collect_everything: false }, isAuthenticated: false, sessionRecording: { endpoint: '/ses/', @@ -83,7 +82,6 @@ describe('Session recording', () => { beforeEach(() => { start({ decideResponseOverrides: { - config: { enable_collect_everything: false }, isAuthenticated: false, sessionRecording: { endpoint: '/ses/', @@ -126,7 +124,6 @@ describe('Session recording', () => { beforeEach(() => { start({ decideResponseOverrides: { - config: { enable_collect_everything: false }, isAuthenticated: false, sessionRecording: { endpoint: '/ses/', @@ -368,7 +365,6 @@ describe('Session recording', () => { beforeEach(() => { start({ decideResponseOverrides: { - config: { enable_collect_everything: false }, isAuthenticated: false, sessionRecording: { endpoint: '/ses/', @@ -397,7 +393,6 @@ describe('Session recording', () => { it.only('can override sampling when starting session recording', () => { cy.intercept('POST', '/decide/*', { - config: { enable_collect_everything: false }, editorParams: {}, isAuthenticated: false, sessionRecording: { @@ -441,7 +436,6 @@ describe('Session recording', () => { cy.reload(true).then(() => { start({ decideResponseOverrides: { - config: { enable_collect_everything: false }, isAuthenticated: false, sessionRecording: { endpoint: '/ses/', diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index a456cd902..a2a2fddba 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -46,7 +46,6 @@ describe('Surveys', () => { beforeEach(() => { cy.intercept('POST', '**/decide/*', { - config: { enable_collect_everything: false }, editorParams: {}, surveys: true, isAuthenticated: false, diff --git a/cypress/support/setup.ts b/cypress/support/setup.ts index 935436e00..dcd4aaafb 100644 --- a/cypress/support/setup.ts +++ b/cypress/support/setup.ts @@ -6,7 +6,6 @@ export const start = ({ resetOnInit = false, options = {}, decideResponseOverrides = { - config: { enable_collect_everything: true }, sessionRecording: undefined, isAuthenticated: false, capturePerformance: true, @@ -27,7 +26,7 @@ export const start = ({ excludedDomains: [], autocaptureExceptions: false, ...decideResponseOverrides, - config: { enable_collect_everything: true, ...decideResponseOverrides.config }, + config: { ...decideResponseOverrides.config }, } cy.intercept('POST', '/decide/*', decideResponse).as('decide') diff --git a/playground/session-recordings/server.js b/playground/session-recordings/server.js index ce5baebfa..5ca27fa11 100644 --- a/playground/session-recordings/server.js +++ b/playground/session-recordings/server.js @@ -15,7 +15,6 @@ app.listen(port, () => { app.post('/decide', function (req, res) { res.json({ - config: { enable_collect_everything: false }, editorParams: {}, featureFlags: ['session-recording-player'], isAuthenticated: false, diff --git a/src/__tests__/decide.js b/src/__tests__/decide.js index f2f8200b9..d7a309dc2 100644 --- a/src/__tests__/decide.js +++ b/src/__tests__/decide.js @@ -1,4 +1,3 @@ -import { autocapture } from '../autocapture' import { Decide } from '../decide' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' @@ -72,7 +71,7 @@ describe('Decide', () => { getGroups: () => ({ organization: '5' }), })) - given('decideResponse', () => ({ enable_collect_everything: true })) + given('decideResponse', () => ({})) given('config', () => ({ api_host: 'https://test.com', persistence: 'memory' })) @@ -195,9 +194,7 @@ describe('Decide', () => { given('subject', () => () => given.decide.parseDecideResponse(given.decideResponse)) it('properly parses decide response', () => { - given('decideResponse', () => ({ - enable_collect_everything: true, - })) + given('decideResponse', () => ({})) given.subject() expect(given.posthog.sessionRecording.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) @@ -220,7 +217,6 @@ describe('Decide', () => { it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags_on_first_load is set', () => { given('decideResponse', () => ({ - enable_collect_everything: true, featureFlags: { 'test-flag': true }, })) given('config', () => ({ @@ -241,7 +237,6 @@ describe('Decide', () => { it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags is set', () => { given('decideResponse', () => ({ - enable_collect_everything: true, featureFlags: { 'test-flag': true }, })) given('config', () => ({ @@ -282,7 +277,6 @@ describe('Decide', () => { it('Make sure surveys are not loaded when decide response says no', () => { given('decideResponse', () => ({ - enable_collect_everything: true, featureFlags: { 'test-flag': true }, surveys: false, })) @@ -299,7 +293,6 @@ describe('Decide', () => { it('Make sure surveys are loaded when decide response says so', () => { given('decideResponse', () => ({ - enable_collect_everything: true, featureFlags: { 'test-flag': true }, surveys: true, })) @@ -316,7 +309,6 @@ describe('Decide', () => { it('Make sure surveys are not loaded when config says no', () => { given('decideResponse', () => ({ - enable_collect_everything: true, featureFlags: { 'test-flag': true }, surveys: true, })) diff --git a/src/types.ts b/src/types.ts index fa393e64d..59dbd4325 100644 --- a/src/types.ts +++ b/src/types.ts @@ -253,10 +253,6 @@ export type FlagVariant = { flag: string; variant: string } export interface DecideResponse { supportedCompression: Compression[] - // NOTE: Remove this entirely as it is never used - config: { - enable_collect_everything: boolean - } featureFlags: Record featureFlagPayloads: Record errorsWhileComputingFlags: boolean From d601ad88838b913e3b838780647883216ae163d3 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:30:17 +0200 Subject: [PATCH 09/41] Fixes --- cypress/e2e/session-recording.cy.ts | 5 +++-- src/autocapture.ts | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index de1e2b8c7..b2620270d 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -361,7 +361,7 @@ describe('Session recording', () => { }) }) - describe.only('with sampling', () => { + describe('with sampling', () => { beforeEach(() => { start({ decideResponseOverrides: { @@ -391,8 +391,9 @@ describe('Session recording', () => { }) }) - it.only('can override sampling when starting session recording', () => { + it('can override sampling when starting session recording', () => { cy.intercept('POST', '/decide/*', { + autocapture_opt_out: true, editorParams: {}, isAuthenticated: false, sessionRecording: { diff --git a/src/autocapture.ts b/src/autocapture.ts index eb59d2ded..79eadf8b3 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -103,6 +103,8 @@ export class Autocapture { this._elementsChainAsString = response.elementsChainAsString } + console.log('IS ENABLED', this.isEnabled) + if (this.isEnabled) { this._addDomEventHandlers() this._initialized = true @@ -110,11 +112,11 @@ export class Autocapture { } public get isEnabled(): boolean { - const disabled_server_side = _isNull(this._isDisabledServerSide) + const disabledServer = _isNull(this._isDisabledServerSide) ? !!this.instance.persistence?.props[AUTOCAPTURE_DISABLED_SERVER_SIDE] : this._isDisabledServerSide - const enabled_client_side = !!this.instance.config.autocapture - return enabled_client_side && !disabled_server_side + const disabledClient = !this.instance.config.autocapture + return !disabledClient && !disabledServer } private _previousElementSibling(el: Element): Element | null { From ee509310cce67a0856ea7ee093c1180bdc484e6b Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:32:04 +0200 Subject: [PATCH 10/41] fix --- src/autocapture.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 79eadf8b3..1747bf5ae 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -103,8 +103,6 @@ export class Autocapture { this._elementsChainAsString = response.elementsChainAsString } - console.log('IS ENABLED', this.isEnabled) - if (this.isEnabled) { this._addDomEventHandlers() this._initialized = true From 315026cf13496000e19681fd4e5f2cb1a865b765 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:39:43 +0200 Subject: [PATCH 11/41] Fix tests --- cypress/e2e/opting-out.cy.ts | 2 ++ cypress/e2e/surveys.cy.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/cypress/e2e/opting-out.cy.ts b/cypress/e2e/opting-out.cy.ts index 0f2a1897c..4b8f369cf 100644 --- a/cypress/e2e/opting-out.cy.ts +++ b/cypress/e2e/opting-out.cy.ts @@ -22,6 +22,7 @@ describe('opting out', () => { endpoint: '/ses/', }, capture_performance: true, + autocapture_opt_out: true, }).as('decide') cy.visit('./playground/cypress') @@ -113,6 +114,7 @@ describe('opting out', () => { it('can override sampling when starting session recording', () => { cy.intercept('POST', '/decide/*', { + autocapture_opt_out: true, editorParams: {}, isAuthenticated: false, sessionRecording: { diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index a2a2fddba..17b0d61ac 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -49,6 +49,7 @@ describe('Surveys', () => { editorParams: {}, surveys: true, isAuthenticated: false, + autocapture_opt_out: true, }).as('decide') }) From 88e58750baa98419d76f73e34a36ceb6f9291c7f Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 16:52:48 +0200 Subject: [PATCH 12/41] Fixes --- cypress/e2e/session-recording.cy.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cypress/e2e/session-recording.cy.ts b/cypress/e2e/session-recording.cy.ts index b2620270d..237479b46 100644 --- a/cypress/e2e/session-recording.cy.ts +++ b/cypress/e2e/session-recording.cy.ts @@ -51,6 +51,7 @@ describe('Session recording', () => { endpoint: '/ses/', }, capturePerformance: true, + autocapture_opt_out: true, }, }) @@ -88,6 +89,7 @@ describe('Session recording', () => { networkPayloadCapture: { recordBody: true }, }, capturePerformance: true, + autocapture_opt_out: true, }, url: './playground/cypress', options: { @@ -129,6 +131,7 @@ describe('Session recording', () => { endpoint: '/ses/', }, capturePerformance: true, + autocapture_opt_out: true, }, url: './playground/cypress', }) @@ -371,6 +374,7 @@ describe('Session recording', () => { sampleRate: '0', }, capturePerformance: true, + autocapture_opt_out: true, }, url: './playground/cypress', }) @@ -443,6 +447,7 @@ describe('Session recording', () => { sampleRate: '0', }, capturePerformance: true, + autocapture_opt_out: true, }, url: './playground/cypress', }) From 82f7ffcb14061f0cb310b9599b69d7ba7a712899 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 17:47:21 +0200 Subject: [PATCH 13/41] Fixed up --- src/autocapture.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 1747bf5ae..788c184b8 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -44,16 +44,13 @@ export class Autocapture { constructor(instance: PostHog) { this.instance = instance - - // precompile the regex - // TODO: This doesn't work - we can't override the config like this - if (this.config?.url_allowlist) { - this.config.url_allowlist = this.config.url_allowlist.map((url) => new RegExp(url)) - } } private get config(): AutocaptureConfig { - return _isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {} + const config = _isObject(this.instance.config.autocapture) ? this.instance.config.autocapture : {} + // precompile the regex + config.url_allowlist = config.url_allowlist?.map((url) => new RegExp(url)) + return config } private _addDomEventHandlers(): void { From e52eca5871fa85c3c7c7d680bff9e8f3fe42f522 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:15:52 +0200 Subject: [PATCH 14/41] Added try catch --- src/autocapture.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 788c184b8..55fd08db6 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -64,7 +64,12 @@ export class Autocapture { } const handler = (e: Event) => { e = e || window?.event - this._captureEvent(e) + try { + this._captureEvent(e) + } catch (e) { + console.error(e) + throw new Error('Error in autocapture event handler') + } } const copiedTextHandler = (e: Event) => { @@ -219,7 +224,7 @@ export class Autocapture { } } - private _captureEvent(e: Event, eventName = '$autocapture', extraProps?: Properties): boolean | void { + private _captureEvent(e: Event, eventName = '$autocapture'): boolean | void { /*** Don't mess with this code without running IE8 tests on it ***/ let target = this._getEventTarget(e) if (isTextNode(target)) { @@ -323,8 +328,7 @@ export class Autocapture { $elements: elementsJson, }, elementsJson[0]?.['$el_text'] ? { $el_text: elementsJson[0]?.['$el_text'] } : {}, - autocaptureAugmentProperties, - extraProps || {} + autocaptureAugmentProperties ) if (eventName === COPY_AUTOCAPTURE_EVENT) { From 4c3ae34b0c9e21d9bc9b1d0c1c511c6a77540c71 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:19:28 +0200 Subject: [PATCH 15/41] Fixes --- src/autocapture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 55fd08db6..50eaf299f 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -67,7 +67,7 @@ export class Autocapture { try { this._captureEvent(e) } catch (e) { - console.error(e) + console.error(e, e.stack) throw new Error('Error in autocapture event handler') } } From 4ffac3c22b8b644a9aa832916ce12f8d67e6147e Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:20:36 +0200 Subject: [PATCH 16/41] fix --- src/autocapture.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 50eaf299f..7a4170fef 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -66,8 +66,9 @@ export class Autocapture { e = e || window?.event try { this._captureEvent(e) - } catch (e) { - console.error(e, e.stack) + } catch (e: any) { + // eslint-disable-next-line no-console + console.error(e, e.stack, 'Error in autocapture event handler') throw new Error('Error in autocapture event handler') } } From 71ab6068f5d2806b332a3d6cf4cfd2d008636cb1 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:25:17 +0200 Subject: [PATCH 17/41] Fix --- src/autocapture-utils.ts | 3 +++ src/autocapture.ts | 10 +++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index 5d5a2e93a..9ef8855ad 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -254,6 +254,7 @@ export function shouldCaptureDomEvent( return true } + console.log('allowedEventTypes', allowedEventTypes, autocaptureCompatibleElements) const tag = el.tagName.toLowerCase() switch (tag) { case 'html': @@ -517,6 +518,8 @@ function extractElements(elements: Properties[]): PHElement[] { nth_of_type: el['nth_of_type'], attributes: {} as { [id: string]: any }, } + + console.log('extractElements', _entries(el)) _entries(el) .filter(([key]) => key.indexOf('attr__') === 0) .forEach(([key, value]) => (response.attributes[key] = value)) diff --git a/src/autocapture.ts b/src/autocapture.ts index 7a4170fef..91f2c0d09 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -64,13 +64,7 @@ export class Autocapture { } const handler = (e: Event) => { e = e || window?.event - try { - this._captureEvent(e) - } catch (e: any) { - // eslint-disable-next-line no-console - console.error(e, e.stack, 'Error in autocapture event handler') - throw new Error('Error in autocapture event handler') - } + this._captureEvent(e) } const copiedTextHandler = (e: Event) => { @@ -140,6 +134,7 @@ export class Autocapture { const props: Properties = {} _each(elem.attributes, function (attr: Attr) { + console.log('attr.name', attr, attr.name) if (attr.name.indexOf('data-ph-capture-attribute') === 0) { const propertyKey = attr.name.replace('data-ph-capture-attribute-', '') const propertyValue = attr.value @@ -156,6 +151,7 @@ export class Autocapture { const props: Properties = { tag_name: tag_name, } + console.log('autocaptureCompatibleElements', autocaptureCompatibleElements) if (autocaptureCompatibleElements.indexOf(tag_name) > -1 && !maskText) { if (tag_name.toLowerCase() === 'a' || tag_name.toLowerCase() === 'button') { props['$el_text'] = limitText(1024, getDirectAndNestedSpanText(elem)) From 1752b65c290d70e8519f81ed234a43ee5d1ae9fd Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:28:48 +0200 Subject: [PATCH 18/41] Fix --- src/autocapture.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/autocapture.ts b/src/autocapture.ts index 91f2c0d09..e76c14e27 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -133,6 +133,7 @@ export class Autocapture { const props: Properties = {} + console.log('elem.attributes', elem.attributes, elem) _each(elem.attributes, function (attr: Attr) { console.log('attr.name', attr, attr.name) if (attr.name.indexOf('data-ph-capture-attribute') === 0) { @@ -269,6 +270,8 @@ export class Autocapture { const autocaptureAugmentProperties: Properties = {} let href, explicitNoCapture = false + + console.log('targetElementList', targetElementList) _each(targetElementList, (el) => { const shouldCaptureEl = shouldCaptureElement(el) From 74e31a2e82686a149b66fb6496d8fb0670dd7f48 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:39:37 +0200 Subject: [PATCH 19/41] Fix? --- src/autocapture.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index e76c14e27..451b2e1ab 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -64,7 +64,11 @@ export class Autocapture { } const handler = (e: Event) => { e = e || window?.event - this._captureEvent(e) + try { + this._captureEvent(e) + } catch (error) { + logger.error('Failed to capture event', error) + } } const copiedTextHandler = (e: Event) => { From 2f2ba57b9b268231cda090a50b87dfd518fbacd1 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 18:45:23 +0200 Subject: [PATCH 20/41] Fix? --- src/autocapture.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index 451b2e1ab..dc2ebee2e 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -137,10 +137,8 @@ export class Autocapture { const props: Properties = {} - console.log('elem.attributes', elem.attributes, elem) _each(elem.attributes, function (attr: Attr) { - console.log('attr.name', attr, attr.name) - if (attr.name.indexOf('data-ph-capture-attribute') === 0) { + if (attr.name && attr.name.indexOf('data-ph-capture-attribute') === 0) { const propertyKey = attr.name.replace('data-ph-capture-attribute-', '') const propertyValue = attr.value if (propertyKey && propertyValue && shouldCaptureValue(propertyValue)) { From 7b9dbec76c226ec9c7cef4ea82801be98d0a3cab Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 21:50:29 +0200 Subject: [PATCH 21/41] fix --- src/autocapture-utils.ts | 2 -- src/autocapture.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index 9ef8855ad..2f90ddfea 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -254,7 +254,6 @@ export function shouldCaptureDomEvent( return true } - console.log('allowedEventTypes', allowedEventTypes, autocaptureCompatibleElements) const tag = el.tagName.toLowerCase() switch (tag) { case 'html': @@ -519,7 +518,6 @@ function extractElements(elements: Properties[]): PHElement[] { attributes: {} as { [id: string]: any }, } - console.log('extractElements', _entries(el)) _entries(el) .filter(([key]) => key.indexOf('attr__') === 0) .forEach(([key, value]) => (response.attributes[key] = value)) diff --git a/src/autocapture.ts b/src/autocapture.ts index dc2ebee2e..a30abf095 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -154,7 +154,6 @@ export class Autocapture { const props: Properties = { tag_name: tag_name, } - console.log('autocaptureCompatibleElements', autocaptureCompatibleElements) if (autocaptureCompatibleElements.indexOf(tag_name) > -1 && !maskText) { if (tag_name.toLowerCase() === 'a' || tag_name.toLowerCase() === 'button') { props['$el_text'] = limitText(1024, getDirectAndNestedSpanText(elem)) @@ -273,7 +272,6 @@ export class Autocapture { let href, explicitNoCapture = false - console.log('targetElementList', targetElementList) _each(targetElementList, (el) => { const shouldCaptureEl = shouldCaptureElement(el) From 140010cbb1a4564a57c89bee08c238def49fe800 Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 22:54:21 +0200 Subject: [PATCH 22/41] Added heatmaps tracking preview code --- playground/nextjs/pages/_app.tsx | 1 + src/heatmaps.ts | 85 ++++++++++++++++++++++++++++++++ src/posthog-core.ts | 13 ++--- src/types.ts | 1 + 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/heatmaps.ts diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index b4f02f7cb..97d2e1930 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -18,6 +18,7 @@ if (typeof window !== 'undefined') { scroll_root_selector: ['#scroll_element', 'html'], persistence: cookieConsentGiven() ? 'localStorage+cookie' : 'memory', __preview_process_person: 'identified_only', + __preview_heatmaps: true, }) ;(window as any).posthog = posthog } diff --git a/src/heatmaps.ts b/src/heatmaps.ts new file mode 100644 index 000000000..ce1ecf1c6 --- /dev/null +++ b/src/heatmaps.ts @@ -0,0 +1,85 @@ +import { _register_event } from './utils' +import RageClick from './extensions/rageclick' +import { Properties } from './types' +import { PostHog } from './posthog-core' + +import { document, window } from './utils/globals' + +export class Heatmaps { + instance: PostHog + rageclicks = new RageClick() + _isDisabledServerSide: boolean | null = null + _initialized = false + _mouseMoveTimeout: number | undefined + + constructor(instance: PostHog) { + this.instance = instance + + if (this.isEnabled) { + this._setupListeners() + } + } + + public get isEnabled(): boolean { + return !!this.instance.config.__preview_heatmaps + } + + private _setupListeners(): void { + if (!window || !document) { + return + } + + _register_event(document, 'click', (e) => this._onClick((e || window?.event) as MouseEvent), false, true) + _register_event( + document, + 'mousemove', + (e) => this._onMouseMove((e || window?.event) as MouseEvent), + false, + true + ) + } + + private _getProperties(e: MouseEvent): Properties { + return { + $mouse_x: e.clientX, + $mouse_y: e.clientY, + } + } + + private _onClick(e: MouseEvent): void { + const properties = this._getProperties(e) + + if (this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())) { + this._capture({ + ...properties, + $heatmap_event: 'rageclick', + }) + } + + // TODO: Detect deadclicks + + this._capture({ + ...properties, + $heatmap_event: 'click', + }) + } + + private _onMouseMove(e: Event): void { + const properties = this._getProperties(e as MouseEvent) + + clearTimeout(this._mouseMoveTimeout) + + this._mouseMoveTimeout = setTimeout(() => { + this._capture({ + ...properties, + $heatmap_event: 'mousemove', + }) + }, 1000) + } + + private _capture(properties: Properties): void { + this.instance.capture('$heatmap', properties, { + _batchKey: 'heatmaps', + }) + } +} diff --git a/src/posthog-core.ts b/src/posthog-core.ts index bfc72d29c..bed7a9674 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -71,6 +71,7 @@ import { SessionPropsManager } from './session-props' import { _isBlockedUA } from './utils/blocked-uas' import { extendURLParams, request, SUPPORTS_REQUEST } from './request' import { Autocapture } from './autocapture' +import { Heatmaps } from './heatmaps' /* SIMPLE STYLE GUIDE: @@ -214,6 +215,7 @@ export class PostHog { sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter autocapture?: Autocapture + heatmaps?: Heatmaps _requestQueue?: RequestQueue _retryQueue?: RetryQueue @@ -368,6 +370,7 @@ export class PostHog { } this.autocapture = new Autocapture(this) + this.heatmaps = new Heatmaps(this) // if any instance on the page has debug = true, we set the // global debug to be true @@ -838,11 +841,9 @@ export class PostHog { properties['title'] = document.title } - if (event_name === '$performance_event') { - const persistenceProps = this.persistence.properties() - // Early exit for $performance_event as we only need session and $current_url - properties['distinct_id'] = persistenceProps.distinct_id - properties['$current_url'] = infoProperties.$current_url + if (event_name === '$heatmap') { + properties = _extend({}, infoProperties, properties) + // Early exit for heatmaps, as they don't need any other properties return properties } @@ -867,7 +868,7 @@ export class PostHog { // update properties with pageview info and super-properties properties = _extend( {}, - _info.properties(), + infoProperties, this.persistence.properties(), this.sessionPersistence.properties(), properties diff --git a/src/types.ts b/src/types.ts index 59dbd4325..365103ec2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,6 +143,7 @@ export interface PostHogConfig { bootstrap: BootstrapConfig segment?: any __preview_send_client_session_params?: boolean + __preview_heatmaps?: boolean 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[] From 3f76d9cbbcb1151b16821d96a4925d7ac60a143e Mon Sep 17 00:00:00 2001 From: Ben White Date: Wed, 10 Apr 2024 23:03:50 +0200 Subject: [PATCH 23/41] Fixes --- src/posthog-core.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index bed7a9674..77ddc0cb1 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -842,6 +842,8 @@ export class PostHog { } if (event_name === '$heatmap') { + const persistenceProps = this.persistence.properties() + properties['distinct_id'] = persistenceProps.distinct_id properties = _extend({}, infoProperties, properties) // Early exit for heatmaps, as they don't need any other properties return properties From 817074f541cbcd4fa36d34d626ac2bf4ecf9646c Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 00:01:24 +0200 Subject: [PATCH 24/41] Fix test --- src/__tests__/posthog-core.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 23d760be8..27b312bf0 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -462,18 +462,6 @@ describe('posthog core', () => { expect(given.overrides.sessionManager.checkAndGetSessionAndWindowId).not.toHaveBeenCalled() }) - it('only adds a few propertes if event is $performance_event', () => { - given('event_name', () => '$performance_event') - expect(given.subject).toEqual({ - distinct_id: 'abc', - event: 'prop', // from actual mock event properties - $current_url: undefined, - $session_id: 'sessionId', - $window_id: 'windowId', - token: 'testtoken', - }) - }) - it('calls sanitize_properties', () => { given('sanitize_properties', () => (props, event_name) => ({ token: props.token, event_name })) From 6af76f4022a59c226b4e58f9eb261207849cdf85 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 09:52:41 +0200 Subject: [PATCH 25/41] Change name --- src/heatmaps.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index ce1ecf1c6..2c6087dec 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -41,8 +41,8 @@ export class Heatmaps { private _getProperties(e: MouseEvent): Properties { return { - $mouse_x: e.clientX, - $mouse_y: e.clientY, + $pointer_x: e.clientX, + $pointer_y: e.clientY, } } From 97ad2b5c6ca50fcd9f5c080b5856bba40d712191 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 11:10:17 +0200 Subject: [PATCH 26/41] chore: Pageview refactor (#1132) --- src/__tests__/page-view.test.ts | 25 ++--- src/page-view.ts | 167 ++++++-------------------------- src/posthog-core.ts | 5 +- src/scroll-manager.ts | 93 ++++++++++++++++++ 4 files changed, 137 insertions(+), 153 deletions(-) create mode 100644 src/scroll-manager.ts diff --git a/src/__tests__/page-view.test.ts b/src/__tests__/page-view.test.ts index 3dcc30fe2..3e296200a 100644 --- a/src/__tests__/page-view.test.ts +++ b/src/__tests__/page-view.test.ts @@ -1,5 +1,6 @@ import { PageViewManager } from '../page-view' import { PostHog } from '../posthog-core' +import { ScrollManager } from '../scroll-manager' const mockWindowGetter = jest.fn() jest.mock('../utils/globals', () => ({ @@ -11,10 +12,15 @@ jest.mock('../utils/globals', () => ({ describe('PageView ID manager', () => { describe('doPageView', () => { - const instance: PostHog = { - config: {}, - } as any + let instance: PostHog + let pageViewIdManager: PageViewManager + beforeEach(() => { + instance = { + config: {}, + } as any + instance.scrollManager = new ScrollManager(instance) + pageViewIdManager = new PageViewManager(instance) mockWindowGetter.mockReturnValue({ location: { pathname: '/pathname', @@ -45,11 +51,10 @@ describe('PageView ID manager', () => { }, }) - const pageViewIdManager = new PageViewManager(instance) pageViewIdManager.doPageView() // force the manager to update the scroll data by calling an internal method - pageViewIdManager._updateScrollData() + instance.scrollManager['_updateScrollData']() const secondPageView = pageViewIdManager.doPageView() expect(secondPageView.$prev_pageview_last_scroll).toEqual(2000) @@ -76,11 +81,10 @@ describe('PageView ID manager', () => { }, }) - const pageViewIdManager = new PageViewManager(instance) pageViewIdManager.doPageView() // force the manager to update the scroll data by calling an internal method - pageViewIdManager._updateScrollData() + instance.scrollManager['_updateScrollData']() const secondPageView = pageViewIdManager.doPageView() expect(secondPageView.$prev_pageview_last_scroll).toEqual(0) @@ -94,9 +98,7 @@ describe('PageView ID manager', () => { }) it('can handle scroll updates before doPageView is called', () => { - const pageViewIdManager = new PageViewManager(instance) - - pageViewIdManager._updateScrollData() + instance.scrollManager['_updateScrollData']() const firstPageView = pageViewIdManager.doPageView() expect(firstPageView.$prev_pageview_last_scroll).toBeUndefined() @@ -105,8 +107,7 @@ describe('PageView ID manager', () => { }) it('should include the pathname', () => { - const pageViewIdManager = new PageViewManager(instance) - + instance.scrollManager['_updateScrollData']() const firstPageView = pageViewIdManager.doPageView() expect(firstPageView.$prev_pageview_pathname).toBeUndefined() const secondPageView = pageViewIdManager.doPageView() diff --git a/src/page-view.ts b/src/page-view.ts index be9b71b71..35d79b1b6 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -1,22 +1,9 @@ import { window } from './utils/globals' import { PostHog } from './posthog-core' -import { _isArray } from './utils/type-utils' +import { _isUndefined } from './utils/type-utils' -interface PageViewData { - pathname: string - // scroll is how far down the page the user has scrolled, - // content is how far down the page the user can view content - // (e.g. if the page is 1000 tall, but the user's screen is only 500 tall, - // and they don't scroll at all, then scroll is 0 and content is 500) - maxScrollHeight?: number - maxScrollY?: number - lastScrollY?: number - maxContentHeight?: number - maxContentY?: number - lastContentY?: number -} - -interface ScrollProperties { +interface PageViewEventProperties { + $prev_pageview_pathname?: string $prev_pageview_last_scroll?: number $prev_pageview_last_scroll_percentage?: number $prev_pageview_max_scroll?: number @@ -27,73 +14,49 @@ interface ScrollProperties { $prev_pageview_max_content_percentage?: number } -interface PageViewEventProperties extends ScrollProperties { - $prev_pageview_pathname?: string -} - export class PageViewManager { - _pageViewData: PageViewData | undefined - _hasSeenPageView = false + _currentPath?: string _instance: PostHog constructor(instance: PostHog) { this._instance = instance } - _createPageViewData(): PageViewData { - return { - pathname: window?.location.pathname ?? '', - } - } - doPageView(): PageViewEventProperties { - let prevPageViewData: PageViewData | undefined - // if there were events created before the first PageView, we would have created a - // pageViewData for them. If this happened, we don't want to create a new pageViewData - if (!this._hasSeenPageView) { - this._hasSeenPageView = true - prevPageViewData = undefined - if (!this._pageViewData) { - this._pageViewData = this._createPageViewData() - } - } else { - prevPageViewData = this._pageViewData - this._pageViewData = this._createPageViewData() - } + const response = this._previousScrollProperties() - // update the scroll properties for the new page, but wait until the next tick - // of the event loop - setTimeout(this._updateScrollData, 0) + // On a pageview we reset the contexts + this._currentPath = window?.location.pathname ?? '' + this._instance.scrollManager.resetContext() - return { - $prev_pageview_pathname: prevPageViewData?.pathname, - ...this._calculatePrevPageScrollProperties(prevPageViewData), - } + return response } doPageLeave(): PageViewEventProperties { - const prevPageViewData = this._pageViewData - return { - $prev_pageview_pathname: prevPageViewData?.pathname, - ...this._calculatePrevPageScrollProperties(prevPageViewData), - } + return this._previousScrollProperties() } - _calculatePrevPageScrollProperties(prevPageViewData: PageViewData | undefined): ScrollProperties { + private _previousScrollProperties(): PageViewEventProperties { + const previousPath = this._currentPath + const scrollContext = this._instance.scrollManager.getContext() + + if (!previousPath || !scrollContext) { + return {} + } + + let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = scrollContext + if ( - !prevPageViewData || - prevPageViewData.maxScrollHeight == null || - prevPageViewData.lastScrollY == null || - prevPageViewData.maxScrollY == null || - prevPageViewData.maxContentHeight == null || - prevPageViewData.lastContentY == null || - prevPageViewData.maxContentY == null + _isUndefined(maxScrollHeight) || + _isUndefined(lastScrollY) || + _isUndefined(maxScrollY) || + _isUndefined(maxContentHeight) || + _isUndefined(lastContentY) || + _isUndefined(maxContentY) ) { return {} } - let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = prevPageViewData - // Use ceil, so that e.g. scrolling 999.5px of a 1000px page is considered 100% scrolled maxScrollHeight = Math.ceil(maxScrollHeight) lastScrollY = Math.ceil(lastScrollY) @@ -109,6 +72,7 @@ export class PageViewManager { const maxContentPercentage = maxContentHeight <= 1 ? 1 : clamp(maxContentY / maxContentHeight, 0, 1) return { + $prev_pageview_pathname: previousPath, $prev_pageview_last_scroll: lastScrollY, $prev_pageview_last_scroll_percentage: lastScrollPercentage, $prev_pageview_max_scroll: maxScrollY, @@ -119,83 +83,6 @@ export class PageViewManager { $prev_pageview_max_content_percentage: maxContentPercentage, } } - - _updateScrollData = () => { - if (!this._pageViewData) { - this._pageViewData = this._createPageViewData() - } - const pageViewData = this._pageViewData - - const scrollY = this._scrollY() - const scrollHeight = this._scrollHeight() - const contentY = this._contentY() - const contentHeight = this._contentHeight() - - pageViewData.lastScrollY = scrollY - pageViewData.maxScrollY = Math.max(scrollY, pageViewData.maxScrollY ?? 0) - pageViewData.maxScrollHeight = Math.max(scrollHeight, pageViewData.maxScrollHeight ?? 0) - - pageViewData.lastContentY = contentY - pageViewData.maxContentY = Math.max(contentY, pageViewData.maxContentY ?? 0) - pageViewData.maxContentHeight = Math.max(contentHeight, pageViewData.maxContentHeight ?? 0) - } - - startMeasuringScrollPosition() { - // 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 - window?.addEventListener('scroll', this._updateScrollData, true) - window?.addEventListener('scrollend', this._updateScrollData, true) - window?.addEventListener('resize', this._updateScrollData) - } - - stopMeasuringScrollPosition() { - window?.removeEventListener('scroll', this._updateScrollData) - window?.removeEventListener('scrollend', this._updateScrollData) - window?.removeEventListener('resize', this._updateScrollData) - } - - _scrollElement(): Element | null | undefined { - if (this._instance.config.scroll_root_selector) { - const selectors = _isArray(this._instance.config.scroll_root_selector) - ? this._instance.config.scroll_root_selector - : [this._instance.config.scroll_root_selector] - for (const selector of selectors) { - const element = window?.document.querySelector(selector) - if (element) { - return element - } - } - return undefined - } else { - return window?.document.documentElement - } - } - - _scrollHeight(): number { - const element = this._scrollElement() - return element ? Math.max(0, element.scrollHeight - element.clientHeight) : 0 - } - - _scrollY(): number { - if (this._instance.config.scroll_root_selector) { - const element = this._scrollElement() - return (element && element.scrollTop) || 0 - } else { - return window ? window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 : 0 - } - } - - _contentHeight(): number { - const element = this._scrollElement() - return element?.scrollHeight || 0 - } - - _contentY(): number { - const element = this._scrollElement() - const clientHeight = element?.clientHeight || 0 - return this._scrollY() + clientHeight - } } function clamp(x: number, min: number, max: number) { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 77ddc0cb1..953fecf6d 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -72,6 +72,7 @@ import { _isBlockedUA } from './utils/blocked-uas' import { extendURLParams, request, SUPPORTS_REQUEST } from './request' import { Autocapture } from './autocapture' import { Heatmaps } from './heatmaps' +import { ScrollManager } from './scroll-manager' /* SIMPLE STYLE GUIDE: @@ -203,6 +204,7 @@ export class PostHog { config: PostHogConfig rateLimiter: RateLimiter + scrollManager: ScrollManager pageViewManager: PageViewManager featureFlags: PostHogFeatureFlags surveys: PostHogSurveys @@ -250,6 +252,7 @@ export class PostHog { this.featureFlags = new PostHogFeatureFlags(this) this.toolbar = new Toolbar(this) + this.scrollManager = new ScrollManager(this) this.pageViewManager = new PageViewManager(this) this.surveys = new PostHogSurveys(this) this.rateLimiter = new RateLimiter() @@ -366,7 +369,7 @@ export class PostHog { this.sessionRecording.startRecordingIfEnabled() if (!this.config.disable_scroll_properties) { - this.pageViewManager.startMeasuringScrollPosition() + this.scrollManager.startMeasuringScrollPosition() } this.autocapture = new Autocapture(this) diff --git a/src/scroll-manager.ts b/src/scroll-manager.ts new file mode 100644 index 000000000..b162e1bfc --- /dev/null +++ b/src/scroll-manager.ts @@ -0,0 +1,93 @@ +import { window } from './utils/globals' +import { PostHog } from './posthog-core' +import { _isArray } from './utils/type-utils' + +export interface ScrollContext { + // scroll is how far down the page the user has scrolled, + // content is how far down the page the user can view content + // (e.g. if the page is 1000 tall, but the user's screen is only 500 tall, + // and they don't scroll at all, then scroll is 0 and content is 500) + maxScrollHeight?: number + maxScrollY?: number + lastScrollY?: number + maxContentHeight?: number + maxContentY?: number + lastContentY?: number +} + +// This class is responsible for tracking scroll events and maintaining the scroll context +export class ScrollManager { + private context: ScrollContext | undefined + + constructor(private instance: PostHog) {} + + getContext(): ScrollContext | undefined { + return this.context + } + + resetContext(): ScrollContext | undefined { + const ctx = this.context + + // update the scroll properties for the new page, but wait until the next tick + // of the event loop + setTimeout(this._updateScrollData, 0) + + return ctx + } + + private _updateScrollData = () => { + if (!this.context) { + this.context = {} + } + + const el = this._scrollElement() + + const scrollY = this._scrollY() + const scrollHeight = el ? Math.max(0, el.scrollHeight - el.clientHeight) : 0 + const contentY = scrollY + (el?.clientHeight || 0) + const contentHeight = el?.scrollHeight || 0 + + this.context.lastScrollY = Math.ceil(scrollY) + this.context.maxScrollY = Math.max(scrollY, this.context.maxScrollY ?? 0) + this.context.maxScrollHeight = Math.max(scrollHeight, this.context.maxScrollHeight ?? 0) + + this.context.lastContentY = contentY + this.context.maxContentY = Math.max(contentY, this.context.maxContentY ?? 0) + this.context.maxContentHeight = Math.max(contentHeight, this.context.maxContentHeight ?? 0) + } + + startMeasuringScrollPosition() { + // 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 + window?.addEventListener('scroll', this._updateScrollData, true) + window?.addEventListener('scrollend', this._updateScrollData, true) + window?.addEventListener('resize', this._updateScrollData) + } + + private _scrollElement(): Element | null | undefined { + if (this.instance.config.scroll_root_selector) { + const selectors = _isArray(this.instance.config.scroll_root_selector) + ? this.instance.config.scroll_root_selector + : [this.instance.config.scroll_root_selector] + for (const selector of selectors) { + const element = window?.document.querySelector(selector) + if (element) { + return element + } + } + return undefined + } else { + return window?.document.documentElement + } + } + + private _scrollY(): number { + if (this.instance.config.scroll_root_selector) { + const element = this._scrollElement() + return (element && element.scrollTop) || 0 + } else { + return window ? window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 : 0 + } + } +} From ae795c2d05ab485310765330733d44d8d50dbf1f Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 12:26:41 +0200 Subject: [PATCH 27/41] Fixes --- playground/nextjs/pages/index.tsx | 6 ++++- src/autocapture-utils.ts | 2 +- src/heatmaps.ts | 39 +++++++++++++++++++++++++++---- src/scroll-manager.ts | 19 +++++++++++---- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/playground/nextjs/pages/index.tsx b/playground/nextjs/pages/index.tsx index bf3d68756..e700cbe2c 100644 --- a/playground/nextjs/pages/index.tsx +++ b/playground/nextjs/pages/index.tsx @@ -30,7 +30,11 @@ export default function Home() {
-

PostHog React

+
+

+ PostHog React +

+

The current time is {time}

diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index 2f90ddfea..a65054cb4 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -173,7 +173,7 @@ function checkIfElementTreePassesCSSSelectorAllowList( return false } -function getParentElement(curEl: Element): Element | false { +export function getParentElement(curEl: Element): Element | false { const parentNode = curEl.parentNode if (!parentNode || !isElementNode(parentNode)) return false return parentNode diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 2c6087dec..82474c25f 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -1,9 +1,28 @@ -import { _register_event } from './utils' +import { _include, _includes, _register_event } from './utils' import RageClick from './extensions/rageclick' import { Properties } from './types' import { PostHog } from './posthog-core' import { document, window } from './utils/globals' +import { getParentElement, isTag } from './autocapture-utils' + +function elementOrParentPositionMatches(el: Element, matches: string[], breakOnElement?: Element): boolean { + let curEl: Element | false = el + + while (curEl && !isTag(curEl, 'body')) { + if (curEl === breakOnElement) { + return false + } + + if (_includes(matches, window?.getComputedStyle(curEl).position)) { + return true + } + + curEl = getParentElement(curEl) + } + + return false +} export class Heatmaps { instance: PostHog @@ -40,9 +59,20 @@ export class Heatmaps { } private _getProperties(e: MouseEvent): Properties { + // We need to know if the target element is fixed or not + // If fixed then we won't account for scrolling + // If not then we will account for scrolling + + const scrollY = this.instance.scrollManager.scrollY() + const scrollX = this.instance.scrollManager.scrollX() + const scrollElement = this.instance.scrollManager.scrollElement() + + const isFixedOrSticky = elementOrParentPositionMatches(e.target as Element, ['fixed', 'sticky'], scrollElement) + return { - $pointer_x: e.clientX, - $pointer_y: e.clientY, + $pointer_x: e.clientX + (isFixedOrSticky ? 0 : scrollX), + $pointer_y: e.clientY + (isFixedOrSticky ? 0 : scrollY), + $pointer_target_fixed: isFixedOrSticky, } } @@ -65,11 +95,10 @@ export class Heatmaps { } private _onMouseMove(e: Event): void { - const properties = this._getProperties(e as MouseEvent) - clearTimeout(this._mouseMoveTimeout) this._mouseMoveTimeout = setTimeout(() => { + const properties = this._getProperties(e as MouseEvent) this._capture({ ...properties, $heatmap_event: 'mousemove', diff --git a/src/scroll-manager.ts b/src/scroll-manager.ts index b162e1bfc..6cd8eec12 100644 --- a/src/scroll-manager.ts +++ b/src/scroll-manager.ts @@ -40,9 +40,9 @@ export class ScrollManager { this.context = {} } - const el = this._scrollElement() + const el = this.scrollElement() - const scrollY = this._scrollY() + const scrollY = this.scrollY() const scrollHeight = el ? Math.max(0, el.scrollHeight - el.clientHeight) : 0 const contentY = scrollY + (el?.clientHeight || 0) const contentHeight = el?.scrollHeight || 0 @@ -65,7 +65,7 @@ export class ScrollManager { window?.addEventListener('resize', this._updateScrollData) } - private _scrollElement(): Element | null | undefined { + public scrollElement(): Element | undefined { if (this.instance.config.scroll_root_selector) { const selectors = _isArray(this.instance.config.scroll_root_selector) ? this.instance.config.scroll_root_selector @@ -82,12 +82,21 @@ export class ScrollManager { } } - private _scrollY(): number { + public scrollY(): number { if (this.instance.config.scroll_root_selector) { - const element = this._scrollElement() + const element = this.scrollElement() return (element && element.scrollTop) || 0 } else { return window ? window.scrollY || window.pageYOffset || window.document.documentElement.scrollTop || 0 : 0 } } + + public scrollX(): number { + if (this.instance.config.scroll_root_selector) { + const element = this.scrollElement() + return (element && element.scrollLeft) || 0 + } else { + return window ? window.scrollX || window.pageXOffset || window.document.documentElement.scrollLeft || 0 : 0 + } + } } From db050c4ad22cc1b3c33b4d95ca0795adb69aced7 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 14:51:11 +0200 Subject: [PATCH 28/41] Fix --- src/heatmaps.ts | 4 ++-- src/posthog-core.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 82474c25f..da66ce9e1 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -1,4 +1,4 @@ -import { _include, _includes, _register_event } from './utils' +import { _includes, _register_event } from './utils' import RageClick from './extensions/rageclick' import { Properties } from './types' import { PostHog } from './posthog-core' @@ -103,7 +103,7 @@ export class Heatmaps { ...properties, $heatmap_event: 'mousemove', }) - }, 1000) + }, 500) } private _capture(properties: Properties): void { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 953fecf6d..402774405 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -849,6 +849,11 @@ export class PostHog { properties['distinct_id'] = persistenceProps.distinct_id properties = _extend({}, infoProperties, properties) // Early exit for heatmaps, as they don't need any other properties + + // TODO: Remove below testing code + const heatmapData = (window.heatmapData = window.heatmapData ?? []) + heatmapData.push(properties) + return properties } From 08d05034b9698e111e0f162f4915dfd7229b3cde Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 11 Apr 2024 15:38:56 +0200 Subject: [PATCH 29/41] Fix --- src/posthog-core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 402774405..02cb30d0c 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -851,7 +851,7 @@ export class PostHog { // Early exit for heatmaps, as they don't need any other properties // TODO: Remove below testing code - const heatmapData = (window.heatmapData = window.heatmapData ?? []) + const heatmapData = (assignableWindow.heatmapData = assignableWindow.heatmapData ?? []) heatmapData.push(properties) return properties From d9c7ab217bd67a583f120b6ce37e6ee94d261e1b Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 12 Apr 2024 11:09:35 +0200 Subject: [PATCH 30/41] Fixes --- src/heatmaps.ts | 34 ++++++++++++++++------------------ src/posthog-core.ts | 18 +++++------------- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index da66ce9e1..4e0d0b0ec 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -31,6 +31,8 @@ export class Heatmaps { _initialized = false _mouseMoveTimeout: number | undefined + private buffer: Properties[] = [] + constructor(instance: PostHog) { this.instance = instance @@ -43,6 +45,10 @@ export class Heatmaps { return !!this.instance.config.__preview_heatmaps } + public getBuffer(): Properties[] { + return this.buffer + } + private _setupListeners(): void { if (!window || !document) { return @@ -58,7 +64,7 @@ export class Heatmaps { ) } - private _getProperties(e: MouseEvent): Properties { + private _getProperties(e: MouseEvent, type: string): Properties { // We need to know if the target element is fixed or not // If fixed then we won't account for scrolling // If not then we will account for scrolling @@ -70,45 +76,37 @@ export class Heatmaps { const isFixedOrSticky = elementOrParentPositionMatches(e.target as Element, ['fixed', 'sticky'], scrollElement) return { - $pointer_x: e.clientX + (isFixedOrSticky ? 0 : scrollX), - $pointer_y: e.clientY + (isFixedOrSticky ? 0 : scrollY), - $pointer_target_fixed: isFixedOrSticky, + x: e.clientX + (isFixedOrSticky ? 0 : scrollX), + y: e.clientY + (isFixedOrSticky ? 0 : scrollY), + target_fixed: isFixedOrSticky, + type, } } private _onClick(e: MouseEvent): void { - const properties = this._getProperties(e) + const properties = this._getProperties(e, 'click') if (this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())) { this._capture({ ...properties, - $heatmap_event: 'rageclick', + type: 'rageclick', }) } // TODO: Detect deadclicks - this._capture({ - ...properties, - $heatmap_event: 'click', - }) + this._capture(properties) } private _onMouseMove(e: Event): void { clearTimeout(this._mouseMoveTimeout) this._mouseMoveTimeout = setTimeout(() => { - const properties = this._getProperties(e as MouseEvent) - this._capture({ - ...properties, - $heatmap_event: 'mousemove', - }) + this._capture(this._getProperties(e as MouseEvent, 'mousemove')) }, 500) } private _capture(properties: Properties): void { - this.instance.capture('$heatmap', properties, { - _batchKey: 'heatmaps', - }) + this.buffer.push(properties) } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 02cb30d0c..4de842148 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -844,19 +844,6 @@ export class PostHog { properties['title'] = document.title } - if (event_name === '$heatmap') { - const persistenceProps = this.persistence.properties() - properties['distinct_id'] = persistenceProps.distinct_id - properties = _extend({}, infoProperties, properties) - // Early exit for heatmaps, as they don't need any other properties - - // TODO: Remove below testing code - const heatmapData = (assignableWindow.heatmapData = assignableWindow.heatmapData ?? []) - heatmapData.push(properties) - - return properties - } - // set $duration if time_event was previously called for this event if (!_isUndefined(start_timestamp)) { const duration_in_ms = new Date().getTime() - start_timestamp @@ -910,6 +897,11 @@ export class PostHog { // add person processing flag as very last step, so it cannot be overridden. process_person=true is default properties['$process_person'] = this._hasPersonProcessing() + const heatmapsBuffer = this.heatmaps?.getBuffer() + if (heatmapsBuffer?.length) { + properties['$heatmap_events'] = heatmapsBuffer + } + return properties } From 1412ffd86395bbea1df2e98d1ad73faf592b6595 Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 12 Apr 2024 11:10:11 +0200 Subject: [PATCH 31/41] Fix --- src/heatmaps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 4e0d0b0ec..3b7b86825 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -31,6 +31,7 @@ export class Heatmaps { _initialized = false _mouseMoveTimeout: number | undefined + // TODO: Periodically flush this if no other event has taken care of it private buffer: Properties[] = [] constructor(instance: PostHog) { From 004ba760e6811f0dfa71dc442327cd0e00041caf Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 12 Apr 2024 11:27:14 +0200 Subject: [PATCH 32/41] Fix --- src/posthog-core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 4de842148..a4e5c3de7 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -899,7 +899,7 @@ export class PostHog { const heatmapsBuffer = this.heatmaps?.getBuffer() if (heatmapsBuffer?.length) { - properties['$heatmap_events'] = heatmapsBuffer + properties['$heatmap_data'] = heatmapsBuffer } return properties From f7c9e78e437335e28b624429bce1ceaf914bd6cb Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 12 Apr 2024 13:27:07 +0200 Subject: [PATCH 33/41] Fixes --- src/heatmaps.ts | 6 ++++-- src/posthog-core.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 3b7b86825..9824e7b5a 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -46,8 +46,10 @@ export class Heatmaps { return !!this.instance.config.__preview_heatmaps } - public getBuffer(): Properties[] { - return this.buffer + public getAndClearBuffer(): Properties[] { + const buffer = this.buffer + this.buffer = [] + return buffer } private _setupListeners(): void { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index a4e5c3de7..a33076c38 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -897,7 +897,7 @@ export class PostHog { // add person processing flag as very last step, so it cannot be overridden. process_person=true is default properties['$process_person'] = this._hasPersonProcessing() - const heatmapsBuffer = this.heatmaps?.getBuffer() + const heatmapsBuffer = this.heatmaps?.getAndClearBuffer() if (heatmapsBuffer?.length) { properties['$heatmap_data'] = heatmapsBuffer } From a9c51fbcdb3970fe663d12994cc6a12cf74448ed Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 12 Apr 2024 18:36:44 +0200 Subject: [PATCH 34/41] Fix --- src/heatmaps.ts | 25 +++++++++++++++++++++---- src/posthog-core.ts | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index 9824e7b5a..fe29395cf 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -32,7 +32,11 @@ export class Heatmaps { _mouseMoveTimeout: number | undefined // TODO: Periodically flush this if no other event has taken care of it - private buffer: Properties[] = [] + private buffer: + | { + [key: string]: Properties[] + } + | undefined constructor(instance: PostHog) { this.instance = instance @@ -46,9 +50,11 @@ export class Heatmaps { return !!this.instance.config.__preview_heatmaps } - public getAndClearBuffer(): Properties[] { + public getAndClearBuffer(): { + [key: string]: Properties[] + } { const buffer = this.buffer - this.buffer = [] + this.buffer = undefined return buffer } @@ -110,6 +116,17 @@ export class Heatmaps { } private _capture(properties: Properties): void { - this.buffer.push(properties) + if (!window) { + return + } + const url = window.location.href + + this.buffer = this.buffer || {} + + if (!this.buffer[url]) { + this.buffer[url] = [] + } + + this.buffer[url].push(properties) } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index a33076c38..cc12dc214 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -898,7 +898,7 @@ export class PostHog { properties['$process_person'] = this._hasPersonProcessing() const heatmapsBuffer = this.heatmaps?.getAndClearBuffer() - if (heatmapsBuffer?.length) { + if (heatmapsBuffer) { properties['$heatmap_data'] = heatmapsBuffer } From 4372aa17cac692d088ae3a6bf2ea3be06d3295da Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 12 Apr 2024 18:44:40 +0200 Subject: [PATCH 35/41] it can be undefined maybe --- src/heatmaps.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index fe29395cf..a7fd16d89 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -6,6 +6,12 @@ import { PostHog } from './posthog-core' import { document, window } from './utils/globals' import { getParentElement, isTag } from './autocapture-utils' +type HeatmapEventBuffer = + | { + [key: string]: Properties[] + } + | undefined + function elementOrParentPositionMatches(el: Element, matches: string[], breakOnElement?: Element): boolean { let curEl: Element | false = el @@ -32,11 +38,7 @@ export class Heatmaps { _mouseMoveTimeout: number | undefined // TODO: Periodically flush this if no other event has taken care of it - private buffer: - | { - [key: string]: Properties[] - } - | undefined + private buffer: HeatmapEventBuffer constructor(instance: PostHog) { this.instance = instance @@ -50,9 +52,7 @@ export class Heatmaps { return !!this.instance.config.__preview_heatmaps } - public getAndClearBuffer(): { - [key: string]: Properties[] - } { + public getAndClearBuffer(): HeatmapEventBuffer { const buffer = this.buffer this.buffer = undefined return buffer From a6f548b6efbc2628d479b3fcf07ab8d532eff7b3 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:19:56 +0200 Subject: [PATCH 36/41] Added skipping for heatmaps on snapshots --- .../replay/sessionrecording.test.ts | 4 + src/__tests__/heatmaps.test.ts | 91 +++++++++++++++++++ src/extensions/exception-autocapture/index.ts | 1 + src/extensions/replay/sessionrecording.ts | 1 + src/posthog-core.ts | 12 ++- src/types.ts | 1 + 6 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/heatmaps.test.ts diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index 53a1cbec8..7d93b2d31 100644 --- a/src/__tests__/extensions/replay/sessionrecording.test.ts +++ b/src/__tests__/extensions/replay/sessionrecording.test.ts @@ -571,6 +571,7 @@ describe('SessionRecording', () => { { _url: 'https://test.com/s/', _noTruncate: true, + _noHeatmaps: true, _batchKey: 'recordings', } ) @@ -606,6 +607,7 @@ describe('SessionRecording', () => { { _url: 'https://test.com/s/', _noTruncate: true, + _noHeatmaps: true, _batchKey: 'recordings', } ) @@ -688,6 +690,7 @@ describe('SessionRecording', () => { { _url: 'https://test.com/s/', _noTruncate: true, + _noHeatmaps: true, _batchKey: 'recordings', } ) @@ -1322,6 +1325,7 @@ describe('SessionRecording', () => { { _batchKey: 'recordings', _noTruncate: true, + _noHeatmaps: true, _url: 'https://test.com/s/', } ) diff --git a/src/__tests__/heatmaps.test.ts b/src/__tests__/heatmaps.test.ts new file mode 100644 index 000000000..d7083d8ca --- /dev/null +++ b/src/__tests__/heatmaps.test.ts @@ -0,0 +1,91 @@ +import { createPosthogInstance } from './helpers/posthog-instance' +import { uuidv7 } from '../uuidv7' +import { PostHog } from '../posthog-core' +jest.mock('../utils/logger') + +describe('heatmaps', () => { + let posthog: PostHog + let onCapture = jest.fn() + + const mockClickEvent = { + target: document.body, + clientX: 10, + clientY: 20, + } as unknown as MouseEvent + + const createMockMouseEvent = (props: Partial = {}) => + ({ + target: document.body, + clientX: 10, + clientY: 10, + ...props, + } as unknown as MouseEvent) + + beforeEach(async () => { + onCapture = jest.fn() + posthog = await createPosthogInstance(uuidv7(), { _onCapture: onCapture }) + }) + + it('should include generated heatmap data', async () => { + posthog.heatmaps?.['_onClick']?.(mockClickEvent as MouseEvent) + posthog.capture('test event') + + expect(onCapture).toBeCalledTimes(1) + expect(onCapture.mock.lastCall).toMatchObject([ + 'test event', + { + event: 'test event', + properties: { + $heatmap_data: { + 'http://localhost/': [ + { + target_fixed: false, + type: 'click', + x: 10, + y: 20, + }, + ], + }, + }, + }, + ]) + }) + + it('should add rageclick events in the same area', async () => { + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + + posthog.capture('test event') + + expect(onCapture).toBeCalledTimes(1) + expect(onCapture.mock.lastCall[1].properties.$heatmap_data['http://localhost/']).toHaveLength(4) + expect(onCapture.mock.lastCall[1].properties.$heatmap_data['http://localhost/'].map((x) => x.type)).toEqual([ + 'click', + 'click', + 'rageclick', + 'click', + ]) + }) + + it('should clear the buffer after each call', async () => { + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + posthog.capture('test event') + expect(onCapture).toBeCalledTimes(1) + expect(onCapture.mock.lastCall[1].properties.$heatmap_data['http://localhost/']).toHaveLength(2) + + posthog.capture('test event 2') + expect(onCapture).toBeCalledTimes(2) + expect(onCapture.mock.lastCall[1].properties.$heatmap_data).toBeUndefined() + }) + + it('should not include generated heatmap data for $snapshot events with _noHeatmaps', async () => { + posthog.heatmaps?.['_onClick']?.(createMockMouseEvent()) + posthog.capture('$snapshot', undefined, { _noHeatmaps: true }) + + expect(onCapture).toBeCalledTimes(1) + expect(onCapture.mock.lastCall).toMatchObject(['$snapshot', {}]) + expect(onCapture.mock.lastCall[1].properties).not.toHaveProperty('$heatmap_data') + }) +}) diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index 6b021b6f1..1b6fd99d2 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -143,6 +143,7 @@ export class ExceptionObserver { this.instance.capture('$exception', properties, { _noTruncate: true, _batchKey: 'exceptionEvent', + _noHeatmaps: true, }) } } diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index f5a26c864..8443eab37 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -916,6 +916,7 @@ export class SessionRecording { _url: this.instance.requestRouter.endpointFor('api', this._endpoint), _noTruncate: true, _batchKey: SESSION_RECORDING_BATCH_KEY, + _noHeatmaps: true, // Session Replay ingestion can't handle heatamap data }) } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index cc12dc214..02c68954a 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -740,6 +740,13 @@ export class PostHog { properties: this._calculate_event_properties(event_name, properties || {}), } + if (!options?._noHeatmaps) { + const heatmapsBuffer = this.heatmaps?.getAndClearBuffer() + if (heatmapsBuffer) { + data.properties['$heatmap_data'] = heatmapsBuffer + } + } + const setProperties = options?.$set if (setProperties) { data.$set = options?.$set @@ -897,11 +904,6 @@ export class PostHog { // add person processing flag as very last step, so it cannot be overridden. process_person=true is default properties['$process_person'] = this._hasPersonProcessing() - const heatmapsBuffer = this.heatmaps?.getAndClearBuffer() - if (heatmapsBuffer) { - properties['$heatmap_data'] = heatmapsBuffer - } - return properties } diff --git a/src/types.ts b/src/types.ts index 365103ec2..b61cdb5d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -243,6 +243,7 @@ export interface CaptureOptions { $set?: Properties /** used with $identify */ $set_once?: Properties /** used with $identify */ _url?: string /** Used to override the desired endpoint for the captured event */ + _noHeatmaps?: boolean /** Used to ensure that heatmap data is not included with this event */ _batchKey?: string /** key of queue, e.g. 'sessionRecording' vs 'event' */ _noTruncate?: boolean /** if set, overrides and disables config.properties_string_max_length */ send_instantly?: boolean /** if set skips the batched queue */ From d46b885a41f5019061149e2f2f4601f2c0d7471e Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:30:26 +0200 Subject: [PATCH 37/41] fix --- src/autocapture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocapture.ts b/src/autocapture.ts index c9e922d3e..bd33e680a 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -22,7 +22,7 @@ import { AutocaptureConfig, DecideResponse, Properties } from './types' import { PostHog } from './posthog-core' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' -import { _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' +import { _isBoolean, _isFunction, _isNull, _isObject, _isUndefined } from './utils/type-utils' import { logger } from './utils/logger' import { document, window } from './utils/globals' From 0cbf653a0d63bf147419947a2ea4929546f0e36f Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:32:31 +0200 Subject: [PATCH 38/41] Fixes --- src/heatmaps.ts | 6 +++++- src/posthog-core.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/heatmaps.ts b/src/heatmaps.ts index a7fd16d89..687bf0b05 100644 --- a/src/heatmaps.ts +++ b/src/heatmaps.ts @@ -42,8 +42,10 @@ export class Heatmaps { constructor(instance: PostHog) { this.instance = instance + } - if (this.isEnabled) { + public startIfEnabled(): void { + if (this.isEnabled && !this._initialized) { this._setupListeners() } } @@ -71,6 +73,8 @@ export class Heatmaps { false, true ) + + this._initialized = true } private _getProperties(e: MouseEvent, type: string): Properties { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 2c0506760..ade8af63f 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -403,8 +403,8 @@ export class PostHog { this.autocapture.startIfEnabled() this.surveys.loadIfEnabled() - this.autocapture = new Autocapture(this) this.heatmaps = new Heatmaps(this) + this.heatmaps.startIfEnabled() // if any instance on the page has debug = true, we set the // global debug to be true @@ -1686,6 +1686,7 @@ export class PostHog { this.sessionRecording?.startIfEnabledOrStop() this.autocapture?.startIfEnabled() + this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() } } From c79d24cf018c0b9486f24675a751e78f81e8d474 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:33:58 +0200 Subject: [PATCH 39/41] Fixes --- src/__tests__/autocapture.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/autocapture.test.ts b/src/__tests__/autocapture.test.ts index b87fb8e0c..acdc9b147 100644 --- a/src/__tests__/autocapture.test.ts +++ b/src/__tests__/autocapture.test.ts @@ -957,7 +957,6 @@ describe('Autocapture system', () => { }) it('should capture click events', () => { - autocapture['_addDomEventHandlers']() const button = document.createElement('button') document.body.appendChild(button) simulateClick(button) From b18767a0cbf1ad6db9a3a8762196f0924d8cf21a Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:34:29 +0200 Subject: [PATCH 40/41] fix --- src/__tests__/decide.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__tests__/decide.js b/src/__tests__/decide.js index a720e150c..1c0bff886 100644 --- a/src/__tests__/decide.js +++ b/src/__tests__/decide.js @@ -171,7 +171,6 @@ describe('Decide', () => { expect(given.posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith(given.decideResponse, false) expect(given.posthog._afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) - expect(given.posthog.autocapture.afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) }) it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { From c2f13a2297157e652807721a23108868f5c09149 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 09:35:16 +0200 Subject: [PATCH 41/41] fix --- playground/nextjs/src/posthog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index 13171f685..aa7c2a590 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -42,7 +42,7 @@ if (typeof window !== 'undefined') { debug: true, scroll_root_selector: ['#scroll_element', 'html'], // persistence: cookieConsentGiven() ? 'localStorage+cookie' : 'memory', - process_person: 'identified_only', + person_profiles: 'identified_only', __preview_heatmaps: true, ...configForConsent(), })