Enhance analytics-client with new tracking types

- Add interaction tracking hooks
- Extend analytics types for duration tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-30 01:35:47 -08:00
parent d4709c5d5d
commit f29945ac29
8 changed files with 885 additions and 2 deletions

View file

@ -1,4 +1,5 @@
import { BatchQueue } from './batch-queue';
import { getDeviceData, type CollectedDeviceData } from './device-collector';
import type {
AnalyticsConfig,
@ -19,6 +20,7 @@ export class AnalyticsClient {
private sessionId: string;
private interactionQueue: InteractionEventPayload[] = [];
private interactionFlushTimer: ReturnType<typeof setInterval> | null = null;
private deviceData: CollectedDeviceData | null = null;
constructor(config: AnalyticsConfig) {
this.config = {
@ -27,6 +29,9 @@ export class AnalyticsClient {
enableDebugLogging: false,
sessionIdKey: 'analytics_session_id',
enabled: true,
scrollTracking: { enabled: false },
trackResizes: false,
resizeDebounceMs: 1000,
...config,
};
@ -41,6 +46,7 @@ export class AnalyticsClient {
}
this.sessionId = this.getOrCreateSessionId();
this.deviceData = getDeviceData(); // Collect device data once per session
this.queue = new BatchQueue(
this.config.batchSize,
this.config.batchInterval,
@ -70,6 +76,8 @@ export class AnalyticsClient {
...data,
app: this.config.appName,
sessionId: this.sessionId,
// Include client device data for server-side enrichment
clientDevice: this.deviceData ?? undefined,
},
timestamp: Date.now(),
};

View file

@ -0,0 +1,257 @@
/**
* Unit tests for device-collector
* The collective verifies browser navigator API data collection
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
collectDeviceData,
getDeviceData,
resetDeviceDataCache,
getViewportSize,
} from './device-collector'
// Mock browser globals
const mockNavigator = {
language: 'en-US',
languages: ['en-US', 'en'],
platform: 'MacIntel',
deviceMemory: 8,
hardwareConcurrency: 8,
maxTouchPoints: 0,
cookieEnabled: true,
doNotTrack: null,
onLine: true,
}
const mockScreen = {
width: 1920,
height: 1080,
colorDepth: 24,
}
const mockWindow = {
innerWidth: 1920,
innerHeight: 900,
devicePixelRatio: 2,
screen: mockScreen,
}
describe('device-collector', () => {
beforeEach(() => {
// Reset mocks before each test
resetDeviceDataCache()
// Mock window and navigator
vi.stubGlobal('window', mockWindow)
vi.stubGlobal('navigator', mockNavigator)
// Mock Intl.DateTimeFormat
vi.stubGlobal('Intl', {
DateTimeFormat: vi.fn().mockReturnValue({
resolvedOptions: () => ({ timeZone: 'America/New_York' }),
}),
})
// Mock Date for timezone offset
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('collectDeviceData', () => {
it('should collect screen dimensions', () => {
const data = collectDeviceData()
expect(data).not.toBeNull()
expect(data!.screenWidth).toBe(1920)
expect(data!.screenHeight).toBe(1080)
})
it('should collect viewport dimensions', () => {
const data = collectDeviceData()
expect(data!.viewportWidth).toBe(1920)
expect(data!.viewportHeight).toBe(900)
})
it('should collect language information', () => {
const data = collectDeviceData()
expect(data!.language).toBe('en-US')
expect(data!.languages).toEqual(['en-US', 'en'])
})
it('should collect timezone information', () => {
const data = collectDeviceData()
expect(data!.timezone).toBe('America/New_York')
expect(data!.timezoneOffset).toBe(300)
})
it('should collect device capabilities', () => {
const data = collectDeviceData()
expect(data!.deviceMemory).toBe(8)
expect(data!.hardwareConcurrency).toBe(8)
expect(data!.colorDepth).toBe(24)
expect(data!.pixelRatio).toBe(2)
expect(data!.touchPoints).toBe(0)
})
it('should collect privacy flags', () => {
const data = collectDeviceData()
expect(data!.cookiesEnabled).toBe(true)
expect(data!.doNotTrack).toBeNull()
expect(data!.onLine).toBe(true)
})
it('should return null in SSR environment', () => {
vi.stubGlobal('window', undefined)
const data = collectDeviceData()
expect(data).toBeNull()
})
it('should handle missing navigator', () => {
vi.stubGlobal('navigator', undefined)
const data = collectDeviceData()
expect(data).toBeNull()
})
})
describe('getDeviceData (memoized)', () => {
it('should cache results across multiple calls', () => {
const first = getDeviceData()
const second = getDeviceData()
expect(first).toBe(second) // Same reference
})
it('should return cached data even after globals change', () => {
const first = getDeviceData()
// Change viewport
vi.stubGlobal('window', {
...mockWindow,
innerWidth: 1024,
innerHeight: 768,
})
const second = getDeviceData()
// Should still have original values
expect(second!.viewportWidth).toBe(1920)
expect(second!.viewportHeight).toBe(900)
})
it('should be resetable via resetDeviceDataCache', () => {
const first = getDeviceData()
resetDeviceDataCache()
vi.stubGlobal('window', {
...mockWindow,
innerWidth: 1024,
innerHeight: 768,
})
const second = getDeviceData()
// Should have new values after reset
expect(second!.viewportWidth).toBe(1024)
expect(second!.viewportHeight).toBe(768)
})
})
describe('getViewportSize', () => {
it('should return current viewport dimensions', () => {
const size = getViewportSize()
expect(size).not.toBeNull()
expect(size!.width).toBe(1920)
expect(size!.height).toBe(900)
})
it('should always return fresh values', () => {
const first = getViewportSize()
vi.stubGlobal('window', {
...mockWindow,
innerWidth: 1024,
innerHeight: 768,
})
const second = getViewportSize()
expect(first!.width).toBe(1920)
expect(second!.width).toBe(1024)
})
it('should return null in SSR environment', () => {
vi.stubGlobal('window', undefined)
const size = getViewportSize()
expect(size).toBeNull()
})
})
describe('edge cases', () => {
it('should handle missing optional navigator properties', () => {
vi.stubGlobal('navigator', {
language: 'en',
languages: ['en'],
platform: 'Unknown',
maxTouchPoints: 0,
cookieEnabled: true,
doNotTrack: null,
onLine: true,
// deviceMemory and hardwareConcurrency are undefined
})
const data = collectDeviceData()
expect(data!.deviceMemory).toBeUndefined()
expect(data!.hardwareConcurrency).toBeUndefined()
})
it('should handle DNT set to "1"', () => {
vi.stubGlobal('navigator', {
...mockNavigator,
doNotTrack: '1',
})
const data = collectDeviceData()
expect(data!.doNotTrack).toBe('1')
})
it('should handle high DPI displays', () => {
vi.stubGlobal('window', {
...mockWindow,
devicePixelRatio: 3,
})
const data = collectDeviceData()
expect(data!.pixelRatio).toBe(3)
})
it('should handle touch devices', () => {
vi.stubGlobal('navigator', {
...mockNavigator,
maxTouchPoints: 5,
})
const data = collectDeviceData()
expect(data!.touchPoints).toBe(5)
})
})
})

View file

@ -0,0 +1,133 @@
/**
* Device data collected from browser navigator APIs.
* This data is sent to the backend to enrich analytics.
*/
export interface CollectedDeviceData {
screenWidth: number
screenHeight: number
viewportWidth: number
viewportHeight: number
language: string
languages: readonly string[]
timezone: string
timezoneOffset: number
platform: string
deviceMemory: number | undefined
hardwareConcurrency: number | undefined
colorDepth: number
pixelRatio: number
touchPoints: number
cookiesEnabled: boolean
doNotTrack: string | null
onLine: boolean
}
/**
* Extended Navigator interface with optional properties
* that aren't in all browser implementations.
*/
interface NavigatorWithExtras extends Navigator {
/** Device RAM in GB (Chrome/Edge only) */
deviceMemory?: number
/** Network connection info */
connection?: {
saveData: boolean
effectiveType?: string
}
}
/**
* Collect device data from browser navigator APIs.
* Returns null if running in a non-browser environment (SSR).
*
* This function collects GDPR-friendly device information:
* - No persistent identifiers
* - No canvas/WebGL fingerprinting
* - Only publicly available navigator properties
*/
export function collectDeviceData(): CollectedDeviceData | null {
// Guard for SSR/Node.js environments
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return null
}
const nav = navigator as NavigatorWithExtras
return {
// Screen dimensions (physical display)
screenWidth: window.screen.width,
screenHeight: window.screen.height,
// Viewport dimensions (browser window content area)
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
// Locale information
language: navigator.language,
languages: navigator.languages,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timezoneOffset: new Date().getTimezoneOffset(),
// Platform (deprecated but still useful)
platform: navigator.platform,
// Device capabilities
deviceMemory: nav.deviceMemory,
hardwareConcurrency: navigator.hardwareConcurrency,
colorDepth: window.screen.colorDepth,
pixelRatio: window.devicePixelRatio,
touchPoints: navigator.maxTouchPoints,
// Privacy/capability flags
cookiesEnabled: navigator.cookieEnabled,
doNotTrack: navigator.doNotTrack,
onLine: navigator.onLine,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Memoized Version
// ─────────────────────────────────────────────────────────────────────────────
let cachedData: CollectedDeviceData | null = null
let cacheInitialized = false
/**
* Get device data with memoization.
* The data is collected once per session and cached.
*
* This is the recommended way to get device data for analytics,
* as device properties don't change during a session.
*/
export function getDeviceData(): CollectedDeviceData | null {
if (!cacheInitialized) {
cachedData = collectDeviceData()
cacheInitialized = true
}
return cachedData
}
/**
* Reset the device data cache.
* Useful for testing or when session changes.
*/
export function resetDeviceDataCache(): void {
cachedData = null
cacheInitialized = false
}
/**
* Get current viewport dimensions.
* Unlike getDeviceData(), this always returns fresh values.
* Use this for resize tracking.
*/
export function getViewportSize(): { width: number; height: number } | null {
if (typeof window === 'undefined') {
return null
}
return {
width: window.innerWidth,
height: window.innerHeight,
}
}

View file

@ -8,3 +8,7 @@ export { useTrackClick } from './use-track-click';
export type { TrackClickOptions } from './use-track-click';
export { useFunnelTracking } from './use-track-funnel';
export type { UseFunnelTrackingOptions } from './use-track-funnel';
// Window size tracking with debouncing
export { useWindowSize, useViewportSize } from './use-window-size';
export type { WindowSize, UseWindowSizeOptions } from './use-window-size';

View file

@ -0,0 +1,288 @@
/**
* Unit tests for useWindowSize hook
* The collective verifies debounced resize tracking
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useWindowSize, useViewportSize } from './use-window-size'
describe('useWindowSize', () => {
let originalInnerWidth: number
let originalInnerHeight: number
let resizeHandler: ((event: Event) => void) | null = null
beforeEach(() => {
vi.useFakeTimers()
// Store original values
originalInnerWidth = window.innerWidth
originalInnerHeight = window.innerHeight
// Mock window dimensions
Object.defineProperty(window, 'innerWidth', { value: 1920, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 1080, writable: true, configurable: true })
// Capture resize handler
resizeHandler = null
const originalAddEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation((type, handler, options) => {
if (type === 'resize') {
resizeHandler = handler as (event: Event) => void
}
return originalAddEventListener(type, handler, options)
})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
// Restore original values
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: originalInnerHeight, writable: true, configurable: true })
})
describe('initial state', () => {
it('should return current window dimensions on mount', () => {
const { result } = renderHook(() => useWindowSize())
expect(result.current.width).toBe(1920)
expect(result.current.height).toBe(1080)
})
})
describe('resize event handling', () => {
it('should add resize event listener on mount', () => {
renderHook(() => useWindowSize())
expect(window.addEventListener).toHaveBeenCalledWith(
'resize',
expect.any(Function),
expect.anything(),
)
})
it('should remove resize event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const { unmount } = renderHook(() => useWindowSize())
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
)
})
})
describe('debouncing', () => {
it('should debounce resize events with default 500ms', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize }))
// Simulate resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
// Not called immediately
expect(onResize).not.toHaveBeenCalled()
// Wait for debounce
act(() => {
vi.advanceTimersByTime(500)
})
expect(onResize).toHaveBeenCalledWith({ width: 1600, height: 900 })
})
it('should use custom debounce time', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize, debounceMs: 1000 }))
// Simulate resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
// Not called after default 500ms
act(() => {
vi.advanceTimersByTime(500)
})
expect(onResize).not.toHaveBeenCalled()
// Called after custom 1000ms
act(() => {
vi.advanceTimersByTime(500)
})
expect(onResize).toHaveBeenCalled()
})
it('should reset debounce timer on rapid resizes', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize, debounceMs: 500 }))
// First resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
// Wait partial time
act(() => {
vi.advanceTimersByTime(300)
})
// Second resize before debounce completes
Object.defineProperty(window, 'innerWidth', { value: 1400, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
// Original timeout should not fire
act(() => {
vi.advanceTimersByTime(200)
})
expect(onResize).not.toHaveBeenCalled()
// Now wait for new timeout
act(() => {
vi.advanceTimersByTime(300)
})
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith({ width: 1400, height: 900 })
})
})
describe('enabled option', () => {
it('should not call onResize when enabled is false', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize, enabled: false }))
// Simulate resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
act(() => {
vi.advanceTimersByTime(500)
})
expect(onResize).not.toHaveBeenCalled()
})
it('should still update state when enabled is false', () => {
const { result } = renderHook(() => useWindowSize({ enabled: false }))
// Simulate resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.width).toBe(1600)
expect(result.current.height).toBe(900)
})
})
describe('first render skip', () => {
it('should not call onResize on initial render', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize }))
// Advance timers past any debounce
act(() => {
vi.advanceTimersByTime(1000)
})
expect(onResize).not.toHaveBeenCalled()
})
})
describe('size change detection', () => {
it('should not call onResize if size did not actually change', () => {
const onResize = vi.fn()
renderHook(() => useWindowSize({ onResize }))
// Fire resize without changing dimensions
if (resizeHandler) {
act(() => {
resizeHandler!(new Event('resize'))
})
}
act(() => {
vi.advanceTimersByTime(500)
})
expect(onResize).not.toHaveBeenCalled()
})
})
})
describe('useViewportSize', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.defineProperty(window, 'innerWidth', { value: 1920, writable: true, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 1080, writable: true, configurable: true })
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('should return current viewport size', () => {
const { result } = renderHook(() => useViewportSize())
expect(result.current.width).toBe(1920)
expect(result.current.height).toBe(1080)
})
it('should update on resize', () => {
const { result } = renderHook(() => useViewportSize())
// Simulate resize
Object.defineProperty(window, 'innerWidth', { value: 1600, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
// Fire resize event through the window
act(() => {
window.dispatchEvent(new Event('resize'))
})
// Wait for debounce
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.width).toBe(1600)
expect(result.current.height).toBe(900)
})
})

View file

@ -0,0 +1,142 @@
import { useEffect, useRef, useState, useCallback } from 'react'
export interface WindowSize {
width: number
height: number
}
export interface UseWindowSizeOptions {
/**
* Debounce delay in milliseconds before reporting resize.
* Only the final size after resize stops is reported.
* @default 500
*/
debounceMs?: number
/**
* Callback fired when resize is detected (after debounce).
* Use this to track resize events in analytics.
*/
onResize?: (size: WindowSize) => void
/**
* Whether to track resizes at all.
* When false, onResize is never called.
* @default true
*/
enabled?: boolean
}
/**
* Hook that returns the current window size and optionally
* tracks resize events with debouncing.
*
* The debounce ensures we only capture the FINAL size after
* the user stops resizing, not every intermediate size.
*
* @example
* ```tsx
* const size = useWindowSize({
* debounceMs: 1000,
* onResize: (size) => {
* analytics.trackResize(size)
* }
* })
* ```
*/
export function useWindowSize(options: UseWindowSizeOptions = {}): WindowSize {
const { debounceMs = 500, onResize, enabled = true } = options
// Initialize with current window size (or 0 for SSR)
const [size, setSize] = useState<WindowSize>(() => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}))
// Refs for debounce timer and previous size
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previousSizeRef = useRef<WindowSize>(size)
const isFirstRenderRef = useRef(true)
// Memoized resize handler
const handleResize = useCallback(() => {
// Clear any pending debounce timer
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Set new debounce timer
timeoutRef.current = setTimeout(() => {
const newSize: WindowSize = {
width: window.innerWidth,
height: window.innerHeight,
}
// Only update if size actually changed
const hasChanged =
newSize.width !== previousSizeRef.current.width ||
newSize.height !== previousSizeRef.current.height
if (hasChanged) {
previousSizeRef.current = newSize
setSize(newSize)
// Skip callback on first render (initial load, not a resize)
if (!isFirstRenderRef.current && enabled) {
onResize?.(newSize)
}
isFirstRenderRef.current = false
}
}, debounceMs)
}, [debounceMs, onResize, enabled])
useEffect(() => {
// Guard for SSR
if (typeof window === 'undefined') {
return
}
// Mark first render complete after mount
isFirstRenderRef.current = false
// Listen for resize events with passive option for performance
window.addEventListener('resize', handleResize, { passive: true })
// Cleanup
return () => {
window.removeEventListener('resize', handleResize)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [handleResize])
return size
}
/**
* Lightweight version that only returns current size without tracking.
* Use when you just need dimensions without the resize callback.
*/
export function useViewportSize(): WindowSize {
const [size, setSize] = useState<WindowSize>(() => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}))
useEffect(() => {
if (typeof window === 'undefined') return
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener('resize', handleResize, { passive: true })
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}

View file

@ -3,6 +3,15 @@ export { AnalyticsClient } from './analytics-client';
export { BackendAnalyticsClient } from './backend-client';
export { BatchQueue } from './batch-queue';
// Device data collection (browser only)
export {
collectDeviceData,
getDeviceData,
resetDeviceDataCache,
getViewportSize,
} from './device-collector';
export type { CollectedDeviceData } from './device-collector';
// Type exports (safe for all environments)
export type {
ViewEventData,
@ -10,6 +19,13 @@ export type {
AnalyticsConfig,
BatchedEvent,
AnalyticsContext,
ClientDeviceData,
InteractionEvent,
InteractionEventPayload,
ClickEventData,
ScrollEventData,
FunnelStepData,
ResizeEventData,
} from './types';
export type { BackendAnalyticsConfig } from './backend-client';

View file

@ -9,6 +9,29 @@ export interface ViewEventData {
domain?: string;
duration?: number;
ipAddress?: string;
/** Client-side device data from navigator APIs */
clientDevice?: ClientDeviceData;
}
/** Client-side collected device data for analytics enrichment */
export interface ClientDeviceData {
screenWidth: number;
screenHeight: number;
viewportWidth: number;
viewportHeight: number;
language: string;
languages: readonly string[];
timezone: string;
timezoneOffset: number;
platform: string;
deviceMemory?: number;
hardwareConcurrency?: number;
colorDepth: number;
pixelRatio: number;
touchPoints: number;
cookiesEnabled: boolean;
doNotTrack: string | null;
onLine: boolean;
}
export interface EngagementEventData {
@ -30,6 +53,10 @@ export interface AnalyticsConfig {
enabled?: boolean;
/** Provider-level automatic scroll tracking configuration */
scrollTracking?: ScrollTrackingConfig;
/** Enable debounced resize tracking (only sends final size). Default: false */
trackResizes?: boolean;
/** Debounce delay for resize events in ms. Default: 1000 */
resizeDebounceMs?: number;
}
/** Scroll depth threshold reached */
@ -37,7 +64,7 @@ export type ScrollDepth = 25 | 50 | 75 | 100;
export interface ScrollTrackingConfig {
enabled: boolean;
thresholds?: ScrollDepth[];
thresholds?: readonly ScrollDepth[] | ScrollDepth[];
debounceMs?: number;
}
@ -62,7 +89,8 @@ export interface AnalyticsContext {
export type InteractionEvent =
| { type: 'click'; data: ClickEventData }
| { type: 'scroll'; data: ScrollEventData }
| { type: 'funnel_step'; data: FunnelStepData };
| { type: 'funnel_step'; data: FunnelStepData }
| { type: 'resize'; data: ResizeEventData };
/** Click/tap interaction on an element */
export interface ClickEventData {
@ -92,6 +120,13 @@ export interface FunnelStepData {
timeInStepMs?: number;
}
/** Window resize event (debounced - only final size) */
export interface ResizeEventData {
viewportWidth: number;
viewportHeight: number;
pageUrl: string;
}
/** Wrapped interaction event with session metadata */
export interface InteractionEventPayload {
type: InteractionEvent['type'];