Skip to content
Open
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
173 changes: 0 additions & 173 deletions app/(site)/log/route.ts

This file was deleted.

66 changes: 66 additions & 0 deletions components/Analytics/PageViewTracker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import PageViewTracker from './PageViewTracker'

const mockLogEvent = vi.fn()
vi.mock('@/hooks/useLogEvent', () => ({
useLogEvent: () => mockLogEvent,
}))

vi.mock('next/navigation', () => ({
usePathname: () => '/docs/introduction',
useSearchParams: () => new URLSearchParams(),
}))

const defineWindowProperty = (target: object, key: string, value: unknown) => {
Object.defineProperty(target, key, { value, configurable: true, writable: true })
}

describe('PageViewTracker', () => {
beforeEach(() => {
mockLogEvent.mockClear()
localStorage.clear()
// jsdom reports zero screen dimensions, which the bot heuristics treat as headless
defineWindowProperty(window, 'screen', { width: 1440, height: 900 })
defineWindowProperty(document.documentElement, 'scrollHeight', 2000)
defineWindowProperty(window, 'innerHeight', 800)
defineWindowProperty(window, 'scrollY', 0)
})

it('logs a page view with a session id', () => {
render(<PageViewTracker />)

expect(mockLogEvent).toHaveBeenCalledTimes(1)
const [payload] = mockLogEvent.mock.calls[0]
expect(payload.eventName).toBe('Website Page View')
expect(payload.attributes.pageLocation).toBe('/docs/introduction')
expect(payload.attributes.$session_id).toBeTruthy()
})

it('sends a page leave beacon with duration and scroll depth linked to the same session', () => {
render(<PageViewTracker />)

const [pageView] = mockLogEvent.mock.calls[0]
mockLogEvent.mockClear()

window.dispatchEvent(new Event('pagehide'))

expect(mockLogEvent).toHaveBeenCalledTimes(1)
const [payload, options] = mockLogEvent.mock.calls[0]
expect(payload.eventName).toBe('Website Page Leave')
expect(payload.attributes.$session_id).toBe(pageView.attributes.$session_id)
expect(payload.attributes.pageDurationSeconds).toBeGreaterThanOrEqual(0)
expect(payload.attributes.scrollDepthPercentage).toBe(40)
expect(options).toEqual({ transport: 'beacon' })
})

it('does not send a duplicate page leave once the page is marked left', () => {
render(<PageViewTracker />)
mockLogEvent.mockClear()

window.dispatchEvent(new Event('pagehide'))
window.dispatchEvent(new Event('pagehide'))

expect(mockLogEvent).toHaveBeenCalledTimes(1)
})
})
41 changes: 21 additions & 20 deletions components/Analytics/PageViewTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { usePathname, useSearchParams } from 'next/navigation'
import { useLogEvent } from '../../hooks/useLogEvent'
import { getPageType } from '../../utils/getPageType'
import { detectBotClientSide } from '../../utils/logEvent'
import { getOrCreatePostHogSessionId } from '../../utils/userClient'
import { getOrCreateAnalyticsSessionId } from '../../utils/userClient'

const FLAGGED_TIMEZONES = (process.env.NEXT_PUBLIC_PAGEVIEW_FLAGGED_TIMEZONES || '')
.split(',')
Expand All @@ -27,15 +27,15 @@ type TrackedPage = {
pageReferrer?: string
sessionId?: string
startedAt: number
maxContentPercentage?: number
maxScrollDepthPercentage?: number
left: boolean
}

const clampPercentage = (value: number) => Math.max(0, Math.min(1, value))
const clampRatio = (value: number) => Math.max(0, Math.min(1, value))

const roundPercentage = (value: number) => Math.round(value * 10000) / 10000
const roundPercentage = (value: number) => Math.round(value * 100) / 100

