- 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 */