@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.
package/src/core.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * Loamly Tracker Core
3
3
  *
4
- * Cookie-free, privacy-first analytics with AI traffic detection.
4
+ * Cookie-free, privacy-first analytics with comprehensive AI traffic detection.
5
+ *
6
+ * Features:
7
+ * - Navigation Timing API (paste vs click detection)
8
+ * - Behavioral ML Classifier (mouse, scroll, interaction patterns)
9
+ * - Focus/Blur Sequence Analysis (copy-paste detection)
10
+ * - Agentic Browser Detection (Comet, CDP, teleporting clicks)
11
+ * - Advanced Scroll Tracking (30% chunk reporting)
12
+ * - Universal Form Tracking (HubSpot, Typeform, native)
13
+ * - SPA Navigation Support (History API hooks)
14
+ * - Event Queue with Retry (offline support)
15
+ * - Real-time Ping (heartbeat)
5
16
  *
6
17
  * @module @loamly/tracker
7
18
  */
@@ -17,6 +28,16 @@ import {
17
28
  FocusBlurAnalyzer,
18
29
  type FocusBlurResult
19
30
  } from './detection/focus-blur'
31
+ import {
32
+ AgenticBrowserAnalyzer,
33
+ type AgenticDetectionResult
34
+ } from './detection/agentic-browser'
35
+ import { EventQueue } from './infrastructure/event-queue'
36
+ import { PingService } from './infrastructure/ping'
37
+ import { ScrollTracker, type ScrollEvent } from './behavioral/scroll-tracker'
38
+ import { TimeTracker, type TimeEvent } from './behavioral/time-tracker'
39
+ import { FormTracker, type FormEvent } from './behavioral/form-tracker'
40
+ import { SPARouter, type NavigationEvent } from './spa/router'
20
41
  import {
21
42
  getVisitorId,
22
43
  getSessionId,
@@ -41,13 +62,27 @@ let initialized = false
41
62
  let debugMode = false
42
63
  let visitorId: string | null = null
43
64
  let sessionId: string | null = null
44
- let sessionStartTime: number | null = null
45
65
  let navigationTiming: NavigationTiming | null = null
46
66
  let aiDetection: AIDetectionResult | null = null
67
+
68
+ // Detection modules
47
69
  let behavioralClassifier: BehavioralClassifier | null = null
48
70
  let behavioralMLResult: BehavioralMLResult | null = null
49
71
  let focusBlurAnalyzer: FocusBlurAnalyzer | null = null
50
72
  let focusBlurResult: FocusBlurMLResult | null = null
73
+ let agenticAnalyzer: AgenticBrowserAnalyzer | null = null
74
+
75
+ // Infrastructure modules
76
+ let eventQueue: EventQueue | null = null
77
+ let pingService: PingService | null = null
78
+
79
+ // Behavioral tracking modules
80
+ let scrollTracker: ScrollTracker | null = null
81
+ let timeTracker: TimeTracker | null = null
82
+ let formTracker: FormTracker | null = null
83
+
84
+ // SPA navigation
85
+ let spaRouter: SPARouter | null = null
51
86
 
52
87
  /**
53
88
  * Debug logger
@@ -91,14 +126,19 @@ function init(userConfig: LoamlyConfig = {}): void {
91
126
  // Get/create session
92
127
  const session = getSessionId()
93
128
  sessionId = session.sessionId
94
- sessionStartTime = Date.now()
95
129
  log('Session ID:', sessionId, session.isNew ? '(new)' : '(existing)')
96
130
 
131
+ // Initialize event queue with batching
132
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
133
+ batchSize: DEFAULT_CONFIG.batchSize,
134
+ batchTimeout: DEFAULT_CONFIG.batchTimeout,
135
+ })
136
+
97
137
  // Detect navigation timing (paste vs click)
98
138
  navigationTiming = detectNavigationType()
99
139
  log('Navigation timing:', navigationTiming)
100
140
 
101
- // Detect AI from referrer
141
+ // Detect AI from referrer/UTM
102
142
  aiDetection = detectAIFromReferrer(document.referrer) || detectAIFromUTM(window.location.href)
103
143
  if (aiDetection) {
104
144
  log('AI detected:', aiDetection)
@@ -113,7 +153,7 @@ function init(userConfig: LoamlyConfig = {}): void {
113
153
 
114
154
  // Set up behavioral tracking unless disabled
115
155
  if (!userConfig.disableBehavioral) {
116
- setupBehavioralTracking()
156
+ setupAdvancedBehavioralTracking()
117
157
  }
118
158
 
119
159
  // Initialize behavioral ML classifier (LOA-180)
@@ -132,9 +172,214 @@ function init(userConfig: LoamlyConfig = {}): void {
132
172
  }
133
173
  }, 5000)
134
174
 
175
+ // Initialize agentic browser detection (LOA-187)
176
+ agenticAnalyzer = new AgenticBrowserAnalyzer()
177
+ agenticAnalyzer.init()
178
+
179
+ // Set up ping service
180
+ if (visitorId && sessionId) {
181
+ pingService = new PingService(sessionId, visitorId, VERSION, {
182
+ interval: DEFAULT_CONFIG.pingInterval,
183
+ endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping),
184
+ })
185
+ pingService.start()
186
+ }
187
+
188
+ // Set up SPA navigation tracking
189
+ spaRouter = new SPARouter({
190
+ onNavigate: handleSPANavigation,
191
+ })
192
+ spaRouter.start()
193
+
194
+ // Set up unload handlers
195
+ setupUnloadHandlers()
196
+
135
197
  log('Initialization complete')
136
198
  }
137
199
 
200
+ /**
201
+ * Set up advanced behavioral tracking with new modules
202
+ */
203
+ function setupAdvancedBehavioralTracking(): void {
204
+ // Scroll tracker with 30% chunks
205
+ scrollTracker = new ScrollTracker({
206
+ chunks: [30, 60, 90, 100],
207
+ onChunkReached: (event: ScrollEvent) => {
208
+ log('Scroll chunk:', event.chunk)
209
+ queueEvent('scroll_depth', {
210
+ depth: event.depth,
211
+ chunk: event.chunk,
212
+ time_to_reach_ms: event.time_to_reach_ms,
213
+ })
214
+ },
215
+ })
216
+ scrollTracker.start()
217
+
218
+ // Time tracker
219
+ timeTracker = new TimeTracker({
220
+ updateIntervalMs: 10000, // Report every 10 seconds
221
+ onUpdate: (event: TimeEvent) => {
222
+ if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
223
+ queueEvent('time_spent', {
224
+ active_time_ms: event.active_time_ms,
225
+ total_time_ms: event.total_time_ms,
226
+ idle_time_ms: event.idle_time_ms,
227
+ is_engaged: event.is_engaged,
228
+ })
229
+ }
230
+ },
231
+ })
232
+ timeTracker.start()
233
+
234
+ // Form tracker with universal support
235
+ formTracker = new FormTracker({
236
+ onFormEvent: (event: FormEvent) => {
237
+ log('Form event:', event.event_type, event.form_id)
238
+ queueEvent(event.event_type, {
239
+ form_id: event.form_id,
240
+ form_type: event.form_type,
241
+ field_name: event.field_name,
242
+ field_type: event.field_type,
243
+ time_to_submit_ms: event.time_to_submit_ms,
244
+ is_conversion: event.is_conversion,
245
+ })
246
+ },
247
+ })
248
+ formTracker.start()
249
+
250
+ // Click tracking for links (basic)
251
+ document.addEventListener('click', (e) => {
252
+ const target = e.target as HTMLElement
253
+ const link = target.closest('a')
254
+
255
+ if (link && link.href) {
256
+ const isExternal = link.hostname !== window.location.hostname
257
+ queueEvent('click', {
258
+ element: 'link',
259
+ href: truncateText(link.href, 200),
260
+ text: truncateText(link.textContent || '', 100),
261
+ is_external: isExternal,
262
+ })
263
+ }
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Queue an event for batched sending
269
+ */
270
+ function queueEvent(eventType: string, data: Record<string, unknown>): void {
271
+ if (!eventQueue) return
272
+
273
+ eventQueue.push(eventType, {
274
+ visitor_id: visitorId,
275
+ session_id: sessionId,
276
+ event_type: eventType,
277
+ ...data,
278
+ url: window.location.href,
279
+ timestamp: new Date().toISOString(),
280
+ tracker_version: VERSION,
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Handle SPA navigation
286
+ */
287
+ function handleSPANavigation(event: NavigationEvent): void {
288
+ log('SPA navigation:', event.navigation_type, event.to_url)
289
+
290
+ // Flush pending events before navigation
291
+ eventQueue?.flush()
292
+
293
+ // Update ping service
294
+ pingService?.updateScrollDepth(0)
295
+
296
+ // Reset scroll tracker for new page
297
+ scrollTracker?.stop()
298
+ scrollTracker = new ScrollTracker({
299
+ chunks: [30, 60, 90, 100],
300
+ onChunkReached: (scrollEvent: ScrollEvent) => {
301
+ queueEvent('scroll_depth', {
302
+ depth: scrollEvent.depth,
303
+ chunk: scrollEvent.chunk,
304
+ time_to_reach_ms: scrollEvent.time_to_reach_ms,
305
+ })
306
+ },
307
+ })
308
+ scrollTracker.start()
309
+
310
+ // Track the virtual pageview
311
+ pageview(event.to_url)
312
+
313
+ // Queue navigation event
314
+ queueEvent('spa_navigation', {
315
+ from_url: event.from_url,
316
+ to_url: event.to_url,
317
+ navigation_type: event.navigation_type,
318
+ time_on_previous_page_ms: event.time_on_previous_page_ms,
319
+ })
320
+ }
321
+
322
+ /**
323
+ * Set up handlers for page unload
324
+ */
325
+ function setupUnloadHandlers(): void {
326
+ const handleUnload = (): void => {
327
+ // Get final scroll depth
328
+ const scrollEvent = scrollTracker?.getFinalEvent()
329
+ if (scrollEvent) {
330
+ sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
331
+ visitor_id: visitorId,
332
+ session_id: sessionId,
333
+ event_type: 'scroll_depth_final',
334
+ data: scrollEvent,
335
+ url: window.location.href,
336
+ })
337
+ }
338
+
339
+ // Get final time metrics
340
+ const timeEvent = timeTracker?.getFinalMetrics()
341
+ if (timeEvent) {
342
+ sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
343
+ visitor_id: visitorId,
344
+ session_id: sessionId,
345
+ event_type: 'time_spent_final',
346
+ data: timeEvent,
347
+ url: window.location.href,
348
+ })
349
+ }
350
+
351
+ // Get agentic detection result
352
+ const agenticResult = agenticAnalyzer?.getResult()
353
+ if (agenticResult && agenticResult.agenticProbability > 0) {
354
+ sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
355
+ visitor_id: visitorId,
356
+ session_id: sessionId,
357
+ event_type: 'agentic_detection',
358
+ data: agenticResult,
359
+ url: window.location.href,
360
+ })
361
+ }
362
+
363
+ // Flush event queue
364
+ eventQueue?.flushBeacon()
365
+
366
+ // Force classify behavioral ML if not done
367
+ if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
368
+ const result = behavioralClassifier.forceClassify()
369
+ if (result) {
370
+ handleBehavioralClassification(result)
371
+ }
372
+ }
373
+ }
374
+
375
+ window.addEventListener('beforeunload', handleUnload)
376
+ document.addEventListener('visibilitychange', () => {
377
+ if (document.visibilityState === 'hidden') {
378
+ handleUnload()
379
+ }
380
+ })
381
+ }
382
+
138
383
  /**
139
384
  * Track a page view
140
385
  */
