From a3da1787a9fdf98fcff1952f85dbb714183a2bf3 Mon Sep 17 00:00:00 2001 From: Robbie Date: Fri, 3 Jan 2025 14:31:35 +0000 Subject: [PATCH] feat: Add pageview and prev pageview tracking (#1634) * Add pageview and prev pageview tracking * Make some invalid states unrepresentable * Add function to get the current pageview id * Add comment justifying storing state in memory only * Fix naming, push config if statement inside pageview manager --- src/__tests__/page-view.test.ts | 22 +++++++----- src/__tests__/posthog-core.ts | 51 ++++++++++++++++++++++----- src/page-view.ts | 61 +++++++++++++++++++++------------ src/posthog-core.ts | 32 +++++++++++------ 4 files changed, 117 insertions(+), 49 deletions(-) diff --git a/src/__tests__/page-view.test.ts b/src/__tests__/page-view.test.ts index c7001a38f..06c707fb7 100644 --- a/src/__tests__/page-view.test.ts +++ b/src/__tests__/page-view.test.ts @@ -14,6 +14,8 @@ describe('PageView ID manager', () => { const firstTimestamp = new Date() const duration = 42 const secondTimestamp = new Date(firstTimestamp.getTime() + duration * 1000) + const pageviewId1 = 'pageview-id-1' + const pageviewId2 = 'pageview-id-2' describe('doPageView', () => { let instance: PostHog @@ -55,12 +57,12 @@ describe('PageView ID manager', () => { }, }) - pageViewIdManager.doPageView(firstTimestamp) + pageViewIdManager.doPageView(firstTimestamp, pageviewId1) // force the manager to update the scroll data by calling an internal method instance.scrollManager['_updateScrollData']() - const secondPageView = pageViewIdManager.doPageView(secondTimestamp) + const secondPageView = pageViewIdManager.doPageView(secondTimestamp, pageviewId2) expect(secondPageView.$prev_pageview_last_scroll).toEqual(2000) expect(secondPageView.$prev_pageview_last_scroll_percentage).toBeCloseTo(2 / 3) expect(secondPageView.$prev_pageview_max_scroll).toEqual(2000) @@ -70,6 +72,8 @@ describe('PageView ID manager', () => { expect(secondPageView.$prev_pageview_max_content).toEqual(3000) expect(secondPageView.$prev_pageview_max_content_percentage).toBeCloseTo(3 / 4) expect(secondPageView.$prev_pageview_duration).toEqual(duration) + expect(secondPageView.$prev_pageview_id).toEqual(pageviewId1) + expect(secondPageView.$pageview_id).toEqual(pageviewId2) }) it('includes scroll position properties for a short page', () => { @@ -86,12 +90,12 @@ describe('PageView ID manager', () => { }, }) - pageViewIdManager.doPageView(firstTimestamp) + pageViewIdManager.doPageView(firstTimestamp, pageviewId1) // force the manager to update the scroll data by calling an internal method instance.scrollManager['_updateScrollData']() - const secondPageView = pageViewIdManager.doPageView(secondTimestamp) + const secondPageView = pageViewIdManager.doPageView(secondTimestamp, pageviewId2) expect(secondPageView.$prev_pageview_last_scroll).toEqual(0) expect(secondPageView.$prev_pageview_last_scroll_percentage).toEqual(1) expect(secondPageView.$prev_pageview_max_scroll).toEqual(0) @@ -101,22 +105,24 @@ describe('PageView ID manager', () => { expect(secondPageView.$prev_pageview_max_content).toEqual(1000) expect(secondPageView.$prev_pageview_max_content_percentage).toEqual(1) expect(secondPageView.$prev_pageview_duration).toEqual(duration) + expect(secondPageView.$prev_pageview_id).toEqual(pageviewId1) + expect(secondPageView.$pageview_id).toEqual(pageviewId2) }) it('can handle scroll updates before doPageView is called', () => { instance.scrollManager['_updateScrollData']() - const firstPageView = pageViewIdManager.doPageView(firstTimestamp) + const firstPageView = pageViewIdManager.doPageView(firstTimestamp, pageviewId1) expect(firstPageView.$prev_pageview_last_scroll).toBeUndefined() - const secondPageView = pageViewIdManager.doPageView(secondTimestamp) + const secondPageView = pageViewIdManager.doPageView(secondTimestamp, pageviewId2) expect(secondPageView.$prev_pageview_last_scroll).toBeDefined() }) it('should include the pathname', () => { instance.scrollManager['_updateScrollData']() - const firstPageView = pageViewIdManager.doPageView(firstTimestamp) + const firstPageView = pageViewIdManager.doPageView(firstTimestamp, pageviewId1) expect(firstPageView.$prev_pageview_pathname).toBeUndefined() - const secondPageView = pageViewIdManager.doPageView(secondTimestamp) + const secondPageView = pageViewIdManager.doPageView(secondTimestamp, pageviewId2) expect(secondPageView.$prev_pageview_pathname).toEqual('/pathname') }) }) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index 3f6b4f113..e6ede1f11 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -392,6 +392,7 @@ describe('posthog core', () => { describe('_calculate_event_properties()', () => { let posthog: PostHog + const uuid = 'uuid' const overrides: Partial = { persistence: { @@ -429,7 +430,7 @@ describe('posthog core', () => { }) it('returns calculated properties', () => { - expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date())).toEqual({ + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date(), uuid)).toEqual({ token: 'testtoken', event: 'prop', $lib: 'web', @@ -451,7 +452,7 @@ describe('posthog core', () => { overrides ) - expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date())).toEqual({ + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date(), uuid)).toEqual({ token: 'testtoken', event: 'prop', $lib: 'web', @@ -477,7 +478,7 @@ describe('posthog core', () => { ) expect( - posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date())[ + posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date(), uuid)[ '$process_person_profile' ] ).toEqual(false) @@ -491,7 +492,7 @@ describe('posthog core', () => { overrides ) - expect(posthog._calculate_event_properties('$snapshot', { event: 'prop' }, new Date())).toEqual({ + expect(posthog._calculate_event_properties('$snapshot', { event: 'prop' }, new Date(), uuid)).toEqual({ token: 'testtoken', event: 'prop', distinct_id: 'abc', @@ -508,7 +509,7 @@ describe('posthog core', () => { overrides ) - expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date())).toEqual({ + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' }, new Date(), uuid)).toEqual({ event_name: 'custom_event', token: 'testtoken', $process_person_profile: false, @@ -537,7 +538,7 @@ describe('posthog core', () => { it('saves $snapshot data and token for $snapshot events', () => { posthog = posthogWith({}, overrides) - expect(posthog._calculate_event_properties('$snapshot', { $snapshot_data: {} }, new Date())).toEqual({ + expect(posthog._calculate_event_properties('$snapshot', { $snapshot_data: {} }, new Date(), uuid)).toEqual({ token: 'testtoken', $snapshot_data: {}, distinct_id: 'abc', @@ -547,7 +548,7 @@ describe('posthog core', () => { it("doesn't modify properties passed into it", () => { const properties = { prop1: 'val1', prop2: 'val2' } - posthog._calculate_event_properties('custom_event', properties, new Date()) + posthog._calculate_event_properties('custom_event', properties, new Date(), uuid) expect(Object.keys(properties)).toEqual(['prop1', 'prop2']) }) @@ -555,10 +556,44 @@ describe('posthog core', () => { it('adds page title to $pageview', () => { document!.title = 'test' - expect(posthog._calculate_event_properties('$pageview', {}, new Date())).toEqual( + expect(posthog._calculate_event_properties('$pageview', {}, new Date(), uuid)).toEqual( expect.objectContaining({ title: 'test' }) ) }) + + it('includes pageview id from previous pageview', () => { + const pageview1Properties = posthog._calculate_event_properties( + '$pageview', + {}, + new Date(), + 'pageview-id-1' + ) + expect(pageview1Properties.$pageview_id).toEqual('pageview-id-1') + + const event1Properties = posthog._calculate_event_properties('custom event', {}, new Date(), 'event-id-1') + expect(event1Properties.$pageview_id).toEqual('pageview-id-1') + + const pageview2Properties = posthog._calculate_event_properties( + '$pageview', + {}, + new Date(), + 'pageview-id-2' + ) + expect(pageview2Properties.$pageview_id).toEqual('pageview-id-2') + expect(pageview2Properties.$prev_pageview_id).toEqual('pageview-id-1') + + const event2Properties = posthog._calculate_event_properties('custom event', {}, new Date(), 'event-id-2') + expect(event2Properties.$pageview_id).toEqual('pageview-id-2') + + const pageleaveProperties = posthog._calculate_event_properties( + '$pageleave', + {}, + new Date(), + 'pageleave-id' + ) + expect(pageleaveProperties.$pageview_id).toEqual('pageview-id-2') + expect(pageleaveProperties.$prev_pageview_id).toEqual('pageview-id-2') + }) }) describe('_handle_unload()', () => { diff --git a/src/page-view.ts b/src/page-view.ts index d34f52b06..d37a576e0 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -2,8 +2,11 @@ import { window } from './utils/globals' import { PostHog } from './posthog-core' import { isUndefined } from './utils/type-utils' import { clampToRange } from './utils/number-utils' +import { extend } from './utils' interface PageViewEventProperties { + $pageview_id?: string + $prev_pageview_id?: string $prev_pageview_pathname?: string $prev_pageview_duration?: number // seconds $prev_pageview_last_scroll?: number @@ -16,42 +19,56 @@ interface PageViewEventProperties { $prev_pageview_max_content_percentage?: number } +// This keeps track of the PageView state (such as the previous PageView's path, timestamp, id, and scroll properties). +// We store the state in memory, which means that for non-SPA sites, the state will be lost on page reload. This means +// that non-SPA sites should always send a $pageleave event on any navigation, before the page unloads. For SPA sites, +// they only need to send a $pageleave event when the user navigates away from the site, as the information is not lost +// on an internal navigation, and is included as the $prev_pageview_ properties in the next $pageview event. + +// Practically, this means that to find the scroll properties for a given pageview, you need to find the event where +// event name is $pageview or $pageleave and where $prev_pageview_id matches the original pageview event's id. + export class PageViewManager { - _currentPath?: string - _prevPageviewTimestamp?: Date + _currentPageview?: { timestamp: Date; pageViewId: string | undefined; pathname: string | undefined } _instance: PostHog constructor(instance: PostHog) { this._instance = instance } - doPageView(timestamp: Date): PageViewEventProperties { - const response = this._previousPageViewProperties(timestamp) + doPageView(timestamp: Date, pageViewId?: string): PageViewEventProperties { + const response = this._previousPageViewProperties(timestamp, pageViewId) // On a pageview we reset the contexts - this._currentPath = window?.location.pathname ?? '' + this._currentPageview = { pathname: window?.location.pathname ?? '', pageViewId, timestamp } this._instance.scrollManager.resetContext() - this._prevPageviewTimestamp = timestamp return response } doPageLeave(timestamp: Date): PageViewEventProperties { - return this._previousPageViewProperties(timestamp) + return this._previousPageViewProperties(timestamp, this._currentPageview?.pageViewId) } - private _previousPageViewProperties(timestamp: Date): PageViewEventProperties { - const previousPath = this._currentPath - const previousTimestamp = this._prevPageviewTimestamp - const scrollContext = this._instance.scrollManager.getContext() + doEvent(): PageViewEventProperties { + return { $pageview_id: this._currentPageview?.pageViewId } + } + + private _previousPageViewProperties(timestamp: Date, pageviewId: string | undefined): PageViewEventProperties { + const previousPageView = this._currentPageview - if (!previousTimestamp) { - // this means there was no previous pageview - return {} + if (!previousPageView) { + return { $pageview_id: pageviewId } } - let properties: PageViewEventProperties = {} - if (scrollContext) { + let properties: PageViewEventProperties = { + $pageview_id: pageviewId, + $prev_pageview_id: previousPageView.pageViewId, + } + + const scrollContext = this._instance.scrollManager.getContext() + + if (scrollContext && !this._instance.config.disable_scroll_properties) { let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = scrollContext @@ -80,7 +97,7 @@ export class PageViewManager { const maxContentPercentage = maxContentHeight <= 1 ? 1 : clampToRange(maxContentY / maxContentHeight, 0, 1) - properties = { + properties = extend(properties, { $prev_pageview_last_scroll: lastScrollY, $prev_pageview_last_scroll_percentage: lastScrollPercentage, $prev_pageview_max_scroll: maxScrollY, @@ -89,16 +106,16 @@ export class PageViewManager { $prev_pageview_last_content_percentage: lastContentPercentage, $prev_pageview_max_content: maxContentY, $prev_pageview_max_content_percentage: maxContentPercentage, - } + }) } } - if (previousPath) { - properties.$prev_pageview_pathname = previousPath + if (previousPageView.pathname) { + properties.$prev_pageview_pathname = previousPageView.pathname } - if (previousTimestamp) { + if (previousPageView.timestamp) { // Use seconds, for consistency with our other duration-related properties like $duration - properties.$prev_pageview_duration = (timestamp.getTime() - previousTimestamp.getTime()) / 1000 + properties.$prev_pageview_duration = (timestamp.getTime() - previousPageView.timestamp.getTime()) / 1000 } return properties diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e5b0ed683..9331abad3 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -861,10 +861,11 @@ export class PostHog { const systemTime = new Date() const timestamp = options?.timestamp || systemTime + const uuid = uuidv7() let data: CaptureResult = { - uuid: uuidv7(), + uuid, event: event_name, - properties: this._calculate_event_properties(event_name, properties || {}, timestamp), + properties: this._calculate_event_properties(event_name, properties || {}, timestamp, uuid), } if (clientRateLimitContext) { @@ -926,7 +927,12 @@ export class PostHog { return this.on('eventCaptured', (data) => callback(data.event, data)) } - _calculate_event_properties(event_name: string, event_properties: Properties, timestamp?: Date): Properties { + _calculate_event_properties( + event_name: string, + event_properties: Properties, + timestamp?: Date, + uuid?: string + ): Properties { timestamp = timestamp || new Date() if (!this.persistence || !this.sessionPersistence) { return event_properties @@ -980,15 +986,15 @@ export class PostHog { properties = extend(properties, sessionProps) } - if (!this.config.disable_scroll_properties) { - let performanceProperties: Record = {} - if (event_name === '$pageview') { - performanceProperties = this.pageViewManager.doPageView(timestamp) - } else if (event_name === '$pageleave') { - performanceProperties = this.pageViewManager.doPageLeave(timestamp) - } - properties = extend(properties, performanceProperties) + let pageviewProperties: Record + if (event_name === '$pageview') { + pageviewProperties = this.pageViewManager.doPageView(timestamp, uuid) + } else if (event_name === '$pageleave') { + pageviewProperties = this.pageViewManager.doPageLeave(timestamp) + } else { + pageviewProperties = this.pageViewManager.doEvent() } + properties = extend(properties, pageviewProperties) if (event_name === '$pageview' && document) { properties['title'] = document.title @@ -2237,6 +2243,10 @@ export class PostHog { } return beforeSendResult } + + public getPageViewId(): string | undefined { + return this.pageViewManager._currentPageview?.pageViewId + } } safewrapClass(PostHog, ['identify'])