const getCurrentContentPercentage = () => {
const getCurrentScrollDepthPercentage = () => {
if (typeof window === 'undefined' || typeof document === 'undefined') return undefined

const documentElement = document.documentElement
Expand All @@ -53,7 +53,7 @@ const getCurrentContentPercentage = () => {
if (!viewportHeight || !scrollHeight) return undefined

const viewportBottom = Math.min(scrollHeight, scrollTop + viewportHeight)
return roundPercentage(clampPercentage(viewportBottom / scrollHeight))
return roundPercentage(clampRatio(viewportBottom / scrollHeight) * 100)
}

export default function PageViewTracker() {
Expand All @@ -64,18 +64,21 @@ export default function PageViewTracker() {
const previousPageUrl = useRef<string | null>(null)
const currentPage = useRef<TrackedPage | null>(null)

const updateMaxContentPercentage = useCallback((page: TrackedPage) => {
const currentContentPercentage = getCurrentContentPercentage()
if (currentContentPercentage === undefined) return
const updateMaxScrollDepthPercentage = useCallback((page: TrackedPage) => {
const currentScrollDepthPercentage = getCurrentScrollDepthPercentage()
if (currentScrollDepthPercentage === undefined) return

page.maxContentPercentage = Math.max(page.maxContentPercentage || 0, currentContentPercentage)
page.maxScrollDepthPercentage = Math.max(
page.maxScrollDepthPercentage || 0,
currentScrollDepthPercentage
)
}, [])

const sendPageLeave = useCallback(
(page: TrackedPage) => {
if (page.left) return
page.left = true
updateMaxContentPercentage(page)
updateMaxScrollDepthPercentage(page)

const durationSeconds = Math.max(0, (Date.now() - page.startedAt) / 1000)

Expand All @@ -90,18 +93,16 @@ export default function PageViewTracker() {
pageTitle: page.pageTitle,
pageReferrer: page.pageReferrer,
$session_id: page.sessionId,
$prev_pageview_duration: durationSeconds,
$prev_pageview_pathname: page.pathname,
$prev_pageview_max_content_percentage: page.maxContentPercentage,
pageDurationSeconds: durationSeconds,
scrollDepthPercentage: page.maxScrollDepthPercentage,
},
},
{
sendToTunnel: false,
transport: 'beacon',
}
)
},
[logEvent, updateMaxContentPercentage]
[logEvent, updateMaxScrollDepthPercentage]
)

useEffect(() => {
Expand All @@ -111,7 +112,7 @@ export default function PageViewTracker() {
animationFrameId = null

if (currentPage.current) {
updateMaxContentPercentage(currentPage.current)
updateMaxScrollDepthPercentage(currentPage.current)
}
}

Expand Down Expand Up @@ -150,7 +151,7 @@ export default function PageViewTracker() {
window.removeEventListener('scroll', requestContentPercentageUpdate)
window.removeEventListener('resize', requestContentPercentageUpdate)
}
}, [sendPageLeave, updateMaxContentPercentage])
}, [sendPageLeave, updateMaxScrollDepthPercentage])

useEffect(() => {
// Combine pathname and searchParams for a complete URL identifier
Expand All @@ -170,7 +171,7 @@ export default function PageViewTracker() {
const pageType = getPageType(pathname || '') // Ensure pathname is defined
const botDetection = detectBotClientSide()
const userAgent = typeof window !== 'undefined' ? window.navigator.userAgent : ''
const sessionId = getOrCreatePostHogSessionId()
const sessionId = getOrCreateAnalyticsSessionId()
const pageUrl =
typeof window !== 'undefined' ? `${window.location.origin}${currentUrl}` : currentUrl
const pageTitle = typeof document !== 'undefined' ? document.title : undefined
Expand Down Expand Up @@ -244,7 +245,7 @@ export default function PageViewTracker() {
pageReferrer,
sessionId,
startedAt: Date.now(),
maxContentPercentage: getCurrentContentPercentage(),
maxScrollDepthPercentage: getCurrentScrollDepthPercentage(),
left: false,
}

Expand Down
10 changes: 7 additions & 3 deletions hooks/useLogEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import { useCallback, useEffect } from 'react'
import { logEvent, LogEventPayload, LogEventOptions, detectBotClientSide } from '../utils/logEvent'
import { getOrCreateAnonymousId, getOrCreatePostHogSessionId, getUserId } from '../utils/userClient'
import {
getOrCreateAnonymousId,
getOrCreateAnalyticsSessionId,
getUserId,
} from '../utils/userClient'
import { extractGroupIdFromEmail } from '../utils/userShared'
import {
getOS,
Expand Down Expand Up @@ -74,7 +78,7 @@ export const useLogEvent = () => {
const utmParams = getStoredUtmParams()
const userIp = Cookies.get('user_ip')
const vercelIp = Cookies.get('vercel_ip')
const postHogSessionId = getOrCreatePostHogSessionId()
const sessionId = getOrCreateAnalyticsSessionId()
const providedAttributes = attributes || {}

const enhancedAttributes = {
Expand All @@ -88,7 +92,7 @@ export const useLogEvent = () => {
pageReferrer:
providedAttributes.pageReferrer ||
(typeof document !== 'undefined' ? document.referrer : undefined),
$session_id: providedAttributes.$session_id || postHogSessionId,
$session_id: providedAttributes.$session_id || sessionId,
custom_ip: userIp || 'unknown',
custom_vercel_ip: vercelIp || 'unknown',
custom_os: getOS(),
Expand Down
Loading
Loading