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