Skip to content

Commit

Permalink
feat: Add pageview and prev pageview tracking (#1634)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
robbie-c authored Jan 3, 2025
1 parent cc48c9d commit a3da178
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 49 deletions.
22 changes: 14 additions & 8 deletions src/__tests__/page-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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', () => {
Expand All @@ -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)
Expand All @@ -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')
})
})
Expand Down
51 changes: 43 additions & 8 deletions src/__tests__/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ describe('posthog core', () => {

describe('_calculate_event_properties()', () => {
let posthog: PostHog
const uuid = 'uuid'

const overrides: Partial<PostHog> = {
persistence: {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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)
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -547,18 +548,52 @@ 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'])
})

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()', () => {
Expand Down
61 changes: 39 additions & 22 deletions src/page-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
32 changes: 21 additions & 11 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -980,15 +986,15 @@ export class PostHog {
properties = extend(properties, sessionProps)
}

if (!this.config.disable_scroll_properties) {
let performanceProperties: Record<string, any> = {}
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<string, any>
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
Expand Down Expand Up @@ -2237,6 +2243,10 @@ export class PostHog {
}
return beforeSendResult
}

public getPageViewId(): string | undefined {
return this.pageViewManager._currentPageview?.pageViewId
}
}

safewrapClass(PostHog, ['identify'])
Expand Down

0 comments on commit a3da178

Please sign in to comment.