@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/core.ts CHANGED
@@ -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,76 +144,106 @@ 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
+ }
154
+
155
+ // Feature flags with defaults (all enabled except ping)
156
+ const features = {
157
+ scroll: true,
158
+ time: true,
159
+ forms: true,
160
+ spa: true,
161
+ behavioralML: true,
162
+ focusBlur: true,
163
+ agentic: true,
164
+ eventQueue: true,
165
+ ping: false, // Opt-in only
166
+ ...userConfig.features,
167
+ }
119
168
 
120
169
  log('Initializing Loamly Tracker v' + VERSION)
170
+ log('Features:', features)
121
171
 
122
172
  // Get/create visitor ID
123
173
  visitorId = getVisitorId()
124
174
  log('Visitor ID:', visitorId)
175
+
176
+ // Initialize event queue (if enabled)
177
+ if (features.eventQueue) {
178
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
179
+ batchSize: DEFAULT_CONFIG.batchSize,
180
+ batchTimeout: DEFAULT_CONFIG.batchTimeout,
181
+ apiKey: config.apiKey,
182
+ })
183
+ }
125
184
 
126
- // Get/create session
127
- const session = getSessionId()
128
- sessionId = session.sessionId
129
- log('Session ID:', sessionId, session.isNew ? '(new)' : '(existing)')
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
-
137
- // Detect navigation timing (paste vs click)
185
+ // Detect navigation timing (paste vs click) - always lightweight
138
186
  navigationTiming = detectNavigationType()
139
187
  log('Navigation timing:', navigationTiming)
140
188
 
141
- // Detect AI from referrer/UTM
189
+ // Detect AI from referrer/UTM - always lightweight
142
190
  aiDetection = detectAIFromReferrer(document.referrer) || detectAIFromUTM(window.location.href)
143
191
  if (aiDetection) {
144
192
  log('AI detected:', aiDetection)
145
193
  }
146
194
 
147
195
  initialized = true
148
-
149
- // Auto pageview unless disabled
150
- if (!userConfig.disableAutoPageview) {
151
- pageview()
152
- }
153
-
154
- // Set up behavioral tracking unless disabled
155
- if (!userConfig.disableBehavioral) {
156
- setupAdvancedBehavioralTracking()
196
+
197
+ // Initialize agentic browser detection early for pageview payloads
198
+ if (features.agentic) {
199
+ agenticAnalyzer = new AgenticBrowserAnalyzer()
200
+ agenticAnalyzer.init()
157
201
  }
158
-
159
- // Initialize behavioral ML classifier (LOA-180)
160
- behavioralClassifier = new BehavioralClassifier(10000) // 10s min session
161
- behavioralClassifier.setOnClassify(handleBehavioralClassification)
162
- setupBehavioralMLTracking()
163
-
164
- // Initialize focus/blur analyzer (LOA-182)
165
- focusBlurAnalyzer = new FocusBlurAnalyzer()
166
- focusBlurAnalyzer.initTracking()
167
-
168
- // Analyze focus/blur after 5 seconds
169
- setTimeout(() => {
170
- if (focusBlurAnalyzer) {
171
- handleFocusBlurAnalysis(focusBlurAnalyzer.analyze())
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()
172
211
  }
173
- }, 5000)
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
- }
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
+ })
187
247
 
188
248
  // Set up SPA navigation tracking
