From a2b4b143f8e55bca3bd781afca312bde7410b9b7 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Thu, 2 Jan 2025 19:49:07 +0000 Subject: [PATCH 1/5] Add pageview and prev pageview tracking --- src/__tests__/page-view.test.ts | 22 ++++++++------ src/__tests__/posthog-core.ts | 51 +++++++++++++++++++++++++++------ src/page-view.ts | 28 ++++++++++++------ src/posthog-core.ts | 16 ++++++++--- 4 files changed, 89 insertions(+), 28 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..7508a7d07 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 @@ -18,6 +21,7 @@ interface PageViewEventProperties { export class PageViewManager { _currentPath?: string + _currentPageviewId?: string _prevPageviewTimestamp?: Date _instance: PostHog @@ -25,32 +29,40 @@ export class PageViewManager { 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._instance.scrollManager.resetContext() this._prevPageviewTimestamp = timestamp + this._currentPageviewId = pageviewId return response } doPageLeave(timestamp: Date): PageViewEventProperties { - return this._previousPageViewProperties(timestamp) + return this._previousPageViewProperties(timestamp, this._currentPageviewId) } - private _previousPageViewProperties(timestamp: Date): PageViewEventProperties { + doEvent(): PageViewEventProperties { + return { $pageview_id: this._currentPageviewId } + } + + private _previousPageViewProperties(timestamp: Date, pageviewId: string | undefined): PageViewEventProperties { const previousPath = this._currentPath const previousTimestamp = this._prevPageviewTimestamp const scrollContext = this._instance.scrollManager.getContext() if (!previousTimestamp) { // this means there was no previous pageview - return {} + return { $pageview_id: pageviewId } } - let properties: PageViewEventProperties = {} + let properties: PageViewEventProperties = { + $pageview_id: pageviewId, + $prev_pageview_id: this._currentPageviewId, + } if (scrollContext) { let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = scrollContext @@ -80,7 +92,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,7 +101,7 @@ export class PageViewManager { $prev_pageview_last_content_percentage: lastContentPercentage, $prev_pageview_max_content: maxContentY, $prev_pageview_max_content_percentage: maxContentPercentage, - } + }) } } diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e5b0ed683..46cff7766 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 @@ -983,9 +989,11 @@ export class PostHog { if (!this.config.disable_scroll_properties) { let performanceProperties: Record = {} if (event_name === '$pageview') { - performanceProperties = this.pageViewManager.doPageView(timestamp) + performanceProperties = this.pageViewManager.doPageView(timestamp, uuid) } else if (event_name === '$pageleave') { performanceProperties = this.pageViewManager.doPageLeave(timestamp) + } else { + performanceProperties = this.pageViewManager.doEvent() } properties = extend(properties, performanceProperties) } From 36344e1638d7a67fc0c25f479f19b1e46eb65135 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Fri, 3 Jan 2025 11:20:18 +0000 Subject: [PATCH 2/5] Make some invalid states unrepresentable --- src/page-view.ts | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/page-view.ts b/src/page-view.ts index 7508a7d07..5ab4529c0 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -20,49 +20,45 @@ interface PageViewEventProperties { } export class PageViewManager { - _currentPath?: string - _currentPageviewId?: string - _prevPageviewTimestamp?: Date + _currentPageview?: { timestamp: Date; pageViewId: string | undefined; pathname: string | undefined } _instance: PostHog constructor(instance: PostHog) { this._instance = instance } - doPageView(timestamp: Date, pageviewId?: string): PageViewEventProperties { - const response = this._previousPageViewProperties(timestamp, pageviewId) + 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 - this._currentPageviewId = pageviewId return response } doPageLeave(timestamp: Date): PageViewEventProperties { - return this._previousPageViewProperties(timestamp, this._currentPageviewId) + return this._previousPageViewProperties(timestamp, this._currentPageview?.pageViewId) } doEvent(): PageViewEventProperties { - return { $pageview_id: this._currentPageviewId } + return { $pageview_id: this._currentPageview?.pageViewId } } private _previousPageViewProperties(timestamp: Date, pageviewId: string | undefined): PageViewEventProperties { - const previousPath = this._currentPath - const previousTimestamp = this._prevPageviewTimestamp - const scrollContext = this._instance.scrollManager.getContext() + const previousPageView = this._currentPageview - if (!previousTimestamp) { - // this means there was no previous pageview + if (!previousPageView) { return { $pageview_id: pageviewId } } let properties: PageViewEventProperties = { $pageview_id: pageviewId, - $prev_pageview_id: this._currentPageviewId, + $prev_pageview_id: previousPageView.pageViewId, } + + const scrollContext = this._instance.scrollManager.getContext() + if (scrollContext) { let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = scrollContext @@ -105,12 +101,12 @@ export class PageViewManager { } } - 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 From 504b239d3f78036bbfff20e850f849199276ac57 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Fri, 3 Jan 2025 11:20:39 +0000 Subject: [PATCH 3/5] Add function to get the current pageview id --- src/posthog-core.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 46cff7766..34d0ec67e 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -2245,6 +2245,10 @@ export class PostHog { } return beforeSendResult } + + public getPageViewId(): string | undefined { + return this.pageViewManager._currentPageview?.pageViewId + } } safewrapClass(PostHog, ['identify']) From 9432d2f2388ad433215faaae6f2b347e624d6b81 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Fri, 3 Jan 2025 12:41:01 +0000 Subject: [PATCH 4/5] Add comment justifying storing state in memory only --- src/page-view.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/page-view.ts b/src/page-view.ts index 5ab4529c0..252235a4c 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -19,6 +19,15 @@ 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 { _currentPageview?: { timestamp: Date; pageViewId: string | undefined; pathname: string | undefined } _instance: PostHog From 996f5dcfdcb48c96af6d3a7ecdbd8936797b9b69 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Fri, 3 Jan 2025 12:47:44 +0000 Subject: [PATCH 5/5] Fix naming, push config if statement inside pageview manager --- src/page-view.ts | 2 +- src/posthog-core.ts | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/page-view.ts b/src/page-view.ts index 252235a4c..d37a576e0 100644 --- a/src/page-view.ts +++ b/src/page-view.ts @@ -68,7 +68,7 @@ export class PageViewManager { const scrollContext = this._instance.scrollManager.getContext() - if (scrollContext) { + if (scrollContext && !this._instance.config.disable_scroll_properties) { let { maxScrollHeight, lastScrollY, maxScrollY, maxContentHeight, lastContentY, maxContentY } = scrollContext diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 34d0ec67e..9331abad3 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -986,17 +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, uuid) - } else if (event_name === '$pageleave') { - performanceProperties = this.pageViewManager.doPageLeave(timestamp) - } else { - performanceProperties = this.pageViewManager.doEvent() - } - 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