import throttle from 'lodash/throttle' import { v4 as uuid } from '@lukeed/uuid' import { fromBfCache } from '../../lib/from-bfcache' import { isSamePage } from '../../lib/is-same-page' import { Context, EventContext } from '../event-context' import { Emitter } from '../event-emitter' import { FocusTimer } from '../time/focus-timer' import { PageDefault, pageDefaults } from './page-info' const MAX_PAGE_TIME = 60 * 60 * 1000 // 1 hour function wrapNavigation(tracker: PageTracker) { const pushState = history.pushState history.pushState = (...args) => { pushState.apply(history, args) tracker.emit('page_tracker.push') } const replaceState = history.replaceState history.replaceState = (...args) => { replaceState.apply(history, args) tracker.emit('page_tracker.replace', ...args) } window.addEventListener('popstate', () => { tracker.emit('page_tracker.pop') }) } export interface PageView { context?: Context message_id?: string page: PageDefault visit_start: Date visit_end?: Date focus_intervals: number[] } export interface PageTime { page: PageDefault time: number } export class PageTracker extends Emitter { private pages: PageView[] = [] private focusTimer: FocusTimer private collecting = false private collectFocusTimeout: number | undefined private collectedSomeFocus = false private context: EventContext private autocapture = true private registered = false constructor(ctx: EventContext) { super() this.context = ctx this.autocapture = ctx.options?.sdk_settings?.autocapture ?? true this.focusTimer = new FocusTimer({ start: this.autocapture }) wrapNavigation(this) // bind a throttled version of this function this.onVisibilityChange = throttle(this.onVisibilityChange.bind(this), 100, { leading: true, trailing: false }) if (this.autocapture) { this.startAutocapture() } } public startAutocapture = () => { if (this.registered) return this.registered = true this.on('page_tracker.push', this.collect) this.on('page_tracker.replace', this.onReplaceState) this.on('page_tracker.pop', this.collect) // Capture when the page regains focus/visibility document.addEventListener('visibilitychange', this.onVisibilityChange) window.addEventListener('focus', this.onVisibilityChange) window.addEventListener('pageshow', this.onPageShow) // avoid `beforeunload` and `unload` because they prevent the page going in the bfcache (back/forward navigation) window.addEventListener('pagehide', this.onPageHide, { capture: true }) this.focusTimer.on('focus_time.end', this.recordFocusTime) this.focusTimer.startAutocapture() // Collect the current page every time the tracker is loaded or autocapture is started // but in setTimeout, so we can add listeners first setTimeout(() => { this.collect() }, 0) } public stopAutocapture = () => { if (!this.registered) return this.registered = false this.off('page_tracker.push', this.collect) this.off('page_tracker.replace', this.onReplaceState) this.off('page_tracker.pop', this.collect) document.removeEventListener('visibilitychange', this.onVisibilityChange) window.removeEventListener('focus', this.onVisibilityChange) window.removeEventListener('pageshow', this.onPageShow) window.removeEventListener('pagehide', this.onPageHide, { capture: true }) this.focusTimer.off('focus_time.end', this.recordFocusTime) this.focusTimer.stopAutocapture() } public allPages = () => { return this.pages } public get currentPage() { return this.pages[this.pages.length - 1] } public get currentFocusTime() { return ( this.focusTimer.currentFocusTime + (this.currentPage?.focus_intervals.reduce((acc, curr) => acc + curr, 0) || 0) ) } public get currentIdleTime() { return this.focusTimer.idleTime || 0 } public get sessionFocusTime() { return ( this.focusTimer.currentFocusTime + this.pages.reduce((acc, curr) => acc + curr.focus_intervals.reduce((acc, curr) => acc + curr, 0), 0) ) } private onReplaceState = (_stateObj: any, _unused: string, url?: string) => { const current = this.currentPage?.page?.url const different = url && !isSamePage(url, current) if (!current || different) { this.collect() } } public onVisibilityChange = () => { // When we regain visibility and the session has expired, // or if the pageview is from more than 1 hour ago // end the page and create a new one if (document.visibilityState === 'visible') { const sesh = this.context.session() const pageSessionId = this.currentPage?.context?.session?.id const now = new Date().getTime() const start = this.currentPage?.visit_start?.getTime() if (pageSessionId !== sesh.id) { this.collect() } else if (!start || Math.abs(now - start) >= MAX_PAGE_TIME) { this.collect() } } } private makePage = (): PageView => { return { context: this.context.current('page'), message_id: uuid(), page: pageDefaults(), visit_start: new Date(), focus_intervals: [] } } private collect = () => { this.collecting = true const prevPage = this.endCurrentPage({ emit: false }) const newPage = this.makePage() this.pages.push(newPage) this.collectedSomeFocus = false const pages = [prevPage, newPage].filter(Boolean) this.emit('page', pages) this.collecting = false } private endCurrentPage = (options?: { emit: boolean }) => { const current = this.currentPage if (!current) { return } window.clearTimeout(this.collectFocusTimeout) this.collectFocusTimeout = undefined this.focusTimer.restart() if (!current.visit_end) { current.visit_end = new Date() if (options?.emit !== false) { this.emit('page', [current]) } return current } } private onPageShow = (e: PageTransitionEvent) => { if (fromBfCache(e)) { // treat back/forward navigation as a new page load this.pages = [] this.collect() } } private onPageHide = () => { this.collecting = true this.endCurrentPage() this.collecting = false } private recordFocusTime = (time: number) => { const current = this.currentPage time = Math.round(time || 0) if (current && time) { current.focus_intervals.push(time) this.emit('new_focus_time') if (this.collecting || this.collectFocusTimeout) { return } const total = current.focus_intervals.reduce((acc, next) => acc + next, 0) const firstFt = !this.collectedSomeFocus && total >= 1000 const thirdFt = current.focus_intervals.length % 3 == 0 const largeFt = time >= 10000 // schedule collection (will get cancelled if we collect before it fires via navigation or pagehide) if (firstFt || thirdFt || largeFt) { this.collectFocusTimeout = window.setTimeout(() => { this.collectFocusTimeout = undefined this.collectedSomeFocus = true this.emit('page', [current]) }, 2000) } } } public get scheduled() { return !!this.collectFocusTimeout } public reset() { this.pages = [] this.focusTimer.clear() } }