@loamly/tracker 2.1.0 → 2.4.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
@@ -17,44 +17,44 @@
17
17
  * @module @loamly/tracker
18
18
  */
19
19
 
20
- import { VERSION, DEFAULT_CONFIG } from './config'
21
- import { detectNavigationType } from './detection/navigation-timing'
22
- import { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
23
- import {
24
- BehavioralClassifier,
25
- type BehavioralClassificationResult
20
+ import { FormTracker, type FormEvent } from './behavioral/form-tracker'
21
+ import { ScrollTracker, type ScrollEvent } from './behavioral/scroll-tracker'
22
+ import { TimeTracker, type TimeEvent } from './behavioral/time-tracker'
23
+ import { DEFAULT_CONFIG, VERSION } from './config'
24
+ import {
25
+ AgenticBrowserAnalyzer,
26
+ type AgenticDetectionResult
27
+ } from './detection/agentic-browser'
28
+ import {
29
+ BehavioralClassifier,
30
+ type BehavioralClassificationResult
26
31
  } from './detection/behavioral-classifier'
27
32
  import {
28
- FocusBlurAnalyzer,
29
- type FocusBlurResult
33
+ FocusBlurAnalyzer,
34
+ type FocusBlurResult
30
35
  } from './detection/focus-blur'
31
- import {
32
- AgenticBrowserAnalyzer,
33
- type AgenticDetectionResult
34
- } from './detection/agentic-browser'
36
+ import { detectNavigationType } from './detection/navigation-timing'
37
+ import { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
35
38
  import { EventQueue } from './infrastructure/event-queue'
36
39
  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
40
  import { SPARouter, type NavigationEvent } from './spa/router'
41
- import {
42
- getVisitorId,
43
- getSessionId,
44
- extractUTMParams,
45
- truncateText,
46
- safeFetch,
47
- sendBeacon
48
- } from './utils'
49
- import type {
50
- LoamlyConfig,
51
- LoamlyTracker,
52
- TrackEventOptions,
53
- NavigationTiming,
54
- AIDetectionResult,
55
- BehavioralMLResult,
56
- FocusBlurMLResult
41
+ import type {
42
+ AIDetectionResult,
43
+ BehavioralMLResult,
44
+ FocusBlurMLResult,
45
+ LoamlyConfig,
46
+ LoamlyTracker,
47
+ NavigationTiming,
48
+ TrackEventOptions
57
49
  } from './types'
50
+ import {
51
+ extractUTMParams,
52
+ getSessionId,
53
+ getVisitorId,
54
+ safeFetch,
55
+ sendBeacon,
56
+ truncateText
57
+ } from './utils'
58
58
 
59
59
  // State
60
60
  let config: LoamlyConfig & { apiHost: string } = { apiHost: DEFAULT_CONFIG.apiHost }
@@ -62,8 +62,10 @@ let initialized = false
62
62
  let debugMode = false
63
63
  let visitorId: string | null = null
64
64
  let sessionId: string | null = null
65
+ let workspaceId: string | null = null
65
66
  let navigationTiming: NavigationTiming | null = null
66
67
  let aiDetection: AIDetectionResult | null = null
68
+ let pageStartTime: number | null = null
67
69
 
68
70
  // Detection modules
69
71
  let behavioralClassifier: BehavioralClassifier | null = null
@@ -100,6 +102,34 @@ function endpoint(path: string): string {
100
102
  return `${config.apiHost}${path}`
101
103
  }
102
104
 
105
+ function buildHeaders(idempotencyKey?: string): Record<string, string> {
106
+ const headers: Record<string, string> = {
107
+ 'Content-Type': 'application/json',
108
+ }
109
+
110
+ if (config.apiKey) {
111
+ headers['X-Loamly-Api-Key'] = config.apiKey
112
+ }
113
+
114
+ if (idempotencyKey) {
115
+ headers['X-Idempotency-Key'] = idempotencyKey
116
+ }
117
+
118
+ return headers
119
+ }
120
+
121
+ function buildBeaconUrl(path: string): string {
122
+ if (!config.apiKey) return path
123
+ const url = new URL(path, config.apiHost)
124
+ url.searchParams.set('api_key', config.apiKey)
125
+ return url.toString()
126
+ }
127
+
128
+ function buildIdempotencyKey(prefix: string): string {
129
+ const base = sessionId || visitorId || 'unknown'
130
+ return `${prefix}:${base}:${Date.now()}`
131
+ }
132
+
103
133
  /**
104
134
  * Initialize the tracker
105
135
  */
@@ -114,8 +144,13 @@ function init(userConfig: LoamlyConfig = {}): void {
114
144
  ...userConfig,
115
145
  apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost,
116
146
  }
147
+ workspaceId = userConfig.workspaceId ?? null
117
148
 
118
149
  debugMode = userConfig.debug ?? false
150
+
151
+ if (config.apiKey && !workspaceId) {
152
+ log('Workspace ID missing. Behavioral events require workspaceId.')
153
+ }
119
154
 
120
155
  // Feature flags with defaults (all enabled except ping)
121
156
  const features = {
@@ -137,17 +172,13 @@ function init(userConfig: LoamlyConfig = {}): void {
137
172
  // Get/create visitor ID
138
173
  visitorId = getVisitorId()
139
174
  log('Visitor ID:', visitorId)
140
-
141
- // Get/create session
142
- const session = getSessionId()
143
- sessionId = session.sessionId
144
- log('Session ID:', sessionId, session.isNew ? '(new)' : '(existing)')
145
-
146
- // Initialize event queue with batching (if enabled)
175
+
176
+ // Initialize event queue (if enabled)
147
177
  if (features.eventQueue) {
148
178
  eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
149
179
  batchSize: DEFAULT_CONFIG.batchSize,
150
180
  batchTimeout: DEFAULT_CONFIG.batchTimeout,
181
+ apiKey: config.apiKey,
151
182
  })
152
183
  }
153
184
 
@@ -162,51 +193,57 @@ function init(userConfig: LoamlyConfig = {}): void {
162
193
  }
163
194
 
164
195
  initialized = true
165
-
166
- // Auto pageview unless disabled
167
- if (!userConfig.disableAutoPageview) {
168
- pageview()
169
- }
170
-
171
- // Set up behavioral tracking (scroll, time, forms) unless disabled
172
- if (!userConfig.disableBehavioral) {
173
- setupAdvancedBehavioralTracking(features)
174
- }
175
-
176
- // Initialize behavioral ML classifier (LOA-180) - if enabled
177
- if (features.behavioralML) {
178
- behavioralClassifier = new BehavioralClassifier(10000) // 10s min session
179
- behavioralClassifier.setOnClassify(handleBehavioralClassification)
180
- setupBehavioralMLTracking()
181
- }
182
-
183
- // Initialize focus/blur analyzer (LOA-182) - if enabled
184
- if (features.focusBlur) {
185
- focusBlurAnalyzer = new FocusBlurAnalyzer()
186
- focusBlurAnalyzer.initTracking()
187
-
188
- // Analyze focus/blur after 5 seconds
189
- setTimeout(() => {
190
- if (focusBlurAnalyzer) {
191
- handleFocusBlurAnalysis(focusBlurAnalyzer.analyze())
192
- }
193
- }, 5000)
194
- }
195
-
196
- // Initialize agentic browser detection (LOA-187) - if enabled
196
+
197
+ // Initialize agentic browser detection early for pageview payloads
197
198
  if (features.agentic) {
198
199
  agenticAnalyzer = new AgenticBrowserAnalyzer()
199
200
  agenticAnalyzer.init()
200
201
  }
201
-
202
- // Set up ping service - if enabled (opt-in)
203
- if (features.ping && visitorId && sessionId) {
204
- pingService = new PingService(sessionId, visitorId, VERSION, {
205
- interval: DEFAULT_CONFIG.pingInterval,
206
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping),
207
- })
208
- pingService.start()
209
- }
202
+
203
+ // Initialize session (async), then start tracking
204
+ void initializeSession().finally(() => {
205
+ // Register service worker for RFC 9421 verification (optional)
206
+ void registerServiceWorker()
207
+
208
+ // Auto pageview unless disabled
209
+ if (!userConfig.disableAutoPageview) {
210
+ pageview()
211
+ }
212
+
213
+ // Set up behavioral tracking (scroll, time, forms) unless disabled
214
+ if (!userConfig.disableBehavioral) {
215
+ setupAdvancedBehavioralTracking(features)
216
+ }
217
+
218
+ // Initialize behavioral ML classifier (LOA-180) - if enabled
219
+ if (features.behavioralML) {
220
+ behavioralClassifier = new BehavioralClassifier(10000) // 10s min session
221
+ behavioralClassifier.setOnClassify(handleBehavioralClassification)
222
+ setupBehavioralMLTracking()
223
+ }
224
+
225
+ // Initialize focus/blur analyzer (LOA-182) - if enabled
226
+ if (features.focusBlur) {
227
+ focusBlurAnalyzer = new FocusBlurAnalyzer()
228
+ focusBlurAnalyzer.initTracking()
229
+
230
+ // Analyze focus/blur after 5 seconds
231
+ setTimeout(() => {
232
+ if (focusBlurAnalyzer) {
233
+ handleFocusBlurAnalysis(focusBlurAnalyzer.analyze())
234
+ }
235
+ }, 5000)
236
+ }
237
+
238
+ // Set up ping service - if enabled (opt-in)
239
+ if (features.ping && visitorId && sessionId) {
240
+ pingService = new PingService(sessionId, visitorId, VERSION, {
241
+ interval: DEFAULT_CONFIG.pingInterval,
242
+ endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping),
243
+ })
244
+ pingService.start()
245
+ }
246
+ })
210
247
 
211
248
  // Set up SPA navigation tracking
212
249
  spaRouter = new SPARouter({
@@ -223,6 +260,101 @@ function init(userConfig: LoamlyConfig = {}): void {
223
260
  log('Initialization complete')
224
261
  }
225
262
 
263
+ async function registerServiceWorker(): Promise<void> {
264
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return
265
+ if (!config.apiKey || !workspaceId) return
266
+
267
+ try {
268
+ const swUrl = new URL('/tracker/loamly-sw.js', window.location.origin)
269
+ swUrl.searchParams.set('workspace_id', workspaceId)
270
+ swUrl.searchParams.set('api_key', config.apiKey)
271
+
272
+ const registration = await navigator.serviceWorker.register(swUrl.toString(), { scope: '/' })
273
+
274
+ registration.addEventListener('updatefound', () => {
275
+ const installing = registration.installing
276
+ installing?.addEventListener('statechange', () => {
277
+ if (installing.state === 'activated') {
278
+ installing.postMessage({ type: 'SKIP_WAITING' })
279
+ }
280
+ })
281
+ })
282
+
283
+ setInterval(() => {
284
+ registration.update().catch(() => {
285
+ // Ignore update failures
286
+ })
287
+ }, 24 * 60 * 60 * 1000)
288
+ } catch {
289
+ // Ignore service worker errors
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Initialize session with server-side tracker_sessions when possible
295
+ */
296
+ async function initializeSession(): Promise<void> {
297
+ const now = Date.now()
298
+ pageStartTime = now
299
+
300
+ // Try sessionStorage first (fast path)
301
+ try {
302
+ const storedSession = sessionStorage.getItem('loamly_session')
303
+ const storedStart = sessionStorage.getItem('loamly_start')
304
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG.sessionTimeout
305
+
306
+ if (storedSession && storedStart) {
307
+ const startTime = parseInt(storedStart, 10)
308
+ const elapsed = now - startTime
309
+ if (elapsed > 0 && elapsed < sessionTimeout) {
310
+ sessionId = storedSession
311
+ log('Session ID:', sessionId, '(existing)')
312
+ return
313
+ }
314
+ }
315
+ } catch {
316
+ // sessionStorage not available
317
+ }
318
+
319
+ // Try server-side session creation if we have required context
320
+ if (config.apiKey && workspaceId && visitorId) {
321
+ try {
322
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG.endpoints.session), {
323
+ method: 'POST',
324
+ headers: buildHeaders(),
325
+ body: JSON.stringify({
326
+ workspace_id: workspaceId,
327
+ visitor_id: visitorId,
328
+ }),
329
+ })
330
+
331
+ if (response?.ok) {
332
+ const data = await response.json()
333
+ sessionId = data.session_id || sessionId
334
+ const startTime = data.start_time || now
335
+
336
+ if (sessionId) {
337
+ try {
338
+ sessionStorage.setItem('loamly_session', sessionId)
339
+ sessionStorage.setItem('loamly_start', String(startTime))
340
+ } catch {
341
+ // Ignore storage failures
342
+ }
343
+ log('Session ID:', sessionId, '(server)')
344
+ return
345
+ }
346
+ }
347
+ } catch {
348
+ // Fall through to local session
349
+ }
350
+ }
351
+
352
+ // Fallback: local session
353
+ const session = getSessionId()
354
+ sessionId = session.sessionId
355
+ log('Session ID:', sessionId, session.isNew ? '(new)' : '(existing)')
356
+ }
357
+
226
358
  /**
227
359
  * Set up advanced behavioral tracking with new modules
228
360
  */
@@ -246,8 +378,8 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
246
378
  onChunkReached: (event: ScrollEvent) => {
247
379
  log('Scroll chunk:', event.chunk)
248
380
  queueEvent('scroll_depth', {
249
- depth: event.depth,
250
- chunk: event.chunk,
381
+ scroll_depth: Math.round((event.depth / 100) * 100) / 100,
382
+ milestone: Math.round((event.chunk / 100) * 100) / 100,
251
383
  time_to_reach_ms: event.time_to_reach_ms,
252
384
  })
253
385
  },
@@ -262,10 +394,8 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
262
394
  onUpdate: (event: TimeEvent) => {
263
395
  if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
264
396
  queueEvent('time_spent', {
265
- active_time_ms: event.active_time_ms,
266
- total_time_ms: event.total_time_ms,
267
- idle_time_ms: event.idle_time_ms,
268
- is_engaged: event.is_engaged,
397
+ visible_time_ms: event.total_time_ms,
398
+ page_start_time: pageStartTime || Date.now(),
269
399
  })
270
400
  }
271
401
  },
@@ -278,13 +408,24 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
278
408
  formTracker = new FormTracker({
279
409
  onFormEvent: (event: FormEvent) => {
280
410
  log('Form event:', event.event_type, event.form_id)
281
- queueEvent(event.event_type, {
411
+ const isSubmitEvent = event.event_type === 'form_submit'
412
+ const isSuccessEvent = event.event_type === 'form_success'
413
+ const normalizedEventType = isSubmitEvent || isSuccessEvent
414
+ ? 'form_submit'
415
+ : 'form_focus'
416
+ const submitSource = event.submit_source || (isSuccessEvent ? 'thank_you' : isSubmitEvent ? 'submit' : null)
417
+ queueEvent(normalizedEventType, {
282
418
  form_id: event.form_id,
283
- form_type: event.form_type,
284
- field_name: event.field_name,
285
- field_type: event.field_type,
286
- time_to_submit_ms: event.time_to_submit_ms,
287
- is_conversion: event.is_conversion,
419
+ form_provider: event.form_type || 'unknown',
420
+ form_field_type: event.field_type || null,
421
+ form_field_name: event.field_name || null,
422
+ form_event_type: event.event_type,
423
+ submit_source: submitSource,
424
+ is_inferred: isSuccessEvent,
425
+ time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1000) : null,
426
+ // LOA-482: Include captured form field values
427
+ fields: event.fields || null,
428
+ email_submitted: event.email_submitted || null,
288
429
  })
289
430
  },
290
431
  })
@@ -310,7 +451,7 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
310
451
  if (link && link.href) {
311
452
  const isExternal = link.hostname !== window.location.hostname
312
453
  queueEvent('click', {
313
- element: 'link',
454
+ element_type: 'link',
314
455
  href: truncateText(link.href, 200),
315
456
  text: truncateText(link.textContent || '', 100),
316
457
  is_external: isExternal,
@@ -324,16 +465,35 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
324
465
  */
325
466
  function queueEvent(eventType: string, data: Record<string, unknown>): void {
326
467
  if (!eventQueue) return
327
-
328
- eventQueue.push(eventType, {
468
+ if (!config.apiKey) {
469
+ log('Missing apiKey, behavioral event skipped:', eventType)
470
+ return
471
+ }
472
+ if (!workspaceId) {
473
+ log('Missing workspaceId, behavioral event skipped:', eventType)
474
+ return
475
+ }
476
+ if (!sessionId) {
477
+ log('Missing sessionId, behavioral event skipped:', eventType)
478
+ return
479
+ }
480
+
481
+ const idempotencyKey = buildIdempotencyKey(eventType)
482
+ const payload: Record<string, unknown> = {
329
483
  visitor_id: visitorId,
330
484
  session_id: sessionId,
331
485
  event_type: eventType,
332
- ...data,
333
- url: window.location.href,
486
+ event_data: data,
487
+ page_url: window.location.href,
488
+ page_path: window.location.pathname,
334
489
  timestamp: new Date().toISOString(),
335
490
  tracker_version: VERSION,
336
- })
491
+ idempotency_key: idempotencyKey,
492
+ }
493
+
494
+ payload.workspace_id = workspaceId
495
+
496
+ eventQueue.push(eventType, payload, buildHeaders(idempotencyKey))
337
497
  }
338
498
 
339
499
  /**
@@ -379,39 +539,46 @@ function handleSPANavigation(event: NavigationEvent): void {
379
539
  */
380
540
  function setupUnloadHandlers(): void {
381
541
  const handleUnload = (): void => {
542
+ if (!workspaceId || !config.apiKey || !sessionId) return
543
+
382
544
  // Get final scroll depth
383
545
  const scrollEvent = scrollTracker?.getFinalEvent()
384
546
  if (scrollEvent) {
385
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
547
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
548
+ workspace_id: workspaceId,
386
549
  visitor_id: visitorId,
387
550
  session_id: sessionId,
388
551
  event_type: 'scroll_depth_final',
389
- data: scrollEvent,
390
- url: window.location.href,
552
+ event_data: {
553
+ scroll_depth: Math.round((scrollEvent.depth / 100) * 100) / 100,
554
+ milestone: Math.round((scrollEvent.chunk / 100) * 100) / 100,
555
+ time_to_reach_ms: scrollEvent.time_to_reach_ms,
556
+ },
557
+ page_url: window.location.href,
558
+ page_path: window.location.pathname,
559
+ timestamp: new Date().toISOString(),
560
+ tracker_version: VERSION,
561
+ idempotency_key: buildIdempotencyKey('scroll_depth_final'),
391
562
  })
392
563
  }
393
564
 
394
565
  // Get final time metrics
395
566
  const timeEvent = timeTracker?.getFinalMetrics()
396
567
  if (timeEvent) {
397
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
398
- visitor_id: visitorId,
399
- session_id: sessionId,
400
- event_type: 'time_spent_final',
401
- data: timeEvent,
402
- url: window.location.href,
403
- })
404
- }
405
-
406
- // Get agentic detection result
407
- const agenticResult = agenticAnalyzer?.getResult()
408
- if (agenticResult && agenticResult.agenticProbability > 0) {
409
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
568
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
569
+ workspace_id: workspaceId,
410
570
  visitor_id: visitorId,
411
571
  session_id: sessionId,
412
- event_type: 'agentic_detection',
413
- data: agenticResult,
414
- url: window.location.href,
572
+ event_type: 'time_spent',
573
+ event_data: {
574
+ visible_time_ms: timeEvent.total_time_ms,
575
+ page_start_time: pageStartTime || Date.now(),
576
+ },
577
+ page_url: window.location.href,
578
+ page_path: window.location.pathname,
579
+ timestamp: new Date().toISOString(),
580
+ tracker_version: VERSION,
581
+ idempotency_key: buildIdempotencyKey('time_spent'),
415
582
  })
416
583
  }
417
584
 
@@ -443,33 +610,60 @@ function pageview(customUrl?: string): void {
443
610
  log('Not initialized, call init() first')
444
611
  return
445
612
  }
613
+ if (!config.apiKey) {
614
+ log('Missing apiKey, pageview skipped')
615
+ return
616
+ }
446
617
 
447
618
  const url = customUrl || window.location.href
448
- const payload = {
619
+ const utmParams = extractUTMParams(url)
620
+ const timestamp = new Date().toISOString()
621
+ const idempotencyKey = buildIdempotencyKey('visit')
622
+ const agenticResult = agenticAnalyzer?.getResult()
623
+ const pagePath = (() => {
624
+ try {
625
+ return new URL(url).pathname
626
+ } catch {
627
+ return window.location.pathname
628
+ }
629
+ })()
630
+
631
+ const payload: Record<string, unknown> = {
449
632
  visitor_id: visitorId,
450
633
  session_id: sessionId,
451
- url,
634
+ page_url: url,
635
+ page_path: pagePath,
452
636
  referrer: document.referrer || null,
453
637
  title: document.title || null,
454
- utm_source: extractUTMParams(url).utm_source || null,
455
- utm_medium: extractUTMParams(url).utm_medium || null,
456
- utm_campaign: extractUTMParams(url).utm_campaign || null,
638
+ utm_source: utmParams.utm_source || null,
639
+ utm_medium: utmParams.utm_medium || null,
640
+ utm_campaign: utmParams.utm_campaign || null,
641
+ utm_term: utmParams.utm_term || null,
642
+ utm_content: utmParams.utm_content || null,
457
643
  user_agent: navigator.userAgent,
458
644
  screen_width: window.screen?.width,
459
645
  screen_height: window.screen?.height,
460
646
  language: navigator.language,
461
647
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
462
648
  tracker_version: VERSION,
649
+ event_type: 'pageview',
650
+ event_data: null,
651
+ timestamp,
463
652
  navigation_timing: navigationTiming,
464
653
  ai_platform: aiDetection?.platform || null,
465
654
  is_ai_referrer: aiDetection?.isAI || false,
655
+ agentic_detection: agenticResult || null,
656
+ }
657
+
658
+ if (workspaceId) {
659
+ payload.workspace_id = workspaceId
466
660
  }
467
661
 
468
662
  log('Pageview:', payload)
469
663
 
470
664
  safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
471
665
  method: 'POST',
472
- headers: { 'Content-Type': 'application/json' },
666
+ headers: buildHeaders(idempotencyKey),
473
667
  body: JSON.stringify(payload),
474
668
  })
475
669
  }
@@ -482,8 +676,13 @@ function track(eventName: string, options: TrackEventOptions = {}): void {
482
676
  log('Not initialized, call init() first')
483
677
  return
484
678
  }
679
+ if (!config.apiKey) {
680
+ log('Missing apiKey, event skipped:', eventName)
681
+ return
682
+ }
485
683
 
486
- const payload = {
684
+ const idempotencyKey = buildIdempotencyKey(`event:${eventName}`)
685
+ const payload: Record<string, unknown> = {
487
686
  visitor_id: visitorId,
488
687
  session_id: sessionId,
489
688
  event_name: eventName,
@@ -491,16 +690,22 @@ function track(eventName: string, options: TrackEventOptions = {}): void {
491
690
  properties: options.properties || {},
492
691
  revenue: options.revenue,
493
692
  currency: options.currency || 'USD',
494
- url: window.location.href,
693
+ page_url: window.location.href,
694
+ referrer: document.referrer || null,
495
695
  timestamp: new Date().toISOString(),
496
696
  tracker_version: VERSION,
697
+ idempotency_key: idempotencyKey,
698
+ }
699
+
700
+ if (workspaceId) {
701
+ payload.workspace_id = workspaceId
497
702
  }
498
703
 
499
704
  log('Event:', eventName, payload)
500
705
 
501
706
  safeFetch(endpoint('/api/ingest/event'), {
502
707
  method: 'POST',
503
- headers: { 'Content-Type': 'application/json' },
708
+ headers: buildHeaders(idempotencyKey),
504
709
  body: JSON.stringify(payload),
505
710
  })
506
711
  }
@@ -520,20 +725,30 @@ function identify(userId: string, traits: Record<string, unknown> = {}): void {
520
725
  log('Not initialized, call init() first')
521
726
  return
522
727
  }
728
+ if (!config.apiKey) {
729
+ log('Missing apiKey, identify skipped')
730
+ return
731
+ }
523
732
 
524
733
  log('Identify:', userId, traits)
525
734
 
526
- const payload = {
735
+ const idempotencyKey = buildIdempotencyKey('identify')
736
+ const payload: Record<string, unknown> = {
527
737
  visitor_id: visitorId,
528
738
  session_id: sessionId,
529
739
  user_id: userId,
530
740
  traits,
531
741
  timestamp: new Date().toISOString(),
742
+ idempotency_key: idempotencyKey,
743
+ }
744
+
745
+ if (workspaceId) {
746
+ payload.workspace_id = workspaceId
532
747
  }
533
748
 
534
749
  safeFetch(endpoint('/api/ingest/identify'), {
535
750
  method: 'POST',
536
- headers: { 'Content-Type': 'application/json' },
751
+ headers: buildHeaders(idempotencyKey),
537
752
  body: JSON.stringify(payload),
538
753
  })
539
754
  }
@@ -738,15 +953,15 @@ function isTrackerInitialized(): boolean {
738
953
  * Used for monitoring and debugging
739
954
  */
740
955
  function reportHealth(status: 'initialized' | 'error' | 'ready', errorMessage?: string): void {
741
- if (!config.apiKey) return
742
-
743
956
  try {
744
957
  const healthData = {
745
- workspace_id: config.apiKey,
958
+ workspace_id: workspaceId,
959
+ visitor_id: visitorId,
960
+ session_id: sessionId,
746
961
  status,
747
962
  error_message: errorMessage || null,
748
- version: VERSION,
749
- url: typeof window !== 'undefined' ? window.location.href : null,
963
+ tracker_version: VERSION,
964
+ page_url: typeof window !== 'undefined' ? window.location.href : null,
750
965
  user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : null,
751
966
  timestamp: new Date().toISOString(),
752
967
  features: {
@@ -825,20 +1040,59 @@ function setDebug(enabled: boolean): void {
825
1040
  log('Debug mode:', enabled ? 'enabled' : 'disabled')
826
1041
  }
827
1042
 
1043
+ /**
1044
+ * Track a behavioral event (for use by secondary modules/plugins)
1045
+ *
1046
+ * This is the recommended way for secondary tracking modules to send events.
1047
+ * It automatically includes workspace_id, visitor_id, session_id, and handles
1048
+ * batching, queueing, and retry logic.
1049
+ *
1050
+ * @param eventType - The type of event (e.g., 'outbound_click', 'button_click')
1051
+ * @param eventData - Additional data for the event
1052
+ *
1053
+ * @example
1054
+ * ```js
1055
+ * Loamly.trackBehavioral('outbound_click', {
1056
+ * url: 'https://example.com',
1057
+ * text: 'Click here',
1058
+ * hostname: 'example.com'
1059
+ * });
1060
+ * ```
1061
+ */
1062
+ function trackBehavioral(eventType: string, eventData: Record<string, unknown>): void {
1063
+ if (!initialized) {
1064
+ log('Not initialized, trackBehavioral skipped:', eventType)
1065
+ return
1066
+ }
1067
+ queueEvent(eventType, eventData)
1068
+ }
1069
+
1070
+ /**
1071
+ * Get the current workspace ID
1072
+ * For internal use by secondary modules
1073
+ */
1074
+ function getCurrentWorkspaceId(): string | null {
1075
+ return workspaceId
1076
+ }
1077
+
828
1078
  /**
829
1079
  * The Loamly Tracker instance
830
1080
  */
831
1081
  export const loamly: LoamlyTracker & {
832
1082
  getAgentic: () => AgenticDetectionResult | null
833
1083
  reportHealth: (status: 'initialized' | 'error' | 'ready', errorMessage?: string) => void
1084
+ trackBehavioral: (eventType: string, eventData: Record<string, unknown>) => void
1085
+ getWorkspaceId: () => string | null
834
1086
  } = {
835
1087
  init,
836
1088
  pageview,
837
1089
  track,
1090
+ trackBehavioral, // NEW: For secondary modules/plugins
838
1091
  conversion,
839
1092
  identify,
840
1093
  getSessionId: getCurrentSessionId,
841
1094
  getVisitorId: getCurrentVisitorId,
1095
+ getWorkspaceId: getCurrentWorkspaceId, // NEW: For debugging/introspection
842
1096
  getAIDetection: getAIDetectionResult,
843
1097
  getNavigationTiming: getNavigationTimingResult,
844
1098
  getBehavioralML: getBehavioralMLResult,