Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add pageview and prev pageview tracking #1634

Merged
merged 5 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

brain is not fully engaged so sorry if this is a silly question

this is entirely in memory, no? so if my app does full page loads on navigation (i.e. not a SPA) then I won't have a previous pageview id?

or am i misunderstanding?

otherwise the logic for tracking it looks valid to me

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true - I'll add a comment about why this is OK

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! thank you! 💖

_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
Loading