@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/README.md +4 -3
- package/dist/index.cjs +1580 -1286
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.mjs +1580 -1286
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1578 -1270
- 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 +107 -1
- package/src/browser.ts +25 -6
- package/src/config.ts +1 -1
- package/src/core.ts +386 -132
- 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 +23 -0
package/src/core.ts
CHANGED
|
@@ -17,44 +17,44 @@
|
|
|
17
17
|
* @module @loamly/tracker
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
33
|
+
FocusBlurAnalyzer,
|
|
34
|
+
type FocusBlurResult
|
|
30
35
|
} from './detection/focus-blur'
|
|
31
|
-
import {
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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: '
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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:
|
|
455
|
-
utm_medium:
|
|
456
|
-
utm_campaign:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
958
|
+
workspace_id: workspaceId,
|
|
959
|
+
visitor_id: visitorId,
|
|
960
|
+
session_id: sessionId,
|
|
746
961
|
status,
|
|
747
962
|
error_message: errorMessage || null,
|
|
748
|
-
|
|
749
|
-
|
|
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,
|