From ba1b4315ab4b7db3009a27da771ed4ce44ff8daf Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 18 Apr 2024 13:21:13 +0200 Subject: [PATCH] feat: Heatmaps instrumentation (#1131) --- playground/nextjs/pages/index.tsx | 6 +- playground/nextjs/src/posthog.ts | 3 +- .../replay/sessionrecording.test.ts | 4 + src/__tests__/heatmaps.test.ts | 91 ++++++++++ src/__tests__/page-view.test.ts | 25 +-- src/__tests__/posthog-core.js | 12 -- src/autocapture-utils.ts | 2 +- src/autocapture.ts | 1 - src/extensions/exception-autocapture/index.ts | 1 + src/extensions/replay/sessionrecording.ts | 1 + src/heatmaps.ts | 136 ++++++++++++++ src/page-view.ts | 167 +++--------------- src/posthog-core.ts | 28 +-- src/scroll-manager.ts | 102 +++++++++++ src/types.ts | 2 + 15 files changed, 403 insertions(+), 178 deletions(-) create mode 100644 src/__tests__/heatmaps.test.ts create mode 100644 src/heatmaps.ts create mode 100644 src/scroll-manager.ts diff --git a/playground/nextjs/pages/index.tsx b/playground/nextjs/pages/index.tsx index e619ff831..350c01053 100644 --- a/playground/nextjs/pages/index.tsx +++ b/playground/nextjs/pages/index.tsx @@ -32,7 +32,11 @@ export default function Home() {
-

PostHog React

+
+

+ PostHog React +

+

The current time is {time}

diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts index 6207a8775..aa7c2a590 100644 --- a/playground/nextjs/src/posthog.ts +++ b/playground/nextjs/src/posthog.ts @@ -42,7 +42,8 @@ if (typeof window !== 'undefined') { debug: true, scroll_root_selector: ['#scroll_element', 'html'], // persistence: cookieConsentGiven() ? 'localStorage+cookie' : 'memory', - __preview_process_person: 'identified_only', + person_profiles: 'identified_only', + __preview_heatmaps: true, ...configForConsent(), }) ;(window as any).posthog = posthog diff --git a/src/__tests__/extensions/replay/sessionrecording.test.ts b/src/__tests__/extensions/replay/sessionrecording.test.ts index c82b990d5..c8ae3cfb3 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/__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/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index ade6ceadb..5b335523e 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -451,18 +451,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 })) 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/autocapture.ts b/src/autocapture.ts index 0229675a4..bd33e680a 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -41,7 +41,6 @@ export class Autocapture { _isDisabledServerSide: boolean | null = null rageclicks = new RageClick() _elementsChainAsString = false - _decideResponse?: DecideResponse constructor(instance: PostHog) { this.instance = instance 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 289917411..1f405b7db 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -927,6 +927,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/heatmaps.ts b/src/heatmaps.ts new file mode 100644 index 000000000..687bf0b05 --- /dev/null +++ b/src/heatmaps.ts @@ -0,0 +1,136 @@ +import { _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' + +type HeatmapEventBuffer = + | { + [key: string]: Properties[] + } + | undefined + +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 + rageclicks = new RageClick() + _isDisabledServerSide: boolean | null = null + _initialized = false + _mouseMoveTimeout: number | undefined + + // TODO: Periodically flush this if no other event has taken care of it + private buffer: HeatmapEventBuffer + + constructor(instance: PostHog) { + this.instance = instance + } + + public startIfEnabled(): void { + if (this.isEnabled && !this._initialized) { + this._setupListeners() + } + } + + public get isEnabled(): boolean { + return !!this.instance.config.__preview_heatmaps + } + + public getAndClearBuffer(): HeatmapEventBuffer { + const buffer = this.buffer + this.buffer = undefined + return buffer + } + + 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 + ) + + this._initialized = true + } + + 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 + + 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 { + 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, 'click') + + if (this.rageclicks?.isRageClick(e.clientX, e.clientY, new Date().getTime())) { + this._capture({ + ...properties, + type: 'rageclick', + }) + } + + // TODO: Detect deadclicks + + this._capture(properties) + } + + private _onMouseMove(e: Event): void { + clearTimeout(this._mouseMoveTimeout) + + this._mouseMoveTimeout = setTimeout(() => { + this._capture(this._getProperties(e as MouseEvent, 'mousemove')) + }, 500) + } + + private _capture(properties: Properties): void { + 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/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 c51b4278a..bd026d17c 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -71,6 +71,8 @@ import { logger } from './utils/logger' import { SessionPropsManager } from './session-props' import { _isBlockedUA } from './utils/blocked-uas' import { extendURLParams, request, SUPPORTS_REQUEST } from './request' +import { Heatmaps } from './heatmaps' +import { ScrollManager } from './scroll-manager' import { SimpleEventEmitter } from './utils/simple-event-emitter' import { Autocapture } from './autocapture' @@ -233,6 +235,7 @@ export class PostHog { config: PostHogConfig rateLimiter: RateLimiter + scrollManager: ScrollManager pageViewManager: PageViewManager featureFlags: PostHogFeatureFlags surveys: PostHogSurveys @@ -245,6 +248,7 @@ export class PostHog { sessionPropsManager?: SessionPropsManager requestRouter: RequestRouter autocapture?: Autocapture + heatmaps?: Heatmaps _requestQueue?: RequestQueue _retryQueue?: RetryQueue @@ -277,6 +281,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(this) @@ -392,13 +397,16 @@ export class PostHog { this.sessionRecording.startIfEnabledOrStop() if (!this.config.disable_scroll_properties) { - this.pageViewManager.startMeasuringScrollPosition() + this.scrollManager.startMeasuringScrollPosition() } this.autocapture = new Autocapture(this) this.autocapture.startIfEnabled() this.surveys.loadIfEnabled() + 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 Config.DEBUG = Config.DEBUG || this.config.debug @@ -762,6 +770,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 @@ -859,14 +874,6 @@ 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 - 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 @@ -888,7 +895,7 @@ export class PostHog { // update properties with pageview info and super-properties properties = _extend( {}, - _info.properties(), + infoProperties, this.persistence.properties(), this.sessionPersistence.properties(), properties @@ -1685,6 +1692,7 @@ export class PostHog { this.sessionRecording?.startIfEnabledOrStop() this.autocapture?.startIfEnabled() + this.heatmaps?.startIfEnabled() this.surveys.loadIfEnabled() } } diff --git a/src/scroll-manager.ts b/src/scroll-manager.ts new file mode 100644 index 000000000..6cd8eec12 --- /dev/null +++ b/src/scroll-manager.ts @@ -0,0 +1,102 @@ +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) + } + + 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 + : [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 + } + } + + public 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 + } + } + + 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 + } + } +} diff --git a/src/types.ts b/src/types.ts index ca64f569c..05c2226f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,6 +145,7 @@ export interface PostHogConfig { bootstrap: BootstrapConfig segment?: SegmentAnalytics __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[] @@ -254,6 +255,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 */