@@ -238,143 +483,8 @@ function identify(userId: string, traits: Record<string, unknown> = {}): void {
238
483
  })
239
484
  }
240
485
 
241
- /**
242
- * Set up behavioral tracking (scroll, time spent, etc.)
243
- */
244
- function setupBehavioralTracking(): void {
245
- let maxScrollDepth = 0
246
- let lastScrollUpdate = 0
247
- let lastTimeUpdate = Date.now()
248
-
249
- // Scroll tracking with requestAnimationFrame throttling
250
- let scrollTicking = false
251
-
252
- window.addEventListener('scroll', () => {
253
- if (!scrollTicking) {
254
- requestAnimationFrame(() => {
255
- const scrollPercent = Math.round(
256
- ((window.scrollY + window.innerHeight) / document.documentElement.scrollHeight) * 100
257
- )
258
-
259
- if (scrollPercent > maxScrollDepth) {
260
- maxScrollDepth = scrollPercent
261
-
262
- // Report at milestones (25%, 50%, 75%, 100%)
263
- const milestones = [25, 50, 75, 100]
264
- for (const milestone of milestones) {
265
- if (scrollPercent >= milestone && lastScrollUpdate < milestone) {
266
- lastScrollUpdate = milestone
267
- sendBehavioralEvent('scroll_depth', { depth: milestone })
268
- }
269
- }
270
- }
271
-
272
- scrollTicking = false
273
- })
274
- scrollTicking = true
275
- }
276
- })
277
-
278
- // Time spent tracking (every 5 seconds minimum)
279
- const trackTimeSpent = (): void => {
280
- const now = Date.now()
281
- const delta = now - lastTimeUpdate
282
-
283
- if (delta >= DEFAULT_CONFIG.timeSpentThresholdMs) {
284
- lastTimeUpdate = now
285
- sendBehavioralEvent('time_spent', {
286
- seconds: Math.round(delta / 1000),
287
- total_seconds: Math.round((now - (sessionStartTime || now)) / 1000)
288
- })
289
- }
290
- }
291
-
292
- // Track on visibility change
293
- document.addEventListener('visibilitychange', () => {
294
- if (document.visibilityState === 'hidden') {
295
- trackTimeSpent()
296
- }
297
- })
298
-
299
- // Track on page unload
300
- window.addEventListener('beforeunload', () => {
301
- trackTimeSpent()
302
-
303
- // Send final scroll depth
304
- if (maxScrollDepth > 0) {
305
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
306
- visitor_id: visitorId,
307
- session_id: sessionId,
308
- event_type: 'scroll_depth_final',
309
- data: { depth: maxScrollDepth },
310
- url: window.location.href,
311
- })
312
- }
313
- })
314
-
315
- // Form interaction tracking
316
- document.addEventListener('focusin', (e) => {
317
- const target = e.target as HTMLElement
318
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
319
- sendBehavioralEvent('form_focus', {
320
- field_type: target.tagName.toLowerCase(),
321
- field_name: (target as HTMLInputElement).name || (target as HTMLInputElement).id || 'unknown',
322
- })
323
- }
324
- })
325
-
326
- // Form submit tracking
327
- document.addEventListener('submit', (e) => {
328
- const form = e.target as HTMLFormElement
329
- sendBehavioralEvent('form_submit', {
330
- form_id: form.id || form.name || 'unknown',
331
- form_action: form.action ? new URL(form.action).pathname : 'unknown',
332
- })
333
- })
334
-
335
- // Click tracking for links
336
- document.addEventListener('click', (e) => {
337
- const target = e.target as HTMLElement
338
- const link = target.closest('a')
339
-
340
- if (link && link.href) {
341
- const isExternal = link.hostname !== window.location.hostname
342
- sendBehavioralEvent('click', {
343
- element: 'link',
344
- href: truncateText(link.href, 200),
345
- text: truncateText(link.textContent || '', 100),
346
- is_external: isExternal,
347
- })
348
- }
349
- })
350
- }
351
-
352
- /**
353
- * Send a behavioral event
354
- */
355
- function sendBehavioralEvent(eventType: string, data: Record<string, unknown>): void {
356
- const payload = {
357
- visitor_id: visitorId,
358
- session_id: sessionId,
359
- event_type: eventType,
360
- data,
361
- url: window.location.href,
362
- timestamp: new Date().toISOString(),
363
- tracker_version: VERSION,
364
- }
365
-
366
- log('Behavioral:', eventType, data)
367
-
368
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
369
- method: 'POST',
370
- headers: { 'Content-Type': 'application/json' },
371
- body: JSON.stringify(payload),
372
- })
373
- }
374
-
375
486
  /**
376
487
  * Set up behavioral ML signal collection (LOA-180)
377
- * Collects mouse, scroll, and interaction signals for Naive Bayes classification
378
488
  */
