@loamly/tracker 1.6.0 → 1.7.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/dist/index.cjs CHANGED
@@ -32,7 +32,7 @@ __export(index_exports, {
32
32
  module.exports = __toCommonJS(index_exports);
33
33
 
34
34
  // src/config.ts
35
- var VERSION = "1.6.0";
35
+ var VERSION = "1.7.0";
36
36
  var DEFAULT_CONFIG = {
37
37
  apiHost: "https://app.loamly.ai",
38
38
  endpoints: {
@@ -209,6 +209,322 @@ function detectAIFromUTM(url) {
209
209
  }
210
210
  }
211
211
 
212
+ // src/detection/behavioral-classifier.ts
213
+ var NAIVE_BAYES_WEIGHTS = {
214
+ human: {
215
+ time_to_first_click_delayed: 0.85,
216
+ time_to_first_click_normal: 0.75,
217
+ time_to_first_click_fast: 0.5,
218
+ time_to_first_click_immediate: 0.25,
219
+ scroll_speed_variable: 0.8,
220
+ scroll_speed_erratic: 0.7,
221
+ scroll_speed_uniform: 0.35,
222
+ scroll_speed_none: 0.45,
223
+ nav_timing_click: 0.75,
224
+ nav_timing_unknown: 0.55,
225
+ nav_timing_paste: 0.35,
226
+ has_referrer: 0.7,
227
+ no_referrer: 0.45,
228
+ homepage_landing: 0.65,
229
+ deep_landing: 0.5,
230
+ mouse_movement_curved: 0.9,
231
+ mouse_movement_linear: 0.3,
232
+ mouse_movement_none: 0.4,
233
+ form_fill_normal: 0.85,
234
+ form_fill_fast: 0.6,
235
+ form_fill_instant: 0.2,
236
+ focus_blur_normal: 0.75,
237
+ focus_blur_rapid: 0.45
238
+ },
239
+ ai_influenced: {
240
+ time_to_first_click_immediate: 0.75,
241
+ time_to_first_click_fast: 0.55,
242
+ time_to_first_click_normal: 0.4,
243
+ time_to_first_click_delayed: 0.35,
244
+ scroll_speed_none: 0.55,
245
+ scroll_speed_uniform: 0.7,
246
+ scroll_speed_variable: 0.35,
247
+ scroll_speed_erratic: 0.4,
248
+ nav_timing_paste: 0.75,
249
+ nav_timing_unknown: 0.5,
250
+ nav_timing_click: 0.35,
251
+ no_referrer: 0.65,
252
+ has_referrer: 0.4,
253
+ deep_landing: 0.6,
254
+ homepage_landing: 0.45,
255
+ mouse_movement_none: 0.6,
256
+ mouse_movement_linear: 0.75,
257
+ mouse_movement_curved: 0.25,
258
+ form_fill_instant: 0.8,
259
+ form_fill_fast: 0.55,
260
+ form_fill_normal: 0.3,
261
+ focus_blur_rapid: 0.6,
262
+ focus_blur_normal: 0.4
263
+ }
264
+ };
265
+ var PRIORS = {
266
+ human: 0.85,
267
+ ai_influenced: 0.15
268
+ };
269
+ var DEFAULT_WEIGHT = 0.5;
270
+ var BehavioralClassifier = class {
271
+ /**
272
+ * Create a new classifier
273
+ * @param minSessionTimeMs Minimum session time before classification (default: 10s)
274
+ */
275
+ constructor(minSessionTimeMs = 1e4) {
276
+ this.classified = false;
277
+ this.result = null;
278
+ this.onClassify = null;
279
+ this.minSessionTime = minSessionTimeMs;
280
+ this.data = {
281
+ firstClickTime: null,
282
+ scrollEvents: [],
283
+ mouseEvents: [],
284
+ formEvents: [],
285
+ focusBlurEvents: [],
286
+ startTime: Date.now()
287
+ };
288
+ }
289
+ /**
290
+ * Set callback for when classification completes
291
+ */
292
+ setOnClassify(callback) {
293
+ this.onClassify = callback;
294
+ }
295
+ /**
296
+ * Record a click event
297
+ */
298
+ recordClick() {
299
+ if (this.data.firstClickTime === null) {
300
+ this.data.firstClickTime = Date.now();
301
+ }
302
+ this.checkAndClassify();
303
+ }
304
+ /**
305
+ * Record a scroll event
306
+ */
307
+ recordScroll(position) {
308
+ this.data.scrollEvents.push({ time: Date.now(), position });
309
+ if (this.data.scrollEvents.length > 50) {
310
+ this.data.scrollEvents = this.data.scrollEvents.slice(-50);
311
+ }
312
+ this.checkAndClassify();
313
+ }
314
+ /**
315
+ * Record mouse movement
316
+ */
317
+ recordMouse(x, y) {
318
+ this.data.mouseEvents.push({ time: Date.now(), x, y });
319
+ if (this.data.mouseEvents.length > 100) {
320
+ this.data.mouseEvents = this.data.mouseEvents.slice(-100);
321
+ }
322
+ this.checkAndClassify();
323
+ }
324
+ /**
325
+ * Record form field interaction start
326
+ */
327
+ recordFormStart(fieldId) {
328
+ const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
329
+ if (!existing) {
330
+ this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
331
+ }
332
+ }
333
+ /**
334
+ * Record form field interaction end
335
+ */
336
+ recordFormEnd(fieldId) {
337
+ const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
338
+ if (event) {
339
+ event.endTime = Date.now();
340
+ }
341
+ this.checkAndClassify();
342
+ }
343
+ /**
344
+ * Record focus/blur event
345
+ */
346
+ recordFocusBlur(type) {
347
+ this.data.focusBlurEvents.push({ type, time: Date.now() });
348
+ if (this.data.focusBlurEvents.length > 20) {
349
+ this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
350
+ }
351
+ }
352
+ /**
353
+ * Check if we have enough data and classify
354
+ */
355
+ checkAndClassify() {
356
+ if (this.classified) return;
357
+ const sessionDuration = Date.now() - this.data.startTime;
358
+ if (sessionDuration < this.minSessionTime) return;
359
+ const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
360
+ if (!hasData) return;
361
+ this.classify();
362
+ }
363
+ /**
364
+ * Force classification (for beforeunload)
365
+ */
366
+ forceClassify() {
367
+ if (this.classified) return this.result;
368
+ return this.classify();
369
+ }
370
+ /**
371
+ * Perform classification
372
+ */
373
+ classify() {
374
+ const sessionDuration = Date.now() - this.data.startTime;
375
+ const signals = this.extractSignals();
376
+ let humanLogProb = Math.log(PRIORS.human);
377
+ let aiLogProb = Math.log(PRIORS.ai_influenced);
378
+ for (const signal of signals) {
379
+ const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
380
+ const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
381
+ humanLogProb += Math.log(humanWeight);
382
+ aiLogProb += Math.log(aiWeight);
383
+ }
384
+ const maxLog = Math.max(humanLogProb, aiLogProb);
385
+ const humanExp = Math.exp(humanLogProb - maxLog);
386
+ const aiExp = Math.exp(aiLogProb - maxLog);
387
+ const total = humanExp + aiExp;
388
+ const humanProbability = humanExp / total;
389
+ const aiProbability = aiExp / total;
390
+ let classification;
391
+ let confidence;
392
+ if (humanProbability > 0.6) {
393
+ classification = "human";
394
+ confidence = humanProbability;
395
+ } else if (aiProbability > 0.6) {
396
+ classification = "ai_influenced";
397
+ confidence = aiProbability;
398
+ } else {
399
+ classification = "uncertain";
400
+ confidence = Math.max(humanProbability, aiProbability);
401
+ }
402
+ this.result = {
403
+ classification,
404
+ humanProbability,
405
+ aiProbability,
406
+ confidence,
407
+ signals,
408
+ timestamp: Date.now(),
409
+ sessionDurationMs: sessionDuration
410
+ };
411
+ this.classified = true;
412
+ if (this.onClassify) {
413
+ this.onClassify(this.result);
414
+ }
415
+ return this.result;
416
+ }
417
+ /**
418
+ * Extract behavioral signals from collected data
419
+ */
420
+ extractSignals() {
421
+ const signals = [];
422
+ if (this.data.firstClickTime !== null) {
423
+ const timeToClick = this.data.firstClickTime - this.data.startTime;
424
+ if (timeToClick < 500) {
425
+ signals.push("time_to_first_click_immediate");
426
+ } else if (timeToClick < 2e3) {
427
+ signals.push("time_to_first_click_fast");
428
+ } else if (timeToClick < 1e4) {
429
+ signals.push("time_to_first_click_normal");
430
+ } else {
431
+ signals.push("time_to_first_click_delayed");
432
+ }
433
+ }
434
+ if (this.data.scrollEvents.length === 0) {
435
+ signals.push("scroll_speed_none");
436
+ } else if (this.data.scrollEvents.length >= 3) {
437
+ const scrollDeltas = [];
438
+ for (let i = 1; i < this.data.scrollEvents.length; i++) {
439
+ const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
440
+ scrollDeltas.push(delta);
441
+ }
442
+ const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
443
+ const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
444
+ const stdDev = Math.sqrt(variance);
445
+ const cv = mean > 0 ? stdDev / mean : 0;
446
+ if (cv < 0.2) {
447
+ signals.push("scroll_speed_uniform");
448
+ } else if (cv < 0.6) {
449
+ signals.push("scroll_speed_variable");
450
+ } else {
451
+ signals.push("scroll_speed_erratic");
452
+ }
453
+ }
454
+ if (this.data.mouseEvents.length === 0) {
455
+ signals.push("mouse_movement_none");
456
+ } else if (this.data.mouseEvents.length >= 10) {
457
+ const n = Math.min(this.data.mouseEvents.length, 20);
458
+ const recentMouse = this.data.mouseEvents.slice(-n);
459
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
460
+ for (const event of recentMouse) {
461
+ sumX += event.x;
462
+ sumY += event.y;
463
+ sumXY += event.x * event.y;
464
+ sumX2 += event.x * event.x;
465
+ }
466
+ const denominator = n * sumX2 - sumX * sumX;
467
+ const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
468
+ const intercept = (sumY - slope * sumX) / n;
469
+ let ssRes = 0, ssTot = 0;
470
+ const yMean = sumY / n;
471
+ for (const event of recentMouse) {
472
+ const yPred = slope * event.x + intercept;
473
+ ssRes += Math.pow(event.y - yPred, 2);
474
+ ssTot += Math.pow(event.y - yMean, 2);
475
+ }
476
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
477
+ if (r2 > 0.95) {
478
+ signals.push("mouse_movement_linear");
479
+ } else {
480
+ signals.push("mouse_movement_curved");
481
+ }
482
+ }
483
+ const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
484
+ if (completedForms.length > 0) {
485
+ const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
486
+ if (avgFillTime < 100) {
487
+ signals.push("form_fill_instant");
488
+ } else if (avgFillTime < 500) {
489
+ signals.push("form_fill_fast");
490
+ } else {
491
+ signals.push("form_fill_normal");
492
+ }
493
+ }
494
+ if (this.data.focusBlurEvents.length >= 4) {
495
+ const recentFB = this.data.focusBlurEvents.slice(-10);
496
+ const intervals = [];
497
+ for (let i = 1; i < recentFB.length; i++) {
498
+ intervals.push(recentFB[i].time - recentFB[i - 1].time);
499
+ }
500
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
501
+ if (avgInterval < 1e3) {
502
+ signals.push("focus_blur_rapid");
503
+ } else {
504
+ signals.push("focus_blur_normal");
505
+ }
506
+ }
507
+ return signals;
508
+ }
509
+ /**
510
+ * Add context signals (set by tracker from external data)
511
+ */
512
+ addContextSignal(_signal) {
513
+ }
514
+ /**
515
+ * Get current result (null if not yet classified)
516
+ */
517
+ getResult() {
518
+ return this.result;
519
+ }
520
+ /**
521
+ * Check if classification has been performed
522
+ */
523
+ hasClassified() {
524
+ return this.classified;
525
+ }
526
+ };
527
+
212
528
  // src/utils.ts
213
529
  function generateUUID() {
214
530
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
@@ -294,6 +610,8 @@ var sessionId = null;
294
610
  var sessionStartTime = null;
295
611
  var navigationTiming = null;
296
612
  var aiDetection = null;
613
+ var behavioralClassifier = null;
614
+ var behavioralMLResult = null;
297
615
  function log(...args) {
298
616
  if (debugMode) {
299
617
  console.log("[Loamly]", ...args);
@@ -333,6 +651,9 @@ function init(userConfig = {}) {
333
651
  if (!userConfig.disableBehavioral) {
334
652
  setupBehavioralTracking();
335
653
  }
654
+ behavioralClassifier = new BehavioralClassifier(1e4);
655
+ behavioralClassifier.setOnClassify(handleBehavioralClassification);
656
+ setupBehavioralMLTracking();
336
657
  log("Initialization complete");
337
658
  }
338
659
  function pageview(customUrl) {
@@ -514,6 +835,89 @@ function sendBehavioralEvent(eventType, data) {
514
835
  body: JSON.stringify(payload)
515
836
  });
516
837
  }
838
+ function setupBehavioralMLTracking() {
839
+ if (!behavioralClassifier) return;
840
+ let mouseSampleCount = 0;
841
+ document.addEventListener("mousemove", (e) => {
842
+ mouseSampleCount++;
843
+ if (mouseSampleCount % 10 === 0 && behavioralClassifier) {
844
+ behavioralClassifier.recordMouse(e.clientX, e.clientY);
845
+ }
846
+ }, { passive: true });
847
+ document.addEventListener("click", () => {
848
+ if (behavioralClassifier) {
849
+ behavioralClassifier.recordClick();
850
+ }
851
+ }, { passive: true });
852
+ let lastScrollY = 0;
853
+ document.addEventListener("scroll", () => {
854
+ const currentY = window.scrollY;
855
+ if (Math.abs(currentY - lastScrollY) > 50 && behavioralClassifier) {
856
+ lastScrollY = currentY;
857
+ behavioralClassifier.recordScroll(currentY);
858
+ }
859
+ }, { passive: true });
860
+ document.addEventListener("focusin", (e) => {
861
+ if (behavioralClassifier) {
862
+ behavioralClassifier.recordFocusBlur("focus");
863
+ const target = e.target;
864
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
865
+ behavioralClassifier.recordFormStart(target.id || target.getAttribute("name") || "unknown");
866
+ }
867
+ }
868
+ }, { passive: true });
869
+ document.addEventListener("focusout", (e) => {
870
+ if (behavioralClassifier) {
871
+ behavioralClassifier.recordFocusBlur("blur");
872
+ const target = e.target;
873
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
874
+ behavioralClassifier.recordFormEnd(target.id || target.getAttribute("name") || "unknown");
875
+ }
876
+ }
877
+ }, { passive: true });
878
+ window.addEventListener("beforeunload", () => {
879
+ if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
880
+ const result = behavioralClassifier.forceClassify();
881
+ if (result) {
882
+ handleBehavioralClassification(result);
883
+ }
884
+ }
885
+ });
886
+ setTimeout(() => {
887
+ if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
888
+ behavioralClassifier.forceClassify();
889
+ }
890
+ }, 3e4);
891
+ }
892
+ function handleBehavioralClassification(result) {
893
+ log("Behavioral ML classification:", result);
894
+ behavioralMLResult = {
895
+ classification: result.classification,
896
+ humanProbability: result.humanProbability,
897
+ aiProbability: result.aiProbability,
898
+ confidence: result.confidence,
899
+ signals: result.signals,
900
+ sessionDurationMs: result.sessionDurationMs
901
+ };
902
+ sendBehavioralEvent("ml_classification", {
903
+ classification: result.classification,
904
+ human_probability: result.humanProbability,
905
+ ai_probability: result.aiProbability,
906
+ confidence: result.confidence,
907
+ signals: result.signals,
908
+ session_duration_ms: result.sessionDurationMs,
909
+ navigation_timing: navigationTiming,
910
+ ai_detection: aiDetection
911
+ });
912
+ if (result.classification === "ai_influenced" && result.confidence >= 0.7) {
913
+ aiDetection = {
914
+ isAI: true,
915
+ confidence: result.confidence,
916
+ method: "behavioral"
917
+ };
918
+ log("AI detection updated from behavioral ML:", aiDetection);
919
+ }
920
+ }
517
921
  function getCurrentSessionId() {
518
922
  return sessionId;
519
923
  }
@@ -526,6 +930,9 @@ function getAIDetectionResult() {
526
930
  function getNavigationTimingResult() {
527
931
  return navigationTiming;
528
932
  }
933
+ function getBehavioralMLResult() {
934
+ return behavioralMLResult;
935
+ }
529
936
  function isTrackerInitialized() {
530
937
  return initialized;
531
938
  }
@@ -537,6 +944,8 @@ function reset() {
537
944
  sessionStartTime = null;
538
945
  navigationTiming = null;
539
946
  aiDetection = null;
947
+ behavioralClassifier = null;
948
+ behavioralMLResult = null;
540
949
  try {
541
950
  sessionStorage.removeItem("loamly_session");
542
951
  sessionStorage.removeItem("loamly_start");
@@ -557,6 +966,7 @@ var loamly = {
557
966
  getVisitorId: getCurrentVisitorId,
558
967
  getAIDetection: getAIDetectionResult,
559
968
  getNavigationTiming: getNavigationTimingResult,
969
+ getBehavioralML: getBehavioralMLResult,
560
970
  isInitialized: isTrackerInitialized,
561
971
  reset,
562
972
  debug: setDebug