@loamly/tracker 2.0.2 → 2.1.1
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.
- package/README.md +27 -3
- package/dist/index.cjs +395 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.mjs +395 -145
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +412 -148
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +1 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +1 -1
- package/src/behavioral/form-tracker.ts +5 -0
- package/src/browser.ts +25 -6
- package/src/config.ts +1 -1
- package/src/core.ts +400 -136
- package/src/index.ts +1 -1
- package/src/infrastructure/event-queue.ts +58 -32
- package/src/infrastructure/ping.ts +19 -3
- package/src/types.ts +28 -0
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface QueuedEvent {
|
|
|
16
16
|
id: string
|
|
17
17
|
type: string
|
|
18
18
|
payload: Record<string, unknown>
|
|
19
|
+
headers?: Record<string, string>
|
|
19
20
|
timestamp: number
|
|
20
21
|
retries: number
|
|
21
22
|
}
|
|
@@ -26,6 +27,7 @@ interface EventQueueConfig {
|
|
|
26
27
|
maxRetries: number
|
|
27
28
|
retryDelayMs: number
|
|
28
29
|
storageKey: string
|
|
30
|
+
apiKey?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const DEFAULT_QUEUE_CONFIG: EventQueueConfig = {
|
|
@@ -52,11 +54,12 @@ export class EventQueue {
|
|
|
52
54
|
/**
|
|
53
55
|
* Add event to queue
|
|
54
56
|
*/
|
|
55
|
-
push(type: string, payload: Record<string, unknown>): void {
|
|
57
|
+
push(type: string, payload: Record<string, unknown>, headers?: Record<string, string>): void {
|
|
56
58
|
const event: QueuedEvent = {
|
|
57
59
|
id: this.generateId(),
|
|
58
60
|
type,
|
|
59
61
|
payload,
|
|
62
|
+
headers,
|
|
60
63
|
timestamp: Date.now(),
|
|
61
64
|
retries: 0,
|
|
62
65
|
}
|
|
@@ -92,24 +95,31 @@ export class EventQueue {
|
|
|
92
95
|
flushBeacon(): boolean {
|
|
93
96
|
if (this.queue.length === 0) return true
|
|
94
97
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
_queue_id: e.id,
|
|
99
|
-
_queue_timestamp: e.timestamp,
|
|
100
|
-
}))
|
|
98
|
+
const baseUrl = this.config.apiKey
|
|
99
|
+
? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}`
|
|
100
|
+
: this.endpoint
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
let allSent = true
|
|
103
|
+
for (const event of this.queue) {
|
|
104
|
+
const payload = {
|
|
105
|
+
...event.payload,
|
|
106
|
+
_queue_id: event.id,
|
|
107
|
+
_queue_timestamp: event.timestamp,
|
|
108
|
+
}
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false
|
|
111
|
+
if (!success) {
|
|
112
|
+
allSent = false
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (allSent) {
|
|
108
118
|
this.queue = []
|
|
109
119
|
this.clearStorage()
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
return
|
|
122
|
+
return allSent
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/**
|
|
@@ -145,28 +155,44 @@ export class EventQueue {
|
|
|
145
155
|
private async sendBatch(events: QueuedEvent[]): Promise<void> {
|
|
146
156
|
if (events.length === 0) return
|
|
147
157
|
|
|
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
158
|
try {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
159
|
+
const results = await Promise.allSettled(
|
|
160
|
+
events.map(async (event) => {
|
|
161
|
+
const response = await fetch(this.endpoint, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: {
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
...(event.headers || {}),
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
...event.payload,
|
|
169
|
+
_queue_id: event.id,
|
|
170
|
+
_queue_timestamp: event.timestamp,
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new Error(`HTTP ${response.status}`)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const failedEvents = events.filter((_, index) => results[index]?.status === 'rejected')
|
|
181
|
+
if (failedEvents.length > 0) {
|
|
182
|
+
// Retry failed events only
|
|
183
|
+
for (const event of failedEvents) {
|
|
184
|
+
if (event.retries < this.config.maxRetries) {
|
|
185
|
+
event.retries++
|
|
186
|
+
this.queue.push(event)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Schedule retry with exponential backoff
|
|
190
|
+
const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1)
|
|
191
|
+
setTimeout(() => this.flush(), delay)
|
|
167
192
|
}
|
|
193
|
+
return
|
|
168
194
|
} catch (error) {
|
|
169
|
-
// Retry
|
|
195
|
+
// Retry all events if we hit an unexpected error
|
|
170
196
|
for (const event of events) {
|
|
171
197
|
if (event.retries < this.config.maxRetries) {
|
|
172
198
|
event.retries++
|
|
@@ -32,6 +32,7 @@ export class PingService {
|
|
|
32
32
|
private config: PingConfig
|
|
33
33
|
private pageLoadTime: number
|
|
34
34
|
private isVisible = true
|
|
35
|
+
private isFocused = true
|
|
35
36
|
private currentScrollDepth = 0
|
|
36
37
|
private sessionId: string
|
|
37
38
|
private visitorId: string
|
|
@@ -47,6 +48,8 @@ export class PingService {
|
|
|
47
48
|
this.visitorId = visitorId
|
|
48
49
|
this.version = version
|
|
49
50
|
this.pageLoadTime = Date.now()
|
|
51
|
+
this.isVisible = document.visibilityState === 'visible'
|
|
52
|
+
this.isFocused = typeof document.hasFocus === 'function' ? document.hasFocus() : true
|
|
50
53
|
this.config = {
|
|
51
54
|
interval: DEFAULT_CONFIG.pingInterval,
|
|
52
55
|
endpoint: '',
|
|
@@ -55,6 +58,8 @@ export class PingService {
|
|
|
55
58
|
|
|
56
59
|
// Track visibility
|
|
57
60
|
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
|
61
|
+
window.addEventListener('focus', this.handleFocusChange)
|
|
62
|
+
window.addEventListener('blur', this.handleFocusChange)
|
|
58
63
|
|
|
59
64
|
// Track scroll depth
|
|
60
65
|
window.addEventListener('scroll', this.handleScroll, { passive: true })
|
|
@@ -67,13 +72,15 @@ export class PingService {
|
|
|
67
72
|
if (this.intervalId) return
|
|
68
73
|
|
|
69
74
|
this.intervalId = setInterval(() => {
|
|
70
|
-
if (this.isVisible) {
|
|
75
|
+
if (this.isVisible && this.isFocused) {
|
|
71
76
|
this.ping()
|
|
72
77
|
}
|
|
73
78
|
}, this.config.interval)
|
|
74
79
|
|
|
75
80
|
// Send initial ping
|
|
76
|
-
this.
|
|
81
|
+
if (this.isVisible && this.isFocused) {
|
|
82
|
+
this.ping()
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/**
|
|
@@ -86,6 +93,8 @@ export class PingService {
|
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
|
96
|
+
window.removeEventListener('focus', this.handleFocusChange)
|
|
97
|
+
window.removeEventListener('blur', this.handleFocusChange)
|
|
89
98
|
window.removeEventListener('scroll', this.handleScroll)
|
|
90
99
|
}
|
|
91
100
|
|
|
@@ -108,7 +117,7 @@ export class PingService {
|
|
|
108
117
|
url: window.location.href,
|
|
109
118
|
time_on_page_ms: Date.now() - this.pageLoadTime,
|
|
110
119
|
scroll_depth: this.currentScrollDepth,
|
|
111
|
-
is_active: this.isVisible,
|
|
120
|
+
is_active: this.isVisible && this.isFocused,
|
|
112
121
|
tracker_version: this.version,
|
|
113
122
|
}
|
|
114
123
|
}
|
|
@@ -137,6 +146,13 @@ export class PingService {
|
|
|
137
146
|
this.isVisible = document.visibilityState === 'visible'
|
|
138
147
|
}
|
|
139
148
|
|
|
149
|
+
private handleFocusChange = (): void => {
|
|
150
|
+
this.isFocused = typeof document.hasFocus === 'function' ? document.hasFocus() : true
|
|
151
|
+
if (this.intervalId && this.isVisible && this.isFocused) {
|
|
152
|
+
this.ping()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
private handleScroll = (): void => {
|
|
141
157
|
const scrollPercent = Math.round(
|
|
142
158
|
((window.scrollY + window.innerHeight) / document.documentElement.scrollHeight) * 100
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
export interface LoamlyConfig {
|
|
7
7
|
/** Your Loamly API key (found in dashboard) */
|
|
8
8
|
apiKey?: string
|
|
9
|
+
|
|
10
|
+
/** Workspace ID (required when using public tracker key) */
|
|
11
|
+
workspaceId?: string
|
|
9
12
|
|
|
10
13
|
/** Custom API host (default: https://app.loamly.ai) */
|
|
11
14
|
apiHost?: string
|
|
@@ -21,6 +24,31 @@ export interface LoamlyConfig {
|
|
|
21
24
|
|
|
22
25
|
/** Custom session timeout in milliseconds (default: 30 minutes) */
|
|
23
26
|
sessionTimeout?: number
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Feature flags for lightweight mode
|
|
30
|
+
* Set to false to reduce initialization overhead
|
|
31
|
+
*/
|
|
32
|
+
features?: {
|
|
33
|
+
/** Scroll depth tracking (default: true) */
|
|
34
|
+
scroll?: boolean
|
|
35
|
+
/** Time on page tracking (default: true) */
|
|
36
|
+
time?: boolean
|
|
37
|
+
/** Form interaction tracking (default: true) */
|
|
38
|
+
forms?: boolean
|
|
39
|
+
/** SPA navigation support (default: true) */
|
|
40
|
+
spa?: boolean
|
|
41
|
+
/** Behavioral ML classification (default: true) */
|
|
42
|
+
behavioralML?: boolean
|
|
43
|
+
/** Focus/blur paste detection (default: true) */
|
|
44
|
+
focusBlur?: boolean
|
|
45
|
+
/** Agentic browser detection (default: true) */
|
|
46
|
+
agentic?: boolean
|
|
47
|
+
/** Event queue with retry (default: true) */
|
|
48
|
+
eventQueue?: boolean
|
|
49
|
+
/** Heartbeat ping service (default: false - opt-in) */
|
|
50
|
+
ping?: boolean
|
|
51
|
+
}
|
|
24
52
|
}
|
|
25
53
|
|
|
26
54
|
export interface TrackEventOptions {
|