379
489
  function setupBehavioralMLTracking(): void {
380
490
  if (!behavioralClassifier) return
@@ -427,16 +537,6 @@ function setupBehavioralMLTracking(): void {
427
537
  }
428
538
  }, { passive: true })
429
539
 
430
- // Force classification on page unload
431
- window.addEventListener('beforeunload', () => {
432
- if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
433
- const result = behavioralClassifier.forceClassify()
434
- if (result) {
435
- handleBehavioralClassification(result)
436
- }
437
- }
438
- })
439
-
440
540
  // Also try to classify after 30 seconds as backup
441
541
  setTimeout(() => {
442
542
  if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
@@ -462,7 +562,7 @@ function handleBehavioralClassification(result: BehavioralClassificationResult):
462
562
  }
463
563
 
464
564
  // Send to backend
465
- sendBehavioralEvent('ml_classification', {
565
+ queueEvent('ml_classification', {
466
566
  classification: result.classification,
467
567
  human_probability: result.humanProbability,
468
568
  ai_probability: result.aiProbability,
@@ -500,7 +600,7 @@ function handleFocusBlurAnalysis(result: FocusBlurResult): void {
500
600
  }
501
601
 
502
602
  // Send to backend
503
- sendBehavioralEvent('focus_blur_analysis', {
603
+ queueEvent('focus_blur_analysis', {
504
604
  nav_type: result.nav_type,
505
605
  confidence: result.confidence,
506
606
  signals: result.signals,
@@ -564,6 +664,13 @@ function getFocusBlurResult(): FocusBlurMLResult | null {
564
664
  return focusBlurResult
565
665
  }
566
666
 
667
+ /**
668
+ * Get agentic browser detection result
669
+ */
670
+ function getAgenticResult(): AgenticDetectionResult | null {
671
+ return agenticAnalyzer?.getResult() || null
672
+ }
673
+
567
674
  /**
568
675
  * Check if initialized
569
676
  */
@@ -576,16 +683,32 @@ function isTrackerInitialized(): boolean {
576
683
  */
577
684
  function reset(): void {
578
685
  log('Resetting tracker')
686
+
687
+ // Stop all services
688
+ pingService?.stop()
689
+ scrollTracker?.stop()
690
+ timeTracker?.stop()
691
+ formTracker?.stop()
692
+ spaRouter?.stop()
693
+ agenticAnalyzer?.destroy()
694
+
695
+ // Reset state
579
696
  initialized = false
580
697
  visitorId = null
581
698
  sessionId = null
582
- sessionStartTime = null
583
699
  navigationTiming = null
584
700
  aiDetection = null
585
701
  behavioralClassifier = null
586
702
  behavioralMLResult = null
587
703
  focusBlurAnalyzer = null
588
704
  focusBlurResult = null
705
+ agenticAnalyzer = null
706
+ eventQueue = null
707
+ pingService = null
708
+ scrollTracker = null
709
+ timeTracker = null
710
+ formTracker = null
711
+ spaRouter = null
589
712
 
590
713
  try {
591
714
  sessionStorage.removeItem('loamly_session')
@@ -606,7 +729,7 @@ function setDebug(enabled: boolean): void {
606
729
  /**
607
730
  * The Loamly Tracker instance
608
731
  */
609
- export const loamly: LoamlyTracker = {
732
+ export const loamly: LoamlyTracker & { getAgentic: () => AgenticDetectionResult | null } = {
610
733
  init,
611
734
  pageview,
612
735
  track,
@@ -618,11 +741,10 @@ export const loamly: LoamlyTracker = {
618
741
  getNavigationTiming: getNavigationTimingResult,
619
742
  getBehavioralML: getBehavioralMLResult,
620
743
  getFocusBlur: getFocusBlurResult,
744
+ getAgentic: getAgenticResult,
621
745
  isInitialized: isTrackerInitialized,
622
746
  reset,
623
747
  debug: setDebug,
624
748
  }
625
749
 
626
750
  export default loamly
627
-
628
-