@loamly/tracker 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Event Queue with Batching, Retry & Persistence
3
+ *
4
+ * Production-grade event delivery with:
5
+ * - Batch processing for network efficiency
6
+ * - Retry with exponential backoff
7
+ * - localStorage persistence for offline support
8
+ * - sendBeacon fallback for unload events
9
+ *
10
+ * @module @loamly/tracker
11
+ */
12
+
13
+ import { DEFAULT_CONFIG } from '../config'
14
+
15
+ export interface QueuedEvent {
16
+ id: string
17
+ type: string
18
+ payload: Record<string, unknown>
19
+ timestamp: number
20
+ retries: number
21
+ }
22
+
23
+ interface EventQueueConfig {
24
+ batchSize: number
25
+ batchTimeout: number
26
+ maxRetries: number
27
+ retryDelayMs: number
28
+ storageKey: string
29
+ }
30
+
31
+ const DEFAULT_QUEUE_CONFIG: EventQueueConfig = {
32
+ batchSize: DEFAULT_CONFIG.batchSize,
33
+ batchTimeout: DEFAULT_CONFIG.batchTimeout,
34
+ maxRetries: 3,
35
+ retryDelayMs: 1000,
36
+ storageKey: '_loamly_queue',
37
+ }
38
+
39
+ export class EventQueue {
40
+ private queue: QueuedEvent[] = []
41
+ private batchTimer: ReturnType<typeof setTimeout> | null = null
42
+ private config: EventQueueConfig
43
+ private endpoint: string
44
+ private isFlushing = false
45
+
46
+ constructor(endpoint: string, config: Partial<EventQueueConfig> = {}) {
47
+ this.endpoint = endpoint
48
+ this.config = { ...DEFAULT_QUEUE_CONFIG, ...config }
49
+ this.loadFromStorage()
50
+ }
51
+
52
+ /**
53
+ * Add event to queue
54
+ */
55
+ push(type: string, payload: Record<string, unknown>): void {
56
+ const event: QueuedEvent = {
57
+ id: this.generateId(),
58
+ type,
59
+ payload,
60
+ timestamp: Date.now(),
61
+ retries: 0,
62
+ }
63
+
64
+ this.queue.push(event)
65
+ this.saveToStorage()
66
+ this.scheduleBatch()
67
+ }
68
+
69
+ /**
70
+ * Force flush all events immediately
71
+ */
72
+ async flush(): Promise<void> {
73
+ if (this.isFlushing || this.queue.length === 0) return
74
+
75
+ this.isFlushing = true
76
+ this.clearBatchTimer()
77
+
78
+ try {
79
+ const events = [...this.queue]
80
+ this.queue = []
81
+
82
+ await this.sendBatch(events)
83
+ } finally {
84
+ this.isFlushing = false
85
+ this.saveToStorage()
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Flush using sendBeacon (for unload events)
91
+ */
92
+ flushBeacon(): boolean {
93
+ if (this.queue.length === 0) return true
94
+
95
+ const events = this.queue.map(e => ({
96
+ type: e.type,
97
+ ...e.payload,
98
+ _queue_id: e.id,
99
+ _queue_timestamp: e.timestamp,
100
+ }))
101
+
102
+ const success = navigator.sendBeacon?.(
103
+ this.endpoint,
104
+ JSON.stringify({ events, beacon: true })
105
+ ) ?? false
106
+
107
+ if (success) {
108
+ this.queue = []
109
+ this.clearStorage()
110
+ }
111
+
112
+ return success
113
+ }
114
+
115
+ /**
116
+ * Get current queue length
117
+ */
118
+ get length(): number {
119
+ return this.queue.length
120
+ }
121
+
122
+ private scheduleBatch(): void {
123
+ if (this.batchTimer) return
124
+
125
+ // Flush immediately if batch size reached
126
+ if (this.queue.length >= this.config.batchSize) {
127
+ this.flush()
128
+ return
129
+ }
130
+
131
+ // Schedule batch flush
132
+ this.batchTimer = setTimeout(() => {
133
+ this.batchTimer = null
134
+ this.flush()
135
+ }, this.config.batchTimeout)
136
+ }
137
+
138
+ private clearBatchTimer(): void {
139
+ if (this.batchTimer) {
140
+ clearTimeout(this.batchTimer)
141
+ this.batchTimer = null
142
+ }
143
+ }
144
+
145
+ private async sendBatch(events: QueuedEvent[]): Promise<void> {
146
+ if (events.length === 0) return
147
+
148
+ const payload = {
149
+ events: events.map(e => ({
150
+ type: e.type,
151
+ ...e.payload,
152
+ _queue_id: e.id,
153
+ _queue_timestamp: e.timestamp,
154
+ })),
155
+ batch: true,
156
+ }
157
+
158
+ try {
159
+ const response = await fetch(this.endpoint, {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify(payload),
163
+ })
164
+
165
+ if (!response.ok) {
166
+ throw new Error(`HTTP ${response.status}`)
167
+ }
168
+ } catch (error) {
169
+ // Retry failed events
170
+ for (const event of events) {
171
+ if (event.retries < this.config.maxRetries) {
172
+ event.retries++
173
+ this.queue.push(event)
174
+ }
175
+ }
176
+
177
+ // Schedule retry with exponential backoff
178
+ if (this.queue.length > 0) {
179
+ const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1)
180
+ setTimeout(() => this.flush(), delay)
181
+ }
182
+ }
183
+ }
184
+
185
+ private loadFromStorage(): void {
186
+ try {
187
+ const stored = localStorage.getItem(this.config.storageKey)
188
+ if (stored) {
189
+ const parsed = JSON.parse(stored)
190
+ if (Array.isArray(parsed)) {
191
+ // Only restore events less than 24 hours old
192
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000
193
+ this.queue = parsed.filter((e: QueuedEvent) => e.timestamp > cutoff)
194
+ }
195
+ }
196
+ } catch {
197
+ // localStorage not available or corrupted
198
+ }
199
+ }
200
+
201
+ private saveToStorage(): void {
202
+ try {
203
+ if (this.queue.length > 0) {
204
+ localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue))
205
+ } else {
206
+ this.clearStorage()
207
+ }
208
+ } catch {
209
+ // localStorage not available
210
+ }
211
+ }
212
+
213
+ private clearStorage(): void {
214
+ try {
215
+ localStorage.removeItem(this.config.storageKey)
216
+ } catch {
217
+ // Ignore
218
+ }
219
+ }
220
+
221
+ private generateId(): string {
222
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
223
+ }
224
+ }
225
+
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Infrastructure Modules
3
+ * @module @loamly/tracker
4
+ */
5
+
6
+ export { EventQueue, type QueuedEvent } from './event-queue'
7
+ export { PingService, type PingData, type PingConfig } from './ping'
8
+
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Real-time Ping Service
3
+ *
4
+ * Heartbeat for active session tracking with:
5
+ * - Configurable interval (default 30s)
6
+ * - Visibility-aware (pauses when tab hidden)
7
+ * - Session metrics aggregation
8
+ *
9
+ * @module @loamly/tracker
10
+ */
11
+
12
+ import { DEFAULT_CONFIG } from '../config'
13
+
14
+ export interface PingData {
15
+ session_id: string
16
+ visitor_id: string
17
+ url: string
18
+ time_on_page_ms: number
19
+ scroll_depth: number
20
+ is_active: boolean
21
+ tracker_version: string
22
+ }
23
+
24
+ export interface PingConfig {
25
+ interval: number
26
+ endpoint: string
27
+ onPing?: (data: PingData) => void
28
+ }
29
+
30
+ export class PingService {
31
+ private intervalId: ReturnType<typeof setInterval> | null = null
32
+ private config: PingConfig
33
+ private pageLoadTime: number
34
+ private isVisible = true
35
+ private currentScrollDepth = 0
36
+ private sessionId: string
37
+ private visitorId: string
38
+ private version: string
39
+
40
+ constructor(
41
+ sessionId: string,
42
+ visitorId: string,
43
+ version: string,
44
+ config: Partial<PingConfig> = {}
45
+ ) {
46
+ this.sessionId = sessionId
47
+ this.visitorId = visitorId
48
+ this.version = version
49
+ this.pageLoadTime = Date.now()
50
+ this.config = {
51
+ interval: DEFAULT_CONFIG.pingInterval,
52
+ endpoint: '',
53
+ ...config,
54
+ }
55
+
56
+ // Track visibility
57
+ document.addEventListener('visibilitychange', this.handleVisibilityChange)
58
+
59
+ // Track scroll depth
60
+ window.addEventListener('scroll', this.handleScroll, { passive: true })
61
+ }
62
+
63
+ /**
64
+ * Start the ping service
65
+ */
66
+ start(): void {
67
+ if (this.intervalId) return
68
+
69
+ this.intervalId = setInterval(() => {
70
+ if (this.isVisible) {
71
+ this.ping()
72
+ }
73
+ }, this.config.interval)
74
+
75
+ // Send initial ping
76
+ this.ping()
77
+ }
78
+
79
+ /**
80
+ * Stop the ping service
81
+ */
82
+ stop(): void {
83
+ if (this.intervalId) {
84
+ clearInterval(this.intervalId)
85
+ this.intervalId = null
86
+ }
87
+
88
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange)
89
+ window.removeEventListener('scroll', this.handleScroll)
90
+ }
91
+
92
+ /**
93
+ * Update scroll depth (called by external scroll tracker)
94
+ */
95
+ updateScrollDepth(depth: number): void {
96
+ if (depth > this.currentScrollDepth) {
97
+ this.currentScrollDepth = depth
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get current ping data
103
+ */
104
+ getData(): PingData {
105
+ return {
106
+ session_id: this.sessionId,
107
+ visitor_id: this.visitorId,
108
+ url: window.location.href,
109
+ time_on_page_ms: Date.now() - this.pageLoadTime,
110
+ scroll_depth: this.currentScrollDepth,
111
+ is_active: this.isVisible,
112
+ tracker_version: this.version,
113
+ }
114
+ }
115
+
116
+ private ping = async (): Promise<void> => {
117
+ const data = this.getData()
118
+
119
+ // Call callback if provided
120
+ this.config.onPing?.(data)
121
+
122
+ // Send to endpoint if configured
123
+ if (this.config.endpoint) {
124
+ try {
125
+ await fetch(this.config.endpoint, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify(data),
129
+ })
130
+ } catch {
131
+ // Ping failures are silent
132
+ }
133
+ }
134
+ }
135
+
136
+ private handleVisibilityChange = (): void => {
137
+ this.isVisible = document.visibilityState === 'visible'
138
+ }
139
+
140
+ private handleScroll = (): void => {
141
+ const scrollPercent = Math.round(
142
+ ((window.scrollY + window.innerHeight) / document.documentElement.scrollHeight) * 100
143
+ )
144
+ if (scrollPercent > this.currentScrollDepth) {
145
+ this.currentScrollDepth = Math.min(scrollPercent, 100)
146
+ }
147
+ }
148
+ }
149
+
@@ -0,0 +1,7 @@
1
+ /**
2
+ * SPA Navigation Modules
3
+ * @module @loamly/tracker
4
+ */
5
+
6
+ export { SPARouter, type NavigationEvent, type RouterConfig } from './router'
7
+
@@ -0,0 +1,147 @@
1
+ /**
2
+ * SPA Navigation Router
3
+ *
4
+ * Detects client-side navigation in SPAs with support for:
5
+ * - History API (pushState, replaceState, popstate)
6
+ * - Hash changes (for hash-based routing)
7
+ * - Next.js, React Router, Vue Router, etc.
8
+ *
9
+ * @module @loamly/tracker
10
+ */
11
+
12
+ export interface NavigationEvent {
13
+ from_url: string
14
+ to_url: string
15
+ navigation_type: 'push' | 'replace' | 'pop' | 'hash' | 'initial'
16
+ time_on_previous_page_ms: number
17
+ }
18
+
19
+ export interface RouterConfig {
20
+ onNavigate?: (event: NavigationEvent) => void
21
+ ignoreHashChange?: boolean
22
+ }
23
+
24
+ export class SPARouter {
25
+ private config: RouterConfig
26
+ private currentUrl: string
27
+ private pageEnterTime: number
28
+ private originalPushState: typeof history.pushState | null = null
29
+ private originalReplaceState: typeof history.replaceState | null = null
30
+
31
+ constructor(config: RouterConfig = {}) {
32
+ this.config = config
33
+ this.currentUrl = window.location.href
34
+ this.pageEnterTime = Date.now()
35
+ }
36
+
37
+ /**
38
+ * Start listening for navigation events
39
+ */
40
+ start(): void {
41
+ // Patch History API
42
+ this.patchHistoryAPI()
43
+
44
+ // Listen for popstate (back/forward)
45
+ window.addEventListener('popstate', this.handlePopState)
46
+
47
+ // Listen for hash changes
48
+ if (!this.config.ignoreHashChange) {
49
+ window.addEventListener('hashchange', this.handleHashChange)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Stop listening and restore original methods
55
+ */
56
+ stop(): void {
57
+ // Restore original History API methods
58
+ if (this.originalPushState) {
59
+ history.pushState = this.originalPushState
60
+ }
61
+ if (this.originalReplaceState) {
62
+ history.replaceState = this.originalReplaceState
63
+ }
64
+
65
+ window.removeEventListener('popstate', this.handlePopState)
66
+ window.removeEventListener('hashchange', this.handleHashChange)
67
+ }
68
+
69
+ /**
70
+ * Manually trigger a navigation event (for custom routers)
71
+ */
72
+ navigate(url: string, type: NavigationEvent['navigation_type'] = 'push'): void {
73
+ this.emitNavigation(url, type)
74
+ }
75
+
76
+ /**
77
+ * Get current URL
78
+ */
79
+ getCurrentUrl(): string {
80
+ return this.currentUrl
81
+ }
82
+
83
+ /**
84
+ * Get time on current page
85
+ */
86
+ getTimeOnPage(): number {
87
+ return Date.now() - this.pageEnterTime
88
+ }
89
+
90
+ private patchHistoryAPI(): void {
91
+ // Store original methods
92
+ this.originalPushState = history.pushState.bind(history)
93
+ this.originalReplaceState = history.replaceState.bind(history)
94
+
95
+ // Patch pushState
96
+ history.pushState = (...args) => {
97
+ const result = this.originalPushState!(...args)
98
+ this.handleStateChange('push')
99
+ return result
100
+ }
101
+
102
+ // Patch replaceState
103
+ history.replaceState = (...args) => {
104
+ const result = this.originalReplaceState!(...args)
105
+ this.handleStateChange('replace')
106
+ return result
107
+ }
108
+ }
109
+
110
+ private handleStateChange = (type: 'push' | 'replace'): void => {
111
+ const newUrl = window.location.href
112
+ if (newUrl !== this.currentUrl) {
113
+ this.emitNavigation(newUrl, type)
114
+ }
115
+ }
116
+
117
+ private handlePopState = (): void => {
118
+ const newUrl = window.location.href
119
+ if (newUrl !== this.currentUrl) {
120
+ this.emitNavigation(newUrl, 'pop')
121
+ }
122
+ }
123
+
124
+ private handleHashChange = (): void => {
125
+ const newUrl = window.location.href
126
+ if (newUrl !== this.currentUrl) {
127
+ this.emitNavigation(newUrl, 'hash')
128
+ }
129
+ }
130
+
131
+ private emitNavigation(toUrl: string, type: NavigationEvent['navigation_type']): void {
132
+ const event: NavigationEvent = {
133
+ from_url: this.currentUrl,
134
+ to_url: toUrl,
135
+ navigation_type: type,
136
+ time_on_previous_page_ms: Date.now() - this.pageEnterTime,
137
+ }
138
+
139
+ // Update current state
140
+ this.currentUrl = toUrl
141
+ this.pageEnterTime = Date.now()
142
+
143
+ // Emit event
144
+ this.config.onNavigate?.(event)
145
+ }
146
+ }
147
+