@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/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * See what AI tells your customers — and track when they click.
6
6
  *
7
7
  * @module @loamly/tracker
8
- * @version 1.8.0
8
+ * @version 2.1.0
9
9
  * @license MIT
10
10
  * @see https://github.com/loamly/loamly
11
11
  * @see https://loamly.ai
@@ -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 events = this.queue.map(e => ({
96
- type: e.type,
97
- ...e.payload,
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
- const success = navigator.sendBeacon?.(
103
- this.endpoint,
104
- JSON.stringify({ events, beacon: true })
105
- ) ?? false
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
- if (success) {
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 success
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 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}`)
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 failed events
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.ping()
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 {