189
249
  spaRouter = new SPARouter({
@@ -200,57 +260,187 @@ function init(userConfig: LoamlyConfig = {}): void {
200
260
  log('Initialization complete')
201
261
  }
202
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
+
203
293
  /**
204
- * Set up advanced behavioral tracking with new modules
294
+ * Initialize session with server-side tracker_sessions when possible
205
295
  */
206
- function setupAdvancedBehavioralTracking(): void {
207
- // Scroll tracker with 30% chunks
208
- scrollTracker = new ScrollTracker({
209
- chunks: [30, 60, 90, 100],
210
- onChunkReached: (event: ScrollEvent) => {
211
- log('Scroll chunk:', event.chunk)
212
- queueEvent('scroll_depth', {
213
- depth: event.depth,
214
- chunk: event.chunk,
215
- time_to_reach_ms: event.time_to_reach_ms,
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
+ }),
216
329
  })
217
- },
218
- })
219
- scrollTracker.start()
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
+
358
+ /**
359
+ * Set up advanced behavioral tracking with new modules
360
+ */
361
+ interface FeatureFlags {
362
+ scroll?: boolean
363
+ time?: boolean
364
+ forms?: boolean
365
+ spa?: boolean
366
+ behavioralML?: boolean
367
+ focusBlur?: boolean
368
+ agentic?: boolean
369
+ eventQueue?: boolean
370
+ ping?: boolean
371
+ }
372
+
373
+ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
374
+ // Scroll tracker with 30% chunks (if enabled)
375
+ if (features.scroll) {
376
+ scrollTracker = new ScrollTracker({
377
+ chunks: [30, 60, 90, 100],
378
+ onChunkReached: (event: ScrollEvent) => {
379
+ log('Scroll chunk:', event.chunk)
380
+ queueEvent('scroll_depth', {
381
+ scroll_depth: Math.round((event.depth / 100) * 100) / 100,
382
+ milestone: Math.round((event.chunk / 100) * 100) / 100,
383
+ time_to_reach_ms: event.time_to_reach_ms,
384
+ })
385
+ },
386
+ })
387
+ scrollTracker.start()
388
+ }
220
389
 
221
- // Time tracker
222
- timeTracker = new TimeTracker({
223
- updateIntervalMs: 10000, // Report every 10 seconds
224
- onUpdate: (event: TimeEvent) => {
225
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
226
- queueEvent('time_spent', {
227
- active_time_ms: event.active_time_ms,
228
- total_time_ms: event.total_time_ms,
229
- idle_time_ms: event.idle_time_ms,
230
- is_engaged: event.is_engaged,
390
+ // Time tracker (if enabled)
391
+ if (features.time) {
392
+ timeTracker = new TimeTracker({
393
+ updateIntervalMs: 10000, // Report every 10 seconds
394
+ onUpdate: (event: TimeEvent) => {
395
+ if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
396
+ queueEvent('time_spent', {
397
+ visible_time_ms: event.total_time_ms,
398
+ page_start_time: pageStartTime || Date.now(),
399
+ })
400
+ }
401
+ },
402
+ })
403
+ timeTracker.start()
404
+ }
405
+
406
+ // Form tracker with universal support (if enabled)
407
+ if (features.forms) {
408
+ formTracker = new FormTracker({
409
+ onFormEvent: (event: FormEvent) => {
410
+ log('Form event:', event.event_type, event.form_id)
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, {
418
+ form_id: event.form_id,
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,
231
426
  })
232
- }
233
- },
234
- })
235
- timeTracker.start()
236
-
237
- // Form tracker with universal support
238
- formTracker = new FormTracker({
239
- onFormEvent: (event: FormEvent) => {
240
- log('Form event:', event.event_type, event.form_id)
241
- queueEvent(event.event_type, {
242
- form_id: event.form_id,
243
- form_type: event.form_type,
244
- field_name: event.field_name,
245
- field_type: event.field_type,
246
- time_to_submit_ms: event.time_to_submit_ms,
247
- is_conversion: event.is_conversion,
248
- })
249
- },
250
- })
251
- formTracker.start()
427
+ },
428
+ })
429
+ formTracker.start()
430
+ }
252
431
 
