@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/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
|
-
//
|
|
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
|
-
//
|
|
150
|
-
if (
|
|
151
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
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
|
-
*
|
|
294
|
+
* Initialize session with server-side tracker_sessions when possible
|
|
205
295
|
*/
|
|
206
|
-
function
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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: '
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
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:
|
|
403
|
-
utm_medium:
|
|
404
|
-
utm_campaign:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
955
|
+
workspace_id: workspaceId,
|
|
956
|
+
visitor_id: visitorId,
|
|
957
|
+
session_id: sessionId,
|
|
694
958
|
status,
|
|
695
959
|
error_message: errorMessage || null,
|
|
696
|
-
|
|
697
|
-
|
|
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: {
|