253
- // Click tracking for links (basic)
432
+ // SPA router (if enabled)
433
+ if (features.spa) {
434
+ spaRouter = new SPARouter({
435
+ onNavigate: (event: NavigationEvent) => {
436
+ log('SPA navigation:', event.navigation_type)
437
+ pageview(event.to_url)
438
+ },
439
+ })
440
+ spaRouter.start()
441
+ }
442
+
443
+ // Click tracking for links (always enabled, lightweight)
254
444
  document.addEventListener('click', (e) => {
255
445
  const target = e.target as HTMLElement
256
446
  const link = target.closest('a')
@@ -258,7 +448,7 @@ function setupAdvancedBehavioralTracking(): void {
258
448
  if (link && link.href) {
259
449
  const isExternal = link.hostname !== window.location.hostname
260
450
  queueEvent('click', {
261
- element: 'link',
451
+ element_type: 'link',
262
452
  href: truncateText(link.href, 200),
263
453
  text: truncateText(link.textContent || '', 100),
264
454
  is_external: isExternal,
@@ -272,16 +462,35 @@ function setupAdvancedBehavioralTracking(): void {
272
462
  */
273
463
  function queueEvent(eventType: string, data: Record<string, unknown>): void {
274
464
  if (!eventQueue) return
275
-
276
- eventQueue.push(eventType, {
465
+ if (!config.apiKey) {
466
+ log('Missing apiKey, behavioral event skipped:', eventType)
467
+ return
468
+ }
469
+ if (!workspaceId) {
470
+ log('Missing workspaceId, behavioral event skipped:', eventType)
471
+ return
472
+ }
473
+ if (!sessionId) {
474
+ log('Missing sessionId, behavioral event skipped:', eventType)
475
+ return
476
+ }
477
+
478
+ const idempotencyKey = buildIdempotencyKey(eventType)
479
+ const payload: Record<string, unknown> = {
277
480
  visitor_id: visitorId,
278
481
  session_id: sessionId,
279
482
  event_type: eventType,
280
- ...data,
281
- url: window.location.href,
483
+ event_data: data,
484
+ page_url: window.location.href,
485
+ page_path: window.location.pathname,
282
486
  timestamp: new Date().toISOString(),
283
487
  tracker_version: VERSION,
284
- })
488
+ idempotency_key: idempotencyKey,
489
+ }
490
+
491
+ payload.workspace_id = workspaceId
492
+
493
+ eventQueue.push(eventType, payload, buildHeaders(idempotencyKey))
285
494
  }
286
495
 
287
496
  /**
@@ -327,39 +536,46 @@ function handleSPANavigation(event: NavigationEvent): void {
327
536
  */
328
537
  function setupUnloadHandlers(): void {
329
538
  const handleUnload = (): void => {
539
+ if (!workspaceId || !config.apiKey || !sessionId) return
540
+
330
541
  // Get final scroll depth
331
542
  const scrollEvent = scrollTracker?.getFinalEvent()
332
543
  if (scrollEvent) {
333
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
544
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
545
+ workspace_id: workspaceId,
334
546
  visitor_id: visitorId,
335
547
  session_id: sessionId,
336
548
  event_type: 'scroll_depth_final',
337
- data: scrollEvent,
338
- url: window.location.href,
549
+ event_data: {
550
+ scroll_depth: Math.round((scrollEvent.depth / 100) * 100) / 100,
551
+ milestone: Math.round((scrollEvent.chunk / 100) * 100) / 100,
552
+ time_to_reach_ms: scrollEvent.time_to_reach_ms,
553
+ },
554
+ page_url: window.location.href,
555
+ page_path: window.location.pathname,
556
+ timestamp: new Date().toISOString(),
557
+ tracker_version: VERSION,
558
+ idempotency_key: buildIdempotencyKey('scroll_depth_final'),
339
559
  })
340
560
  }
341
561
 
342
562
  // Get final time metrics
343
563
  const timeEvent = timeTracker?.getFinalMetrics()
344
564
  if (timeEvent) {
345
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
346
- visitor_id: visitorId,
347
- session_id: sessionId,
348
- event_type: 'time_spent_final',
349
- data: timeEvent,
350
- url: window.location.href,
351
- })
352
- }
353
-
354
- // Get agentic detection result
355
- const agenticResult = agenticAnalyzer?.getResult()
356
- if (agenticResult && agenticResult.agenticProbability > 0) {
357
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
565
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
566
+ workspace_id: workspaceId,
358
567
  visitor_id: visitorId,
359
568
  session_id: sessionId,
360
- event_type: 'agentic_detection',
361
- data: agenticResult,
362
- url: window.location.href,
569
+ event_type: 'time_spent',
570
+ event_data: {
571
+ visible_time_ms: timeEvent.total_time_ms,
572
+ page_start_time: pageStartTime || Date.now(),
573
+ },
574
+ page_url: window.location.href,
575
+ page_path: window.location.pathname,
576
+ timestamp: new Date().toISOString(),
577
+ tracker_version: VERSION,
578
+ idempotency_key: buildIdempotencyKey('time_spent'),
363
579
  })
364
580
  }
365
581
 
@@ -391,33 +607,60 @@ function pageview(customUrl?: string): void {
391
607
  log('Not initialized, call init() first')
392
608
  return
393
609
  }
610
+ if (!config.apiKey) {
611
+ log('Missing apiKey, pageview skipped')
612
+ return
613
+ }
394
614
 
395
615
  const url = customUrl || window.location.href
396
- const payload = {
616
+ const utmParams = extractUTMParams(url)
617
+ const timestamp = new Date().toISOString()
618
+ const idempotencyKey = buildIdempotencyKey('visit')
619
+ const agenticResult = agenticAnalyzer?.getResult()
620
+ const pagePath = (() => {
621
+ try {
622
+ return new URL(url).pathname
623
+ } catch {
624
+ return window.location.pathname
625
+ }
626
+ })()
627
+
628
+ const payload: Record<string, unknown> = {
397
629
  visitor_id: visitorId,
398
630
  session_id: sessionId,
399
- url,
631
+ page_url: url,
632
+ page_path: pagePath,
400
633
  referrer: document.referrer || null,
401
634
  title: document.title || null,
402
- utm_source: extractUTMParams(url).utm_source || null,
403
- utm_medium: extractUTMParams(url).utm_medium || null,
404
- utm_campaign: extractUTMParams(url).utm_campaign || null,
635
+ utm_source: utmParams.utm_source || null,
636
+ utm_medium: utmParams.utm_medium || null,
637
+ utm_campaign: utmParams.utm_campaign || null,
638
+ utm_term: utmParams.utm_term || null,
639
+ utm_content: utmParams.utm_content || null,
405
640
  user_agent: navigator.userAgent,
406
641
  screen_width: window.screen?.width,
407
642
  screen_height: window.screen?.height,
408
643
  language: navigator.language,
409
644
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
410
645
  tracker_version: VERSION,
646
+ event_type: 'pageview',
647
+ event_data: null,
648
+ timestamp,
411
649
  navigation_timing: navigationTiming,
412
650
  ai_platform: aiDetection?.platform || null,
413
651
  is_ai_referrer: aiDetection?.isAI || false,
652
+ agentic_detection: agenticResult || null,
653
+ }
654
+
655
+ if (workspaceId) {
656
+ payload.workspace_id = workspaceId
414
657
  }
415
658
 
416
659
  log('Pageview:', payload)
417
660
 
418
661
  safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
419
662
  method: 'POST',
420
- headers: { 'Content-Type': 'application/json' },
663
+ headers: buildHeaders(idempotencyKey),
421
664
  body: JSON.stringify(payload),
422
665
  })
423
666
  }
@@ -430,8 +673,13 @@ function track(eventName: string, options: TrackEventOptions = {}): void {
430
673
  log('Not initialized, call init() first')
431
674
  return
432
675
  }
676
+ if (!config.apiKey) {
677
+ log('Missing apiKey, event skipped:', eventName)
678
+ return
679
+ }
433
680
 
434
- const payload = {
681
+ const idempotencyKey = buildIdempotencyKey(`event:${eventName}`)
682
+ const payload: Record<string, unknown> = {
435
683
  visitor_id: visitorId,
436
684
  session_id: sessionId,
437
685
  event_name: eventName,
@@ -439,16 +687,22 @@ function track(eventName: string, options: TrackEventOptions = {}): void {
439
687
  properties: options.properties || {},
440
688
  revenue: options.revenue,
441
689
  currency: options.currency || 'USD',
442
- url: window.location.href,
690
+ page_url: window.location.href,
691
+ referrer: document.referrer || null,
443
692
  timestamp: new Date().toISOString(),
444
693
  tracker_version: VERSION,
694
+ idempotency_key: idempotencyKey,
695
+ }
696
+
697
+ if (workspaceId) {
698
+ payload.workspace_id = workspaceId
445
699
  }
446
700
 
447
701
  log('Event:', eventName, payload)
448
702
 
449
703
  safeFetch(endpoint('/api/ingest/event'), {
450
704
  method: 'POST',
451
- headers: { 'Content-Type': 'application/json' },
705
+ headers: buildHeaders(idempotencyKey),
452
706
  body: JSON.stringify(payload),
453
707
  })
454
708
  }
@@ -468,20 +722,30 @@ function identify(userId: string, traits: Record<string, unknown> = {}): void {
468
722
  log('Not initialized, call init() first')
469
723
  return
470
724
  }
725
+ if (!config.apiKey) {
726
+ log('Missing apiKey, identify skipped')
727
+ return
728
+ }
471
729
 
472
730
  log('Identify:', userId, traits)
473
731
 
474
- const payload = {
732
+ const idempotencyKey = buildIdempotencyKey('identify')
733
+ const payload: Record<string, unknown> = {
475
734
  visitor_id: visitorId,
476
735
  session_id: sessionId,
477
736
  user_id: userId,
478
737
  traits,
479
738
  timestamp: new Date().toISOString(),
739
+ idempotency_key: idempotencyKey,
740
+ }
741
+
742
+ if (workspaceId) {
743
+ payload.workspace_id = workspaceId
480
744
  }
481
745
 
482
746
  safeFetch(endpoint('/api/ingest/identify'), {
483
747
  method: 'POST',
484
- headers: { 'Content-Type': 'application/json' },
748
+ headers: buildHeaders(idempotencyKey),
485
749
  body: JSON.stringify(payload),
486
750
  })
487
751
  }
@@ -686,15 +950,15 @@ function isTrackerInitialized(): boolean {
686
950
  * Used for monitoring and debugging
687
951
  */
688
952
  function reportHealth(status: 'initialized' | 'error' | 'ready', errorMessage?: string): void {
689
- if (!config.apiKey) return
690
-
691
953
  try {
692
954
  const healthData = {
693
- workspace_id: config.apiKey,
955
+ workspace_id: workspaceId,
956
+ visitor_id: visitorId,
957
+ session_id: sessionId,
694
958
  status,
695
959
  error_message: errorMessage || null,
696
- version: VERSION,
697
- url: typeof window !== 'undefined' ? window.location.href : null,
960
+ tracker_version: VERSION,
961
+ page_url: typeof window !== 'undefined' ? window.location.href : null,
698
962
  user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : null,
699
963
  timestamp: new Date().toISOString(),
700
964
  features: {