@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/dist/index.cjs CHANGED
@@ -33,645 +33,596 @@ __export(index_exports, {
33
33
  });
34
34
  module.exports = __toCommonJS(index_exports);
35
35
 
36
- // src/config.ts
37
- var VERSION = "2.1.0";
36
+ // src/behavioral/form-tracker.ts
38
37
  var DEFAULT_CONFIG = {
39
- apiHost: "https://app.loamly.ai",
40
- endpoints: {
41
- visit: "/api/ingest/visit",
42
- behavioral: "/api/ingest/behavioral",
43
- session: "/api/ingest/session",
44
- resolve: "/api/tracker/resolve",
45
- health: "/api/tracker/health",
46
- ping: "/api/tracker/ping"
47
- },
48
- pingInterval: 3e4,
49
- // 30 seconds
50
- batchSize: 10,
51
- batchTimeout: 5e3,
52
- sessionTimeout: 18e5,
53
- // 30 minutes
54
- maxTextLength: 100,
55
- timeSpentThresholdMs: 5e3
56
- // Only send time_spent when delta >= 5 seconds
57
- };
58
- var AI_PLATFORMS = {
59
- "chatgpt.com": "chatgpt",
60
- "chat.openai.com": "chatgpt",
61
- "claude.ai": "claude",
62
- "perplexity.ai": "perplexity",
63
- "bard.google.com": "bard",
64
- "gemini.google.com": "gemini",
65
- "copilot.microsoft.com": "copilot",
66
- "github.com/copilot": "github-copilot",
67
- "you.com": "you",
68
- "phind.com": "phind",
69
- "poe.com": "poe"
38
+ sensitiveFields: [
39
+ "password",
40
+ "pwd",
41
+ "pass",
42
+ "credit",
43
+ "card",
44
+ "cvv",
45
+ "cvc",
46
+ "ssn",
47
+ "social",
48
+ "secret",
49
+ "token",
50
+ "key",
51
+ "pin",
52
+ "security",
53
+ "answer"
54
+ ],
55
+ trackableFields: [
56
+ "email",
57
+ "name",
58
+ "phone",
59
+ "company",
60
+ "first",
61
+ "last",
62
+ "city",
63
+ "country",
64
+ "domain",
65
+ "website",
66
+ "url",
67
+ "organization"
68
+ ],
69
+ thankYouPatterns: [
70
+ /thank[-_]?you/i,
71
+ /success/i,
72
+ /confirmation/i,
73
+ /submitted/i,
74
+ /complete/i
75
+ ],
76
+ // LOA-482: Enable field value capture by default
77
+ captureFieldValues: true,
78
+ maxFieldValueLength: 200
70
79
  };
71
- var AI_BOT_PATTERNS = [
72
- "GPTBot",
73
- "ChatGPT-User",
74
- "ClaudeBot",
75
- "Claude-Web",
76
- "PerplexityBot",
77
- "Amazonbot",
78
- "Google-Extended",
79
- "CCBot",
80
- "anthropic-ai",
81
- "cohere-ai"
82
- ];
83
-
84
- // src/detection/navigation-timing.ts
85
- function detectNavigationType() {
86
- try {
87
- const entries = performance.getEntriesByType("navigation");
88
- if (!entries || entries.length === 0) {
89
- return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
90
- }
91
- const nav = entries[0];
92
- const signals = [];
93
- let pasteScore = 0;
94
- const fetchStartDelta = nav.fetchStart - nav.startTime;
95
- if (fetchStartDelta < 5) {
96
- pasteScore += 0.25;
97
- signals.push("instant_fetch_start");
98
- } else if (fetchStartDelta < 20) {
99
- pasteScore += 0.15;
100
- signals.push("fast_fetch_start");
101
- }
102
- const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
103
- if (dnsTime === 0) {
104
- pasteScore += 0.15;
105
- signals.push("no_dns_lookup");
106
- }
107
- const connectTime = nav.connectEnd - nav.connectStart;
108
- if (connectTime === 0) {
109
- pasteScore += 0.15;
110
- signals.push("no_tcp_connect");
111
- }
112
- if (nav.redirectCount === 0) {
113
- pasteScore += 0.1;
114
- signals.push("no_redirects");
115
- }
116
- const timingVariance = calculateTimingVariance(nav);
117
- if (timingVariance < 10) {
118
- pasteScore += 0.15;
119
- signals.push("uniform_timing");
120
- }
121
- if (!document.referrer || document.referrer === "") {
122
- pasteScore += 0.1;
123
- signals.push("no_referrer");
124
- }
125
- const confidence = Math.min(pasteScore, 1);
126
- const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
127
- return {
128
- nav_type,
129
- confidence: Math.round(confidence * 1e3) / 1e3,
130
- signals
131
- };
132
- } catch {
133
- return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
134
- }
135
- }
136
- function calculateTimingVariance(nav) {
137
- const timings = [
138
- nav.fetchStart - nav.startTime,
139
- nav.domainLookupEnd - nav.domainLookupStart,
140
- nav.connectEnd - nav.connectStart,
141
- nav.responseStart - nav.requestStart
142
- ].filter((t) => t >= 0);
143
- if (timings.length === 0) return 100;
144
- const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
145
- const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
146
- return Math.sqrt(variance);
147
- }
148
-
149
- // src/detection/referrer.ts
150
- function detectAIFromReferrer(referrer) {
151
- if (!referrer) {
152
- return null;
153
- }
154
- try {
155
- const url = new URL(referrer);
156
- const hostname = url.hostname.toLowerCase();
157
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
158
- if (hostname.includes(pattern) || referrer.includes(pattern)) {
159
- return {
160
- isAI: true,
161
- platform,
162
- confidence: 0.95,
163
- // High confidence when referrer matches
164
- method: "referrer"
165
- };
166
- }
167
- }
168
- return null;
169
- } catch {
170
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
171
- if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
172
- return {
173
- isAI: true,
174
- platform,
175
- confidence: 0.85,
176
- method: "referrer"
177
- };
178
- }
179
- }
180
- return null;
181
- }
182
- }
183
- function detectAIFromUTM(url) {
184
- try {
185
- const params = new URL(url).searchParams;
186
- const utmSource = params.get("utm_source")?.toLowerCase();
187
- if (utmSource) {
188
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
189
- if (utmSource.includes(pattern.split(".")[0])) {
190
- return {
191
- isAI: true,
192
- platform,
193
- confidence: 0.99,
194
- // Very high confidence from explicit UTM
195
- method: "referrer"
196
- };
197
- }
80
+ var FormTracker = class {
81
+ constructor(config2 = {}) {
82
+ this.formStartTimes = /* @__PURE__ */ new Map();
83
+ this.interactedForms = /* @__PURE__ */ new Set();
84
+ this.mutationObserver = null;
85
+ this.handleFocusIn = (e) => {
86
+ const target = e.target;
87
+ if (!this.isFormField(target)) return;
88
+ const form = target.closest("form");
89
+ const formId = this.getFormId(form || target);
90
+ if (!this.formStartTimes.has(formId)) {
91
+ this.formStartTimes.set(formId, Date.now());
92
+ this.interactedForms.add(formId);
93
+ this.emitEvent({
94
+ event_type: "form_start",
95
+ form_id: formId,
96
+ form_type: this.detectFormType(form || target)
97
+ });
198
98
  }
199
- if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
200
- return {
201
- isAI: true,
202
- platform: utmSource,
203
- confidence: 0.9,
204
- method: "referrer"
205
- };
99
+ const fieldName = this.getFieldName(target);
100
+ if (fieldName && !this.isSensitiveField(fieldName)) {
101
+ this.emitEvent({
102
+ event_type: "form_field",
103
+ form_id: formId,
104
+ form_type: this.detectFormType(form || target),
105
+ field_name: this.sanitizeFieldName(fieldName),
106
+ field_type: target.type || target.tagName.toLowerCase()
107
+ });
206
108
  }
207
- }
208
- return null;
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- // src/detection/behavioral-classifier.ts
215
- var NAIVE_BAYES_WEIGHTS = {
216
- human: {
217
- time_to_first_click_delayed: 0.85,
218
- time_to_first_click_normal: 0.75,
219
- time_to_first_click_fast: 0.5,
220
- time_to_first_click_immediate: 0.25,
221
- scroll_speed_variable: 0.8,
222
- scroll_speed_erratic: 0.7,
223
- scroll_speed_uniform: 0.35,
224
- scroll_speed_none: 0.45,
225
- nav_timing_click: 0.75,
226
- nav_timing_unknown: 0.55,
227
- nav_timing_paste: 0.35,
228
- has_referrer: 0.7,
229
- no_referrer: 0.45,
230
- homepage_landing: 0.65,
231
- deep_landing: 0.5,
232
- mouse_movement_curved: 0.9,
233
- mouse_movement_linear: 0.3,
234
- mouse_movement_none: 0.4,
235
- form_fill_normal: 0.85,
236
- form_fill_fast: 0.6,
237
- form_fill_instant: 0.2,
238
- focus_blur_normal: 0.75,
239
- focus_blur_rapid: 0.45
240
- },
241
- ai_influenced: {
242
- time_to_first_click_immediate: 0.75,
243
- time_to_first_click_fast: 0.55,
244
- time_to_first_click_normal: 0.4,
245
- time_to_first_click_delayed: 0.35,
246
- scroll_speed_none: 0.55,
247
- scroll_speed_uniform: 0.7,
248
- scroll_speed_variable: 0.35,
249
- scroll_speed_erratic: 0.4,
250
- nav_timing_paste: 0.75,
251
- nav_timing_unknown: 0.5,
252
- nav_timing_click: 0.35,
253
- no_referrer: 0.65,
254
- has_referrer: 0.4,
255
- deep_landing: 0.6,
256
- homepage_landing: 0.45,
257
- mouse_movement_none: 0.6,
258
- mouse_movement_linear: 0.75,
259
- mouse_movement_curved: 0.25,
260
- form_fill_instant: 0.8,
261
- form_fill_fast: 0.55,
262
- form_fill_normal: 0.3,
263
- focus_blur_rapid: 0.6,
264
- focus_blur_normal: 0.4
265
- }
266
- };
267
- var PRIORS = {
268
- human: 0.85,
269
- ai_influenced: 0.15
270
- };
271
- var DEFAULT_WEIGHT = 0.5;
272
- var BehavioralClassifier = class {
273
- /**
274
- * Create a new classifier
275
- * @param minSessionTimeMs Minimum session time before classification (default: 10s)
276
- */
277
- constructor(minSessionTimeMs = 1e4) {
278
- this.classified = false;
279
- this.result = null;
280
- this.onClassify = null;
281
- this.minSessionTime = minSessionTimeMs;
282
- this.data = {
283
- firstClickTime: null,
284
- scrollEvents: [],
285
- mouseEvents: [],
286
- formEvents: [],
287
- focusBlurEvents: [],
288
- startTime: Date.now()
109
+ };
110
+ this.handleSubmit = (e) => {
111
+ const form = e.target;
112
+ if (!form || form.tagName !== "FORM") return;
113
+ const formId = this.getFormId(form);
114
+ const startTime = this.formStartTimes.get(formId);
115
+ const { fields, emailSubmitted } = this.captureFormFields(form);
116
+ this.emitEvent({
117
+ event_type: "form_submit",
118
+ form_id: formId,
119
+ form_type: this.detectFormType(form),
120
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
121
+ is_conversion: true,
122
+ submit_source: "submit",
123
+ fields: fields.length > 0 ? fields : void 0,
124
+ email_submitted: emailSubmitted
125
+ });
126
+ };
127
+ this.handleClick = (e) => {
128
+ const target = e.target;
129
+ if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
130
+ const form = target.closest("form");
131
+ if (form && form.classList.contains("hs-form")) {
132
+ const formId = this.getFormId(form);
133
+ const startTime = this.formStartTimes.get(formId);
134
+ const { fields, emailSubmitted } = this.captureFormFields(form);
135
+ this.emitEvent({
136
+ event_type: "form_submit",
137
+ form_id: formId,
138
+ form_type: "hubspot",
139
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
140
+ is_conversion: true,
141
+ submit_source: "click",
142
+ fields: fields.length > 0 ? fields : void 0,
143
+ email_submitted: emailSubmitted
144
+ });
145
+ }
146
+ }
147
+ if (target.closest('[data-qa="submit-button"]')) {
148
+ this.emitEvent({
149
+ event_type: "form_submit",
150
+ form_id: "typeform_embed",
151
+ form_type: "typeform",
152
+ is_conversion: true,
153
+ submit_source: "click"
154
+ });
155
+ }
156
+ };
157
+ this.config = {
158
+ ...DEFAULT_CONFIG,
159
+ ...config2,
160
+ sensitiveFields: [
161
+ ...DEFAULT_CONFIG.sensitiveFields,
162
+ ...config2.sensitiveFields || []
163
+ ]
289
164
  };
290
165
  }
291
166
  /**
292
- * Set callback for when classification completes
293
- */
294
- setOnClassify(callback) {
295
- this.onClassify = callback;
296
- }
297
- /**
298
- * Record a click event
299
- */
300
- recordClick() {
301
- if (this.data.firstClickTime === null) {
302
- this.data.firstClickTime = Date.now();
303
- }
304
- this.checkAndClassify();
305
- }
306
- /**
307
- * Record a scroll event
308
- */
309
- recordScroll(position) {
310
- this.data.scrollEvents.push({ time: Date.now(), position });
311
- if (this.data.scrollEvents.length > 50) {
312
- this.data.scrollEvents = this.data.scrollEvents.slice(-50);
313
- }
314
- this.checkAndClassify();
315
- }
316
- /**
317
- * Record mouse movement
318
- */
319
- recordMouse(x, y) {
320
- this.data.mouseEvents.push({ time: Date.now(), x, y });
321
- if (this.data.mouseEvents.length > 100) {
322
- this.data.mouseEvents = this.data.mouseEvents.slice(-100);
323
- }
324
- this.checkAndClassify();
325
- }
326
- /**
327
- * Record form field interaction start
328
- */
329
- recordFormStart(fieldId) {
330
- const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
331
- if (!existing) {
332
- this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
333
- }
334
- }
335
- /**
336
- * Record form field interaction end
337
- */
338
- recordFormEnd(fieldId) {
339
- const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
340
- if (event) {
341
- event.endTime = Date.now();
342
- }
343
- this.checkAndClassify();
344
- }
345
- /**
346
- * Record focus/blur event
167
+ * Start tracking forms
347
168
  */
348
- recordFocusBlur(type) {
349
- this.data.focusBlurEvents.push({ type, time: Date.now() });
350
- if (this.data.focusBlurEvents.length > 20) {
351
- this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
352
- }
169
+ start() {
170
+ document.addEventListener("focusin", this.handleFocusIn, { passive: true });
171
+ document.addEventListener("submit", this.handleSubmit);
172
+ document.addEventListener("click", this.handleClick, { passive: true });
173
+ this.startMutationObserver();
174
+ this.checkThankYouPage();
175
+ this.scanForEmbeddedForms();
353
176
  }
354
177
  /**
355
- * Check if we have enough data and classify
178
+ * Stop tracking
356
179
  */
357
- checkAndClassify() {
358
- if (this.classified) return;
359
- const sessionDuration = Date.now() - this.data.startTime;
360
- if (sessionDuration < this.minSessionTime) return;
361
- const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
362
- if (!hasData) return;
363
- this.classify();
180
+ stop() {
181
+ document.removeEventListener("focusin", this.handleFocusIn);
182
+ document.removeEventListener("submit", this.handleSubmit);
183
+ document.removeEventListener("click", this.handleClick);
184
+ this.mutationObserver?.disconnect();
364
185
  }
365
186
  /**
366
- * Force classification (for beforeunload)
187
+ * Get forms that had interaction
367
188
  */
368
- forceClassify() {
369
- if (this.classified) return this.result;
370
- return this.classify();
189
+ getInteractedForms() {
190
+ return Array.from(this.interactedForms);
371
191
  }
372
192
  /**
373
- * Perform classification
193
+ * LOA-482: Capture form field values with privacy-safe sanitization
194
+ * - Never captures sensitive fields (password, credit card, etc.)
195
+ * - Truncates values to maxFieldValueLength
196
+ * - Extracts email if found
374
197
  */
375
- classify() {
376
- const sessionDuration = Date.now() - this.data.startTime;
377
- const signals = this.extractSignals();
378
- let humanLogProb = Math.log(PRIORS.human);
379
- let aiLogProb = Math.log(PRIORS.ai_influenced);
380
- for (const signal of signals) {
381
- const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
382
- const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
383
- humanLogProb += Math.log(humanWeight);
384
- aiLogProb += Math.log(aiWeight);
385
- }
386
- const maxLog = Math.max(humanLogProb, aiLogProb);
387
- const humanExp = Math.exp(humanLogProb - maxLog);
388
- const aiExp = Math.exp(aiLogProb - maxLog);
389
- const total = humanExp + aiExp;
390
- const humanProbability = humanExp / total;
391
- const aiProbability = aiExp / total;
392
- let classification;
393
- let confidence;
394
- if (humanProbability > 0.6) {
395
- classification = "human";
396
- confidence = humanProbability;
397
- } else if (aiProbability > 0.6) {
398
- classification = "ai_influenced";
399
- confidence = aiProbability;
400
- } else {
401
- classification = "uncertain";
402
- confidence = Math.max(humanProbability, aiProbability);
198
+ captureFormFields(form) {
199
+ const fields = [];
200
+ let emailSubmitted;
201
+ if (!this.config.captureFieldValues) {
202
+ return { fields, emailSubmitted };
403
203
  }
404
- this.result = {
405
- classification,
406
- humanProbability,
407
- aiProbability,
408
- confidence,
409
- signals,
410
- timestamp: Date.now(),
411
- sessionDurationMs: sessionDuration
412
- };
413
- this.classified = true;
414
- if (this.onClassify) {
415
- this.onClassify(this.result);
204
+ try {
205
+ const formData = new FormData(form);
206
+ for (const [name, value] of formData.entries()) {
207
+ if (this.isSensitiveField(name)) {
208
+ continue;
209
+ }
210
+ const input = form.elements.namedItem(name);
211
+ const inputType = input?.type || "text";
212
+ if (inputType === "file" || value instanceof File) {
213
+ continue;
214
+ }
215
+ const stringValue = String(value);
216
+ if (this.isEmailField(name, stringValue)) {
217
+ emailSubmitted = stringValue.substring(0, 254);
218
+ }
219
+ const truncatedValue = stringValue.length > this.config.maxFieldValueLength ? stringValue.substring(0, this.config.maxFieldValueLength) + "..." : stringValue;
220
+ fields.push({
221
+ name: this.sanitizeFieldName(name),
222
+ type: inputType,
223
+ value: truncatedValue
224
+ });
225
+ }
226
+ } catch (err) {
227
+ console.warn("[Loamly] Failed to capture form fields:", err);
416
228
  }
417
- return this.result;
229
+ return { fields, emailSubmitted };
418
230
  }
419
231
  /**
420
- * Extract behavioral signals from collected data
232
+ * Check if a field contains an email
421
233
  */
422
- extractSignals() {
423
- const signals = [];
424
- if (this.data.firstClickTime !== null) {
425
- const timeToClick = this.data.firstClickTime - this.data.startTime;
426
- if (timeToClick < 500) {
427
- signals.push("time_to_first_click_immediate");
428
- } else if (timeToClick < 2e3) {
429
- signals.push("time_to_first_click_fast");
430
- } else if (timeToClick < 1e4) {
431
- signals.push("time_to_first_click_normal");
432
- } else {
433
- signals.push("time_to_first_click_delayed");
234
+ isEmailField(fieldName, value) {
235
+ const lowerName = fieldName.toLowerCase();
236
+ const isEmailName = lowerName.includes("email") || lowerName === "e-mail";
237
+ const isEmailValue = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
238
+ return isEmailName && isEmailValue;
239
+ }
240
+ startMutationObserver() {
241
+ this.mutationObserver = new MutationObserver((mutations) => {
242
+ for (const mutation of mutations) {
243
+ for (const node of mutation.addedNodes) {
244
+ if (node instanceof HTMLElement) {
245
+ if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
246
+ this.trackEmbeddedForm(node, "hubspot");
247
+ }
248
+ if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
249
+ this.trackEmbeddedForm(node, "typeform");
250
+ }
251
+ if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
252
+ this.trackEmbeddedForm(node, "jotform");
253
+ }
254
+ if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
255
+ this.trackEmbeddedForm(node, "gravity");
256
+ }
257
+ }
258
+ }
434
259
  }
435
- }
436
- if (this.data.scrollEvents.length === 0) {
437
- signals.push("scroll_speed_none");
438
- } else if (this.data.scrollEvents.length >= 3) {
439
- const scrollDeltas = [];
440
- for (let i = 1; i < this.data.scrollEvents.length; i++) {
441
- const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
442
- scrollDeltas.push(delta);
260
+ });
261
+ this.mutationObserver.observe(document.body, {
262
+ childList: true,
263
+ subtree: true
264
+ });
265
+ }
266
+ scanForEmbeddedForms() {
267
+ document.querySelectorAll(".hs-form").forEach((form) => {
268
+ this.trackEmbeddedForm(form, "hubspot");
269
+ });
270
+ document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
271
+ this.trackEmbeddedForm(form, "typeform");
272
+ });
273
+ document.querySelectorAll(".jotform-form").forEach((form) => {
274
+ this.trackEmbeddedForm(form, "jotform");
275
+ });
276
+ document.querySelectorAll(".gform_wrapper").forEach((form) => {
277
+ this.trackEmbeddedForm(form, "gravity");
278
+ });
279
+ }
280
+ trackEmbeddedForm(element, type) {
281
+ const formId = `${type}_${this.getFormId(element)}`;
282
+ element.addEventListener("focusin", () => {
283
+ if (!this.formStartTimes.has(formId)) {
284
+ this.formStartTimes.set(formId, Date.now());
285
+ this.interactedForms.add(formId);
286
+ this.emitEvent({
287
+ event_type: "form_start",
288
+ form_id: formId,
289
+ form_type: type
290
+ });
443
291
  }
444
- const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
445
- const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
446
- const stdDev = Math.sqrt(variance);
447
- const cv = mean > 0 ? stdDev / mean : 0;
448
- if (cv < 0.2) {
449
- signals.push("scroll_speed_uniform");
450
- } else if (cv < 0.6) {
451
- signals.push("scroll_speed_variable");
452
- } else {
453
- signals.push("scroll_speed_erratic");
292
+ }, { passive: true });
293
+ }
294
+ checkThankYouPage() {
295
+ const url = window.location.href.toLowerCase();
296
+ const title = document.title.toLowerCase();
297
+ for (const pattern of this.config.thankYouPatterns) {
298
+ if (pattern.test(url) || pattern.test(title)) {
299
+ this.emitEvent({
300
+ event_type: "form_success",
301
+ form_id: "page_conversion",
302
+ form_type: "unknown",
303
+ is_conversion: true,
304
+ submit_source: "thank_you"
305
+ });
306
+ break;
454
307
  }
455
308
  }
456
- if (this.data.mouseEvents.length === 0) {
457
- signals.push("mouse_movement_none");
458
- } else if (this.data.mouseEvents.length >= 10) {
459
- const n = Math.min(this.data.mouseEvents.length, 20);
460
- const recentMouse = this.data.mouseEvents.slice(-n);
461
- let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
462
- for (const event of recentMouse) {
463
- sumX += event.x;
464
- sumY += event.y;
465
- sumXY += event.x * event.y;
466
- sumX2 += event.x * event.x;
467
- }
468
- const denominator = n * sumX2 - sumX * sumX;
469
- const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
470
- const intercept = (sumY - slope * sumX) / n;
471
- let ssRes = 0, ssTot = 0;
472
- const yMean = sumY / n;
473
- for (const event of recentMouse) {
474
- const yPred = slope * event.x + intercept;
475
- ssRes += Math.pow(event.y - yPred, 2);
476
- ssTot += Math.pow(event.y - yMean, 2);
477
- }
478
- const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
479
- if (r2 > 0.95) {
480
- signals.push("mouse_movement_linear");
481
- } else {
482
- signals.push("mouse_movement_curved");
483
- }
309
+ }
310
+ isFormField(element) {
311
+ const tagName = element.tagName;
312
+ return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
313
+ }
314
+ getFormId(element) {
315
+ if (!element) return "unknown";
316
+ return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
317
+ }
318
+ getFieldName(input) {
319
+ return input.name || input.id || input.getAttribute("data-name") || "";
320
+ }
321
+ isSensitiveField(fieldName) {
322
+ const lowerName = fieldName.toLowerCase();
323
+ return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
324
+ }
325
+ sanitizeFieldName(fieldName) {
326
+ return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
327
+ }
328
+ detectFormType(element) {
329
+ if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
330
+ return "hubspot";
331
+ }
332
+ if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
333
+ return "typeform";
334
+ }
335
+ if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
336
+ return "jotform";
484
337
  }
485
- const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
486
- if (completedForms.length > 0) {
487
- const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
488
- if (avgFillTime < 100) {
489
- signals.push("form_fill_instant");
490
- } else if (avgFillTime < 500) {
491
- signals.push("form_fill_fast");
492
- } else {
493
- signals.push("form_fill_normal");
494
- }
338
+ if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
339
+ return "gravity";
495
340
  }
496
- if (this.data.focusBlurEvents.length >= 4) {
497
- const recentFB = this.data.focusBlurEvents.slice(-10);
498
- const intervals = [];
499
- for (let i = 1; i < recentFB.length; i++) {
500
- intervals.push(recentFB[i].time - recentFB[i - 1].time);
501
- }
502
- const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
503
- if (avgInterval < 1e3) {
504
- signals.push("focus_blur_rapid");
505
- } else {
506
- signals.push("focus_blur_normal");
507
- }
341
+ if (element.tagName === "FORM") {
342
+ return "native";
508
343
  }
509
- return signals;
344
+ return "unknown";
345
+ }
346
+ emitEvent(event) {
347
+ this.config.onFormEvent?.(event);
348
+ }
349
+ };
350
+
351
+ // src/behavioral/scroll-tracker.ts
352
+ var DEFAULT_CHUNKS = [30, 60, 90, 100];
353
+ var ScrollTracker = class {
354
+ constructor(config2 = {}) {
355
+ this.maxDepth = 0;
356
+ this.reportedChunks = /* @__PURE__ */ new Set();
357
+ this.ticking = false;
358
+ this.isVisible = true;
359
+ this.handleScroll = () => {
360
+ if (!this.ticking && this.isVisible) {
361
+ requestAnimationFrame(() => {
362
+ this.checkScrollDepth();
363
+ this.ticking = false;
364
+ });
365
+ this.ticking = true;
366
+ }
367
+ };
368
+ this.handleVisibility = () => {
369
+ this.isVisible = document.visibilityState === "visible";
370
+ };
371
+ this.config = {
372
+ chunks: DEFAULT_CHUNKS,
373
+ ...config2
374
+ };
375
+ this.startTime = Date.now();
510
376
  }
511
377
  /**
512
- * Add context signals (set by tracker from external data)
378
+ * Start tracking scroll depth
513
379
  */
514
- addContextSignal(_signal) {
380
+ start() {
381
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
382
+ document.addEventListener("visibilitychange", this.handleVisibility);
383
+ this.checkScrollDepth();
515
384
  }
516
385
  /**
517
- * Get current result (null if not yet classified)
386
+ * Stop tracking
518
387
  */
519
- getResult() {
520
- return this.result;
388
+ stop() {
389
+ window.removeEventListener("scroll", this.handleScroll);
390
+ document.removeEventListener("visibilitychange", this.handleVisibility);
521
391
  }
522
392
  /**
523
- * Check if classification has been performed
393
+ * Get current max scroll depth
524
394
  */
525
- hasClassified() {
526
- return this.classified;
527
- }
528
- };
529
-
530
- // src/detection/focus-blur.ts
531
- var FocusBlurAnalyzer = class {
532
- constructor() {
533
- this.sequence = [];
534
- this.firstInteractionTime = null;
535
- this.analyzed = false;
536
- this.result = null;
537
- this.pageLoadTime = performance.now();
395
+ getMaxDepth() {
396
+ return this.maxDepth;
538
397
  }
539
398
  /**
540
- * Initialize event tracking
541
- * Must be called after DOM is ready
399
+ * Get reported chunks
542
400
  */
543
- initTracking() {
544
- document.addEventListener("focus", (e) => {
545
- this.recordEvent("focus", e.target);
546
- }, true);
547
- document.addEventListener("blur", (e) => {
548
- this.recordEvent("blur", e.target);
549
- }, true);
550
- window.addEventListener("focus", () => {
551
- this.recordEvent("window_focus", null);
552
- });
553
- window.addEventListener("blur", () => {
554
- this.recordEvent("window_blur", null);
555
- });
556
- const recordFirstInteraction = () => {
557
- if (this.firstInteractionTime === null) {
558
- this.firstInteractionTime = performance.now();
559
- }
560
- };
561
- document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
562
- document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
401
+ getReportedChunks() {
402
+ return Array.from(this.reportedChunks).sort((a, b) => a - b);
563
403
  }
564
404
  /**
565
- * Record a focus/blur event
405
+ * Get final scroll event (for unload)
566
406
  */
567
- recordEvent(type, target) {
568
- const event = {
569
- type,
570
- target: target?.tagName || "WINDOW",
571
- timestamp: performance.now()
407
+ getFinalEvent() {
408
+ const docHeight = document.documentElement.scrollHeight;
409
+ const viewportHeight = window.innerHeight;
410
+ return {
411
+ depth: this.maxDepth,
412
+ chunk: this.getChunkForDepth(this.maxDepth),
413
+ time_to_reach_ms: Date.now() - this.startTime,
414
+ total_height: docHeight,
415
+ viewport_height: viewportHeight
572
416
  };
573
- this.sequence.push(event);
574
- if (this.sequence.length > 20) {
575
- this.sequence = this.sequence.slice(-20);
576
- }
577
417
  }
578
- /**
579
- * Analyze the focus/blur sequence for paste patterns
580
- */
581
- analyze() {
582
- if (this.analyzed && this.result) {
583
- return this.result;
584
- }
585
- const signals = [];
586
- let confidence = 0;
587
- const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
588
- const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
589
- if (hasEarlyWindowFocus) {
590
- signals.push("early_window_focus");
591
- confidence += 0.15;
592
- }
593
- const hasEarlyBodyFocus = earlyEvents.some(
594
- (e) => e.type === "focus" && e.target === "BODY"
595
- );
596
- if (hasEarlyBodyFocus) {
597
- signals.push("early_body_focus");
598
- confidence += 0.15;
599
- }
600
- const hasLinkFocus = this.sequence.some(
601
- (e) => e.type === "focus" && e.target === "A"
602
- );
603
- if (!hasLinkFocus) {
604
- signals.push("no_link_focus");
605
- confidence += 0.1;
606
- }
607
- const firstFocus = this.sequence.find((e) => e.type === "focus");
608
- if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
609
- signals.push("first_focus_body");
610
- confidence += 0.1;
611
- }
612
- const windowEvents = this.sequence.filter(
613
- (e) => e.type === "window_focus" || e.type === "window_blur"
614
- );
615
- if (windowEvents.length <= 2) {
616
- signals.push("minimal_window_switches");
617
- confidence += 0.05;
418
+ checkScrollDepth() {
419
+ const scrollY = window.scrollY;
420
+ const viewportHeight = window.innerHeight;
421
+ const docHeight = document.documentElement.scrollHeight;
422
+ if (docHeight <= viewportHeight) {
423
+ this.updateDepth(100);
424
+ return;
618
425
  }
619
- if (this.firstInteractionTime !== null) {
620
- const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
621
- if (timeToInteraction > 3e3) {
622
- signals.push("delayed_first_interaction");
623
- confidence += 0.1;
426
+ const scrollableHeight = docHeight - viewportHeight;
427
+ const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
428
+ this.updateDepth(currentDepth);
429
+ }
430
+ updateDepth(depth) {
431
+ if (depth <= this.maxDepth) return;
432
+ this.maxDepth = depth;
433
+ this.config.onDepthChange?.(depth);
434
+ for (const chunk of this.config.chunks) {
435
+ if (depth >= chunk && !this.reportedChunks.has(chunk)) {
436
+ this.reportedChunks.add(chunk);
437
+ this.reportChunk(chunk);
624
438
  }
625
439
  }
626
- confidence = Math.min(confidence, 0.65);
627
- let navType;
628
- if (confidence >= 0.35) {
629
- navType = "likely_paste";
630
- } else if (signals.length === 0) {
631
- navType = "unknown";
632
- } else {
633
- navType = "likely_click";
440
+ }
441
+ reportChunk(chunk) {
442
+ const docHeight = document.documentElement.scrollHeight;
443
+ const viewportHeight = window.innerHeight;
444
+ const event = {
445
+ depth: this.maxDepth,
446
+ chunk,
447
+ time_to_reach_ms: Date.now() - this.startTime,
448
+ total_height: docHeight,
449
+ viewport_height: viewportHeight
450
+ };
451
+ this.config.onChunkReached?.(event);
452
+ }
453
+ getChunkForDepth(depth) {
454
+ const chunks = this.config.chunks.sort((a, b) => b - a);
455
+ for (const chunk of chunks) {
456
+ if (depth >= chunk) return chunk;
634
457
  }
635
- this.result = {
636
- nav_type: navType,
637
- confidence,
638
- signals,
639
- sequence: this.sequence.slice(-10),
640
- time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
458
+ return 0;
459
+ }
460
+ };
461
+
462
+ // src/behavioral/time-tracker.ts
463
+ var DEFAULT_CONFIG2 = {
464
+ idleThresholdMs: 3e4,
465
+ // 30 seconds
466
+ updateIntervalMs: 5e3
467
+ // 5 seconds
468
+ };
469
+ var TimeTracker = class {
470
+ constructor(config2 = {}) {
471
+ this.activeTime = 0;
472
+ this.idleTime = 0;
473
+ this.isVisible = true;
474
+ this.isIdle = false;
475
+ this.updateInterval = null;
476
+ this.idleCheckInterval = null;
477
+ this.handleVisibility = () => {
478
+ const wasVisible = this.isVisible;
479
+ this.isVisible = document.visibilityState === "visible";
480
+ if (wasVisible && !this.isVisible) {
481
+ this.updateTimes();
482
+ } else if (!wasVisible && this.isVisible) {
483
+ this.lastUpdateTime = Date.now();
484
+ this.lastActivityTime = Date.now();
485
+ }
641
486
  };
642
- this.analyzed = true;
643
- return this.result;
487
+ this.handleActivity = () => {
488
+ const now = Date.now();
489
+ if (this.isIdle) {
490
+ this.isIdle = false;
491
+ }
492
+ this.lastActivityTime = now;
493
+ };
494
+ this.config = { ...DEFAULT_CONFIG2, ...config2 };
495
+ this.startTime = Date.now();
496
+ this.lastActivityTime = this.startTime;
497
+ this.lastUpdateTime = this.startTime;
644
498
  }
645
499
  /**
646
- * Get current result (analyze if not done)
500
+ * Start tracking time
647
501
  */
648
- getResult() {
649
- return this.analyze();
502
+ start() {
503
+ document.addEventListener("visibilitychange", this.handleVisibility);
504
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
505
+ activityEvents.forEach((event) => {
506
+ document.addEventListener(event, this.handleActivity, { passive: true });
507
+ });
508
+ this.updateInterval = setInterval(() => {
509
+ this.update();
510
+ }, this.config.updateIntervalMs);
511
+ this.idleCheckInterval = setInterval(() => {
512
+ this.checkIdle();
513
+ }, 1e3);
650
514
  }
651
515
  /**
652
- * Check if analysis has been performed
516
+ * Stop tracking
653
517
  */
654
- hasAnalyzed() {
655
- return this.analyzed;
518
+ stop() {
519
+ document.removeEventListener("visibilitychange", this.handleVisibility);
520
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
521
+ activityEvents.forEach((event) => {
522
+ document.removeEventListener(event, this.handleActivity);
523
+ });
524
+ if (this.updateInterval) {
525
+ clearInterval(this.updateInterval);
526
+ this.updateInterval = null;
527
+ }
528
+ if (this.idleCheckInterval) {
529
+ clearInterval(this.idleCheckInterval);
530
+ this.idleCheckInterval = null;
531
+ }
656
532
  }
657
533
  /**
658
- * Get the raw sequence for debugging
534
+ * Get current time metrics
659
535
  */
660
- getSequence() {
661
- return [...this.sequence];
536
+ getMetrics() {
537
+ this.updateTimes();
538
+ return {
539
+ active_time_ms: this.activeTime,
540
+ total_time_ms: Date.now() - this.startTime,
541
+ idle_time_ms: this.idleTime,
542
+ is_engaged: !this.isIdle && this.isVisible
543
+ };
662
544
  }
663
545
  /**
664
- * Reset the analyzer
546
+ * Get final metrics (for unload)
665
547
  */
666
- reset() {
667
- this.sequence = [];
668
- this.pageLoadTime = performance.now();
669
- this.firstInteractionTime = null;
670
- this.analyzed = false;
671
- this.result = null;
548
+ getFinalMetrics() {
549
+ this.updateTimes();
550
+ return this.getMetrics();
551
+ }
552
+ checkIdle() {
553
+ const now = Date.now();
554
+ const timeSinceActivity = now - this.lastActivityTime;
555
+ if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
556
+ this.isIdle = true;
557
+ }
558
+ }
559
+ updateTimes() {
560
+ const now = Date.now();
561
+ const elapsed = now - this.lastUpdateTime;
562
+ if (this.isVisible) {
563
+ if (this.isIdle) {
564
+ this.idleTime += elapsed;
565
+ } else {
566
+ this.activeTime += elapsed;
567
+ }
568
+ }
569
+ this.lastUpdateTime = now;
570
+ }
571
+ update() {
572
+ if (!this.isVisible) return;
573
+ this.updateTimes();
574
+ this.config.onUpdate?.(this.getMetrics());
672
575
  }
673
576
  };
674
577
 
578
+ // src/config.ts
579
+ var VERSION = "2.4.0";
580
+ var DEFAULT_CONFIG3 = {
581
+ apiHost: "https://app.loamly.ai",
582
+ endpoints: {
583
+ visit: "/api/ingest/visit",
584
+ behavioral: "/api/ingest/behavioral",
585
+ session: "/api/ingest/session",
586
+ resolve: "/api/tracker/resolve",
587
+ health: "/api/tracker/health",
588
+ ping: "/api/tracker/ping"
589
+ },
590
+ pingInterval: 3e4,
591
+ // 30 seconds
592
+ batchSize: 10,
593
+ batchTimeout: 5e3,
594
+ sessionTimeout: 18e5,
595
+ // 30 minutes
596
+ maxTextLength: 100,
597
+ timeSpentThresholdMs: 5e3
598
+ // Only send time_spent when delta >= 5 seconds
599
+ };
600
+ var AI_PLATFORMS = {
601
+ "chatgpt.com": "chatgpt",
602
+ "chat.openai.com": "chatgpt",
603
+ "claude.ai": "claude",
604
+ "perplexity.ai": "perplexity",
605
+ "bard.google.com": "bard",
606
+ "gemini.google.com": "gemini",
607
+ "copilot.microsoft.com": "copilot",
608
+ "github.com/copilot": "github-copilot",
609
+ "you.com": "you",
610
+ "phind.com": "phind",
611
+ "poe.com": "poe"
612
+ };
613
+ var AI_BOT_PATTERNS = [
614
+ "GPTBot",
615
+ "ChatGPT-User",
616
+ "ClaudeBot",
617
+ "Claude-Web",
618
+ "PerplexityBot",
619
+ "Amazonbot",
620
+ "Google-Extended",
621
+ "CCBot",
622
+ "anthropic-ai",
623
+ "cohere-ai"
624
+ ];
625
+
675
626
  // src/detection/agentic-browser.ts
676
627
  var CometDetector = class {
677
628
  constructor() {
@@ -854,745 +805,896 @@ var AgenticBrowserAnalyzer = class {
854
805
  };
855
806
  }
856
807
  /**
857
- * Cleanup resources
808
+ * Cleanup resources
809
+ */
810
+ destroy() {
811
+ this.cometDetector.destroy();
812
+ this.mouseAnalyzer.destroy();
813
+ }
814
+ };
815
+ function createAgenticAnalyzer() {
816
+ const analyzer = new AgenticBrowserAnalyzer();
817
+ if (typeof document !== "undefined") {
818
+ if (document.readyState === "loading") {
819
+ document.addEventListener("DOMContentLoaded", () => analyzer.init());
820
+ } else {
821
+ analyzer.init();
822
+ }
823
+ }
824
+ return analyzer;
825
+ }
826
+
827
+ // src/detection/behavioral-classifier.ts
828
+ var NAIVE_BAYES_WEIGHTS = {
829
+ human: {
830
+ time_to_first_click_delayed: 0.85,
831
+ time_to_first_click_normal: 0.75,
832
+ time_to_first_click_fast: 0.5,
833
+ time_to_first_click_immediate: 0.25,
834
+ scroll_speed_variable: 0.8,
835
+ scroll_speed_erratic: 0.7,
836
+ scroll_speed_uniform: 0.35,
837
+ scroll_speed_none: 0.45,
838
+ nav_timing_click: 0.75,
839
+ nav_timing_unknown: 0.55,
840
+ nav_timing_paste: 0.35,
841
+ has_referrer: 0.7,
842
+ no_referrer: 0.45,
843
+ homepage_landing: 0.65,
844
+ deep_landing: 0.5,
845
+ mouse_movement_curved: 0.9,
846
+ mouse_movement_linear: 0.3,
847
+ mouse_movement_none: 0.4,
848
+ form_fill_normal: 0.85,
849
+ form_fill_fast: 0.6,
850
+ form_fill_instant: 0.2,
851
+ focus_blur_normal: 0.75,
852
+ focus_blur_rapid: 0.45
853
+ },
854
+ ai_influenced: {
855
+ time_to_first_click_immediate: 0.75,
856
+ time_to_first_click_fast: 0.55,
857
+ time_to_first_click_normal: 0.4,
858
+ time_to_first_click_delayed: 0.35,
859
+ scroll_speed_none: 0.55,
860
+ scroll_speed_uniform: 0.7,
861
+ scroll_speed_variable: 0.35,
862
+ scroll_speed_erratic: 0.4,
863
+ nav_timing_paste: 0.75,
864
+ nav_timing_unknown: 0.5,
865
+ nav_timing_click: 0.35,
866
+ no_referrer: 0.65,
867
+ has_referrer: 0.4,
868
+ deep_landing: 0.6,
869
+ homepage_landing: 0.45,
870
+ mouse_movement_none: 0.6,
871
+ mouse_movement_linear: 0.75,
872
+ mouse_movement_curved: 0.25,
873
+ form_fill_instant: 0.8,
874
+ form_fill_fast: 0.55,
875
+ form_fill_normal: 0.3,
876
+ focus_blur_rapid: 0.6,
877
+ focus_blur_normal: 0.4
878
+ }
879
+ };
880
+ var PRIORS = {
881
+ human: 0.85,
882
+ ai_influenced: 0.15
883
+ };
884
+ var DEFAULT_WEIGHT = 0.5;
885
+ var BehavioralClassifier = class {
886
+ /**
887
+ * Create a new classifier
888
+ * @param minSessionTimeMs Minimum session time before classification (default: 10s)
889
+ */
890
+ constructor(minSessionTimeMs = 1e4) {
891
+ this.classified = false;
892
+ this.result = null;
893
+ this.onClassify = null;
894
+ this.minSessionTime = minSessionTimeMs;
895
+ this.data = {
896
+ firstClickTime: null,
897
+ scrollEvents: [],
898
+ mouseEvents: [],
899
+ formEvents: [],
900
+ focusBlurEvents: [],
901
+ startTime: Date.now()
902
+ };
903
+ }
904
+ /**
905
+ * Set callback for when classification completes
906
+ */
907
+ setOnClassify(callback) {
908
+ this.onClassify = callback;
909
+ }
910
+ /**
911
+ * Record a click event
912
+ */
913
+ recordClick() {
914
+ if (this.data.firstClickTime === null) {
915
+ this.data.firstClickTime = Date.now();
916
+ }
917
+ this.checkAndClassify();
918
+ }
919
+ /**
920
+ * Record a scroll event
858
921
  */
859
- destroy() {
860
- this.cometDetector.destroy();
861
- this.mouseAnalyzer.destroy();
862
- }
863
- };
864
- function createAgenticAnalyzer() {
865
- const analyzer = new AgenticBrowserAnalyzer();
866
- if (typeof document !== "undefined") {
867
- if (document.readyState === "loading") {
868
- document.addEventListener("DOMContentLoaded", () => analyzer.init());
869
- } else {
870
- analyzer.init();
922
+ recordScroll(position) {
923
+ this.data.scrollEvents.push({ time: Date.now(), position });
924
+ if (this.data.scrollEvents.length > 50) {
925
+ this.data.scrollEvents = this.data.scrollEvents.slice(-50);
871
926
  }
927
+ this.checkAndClassify();
872
928
  }
873
- return analyzer;
874
- }
875
-
876
- // src/infrastructure/event-queue.ts
877
- var DEFAULT_QUEUE_CONFIG = {
878
- batchSize: DEFAULT_CONFIG.batchSize,
879
- batchTimeout: DEFAULT_CONFIG.batchTimeout,
880
- maxRetries: 3,
881
- retryDelayMs: 1e3,
882
- storageKey: "_loamly_queue"
883
- };
884
- var EventQueue = class {
885
- constructor(endpoint2, config2 = {}) {
886
- this.queue = [];
887
- this.batchTimer = null;
888
- this.isFlushing = false;
889
- this.endpoint = endpoint2;
890
- this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
891
- this.loadFromStorage();
929
+ /**
930
+ * Record mouse movement
931
+ */
932
+ recordMouse(x, y) {
933
+ this.data.mouseEvents.push({ time: Date.now(), x, y });
934
+ if (this.data.mouseEvents.length > 100) {
935
+ this.data.mouseEvents = this.data.mouseEvents.slice(-100);
936
+ }
937
+ this.checkAndClassify();
892
938
  }
893
939
  /**
894
- * Add event to queue
940
+ * Record form field interaction start
895
941
  */
896
- push(type, payload) {
897
- const event = {
898
- id: this.generateId(),
899
- type,
900
- payload,
901
- timestamp: Date.now(),
902
- retries: 0
903
- };
904
- this.queue.push(event);
905
- this.saveToStorage();
906
- this.scheduleBatch();
942
+ recordFormStart(fieldId) {
943
+ const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
944
+ if (!existing) {
945
+ this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
946
+ }
907
947
  }
908
948
  /**
909
- * Force flush all events immediately
949
+ * Record form field interaction end
910
950
  */
911
- async flush() {
912
- if (this.isFlushing || this.queue.length === 0) return;
913
- this.isFlushing = true;
914
- this.clearBatchTimer();
915
- try {
916
- const events = [...this.queue];
917
- this.queue = [];
918
- await this.sendBatch(events);
919
- } finally {
920
- this.isFlushing = false;
921
- this.saveToStorage();
951
+ recordFormEnd(fieldId) {
952
+ const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
953
+ if (event) {
954
+ event.endTime = Date.now();
922
955
  }
956
+ this.checkAndClassify();
923
957
  }
924
958
  /**
925
- * Flush using sendBeacon (for unload events)
959
+ * Record focus/blur event
926
960
  */
927
- flushBeacon() {
928
- if (this.queue.length === 0) return true;
929
- const events = this.queue.map((e) => ({
930
- type: e.type,
931
- ...e.payload,
932
- _queue_id: e.id,
933
- _queue_timestamp: e.timestamp
934
- }));
935
- const success = navigator.sendBeacon?.(
936
- this.endpoint,
937
- JSON.stringify({ events, beacon: true })
938
- ) ?? false;
939
- if (success) {
940
- this.queue = [];
941
- this.clearStorage();
961
+ recordFocusBlur(type) {
962
+ this.data.focusBlurEvents.push({ type, time: Date.now() });
963
+ if (this.data.focusBlurEvents.length > 20) {
964
+ this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
942
965
  }
943
- return success;
944
966
  }
945
967
  /**
946
- * Get current queue length
968
+ * Check if we have enough data and classify
947
969
  */
948
- get length() {
949
- return this.queue.length;
970
+ checkAndClassify() {
971
+ if (this.classified) return;
972
+ const sessionDuration = Date.now() - this.data.startTime;
973
+ if (sessionDuration < this.minSessionTime) return;
974
+ const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
975
+ if (!hasData) return;
976
+ this.classify();
950
977
  }
951
- scheduleBatch() {
952
- if (this.batchTimer) return;
953
- if (this.queue.length >= this.config.batchSize) {
954
- this.flush();
955
- return;
956
- }
957
- this.batchTimer = setTimeout(() => {
958
- this.batchTimer = null;
959
- this.flush();
960
- }, this.config.batchTimeout);
978
+ /**
979
+ * Force classification (for beforeunload)
980
+ */
981
+ forceClassify() {
982
+ if (this.classified) return this.result;
983
+ return this.classify();
961
984
  }
962
- clearBatchTimer() {
963
- if (this.batchTimer) {
964
- clearTimeout(this.batchTimer);
965
- this.batchTimer = null;
985
+ /**
986
+ * Perform classification
987
+ */
988
+ classify() {
989
+ const sessionDuration = Date.now() - this.data.startTime;
990
+ const signals = this.extractSignals();
991
+ let humanLogProb = Math.log(PRIORS.human);
992
+ let aiLogProb = Math.log(PRIORS.ai_influenced);
993
+ for (const signal of signals) {
994
+ const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
995
+ const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
996
+ humanLogProb += Math.log(humanWeight);
997
+ aiLogProb += Math.log(aiWeight);
966
998
  }
967
- }
968
- async sendBatch(events) {
969
- if (events.length === 0) return;
970
- const payload = {
971
- events: events.map((e) => ({
972
- type: e.type,
973
- ...e.payload,
974
- _queue_id: e.id,
975
- _queue_timestamp: e.timestamp
976
- })),
977
- batch: true
999
+ const maxLog = Math.max(humanLogProb, aiLogProb);
1000
+ const humanExp = Math.exp(humanLogProb - maxLog);
1001
+ const aiExp = Math.exp(aiLogProb - maxLog);
1002
+ const total = humanExp + aiExp;
1003
+ const humanProbability = humanExp / total;
1004
+ const aiProbability = aiExp / total;
1005
+ let classification;
1006
+ let confidence;
1007
+ if (humanProbability > 0.6) {
1008
+ classification = "human";
1009
+ confidence = humanProbability;
1010
+ } else if (aiProbability > 0.6) {
1011
+ classification = "ai_influenced";
1012
+ confidence = aiProbability;
1013
+ } else {
1014
+ classification = "uncertain";
1015
+ confidence = Math.max(humanProbability, aiProbability);
1016
+ }
1017
+ this.result = {
1018
+ classification,
1019
+ humanProbability,
1020
+ aiProbability,
1021
+ confidence,
1022
+ signals,
1023
+ timestamp: Date.now(),
1024
+ sessionDurationMs: sessionDuration
978
1025
  };
979
- try {
980
- const response = await fetch(this.endpoint, {
981
- method: "POST",
982
- headers: { "Content-Type": "application/json" },
983
- body: JSON.stringify(payload)
984
- });
985
- if (!response.ok) {
986
- throw new Error(`HTTP ${response.status}`);
1026
+ this.classified = true;
1027
+ if (this.onClassify) {
1028
+ this.onClassify(this.result);
1029
+ }
1030
+ return this.result;
1031
+ }
1032
+ /**
1033
+ * Extract behavioral signals from collected data
1034
+ */
1035
+ extractSignals() {
1036
+ const signals = [];
1037
+ if (this.data.firstClickTime !== null) {
1038
+ const timeToClick = this.data.firstClickTime - this.data.startTime;
1039
+ if (timeToClick < 500) {
1040
+ signals.push("time_to_first_click_immediate");
1041
+ } else if (timeToClick < 2e3) {
1042
+ signals.push("time_to_first_click_fast");
1043
+ } else if (timeToClick < 1e4) {
1044
+ signals.push("time_to_first_click_normal");
1045
+ } else {
1046
+ signals.push("time_to_first_click_delayed");
987
1047
  }
988
- } catch (error) {
989
- for (const event of events) {
990
- if (event.retries < this.config.maxRetries) {
991
- event.retries++;
992
- this.queue.push(event);
993
- }
1048
+ }
1049
+ if (this.data.scrollEvents.length === 0) {
1050
+ signals.push("scroll_speed_none");
1051
+ } else if (this.data.scrollEvents.length >= 3) {
1052
+ const scrollDeltas = [];
1053
+ for (let i = 1; i < this.data.scrollEvents.length; i++) {
1054
+ const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
1055
+ scrollDeltas.push(delta);
994
1056
  }
995
- if (this.queue.length > 0) {
996
- const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
997
- setTimeout(() => this.flush(), delay);
1057
+ const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
1058
+ const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
1059
+ const stdDev = Math.sqrt(variance);
1060
+ const cv = mean > 0 ? stdDev / mean : 0;
1061
+ if (cv < 0.2) {
1062
+ signals.push("scroll_speed_uniform");
1063
+ } else if (cv < 0.6) {
1064
+ signals.push("scroll_speed_variable");
1065
+ } else {
1066
+ signals.push("scroll_speed_erratic");
998
1067
  }
999
1068
  }
1000
- }
1001
- loadFromStorage() {
1002
- try {
1003
- const stored = localStorage.getItem(this.config.storageKey);
1004
- if (stored) {
1005
- const parsed = JSON.parse(stored);
1006
- if (Array.isArray(parsed)) {
1007
- const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
1008
- this.queue = parsed.filter((e) => e.timestamp > cutoff);
1009
- }
1069
+ if (this.data.mouseEvents.length === 0) {
1070
+ signals.push("mouse_movement_none");
1071
+ } else if (this.data.mouseEvents.length >= 10) {
1072
+ const n = Math.min(this.data.mouseEvents.length, 20);
1073
+ const recentMouse = this.data.mouseEvents.slice(-n);
1074
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
1075
+ for (const event of recentMouse) {
1076
+ sumX += event.x;
1077
+ sumY += event.y;
1078
+ sumXY += event.x * event.y;
1079
+ sumX2 += event.x * event.x;
1010
1080
  }
1011
- } catch {
1012
- }
1013
- }
1014
- saveToStorage() {
1015
- try {
1016
- if (this.queue.length > 0) {
1017
- localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1081
+ const denominator = n * sumX2 - sumX * sumX;
1082
+ const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
1083
+ const intercept = (sumY - slope * sumX) / n;
1084
+ let ssRes = 0, ssTot = 0;
1085
+ const yMean = sumY / n;
1086
+ for (const event of recentMouse) {
1087
+ const yPred = slope * event.x + intercept;
1088
+ ssRes += Math.pow(event.y - yPred, 2);
1089
+ ssTot += Math.pow(event.y - yMean, 2);
1090
+ }
1091
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
1092
+ if (r2 > 0.95) {
1093
+ signals.push("mouse_movement_linear");
1018
1094
  } else {
1019
- this.clearStorage();
1095
+ signals.push("mouse_movement_curved");
1020
1096
  }
1021
- } catch {
1022
- }
1023
- }
1024
- clearStorage() {
1025
- try {
1026
- localStorage.removeItem(this.config.storageKey);
1027
- } catch {
1028
1097
  }
1029
- }
1030
- generateId() {
1031
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1032
- }
1033
- };
1034
-
1035
- // src/infrastructure/ping.ts
1036
- var PingService = class {
1037
- constructor(sessionId2, visitorId2, version, config2 = {}) {
1038
- this.intervalId = null;
1039
- this.isVisible = true;
1040
- this.currentScrollDepth = 0;
1041
- this.ping = async () => {
1042
- const data = this.getData();
1043
- this.config.onPing?.(data);
1044
- if (this.config.endpoint) {
1045
- try {
1046
- await fetch(this.config.endpoint, {
1047
- method: "POST",
1048
- headers: { "Content-Type": "application/json" },
1049
- body: JSON.stringify(data)
1050
- });
1051
- } catch {
1052
- }
1098
+ const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
1099
+ if (completedForms.length > 0) {
1100
+ const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
1101
+ if (avgFillTime < 100) {
1102
+ signals.push("form_fill_instant");
1103
+ } else if (avgFillTime < 500) {
1104
+ signals.push("form_fill_fast");
1105
+ } else {
1106
+ signals.push("form_fill_normal");
1053
1107
  }
1054
- };
1055
- this.handleVisibilityChange = () => {
1056
- this.isVisible = document.visibilityState === "visible";
1057
- };
1058
- this.handleScroll = () => {
1059
- const scrollPercent = Math.round(
1060
- (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1061
- );
1062
- if (scrollPercent > this.currentScrollDepth) {
1063
- this.currentScrollDepth = Math.min(scrollPercent, 100);
1108
+ }
1109
+ if (this.data.focusBlurEvents.length >= 4) {
1110
+ const recentFB = this.data.focusBlurEvents.slice(-10);
1111
+ const intervals = [];
1112
+ for (let i = 1; i < recentFB.length; i++) {
1113
+ intervals.push(recentFB[i].time - recentFB[i - 1].time);
1064
1114
  }
1065
- };
1066
- this.sessionId = sessionId2;
1067
- this.visitorId = visitorId2;
1068
- this.version = version;
1069
- this.pageLoadTime = Date.now();
1070
- this.config = {
1071
- interval: DEFAULT_CONFIG.pingInterval,
1072
- endpoint: "",
1073
- ...config2
1074
- };
1075
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
1076
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1077
- }
1078
- /**
1079
- * Start the ping service
1080
- */
1081
- start() {
1082
- if (this.intervalId) return;
1083
- this.intervalId = setInterval(() => {
1084
- if (this.isVisible) {
1085
- this.ping();
1115
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1116
+ if (avgInterval < 1e3) {
1117
+ signals.push("focus_blur_rapid");
1118
+ } else {
1119
+ signals.push("focus_blur_normal");
1086
1120
  }
1087
- }, this.config.interval);
1088
- this.ping();
1121
+ }
1122
+ return signals;
1089
1123
  }
1090
1124
  /**
1091
- * Stop the ping service
1125
+ * Add context signals (set by tracker from external data)
1092
1126
  */
1093
- stop() {
1094
- if (this.intervalId) {
1095
- clearInterval(this.intervalId);
1096
- this.intervalId = null;
1097
- }
1098
- document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1099
- window.removeEventListener("scroll", this.handleScroll);
1127
+ addContextSignal(_signal) {
1100
1128
  }
1101
1129
  /**
1102
- * Update scroll depth (called by external scroll tracker)
1130
+ * Get current result (null if not yet classified)
1103
1131
  */
1104
- updateScrollDepth(depth) {
1105
- if (depth > this.currentScrollDepth) {
1106
- this.currentScrollDepth = depth;
1107
- }
1132
+ getResult() {
1133
+ return this.result;
1108
1134
  }
1109
1135
  /**
1110
- * Get current ping data
1136
+ * Check if classification has been performed
1111
1137
  */
1112
- getData() {
1113
- return {
1114
- session_id: this.sessionId,
1115
- visitor_id: this.visitorId,
1116
- url: window.location.href,
1117
- time_on_page_ms: Date.now() - this.pageLoadTime,
1118
- scroll_depth: this.currentScrollDepth,
1119
- is_active: this.isVisible,
1120
- tracker_version: this.version
1121
- };
1138
+ hasClassified() {
1139
+ return this.classified;
1122
1140
  }
1123
1141
  };
1124
1142
 
1125
- // src/behavioral/scroll-tracker.ts
1126
- var DEFAULT_CHUNKS = [30, 60, 90, 100];
1127
- var ScrollTracker = class {
1128
- constructor(config2 = {}) {
1129
- this.maxDepth = 0;
1130
- this.reportedChunks = /* @__PURE__ */ new Set();
1131
- this.ticking = false;
1132
- this.isVisible = true;
1133
- this.handleScroll = () => {
1134
- if (!this.ticking && this.isVisible) {
1135
- requestAnimationFrame(() => {
1136
- this.checkScrollDepth();
1137
- this.ticking = false;
1138
- });
1139
- this.ticking = true;
1143
+ // src/detection/focus-blur.ts
1144
+ var FocusBlurAnalyzer = class {
1145
+ constructor() {
1146
+ this.sequence = [];
1147
+ this.firstInteractionTime = null;
1148
+ this.analyzed = false;
1149
+ this.result = null;
1150
+ this.pageLoadTime = performance.now();
1151
+ }
1152
+ /**
1153
+ * Initialize event tracking
1154
+ * Must be called after DOM is ready
1155
+ */
1156
+ initTracking() {
1157
+ document.addEventListener("focus", (e) => {
1158
+ this.recordEvent("focus", e.target);
1159
+ }, true);
1160
+ document.addEventListener("blur", (e) => {
1161
+ this.recordEvent("blur", e.target);
1162
+ }, true);
1163
+ window.addEventListener("focus", () => {
1164
+ this.recordEvent("window_focus", null);
1165
+ });
1166
+ window.addEventListener("blur", () => {
1167
+ this.recordEvent("window_blur", null);
1168
+ });
1169
+ const recordFirstInteraction = () => {
1170
+ if (this.firstInteractionTime === null) {
1171
+ this.firstInteractionTime = performance.now();
1140
1172
  }
1141
1173
  };
1142
- this.handleVisibility = () => {
1143
- this.isVisible = document.visibilityState === "visible";
1144
- };
1145
- this.config = {
1146
- chunks: DEFAULT_CHUNKS,
1147
- ...config2
1148
- };
1149
- this.startTime = Date.now();
1174
+ document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
1175
+ document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
1150
1176
  }
1151
1177
  /**
1152
- * Start tracking scroll depth
1178
+ * Record a focus/blur event
1153
1179
  */
1154
- start() {
1155
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1156
- document.addEventListener("visibilitychange", this.handleVisibility);
1157
- this.checkScrollDepth();
1180
+ recordEvent(type, target) {
1181
+ const event = {
1182
+ type,
1183
+ target: target?.tagName || "WINDOW",
1184
+ timestamp: performance.now()
1185
+ };
1186
+ this.sequence.push(event);
1187
+ if (this.sequence.length > 20) {
1188
+ this.sequence = this.sequence.slice(-20);
1189
+ }
1158
1190
  }
1159
1191
  /**
1160
- * Stop tracking
1192
+ * Analyze the focus/blur sequence for paste patterns
1161
1193
  */
1162
- stop() {
1163
- window.removeEventListener("scroll", this.handleScroll);
1164
- document.removeEventListener("visibilitychange", this.handleVisibility);
1194
+ analyze() {
1195
+ if (this.analyzed && this.result) {
1196
+ return this.result;
1197
+ }
1198
+ const signals = [];
1199
+ let confidence = 0;
1200
+ const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
1201
+ const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
1202
+ if (hasEarlyWindowFocus) {
1203
+ signals.push("early_window_focus");
1204
+ confidence += 0.15;
1205
+ }
1206
+ const hasEarlyBodyFocus = earlyEvents.some(
1207
+ (e) => e.type === "focus" && e.target === "BODY"
1208
+ );
1209
+ if (hasEarlyBodyFocus) {
1210
+ signals.push("early_body_focus");
1211
+ confidence += 0.15;
1212
+ }
1213
+ const hasLinkFocus = this.sequence.some(
1214
+ (e) => e.type === "focus" && e.target === "A"
1215
+ );
1216
+ if (!hasLinkFocus) {
1217
+ signals.push("no_link_focus");
1218
+ confidence += 0.1;
1219
+ }
1220
+ const firstFocus = this.sequence.find((e) => e.type === "focus");
1221
+ if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
1222
+ signals.push("first_focus_body");
1223
+ confidence += 0.1;
1224
+ }
1225
+ const windowEvents = this.sequence.filter(
1226
+ (e) => e.type === "window_focus" || e.type === "window_blur"
1227
+ );
1228
+ if (windowEvents.length <= 2) {
1229
+ signals.push("minimal_window_switches");
1230
+ confidence += 0.05;
1231
+ }
1232
+ if (this.firstInteractionTime !== null) {
1233
+ const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
1234
+ if (timeToInteraction > 3e3) {
1235
+ signals.push("delayed_first_interaction");
1236
+ confidence += 0.1;
1237
+ }
1238
+ }
1239
+ confidence = Math.min(confidence, 0.65);
1240
+ let navType;
1241
+ if (confidence >= 0.35) {
1242
+ navType = "likely_paste";
1243
+ } else if (signals.length === 0) {
1244
+ navType = "unknown";
1245
+ } else {
1246
+ navType = "likely_click";
1247
+ }
1248
+ this.result = {
1249
+ nav_type: navType,
1250
+ confidence,
1251
+ signals,
1252
+ sequence: this.sequence.slice(-10),
1253
+ time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
1254
+ };
1255
+ this.analyzed = true;
1256
+ return this.result;
1257
+ }
1258
+ /**
1259
+ * Get current result (analyze if not done)
1260
+ */
1261
+ getResult() {
1262
+ return this.analyze();
1165
1263
  }
1166
1264
  /**
1167
- * Get current max scroll depth
1265
+ * Check if analysis has been performed
1168
1266
  */
1169
- getMaxDepth() {
1170
- return this.maxDepth;
1267
+ hasAnalyzed() {
1268
+ return this.analyzed;
1171
1269
  }
1172
1270
  /**
1173
- * Get reported chunks
1271
+ * Get the raw sequence for debugging
1174
1272
  */
1175
- getReportedChunks() {
1176
- return Array.from(this.reportedChunks).sort((a, b) => a - b);
1273
+ getSequence() {
1274
+ return [...this.sequence];
1177
1275
  }
1178
1276
  /**
1179
- * Get final scroll event (for unload)
1277
+ * Reset the analyzer
1180
1278
  */
1181
- getFinalEvent() {
1182
- const docHeight = document.documentElement.scrollHeight;
1183
- const viewportHeight = window.innerHeight;
1279
+ reset() {
1280
+ this.sequence = [];
1281
+ this.pageLoadTime = performance.now();
1282
+ this.firstInteractionTime = null;
1283
+ this.analyzed = false;
1284
+ this.result = null;
1285
+ }
1286
+ };
1287
+
1288
+ // src/detection/navigation-timing.ts
1289
+ function detectNavigationType() {
1290
+ try {
1291
+ const entries = performance.getEntriesByType("navigation");
1292
+ if (!entries || entries.length === 0) {
1293
+ return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
1294
+ }
1295
+ const nav = entries[0];
1296
+ const signals = [];
1297
+ let pasteScore = 0;
1298
+ const fetchStartDelta = nav.fetchStart - nav.startTime;
1299
+ if (fetchStartDelta < 5) {
1300
+ pasteScore += 0.25;
1301
+ signals.push("instant_fetch_start");
1302
+ } else if (fetchStartDelta < 20) {
1303
+ pasteScore += 0.15;
1304
+ signals.push("fast_fetch_start");
1305
+ }
1306
+ const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
1307
+ if (dnsTime === 0) {
1308
+ pasteScore += 0.15;
1309
+ signals.push("no_dns_lookup");
1310
+ }
1311
+ const connectTime = nav.connectEnd - nav.connectStart;
1312
+ if (connectTime === 0) {
1313
+ pasteScore += 0.15;
1314
+ signals.push("no_tcp_connect");
1315
+ }
1316
+ if (nav.redirectCount === 0) {
1317
+ pasteScore += 0.1;
1318
+ signals.push("no_redirects");
1319
+ }
1320
+ const timingVariance = calculateTimingVariance(nav);
1321
+ if (timingVariance < 10) {
1322
+ pasteScore += 0.15;
1323
+ signals.push("uniform_timing");
1324
+ }
1325
+ if (!document.referrer || document.referrer === "") {
1326
+ pasteScore += 0.1;
1327
+ signals.push("no_referrer");
1328
+ }
1329
+ const confidence = Math.min(pasteScore, 1);
1330
+ const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
1184
1331
  return {
1185
- depth: this.maxDepth,
1186
- chunk: this.getChunkForDepth(this.maxDepth),
1187
- time_to_reach_ms: Date.now() - this.startTime,
1188
- total_height: docHeight,
1189
- viewport_height: viewportHeight
1332
+ nav_type,
1333
+ confidence: Math.round(confidence * 1e3) / 1e3,
1334
+ signals
1190
1335
  };
1336
+ } catch {
1337
+ return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
1191
1338
  }
1192
- checkScrollDepth() {
1193
- const scrollY = window.scrollY;
1194
- const viewportHeight = window.innerHeight;
1195
- const docHeight = document.documentElement.scrollHeight;
1196
- if (docHeight <= viewportHeight) {
1197
- this.updateDepth(100);
1198
- return;
1199
- }
1200
- const scrollableHeight = docHeight - viewportHeight;
1201
- const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
1202
- this.updateDepth(currentDepth);
1339
+ }
1340
+ function calculateTimingVariance(nav) {
1341
+ const timings = [
1342
+ nav.fetchStart - nav.startTime,
1343
+ nav.domainLookupEnd - nav.domainLookupStart,
1344
+ nav.connectEnd - nav.connectStart,
1345
+ nav.responseStart - nav.requestStart
1346
+ ].filter((t) => t >= 0);
1347
+ if (timings.length === 0) return 100;
1348
+ const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
1349
+ const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
1350
+ return Math.sqrt(variance);
1351
+ }
1352
+
1353
+ // src/detection/referrer.ts
1354
+ function detectAIFromReferrer(referrer) {
1355
+ if (!referrer) {
1356
+ return null;
1203
1357
  }
1204
- updateDepth(depth) {
1205
- if (depth <= this.maxDepth) return;
1206
- this.maxDepth = depth;
1207
- this.config.onDepthChange?.(depth);
1208
- for (const chunk of this.config.chunks) {
1209
- if (depth >= chunk && !this.reportedChunks.has(chunk)) {
1210
- this.reportedChunks.add(chunk);
1211
- this.reportChunk(chunk);
1358
+ try {
1359
+ const url = new URL(referrer);
1360
+ const hostname = url.hostname.toLowerCase();
1361
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1362
+ if (hostname.includes(pattern) || referrer.includes(pattern)) {
1363
+ return {
1364
+ isAI: true,
1365
+ platform,
1366
+ confidence: 0.95,
1367
+ // High confidence when referrer matches
1368
+ method: "referrer"
1369
+ };
1212
1370
  }
1213
1371
  }
1372
+ return null;
1373
+ } catch {
1374
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1375
+ if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
1376
+ return {
1377
+ isAI: true,
1378
+ platform,
1379
+ confidence: 0.85,
1380
+ method: "referrer"
1381
+ };
1382
+ }
1383
+ }
1384
+ return null;
1214
1385
  }
1215
- reportChunk(chunk) {
1216
- const docHeight = document.documentElement.scrollHeight;
1217
- const viewportHeight = window.innerHeight;
1218
- const event = {
1219
- depth: this.maxDepth,
1220
- chunk,
1221
- time_to_reach_ms: Date.now() - this.startTime,
1222
- total_height: docHeight,
1223
- viewport_height: viewportHeight
1224
- };
1225
- this.config.onChunkReached?.(event);
1226
- }
1227
- getChunkForDepth(depth) {
1228
- const chunks = this.config.chunks.sort((a, b) => b - a);
1229
- for (const chunk of chunks) {
1230
- if (depth >= chunk) return chunk;
1386
+ }
1387
+ function detectAIFromUTM(url) {
1388
+ try {
1389
+ const params = new URL(url).searchParams;
1390
+ const utmSource = params.get("utm_source")?.toLowerCase();
1391
+ if (utmSource) {
1392
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1393
+ if (utmSource.includes(pattern.split(".")[0])) {
1394
+ return {
1395
+ isAI: true,
1396
+ platform,
1397
+ confidence: 0.99,
1398
+ // Very high confidence from explicit UTM
1399
+ method: "referrer"
1400
+ };
1401
+ }
1402
+ }
1403
+ if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
1404
+ return {
1405
+ isAI: true,
1406
+ platform: utmSource,
1407
+ confidence: 0.9,
1408
+ method: "referrer"
1409
+ };
1410
+ }
1231
1411
  }
1232
- return 0;
1412
+ return null;
1413
+ } catch {
1414
+ return null;
1233
1415
  }
1234
- };
1416
+ }
1235
1417
 
1236
- // src/behavioral/time-tracker.ts
1237
- var DEFAULT_CONFIG2 = {
1238
- idleThresholdMs: 3e4,
1239
- // 30 seconds
1240
- updateIntervalMs: 5e3
1241
- // 5 seconds
1418
+ // src/infrastructure/event-queue.ts
1419
+ var DEFAULT_QUEUE_CONFIG = {
1420
+ batchSize: DEFAULT_CONFIG3.batchSize,
1421
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1422
+ maxRetries: 3,
1423
+ retryDelayMs: 1e3,
1424
+ storageKey: "_loamly_queue"
1242
1425
  };
1243
- var TimeTracker = class {
1244
- constructor(config2 = {}) {
1245
- this.activeTime = 0;
1246
- this.idleTime = 0;
1247
- this.isVisible = true;
1248
- this.isIdle = false;
1249
- this.updateInterval = null;
1250
- this.idleCheckInterval = null;
1251
- this.handleVisibility = () => {
1252
- const wasVisible = this.isVisible;
1253
- this.isVisible = document.visibilityState === "visible";
1254
- if (wasVisible && !this.isVisible) {
1255
- this.updateTimes();
1256
- } else if (!wasVisible && this.isVisible) {
1257
- this.lastUpdateTime = Date.now();
1258
- this.lastActivityTime = Date.now();
1259
- }
1260
- };
1261
- this.handleActivity = () => {
1262
- const now = Date.now();
1263
- if (this.isIdle) {
1264
- this.isIdle = false;
1265
- }
1266
- this.lastActivityTime = now;
1267
- };
1268
- this.config = { ...DEFAULT_CONFIG2, ...config2 };
1269
- this.startTime = Date.now();
1270
- this.lastActivityTime = this.startTime;
1271
- this.lastUpdateTime = this.startTime;
1426
+ var EventQueue = class {
1427
+ constructor(endpoint2, config2 = {}) {
1428
+ this.queue = [];
1429
+ this.batchTimer = null;
1430
+ this.isFlushing = false;
1431
+ this.endpoint = endpoint2;
1432
+ this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
1433
+ this.loadFromStorage();
1272
1434
  }
1273
1435
  /**
1274
- * Start tracking time
1436
+ * Add event to queue
1275
1437
  */
1276
- start() {
1277
- document.addEventListener("visibilitychange", this.handleVisibility);
1278
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1279
- activityEvents.forEach((event) => {
1280
- document.addEventListener(event, this.handleActivity, { passive: true });
1281
- });
1282
- this.updateInterval = setInterval(() => {
1283
- this.update();
1284
- }, this.config.updateIntervalMs);
1285
- this.idleCheckInterval = setInterval(() => {
1286
- this.checkIdle();
1287
- }, 1e3);
1438
+ push(type, payload, headers) {
1439
+ const event = {
1440
+ id: this.generateId(),
1441
+ type,
1442
+ payload,
1443
+ headers,
1444
+ timestamp: Date.now(),
1445
+ retries: 0
1446
+ };
1447
+ this.queue.push(event);
1448
+ this.saveToStorage();
1449
+ this.scheduleBatch();
1288
1450
  }
1289
1451
  /**
1290
- * Stop tracking
1452
+ * Force flush all events immediately
1291
1453
  */
1292
- stop() {
1293
- document.removeEventListener("visibilitychange", this.handleVisibility);
1294
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1295
- activityEvents.forEach((event) => {
1296
- document.removeEventListener(event, this.handleActivity);
1297
- });
1298
- if (this.updateInterval) {
1299
- clearInterval(this.updateInterval);
1300
- this.updateInterval = null;
1301
- }
1302
- if (this.idleCheckInterval) {
1303
- clearInterval(this.idleCheckInterval);
1304
- this.idleCheckInterval = null;
1454
+ async flush() {
1455
+ if (this.isFlushing || this.queue.length === 0) return;
1456
+ this.isFlushing = true;
1457
+ this.clearBatchTimer();
1458
+ try {
1459
+ const events = [...this.queue];
1460
+ this.queue = [];
1461
+ await this.sendBatch(events);
1462
+ } finally {
1463
+ this.isFlushing = false;
1464
+ this.saveToStorage();
1305
1465
  }
1306
1466
  }
1307
1467
  /**
1308
- * Get current time metrics
1468
+ * Flush using sendBeacon (for unload events)
1309
1469
  */
1310
- getMetrics() {
1311
- this.updateTimes();
1312
- return {
1313
- active_time_ms: this.activeTime,
1314
- total_time_ms: Date.now() - this.startTime,
1315
- idle_time_ms: this.idleTime,
1316
- is_engaged: !this.isIdle && this.isVisible
1317
- };
1470
+ flushBeacon() {
1471
+ if (this.queue.length === 0) return true;
1472
+ const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
1473
+ let allSent = true;
1474
+ for (const event of this.queue) {
1475
+ const payload = {
1476
+ ...event.payload,
1477
+ _queue_id: event.id,
1478
+ _queue_timestamp: event.timestamp
1479
+ };
1480
+ const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
1481
+ if (!success) {
1482
+ allSent = false;
1483
+ break;
1484
+ }
1485
+ }
1486
+ if (allSent) {
1487
+ this.queue = [];
1488
+ this.clearStorage();
1489
+ }
1490
+ return allSent;
1318
1491
  }
1319
1492
  /**
1320
- * Get final metrics (for unload)
1493
+ * Get current queue length
1321
1494
  */
1322
- getFinalMetrics() {
1323
- this.updateTimes();
1324
- return this.getMetrics();
1495
+ get length() {
1496
+ return this.queue.length;
1325
1497
  }
1326
- checkIdle() {
1327
- const now = Date.now();
1328
- const timeSinceActivity = now - this.lastActivityTime;
1329
- if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
1330
- this.isIdle = true;
1498
+ scheduleBatch() {
1499
+ if (this.batchTimer) return;
1500
+ if (this.queue.length >= this.config.batchSize) {
1501
+ this.flush();
1502
+ return;
1331
1503
  }
1504
+ this.batchTimer = setTimeout(() => {
1505
+ this.batchTimer = null;
1506
+ this.flush();
1507
+ }, this.config.batchTimeout);
1332
1508
  }
1333
- updateTimes() {
1334
- const now = Date.now();
1335
- const elapsed = now - this.lastUpdateTime;
1336
- if (this.isVisible) {
1337
- if (this.isIdle) {
1338
- this.idleTime += elapsed;
1509
+ clearBatchTimer() {
1510
+ if (this.batchTimer) {
1511
+ clearTimeout(this.batchTimer);
1512
+ this.batchTimer = null;
1513
+ }
1514
+ }
1515
+ async sendBatch(events) {
1516
+ if (events.length === 0) return;
1517
+ try {
1518
+ const results = await Promise.allSettled(
1519
+ events.map(async (event) => {
1520
+ const response = await fetch(this.endpoint, {
1521
+ method: "POST",
1522
+ headers: {
1523
+ "Content-Type": "application/json",
1524
+ ...event.headers || {}
1525
+ },
1526
+ body: JSON.stringify({
1527
+ ...event.payload,
1528
+ _queue_id: event.id,
1529
+ _queue_timestamp: event.timestamp
1530
+ })
1531
+ });
1532
+ if (!response.ok) {
1533
+ throw new Error(`HTTP ${response.status}`);
1534
+ }
1535
+ })
1536
+ );
1537
+ const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
1538
+ if (failedEvents.length > 0) {
1539
+ for (const event of failedEvents) {
1540
+ if (event.retries < this.config.maxRetries) {
1541
+ event.retries++;
1542
+ this.queue.push(event);
1543
+ }
1544
+ }
1545
+ const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
1546
+ setTimeout(() => this.flush(), delay);
1547
+ }
1548
+ return;
1549
+ } catch (error) {
1550
+ for (const event of events) {
1551
+ if (event.retries < this.config.maxRetries) {
1552
+ event.retries++;
1553
+ this.queue.push(event);
1554
+ }
1555
+ }
1556
+ if (this.queue.length > 0) {
1557
+ const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
1558
+ setTimeout(() => this.flush(), delay);
1559
+ }
1560
+ }
1561
+ }
1562
+ loadFromStorage() {
1563
+ try {
1564
+ const stored = localStorage.getItem(this.config.storageKey);
1565
+ if (stored) {
1566
+ const parsed = JSON.parse(stored);
1567
+ if (Array.isArray(parsed)) {
1568
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
1569
+ this.queue = parsed.filter((e) => e.timestamp > cutoff);
1570
+ }
1571
+ }
1572
+ } catch {
1573
+ }
1574
+ }
1575
+ saveToStorage() {
1576
+ try {
1577
+ if (this.queue.length > 0) {
1578
+ localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1339
1579
  } else {
1340
- this.activeTime += elapsed;
1580
+ this.clearStorage();
1341
1581
  }
1582
+ } catch {
1342
1583
  }
1343
- this.lastUpdateTime = now;
1344
1584
  }
1345
- update() {
1346
- if (!this.isVisible) return;
1347
- this.updateTimes();
1348
- this.config.onUpdate?.(this.getMetrics());
1585
+ clearStorage() {
1586
+ try {
1587
+ localStorage.removeItem(this.config.storageKey);
1588
+ } catch {
1589
+ }
1590
+ }
1591
+ generateId() {
1592
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1349
1593
  }
1350
1594
  };
1351
1595
 
1352
- // src/behavioral/form-tracker.ts
1353
- var DEFAULT_CONFIG3 = {
1354
- sensitiveFields: [
1355
- "password",
1356
- "pwd",
1357
- "pass",
1358
- "credit",
1359
- "card",
1360
- "cvv",
1361
- "cvc",
1362
- "ssn",
1363
- "social",
1364
- "secret",
1365
- "token",
1366
- "key"
1367
- ],
1368
- trackableFields: [
1369
- "email",
1370
- "name",
1371
- "phone",
1372
- "company",
1373
- "first",
1374
- "last",
1375
- "city",
1376
- "country"
1377
- ],
1378
- thankYouPatterns: [
1379
- /thank[-_]?you/i,
1380
- /success/i,
1381
- /confirmation/i,
1382
- /submitted/i,
1383
- /complete/i
1384
- ]
1385
- };
1386
- var FormTracker = class {
1387
- constructor(config2 = {}) {
1388
- this.formStartTimes = /* @__PURE__ */ new Map();
1389
- this.interactedForms = /* @__PURE__ */ new Set();
1390
- this.mutationObserver = null;
1391
- this.handleFocusIn = (e) => {
1392
- const target = e.target;
1393
- if (!this.isFormField(target)) return;
1394
- const form = target.closest("form");
1395
- const formId = this.getFormId(form || target);
1396
- if (!this.formStartTimes.has(formId)) {
1397
- this.formStartTimes.set(formId, Date.now());
1398
- this.interactedForms.add(formId);
1399
- this.emitEvent({
1400
- event_type: "form_start",
1401
- form_id: formId,
1402
- form_type: this.detectFormType(form || target)
1403
- });
1404
- }
1405
- const fieldName = this.getFieldName(target);
1406
- if (fieldName && !this.isSensitiveField(fieldName)) {
1407
- this.emitEvent({
1408
- event_type: "form_field",
1409
- form_id: formId,
1410
- form_type: this.detectFormType(form || target),
1411
- field_name: this.sanitizeFieldName(fieldName),
1412
- field_type: target.type || target.tagName.toLowerCase()
1413
- });
1414
- }
1415
- };
1416
- this.handleSubmit = (e) => {
1417
- const form = e.target;
1418
- if (!form || form.tagName !== "FORM") return;
1419
- const formId = this.getFormId(form);
1420
- const startTime = this.formStartTimes.get(formId);
1421
- this.emitEvent({
1422
- event_type: "form_submit",
1423
- form_id: formId,
1424
- form_type: this.detectFormType(form),
1425
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1426
- is_conversion: true
1427
- });
1428
- };
1429
- this.handleClick = (e) => {
1430
- const target = e.target;
1431
- if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
1432
- const form = target.closest("form");
1433
- if (form && form.classList.contains("hs-form")) {
1434
- const formId = this.getFormId(form);
1435
- const startTime = this.formStartTimes.get(formId);
1436
- this.emitEvent({
1437
- event_type: "form_submit",
1438
- form_id: formId,
1439
- form_type: "hubspot",
1440
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1441
- is_conversion: true
1596
+ // src/infrastructure/ping.ts
1597
+ var PingService = class {
1598
+ constructor(sessionId2, visitorId2, version, config2 = {}) {
1599
+ this.intervalId = null;
1600
+ this.isVisible = true;
1601
+ this.isFocused = true;
1602
+ this.currentScrollDepth = 0;
1603
+ this.ping = async () => {
1604
+ const data = this.getData();
1605
+ this.config.onPing?.(data);
1606
+ if (this.config.endpoint) {
1607
+ try {
1608
+ await fetch(this.config.endpoint, {
1609
+ method: "POST",
1610
+ headers: { "Content-Type": "application/json" },
1611
+ body: JSON.stringify(data)
1442
1612
  });
1613
+ } catch {
1443
1614
  }
1444
1615
  }
1445
- if (target.closest('[data-qa="submit-button"]')) {
1446
- this.emitEvent({
1447
- event_type: "form_submit",
1448
- form_id: "typeform_embed",
1449
- form_type: "typeform",
1450
- is_conversion: true
1451
- });
1616
+ };
1617
+ this.handleVisibilityChange = () => {
1618
+ this.isVisible = document.visibilityState === "visible";
1619
+ };
1620
+ this.handleFocusChange = () => {
1621
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1622
+ if (this.intervalId && this.isVisible && this.isFocused) {
1623
+ this.ping();
1452
1624
  }
1453
1625
  };
1626
+ this.handleScroll = () => {
1627
+ const scrollPercent = Math.round(
1628
+ (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1629
+ );
1630
+ if (scrollPercent > this.currentScrollDepth) {
1631
+ this.currentScrollDepth = Math.min(scrollPercent, 100);
1632
+ }
1633
+ };
1634
+ this.sessionId = sessionId2;
1635
+ this.visitorId = visitorId2;
1636
+ this.version = version;
1637
+ this.pageLoadTime = Date.now();
1638
+ this.isVisible = document.visibilityState === "visible";
1639
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1454
1640
  this.config = {
1455
- ...DEFAULT_CONFIG3,
1456
- ...config2,
1457
- sensitiveFields: [
1458
- ...DEFAULT_CONFIG3.sensitiveFields,
1459
- ...config2.sensitiveFields || []
1460
- ]
1641
+ interval: DEFAULT_CONFIG3.pingInterval,
1642
+ endpoint: "",
1643
+ ...config2
1461
1644
  };
1645
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
1646
+ window.addEventListener("focus", this.handleFocusChange);
1647
+ window.addEventListener("blur", this.handleFocusChange);
1648
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
1462
1649
  }
1463
1650
  /**
1464
- * Start tracking forms
1651
+ * Start the ping service
1465
1652
  */
1466
1653
  start() {
1467
- document.addEventListener("focusin", this.handleFocusIn, { passive: true });
1468
- document.addEventListener("submit", this.handleSubmit);
1469
- document.addEventListener("click", this.handleClick, { passive: true });
1470
- this.startMutationObserver();
1471
- this.checkThankYouPage();
1472
- this.scanForEmbeddedForms();
1654
+ if (this.intervalId) return;
1655
+ this.intervalId = setInterval(() => {
1656
+ if (this.isVisible && this.isFocused) {
1657
+ this.ping();
1658
+ }
1659
+ }, this.config.interval);
1660
+ if (this.isVisible && this.isFocused) {
1661
+ this.ping();
1662
+ }
1473
1663
  }
1474
1664
  /**
1475
- * Stop tracking
1665
+ * Stop the ping service
1476
1666
  */
1477
1667
  stop() {
1478
- document.removeEventListener("focusin", this.handleFocusIn);
1479
- document.removeEventListener("submit", this.handleSubmit);
1480
- document.removeEventListener("click", this.handleClick);
1481
- this.mutationObserver?.disconnect();
1668
+ if (this.intervalId) {
1669
+ clearInterval(this.intervalId);
1670
+ this.intervalId = null;
1671
+ }
1672
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1673
+ window.removeEventListener("focus", this.handleFocusChange);
1674
+ window.removeEventListener("blur", this.handleFocusChange);
1675
+ window.removeEventListener("scroll", this.handleScroll);
1482
1676
  }
1483
1677
  /**
1484
- * Get forms that had interaction
1678
+ * Update scroll depth (called by external scroll tracker)
1485
1679
  */
1486
- getInteractedForms() {
1487
- return Array.from(this.interactedForms);
1488
- }
1489
- startMutationObserver() {
1490
- this.mutationObserver = new MutationObserver((mutations) => {
1491
- for (const mutation of mutations) {
1492
- for (const node of mutation.addedNodes) {
1493
- if (node instanceof HTMLElement) {
1494
- if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
1495
- this.trackEmbeddedForm(node, "hubspot");
1496
- }
1497
- if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
1498
- this.trackEmbeddedForm(node, "typeform");
1499
- }
1500
- if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
1501
- this.trackEmbeddedForm(node, "jotform");
1502
- }
1503
- if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
1504
- this.trackEmbeddedForm(node, "gravity");
1505
- }
1506
- }
1507
- }
1508
- }
1509
- });
1510
- this.mutationObserver.observe(document.body, {
1511
- childList: true,
1512
- subtree: true
1513
- });
1514
- }
1515
- scanForEmbeddedForms() {
1516
- document.querySelectorAll(".hs-form").forEach((form) => {
1517
- this.trackEmbeddedForm(form, "hubspot");
1518
- });
1519
- document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
1520
- this.trackEmbeddedForm(form, "typeform");
1521
- });
1522
- document.querySelectorAll(".jotform-form").forEach((form) => {
1523
- this.trackEmbeddedForm(form, "jotform");
1524
- });
1525
- document.querySelectorAll(".gform_wrapper").forEach((form) => {
1526
- this.trackEmbeddedForm(form, "gravity");
1527
- });
1528
- }
1529
- trackEmbeddedForm(element, type) {
1530
- const formId = `${type}_${this.getFormId(element)}`;
1531
- element.addEventListener("focusin", () => {
1532
- if (!this.formStartTimes.has(formId)) {
1533
- this.formStartTimes.set(formId, Date.now());
1534
- this.interactedForms.add(formId);
1535
- this.emitEvent({
1536
- event_type: "form_start",
1537
- form_id: formId,
1538
- form_type: type
1539
- });
1540
- }
1541
- }, { passive: true });
1542
- }
1543
- checkThankYouPage() {
1544
- const url = window.location.href.toLowerCase();
1545
- const title = document.title.toLowerCase();
1546
- for (const pattern of this.config.thankYouPatterns) {
1547
- if (pattern.test(url) || pattern.test(title)) {
1548
- this.emitEvent({
1549
- event_type: "form_success",
1550
- form_id: "page_conversion",
1551
- form_type: "unknown",
1552
- is_conversion: true
1553
- });
1554
- break;
1555
- }
1556
- }
1557
- }
1558
- isFormField(element) {
1559
- const tagName = element.tagName;
1560
- return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
1561
- }
1562
- getFormId(element) {
1563
- if (!element) return "unknown";
1564
- return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
1565
- }
1566
- getFieldName(input) {
1567
- return input.name || input.id || input.getAttribute("data-name") || "";
1568
- }
1569
- isSensitiveField(fieldName) {
1570
- const lowerName = fieldName.toLowerCase();
1571
- return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
1572
- }
1573
- sanitizeFieldName(fieldName) {
1574
- return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
1575
- }
1576
- detectFormType(element) {
1577
- if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
1578
- return "hubspot";
1579
- }
1580
- if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
1581
- return "typeform";
1582
- }
1583
- if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
1584
- return "jotform";
1585
- }
1586
- if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
1587
- return "gravity";
1588
- }
1589
- if (element.tagName === "FORM") {
1590
- return "native";
1680
+ updateScrollDepth(depth) {
1681
+ if (depth > this.currentScrollDepth) {
1682
+ this.currentScrollDepth = depth;
1591
1683
  }
1592
- return "unknown";
1593
1684
  }
1594
- emitEvent(event) {
1595
- this.config.onFormEvent?.(event);
1685
+ /**
1686
+ * Get current ping data
1687
+ */
1688
+ getData() {
1689
+ return {
1690
+ session_id: this.sessionId,
1691
+ visitor_id: this.visitorId,
1692
+ url: window.location.href,
1693
+ time_on_page_ms: Date.now() - this.pageLoadTime,
1694
+ scroll_depth: this.currentScrollDepth,
1695
+ is_active: this.isVisible && this.isFocused,
1696
+ tracker_version: this.version
1697
+ };
1596
1698
  }
1597
1699
  };
1598
1700
 
@@ -1768,13 +1870,15 @@ function sendBeacon(url, data) {
1768
1870
  }
1769
1871
 
1770
1872
  // src/core.ts
1771
- var config = { apiHost: DEFAULT_CONFIG.apiHost };
1873
+ var config = { apiHost: DEFAULT_CONFIG3.apiHost };
1772
1874
  var initialized = false;
1773
1875
  var debugMode = false;
1774
1876
  var visitorId = null;
1775
1877
  var sessionId = null;
1878
+ var workspaceId = null;
1776
1879
  var navigationTiming = null;
1777
1880
  var aiDetection = null;
1881
+ var pageStartTime = null;
1778
1882
  var behavioralClassifier = null;
1779
1883
  var behavioralMLResult = null;
1780
1884
  var focusBlurAnalyzer = null;
@@ -1794,6 +1898,28 @@ function log(...args) {
1794
1898
  function endpoint(path) {
1795
1899
  return `${config.apiHost}${path}`;
1796
1900
  }
1901
+ function buildHeaders(idempotencyKey) {
1902
+ const headers = {
1903
+ "Content-Type": "application/json"
1904
+ };
1905
+ if (config.apiKey) {
1906
+ headers["X-Loamly-Api-Key"] = config.apiKey;
1907
+ }
1908
+ if (idempotencyKey) {
1909
+ headers["X-Idempotency-Key"] = idempotencyKey;
1910
+ }
1911
+ return headers;
1912
+ }
1913
+ function buildBeaconUrl(path) {
1914
+ if (!config.apiKey) return path;
1915
+ const url = new URL(path, config.apiHost);
1916
+ url.searchParams.set("api_key", config.apiKey);
1917
+ return url.toString();
1918
+ }
1919
+ function buildIdempotencyKey(prefix) {
1920
+ const base = sessionId || visitorId || "unknown";
1921
+ return `${prefix}:${base}:${Date.now()}`;
1922
+ }
1797
1923
  function init(userConfig = {}) {
1798
1924
  if (initialized) {
1799
1925
  log("Already initialized");
@@ -1802,9 +1928,13 @@ function init(userConfig = {}) {
1802
1928
  config = {
1803
1929
  ...config,
1804
1930
  ...userConfig,
1805
- apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost
1931
+ apiHost: userConfig.apiHost || DEFAULT_CONFIG3.apiHost
1806
1932
  };
1933
+ workspaceId = userConfig.workspaceId ?? null;
1807
1934
  debugMode = userConfig.debug ?? false;
1935
+ if (config.apiKey && !workspaceId) {
1936
+ log("Workspace ID missing. Behavioral events require workspaceId.");
1937
+ }
1808
1938
  const features = {
1809
1939
  scroll: true,
1810
1940
  time: true,
@@ -1822,13 +1952,11 @@ function init(userConfig = {}) {
1822
1952
  log("Features:", features);
1823
1953
  visitorId = getVisitorId();
1824
1954
  log("Visitor ID:", visitorId);
1825
- const session = getSessionId();
1826
- sessionId = session.sessionId;
1827
- log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
1828
1955
  if (features.eventQueue) {
1829
- eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1830
- batchSize: DEFAULT_CONFIG.batchSize,
1831
- batchTimeout: DEFAULT_CONFIG.batchTimeout
1956
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG3.endpoints.behavioral), {
1957
+ batchSize: DEFAULT_CONFIG3.batchSize,
1958
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1959
+ apiKey: config.apiKey
1832
1960
  });
1833
1961
  }
1834
1962
  navigationTiming = detectNavigationType();
@@ -1838,37 +1966,40 @@ function init(userConfig = {}) {
1838
1966
  log("AI detected:", aiDetection);
1839
1967
  }
1840
1968
  initialized = true;
1841
- if (!userConfig.disableAutoPageview) {
1842
- pageview();
1843
- }
1844
- if (!userConfig.disableBehavioral) {
1845
- setupAdvancedBehavioralTracking(features);
1846
- }
1847
- if (features.behavioralML) {
1848
- behavioralClassifier = new BehavioralClassifier(1e4);
1849
- behavioralClassifier.setOnClassify(handleBehavioralClassification);
1850
- setupBehavioralMLTracking();
1851
- }
1852
- if (features.focusBlur) {
1853
- focusBlurAnalyzer = new FocusBlurAnalyzer();
1854
- focusBlurAnalyzer.initTracking();
1855
- setTimeout(() => {
1856
- if (focusBlurAnalyzer) {
1857
- handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1858
- }
1859
- }, 5e3);
1860
- }
1861
1969
  if (features.agentic) {
1862
1970
  agenticAnalyzer = new AgenticBrowserAnalyzer();
1863
1971
  agenticAnalyzer.init();
1864
1972
  }
1865
- if (features.ping && visitorId && sessionId) {
1866
- pingService = new PingService(sessionId, visitorId, VERSION, {
1867
- interval: DEFAULT_CONFIG.pingInterval,
1868
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1869
- });
1870
- pingService.start();
1871
- }
1973
+ void initializeSession().finally(() => {
1974
+ void registerServiceWorker();
1975
+ if (!userConfig.disableAutoPageview) {
1976
+ pageview();
1977
+ }
1978
+ if (!userConfig.disableBehavioral) {
1979
+ setupAdvancedBehavioralTracking(features);
1980
+ }
1981
+ if (features.behavioralML) {
1982
+ behavioralClassifier = new BehavioralClassifier(1e4);
1983
+ behavioralClassifier.setOnClassify(handleBehavioralClassification);
1984
+ setupBehavioralMLTracking();
1985
+ }
1986
+ if (features.focusBlur) {
1987
+ focusBlurAnalyzer = new FocusBlurAnalyzer();
1988
+ focusBlurAnalyzer.initTracking();
1989
+ setTimeout(() => {
1990
+ if (focusBlurAnalyzer) {
1991
+ handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1992
+ }
1993
+ }, 5e3);
1994
+ }
1995
+ if (features.ping && visitorId && sessionId) {
1996
+ pingService = new PingService(sessionId, visitorId, VERSION, {
1997
+ interval: DEFAULT_CONFIG3.pingInterval,
1998
+ endpoint: endpoint(DEFAULT_CONFIG3.endpoints.ping)
1999
+ });
2000
+ pingService.start();
2001
+ }
2002
+ });
1872
2003
  spaRouter = new SPARouter({
1873
2004
  onNavigate: handleSPANavigation
1874
2005
  });
@@ -1877,6 +2008,78 @@ function init(userConfig = {}) {
1877
2008
  reportHealth("initialized");
1878
2009
  log("Initialization complete");
1879
2010
  }
2011
+ async function registerServiceWorker() {
2012
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
2013
+ if (!config.apiKey || !workspaceId) return;
2014
+ try {
2015
+ const swUrl = new URL("/tracker/loamly-sw.js", window.location.origin);
2016
+ swUrl.searchParams.set("workspace_id", workspaceId);
2017
+ swUrl.searchParams.set("api_key", config.apiKey);
2018
+ const registration = await navigator.serviceWorker.register(swUrl.toString(), { scope: "/" });
2019
+ registration.addEventListener("updatefound", () => {
2020
+ const installing = registration.installing;
2021
+ installing?.addEventListener("statechange", () => {
2022
+ if (installing.state === "activated") {
2023
+ installing.postMessage({ type: "SKIP_WAITING" });
2024
+ }
2025
+ });
2026
+ });
2027
+ setInterval(() => {
2028
+ registration.update().catch(() => {
2029
+ });
2030
+ }, 24 * 60 * 60 * 1e3);
2031
+ } catch {
2032
+ }
2033
+ }
2034
+ async function initializeSession() {
2035
+ const now = Date.now();
2036
+ pageStartTime = now;
2037
+ try {
2038
+ const storedSession = sessionStorage.getItem("loamly_session");
2039
+ const storedStart = sessionStorage.getItem("loamly_start");
2040
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG3.sessionTimeout;
2041
+ if (storedSession && storedStart) {
2042
+ const startTime = parseInt(storedStart, 10);
2043
+ const elapsed = now - startTime;
2044
+ if (elapsed > 0 && elapsed < sessionTimeout) {
2045
+ sessionId = storedSession;
2046
+ log("Session ID:", sessionId, "(existing)");
2047
+ return;
2048
+ }
2049
+ }
2050
+ } catch {
2051
+ }
2052
+ if (config.apiKey && workspaceId && visitorId) {
2053
+ try {
2054
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.session), {
2055
+ method: "POST",
2056
+ headers: buildHeaders(),
2057
+ body: JSON.stringify({
2058
+ workspace_id: workspaceId,
2059
+ visitor_id: visitorId
2060
+ })
2061
+ });
2062
+ if (response?.ok) {
2063
+ const data = await response.json();
2064
+ sessionId = data.session_id || sessionId;
2065
+ const startTime = data.start_time || now;
2066
+ if (sessionId) {
2067
+ try {
2068
+ sessionStorage.setItem("loamly_session", sessionId);
2069
+ sessionStorage.setItem("loamly_start", String(startTime));
2070
+ } catch {
2071
+ }
2072
+ log("Session ID:", sessionId, "(server)");
2073
+ return;
2074
+ }
2075
+ }
2076
+ } catch {
2077
+ }
2078
+ }
2079
+ const session = getSessionId();
2080
+ sessionId = session.sessionId;
2081
+ log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
2082
+ }
1880
2083
  function setupAdvancedBehavioralTracking(features) {
1881
2084
  if (features.scroll) {
1882
2085
  scrollTracker = new ScrollTracker({
@@ -1884,8 +2087,8 @@ function setupAdvancedBehavioralTracking(features) {
1884
2087
  onChunkReached: (event) => {
1885
2088
  log("Scroll chunk:", event.chunk);
1886
2089
  queueEvent("scroll_depth", {
1887
- depth: event.depth,
1888
- chunk: event.chunk,
2090
+ scroll_depth: Math.round(event.depth / 100 * 100) / 100,
2091
+ milestone: Math.round(event.chunk / 100 * 100) / 100,
1889
2092
  time_to_reach_ms: event.time_to_reach_ms
1890
2093
  });
1891
2094
  }
@@ -1897,12 +2100,10 @@ function setupAdvancedBehavioralTracking(features) {
1897
2100
  updateIntervalMs: 1e4,
1898
2101
  // Report every 10 seconds
1899
2102
  onUpdate: (event) => {
1900
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
2103
+ if (event.active_time_ms >= DEFAULT_CONFIG3.timeSpentThresholdMs) {
1901
2104
  queueEvent("time_spent", {
1902
- active_time_ms: event.active_time_ms,
1903
- total_time_ms: event.total_time_ms,
1904
- idle_time_ms: event.idle_time_ms,
1905
- is_engaged: event.is_engaged
2105
+ visible_time_ms: event.total_time_ms,
2106
+ page_start_time: pageStartTime || Date.now()
1906
2107
  });
1907
2108
  }
1908
2109
  }
@@ -1913,13 +2114,22 @@ function setupAdvancedBehavioralTracking(features) {
1913
2114
  formTracker = new FormTracker({
1914
2115
  onFormEvent: (event) => {
1915
2116
  log("Form event:", event.event_type, event.form_id);
1916
- queueEvent(event.event_type, {
2117
+ const isSubmitEvent = event.event_type === "form_submit";
2118
+ const isSuccessEvent = event.event_type === "form_success";
2119
+ const normalizedEventType = isSubmitEvent || isSuccessEvent ? "form_submit" : "form_focus";
2120
+ const submitSource = event.submit_source || (isSuccessEvent ? "thank_you" : isSubmitEvent ? "submit" : null);
2121
+ queueEvent(normalizedEventType, {
1917
2122
  form_id: event.form_id,
1918
- form_type: event.form_type,
1919
- field_name: event.field_name,
1920
- field_type: event.field_type,
1921
- time_to_submit_ms: event.time_to_submit_ms,
1922
- is_conversion: event.is_conversion
2123
+ form_provider: event.form_type || "unknown",
2124
+ form_field_type: event.field_type || null,
2125
+ form_field_name: event.field_name || null,
2126
+ form_event_type: event.event_type,
2127
+ submit_source: submitSource,
2128
+ is_inferred: isSuccessEvent,
2129
+ time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null,
2130
+ // LOA-482: Include captured form field values
2131
+ fields: event.fields || null,
2132
+ email_submitted: event.email_submitted || null
1923
2133
  });
1924
2134
  }
1925
2135
  });
@@ -1940,7 +2150,7 @@ function setupAdvancedBehavioralTracking(features) {
1940
2150
  if (link && link.href) {
1941
2151
  const isExternal = link.hostname !== window.location.hostname;
1942
2152
  queueEvent("click", {
1943
- element: "link",
2153
+ element_type: "link",
1944
2154
  href: truncateText(link.href, 200),
1945
2155
  text: truncateText(link.textContent || "", 100),
1946
2156
  is_external: isExternal
@@ -1950,15 +2160,32 @@ function setupAdvancedBehavioralTracking(features) {
1950
2160
  }
1951
2161
  function queueEvent(eventType, data) {
1952
2162
  if (!eventQueue) return;
1953
- eventQueue.push(eventType, {
2163
+ if (!config.apiKey) {
2164
+ log("Missing apiKey, behavioral event skipped:", eventType);
2165
+ return;
2166
+ }
2167
+ if (!workspaceId) {
2168
+ log("Missing workspaceId, behavioral event skipped:", eventType);
2169
+ return;
2170
+ }
2171
+ if (!sessionId) {
2172
+ log("Missing sessionId, behavioral event skipped:", eventType);
2173
+ return;
2174
+ }
2175
+ const idempotencyKey = buildIdempotencyKey(eventType);
2176
+ const payload = {
1954
2177
  visitor_id: visitorId,
1955
2178
  session_id: sessionId,
1956
2179
  event_type: eventType,
1957
- ...data,
1958
- url: window.location.href,
2180
+ event_data: data,
2181
+ page_url: window.location.href,
2182
+ page_path: window.location.pathname,
1959
2183
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1960
- tracker_version: VERSION
1961
- });
2184
+ tracker_version: VERSION,
2185
+ idempotency_key: idempotencyKey
2186
+ };
2187
+ payload.workspace_id = workspaceId;
2188
+ eventQueue.push(eventType, payload, buildHeaders(idempotencyKey));
1962
2189
  }
1963
2190
  function handleSPANavigation(event) {
1964
2191
  log("SPA navigation:", event.navigation_type, event.to_url);
@@ -1986,34 +2213,42 @@ function handleSPANavigation(event) {
1986
2213
  }
1987
2214
  function setupUnloadHandlers() {
1988
2215
  const handleUnload = () => {
2216
+ if (!workspaceId || !config.apiKey || !sessionId) return;
1989
2217
  const scrollEvent = scrollTracker?.getFinalEvent();
1990
2218
  if (scrollEvent) {
1991
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2219
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2220
+ workspace_id: workspaceId,
1992
2221
  visitor_id: visitorId,
1993
2222
  session_id: sessionId,
1994
2223
  event_type: "scroll_depth_final",
1995
- data: scrollEvent,
1996
- url: window.location.href
2224
+ event_data: {
2225
+ scroll_depth: Math.round(scrollEvent.depth / 100 * 100) / 100,
2226
+ milestone: Math.round(scrollEvent.chunk / 100 * 100) / 100,
2227
+ time_to_reach_ms: scrollEvent.time_to_reach_ms
2228
+ },
2229
+ page_url: window.location.href,
2230
+ page_path: window.location.pathname,
2231
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2232
+ tracker_version: VERSION,
2233
+ idempotency_key: buildIdempotencyKey("scroll_depth_final")
1997
2234
  });
1998
2235
  }
1999
2236
  const timeEvent = timeTracker?.getFinalMetrics();
2000
2237
  if (timeEvent) {
2001
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2238
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2239
+ workspace_id: workspaceId,
2002
2240
  visitor_id: visitorId,
2003
2241
  session_id: sessionId,
2004
- event_type: "time_spent_final",
2005
- data: timeEvent,
2006
- url: window.location.href
2007
- });
2008
- }
2009
- const agenticResult = agenticAnalyzer?.getResult();
2010
- if (agenticResult && agenticResult.agenticProbability > 0) {
2011
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2012
- visitor_id: visitorId,
2013
- session_id: sessionId,
2014
- event_type: "agentic_detection",
2015
- data: agenticResult,
2016
- url: window.location.href
2242
+ event_type: "time_spent",
2243
+ event_data: {
2244
+ visible_time_ms: timeEvent.total_time_ms,
2245
+ page_start_time: pageStartTime || Date.now()
2246
+ },
2247
+ page_url: window.location.href,
2248
+ page_path: window.location.pathname,
2249
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2250
+ tracker_version: VERSION,
2251
+ idempotency_key: buildIdempotencyKey("time_spent")
2017
2252
  });
2018
2253
  }
2019
2254
  eventQueue?.flushBeacon();
@@ -2036,30 +2271,55 @@ function pageview(customUrl) {
2036
2271
  log("Not initialized, call init() first");
2037
2272
  return;
2038
2273
  }
2274
+ if (!config.apiKey) {
2275
+ log("Missing apiKey, pageview skipped");
2276
+ return;
2277
+ }
2039
2278
  const url = customUrl || window.location.href;
2279
+ const utmParams = extractUTMParams(url);
2280
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2281
+ const idempotencyKey = buildIdempotencyKey("visit");
2282
+ const agenticResult = agenticAnalyzer?.getResult();
2283
+ const pagePath = (() => {
2284
+ try {
2285
+ return new URL(url).pathname;
2286
+ } catch {
2287
+ return window.location.pathname;
2288
+ }
2289
+ })();
2040
2290
  const payload = {
2041
2291
  visitor_id: visitorId,
2042
2292
  session_id: sessionId,
2043
- url,
2293
+ page_url: url,
2294
+ page_path: pagePath,
2044
2295
  referrer: document.referrer || null,
2045
2296
  title: document.title || null,
2046
- utm_source: extractUTMParams(url).utm_source || null,
2047
- utm_medium: extractUTMParams(url).utm_medium || null,
2048
- utm_campaign: extractUTMParams(url).utm_campaign || null,
2297
+ utm_source: utmParams.utm_source || null,
2298
+ utm_medium: utmParams.utm_medium || null,
2299
+ utm_campaign: utmParams.utm_campaign || null,
2300
+ utm_term: utmParams.utm_term || null,
2301
+ utm_content: utmParams.utm_content || null,
2049
2302
  user_agent: navigator.userAgent,
2050
2303
  screen_width: window.screen?.width,
2051
2304
  screen_height: window.screen?.height,
2052
2305
  language: navigator.language,
2053
2306
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
2054
2307
  tracker_version: VERSION,
2308
+ event_type: "pageview",
2309
+ event_data: null,
2310
+ timestamp,
2055
2311
  navigation_timing: navigationTiming,
2056
2312
  ai_platform: aiDetection?.platform || null,
2057
- is_ai_referrer: aiDetection?.isAI || false
2313
+ is_ai_referrer: aiDetection?.isAI || false,
2314
+ agentic_detection: agenticResult || null
2058
2315
  };
2316
+ if (workspaceId) {
2317
+ payload.workspace_id = workspaceId;
2318
+ }
2059
2319
  log("Pageview:", payload);
2060
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
2320
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.visit), {
2061
2321
  method: "POST",
2062
- headers: { "Content-Type": "application/json" },
2322
+ headers: buildHeaders(idempotencyKey),
2063
2323
  body: JSON.stringify(payload)
2064
2324
  });
2065
2325
  }
@@ -2068,6 +2328,11 @@ function track(eventName, options = {}) {
2068
2328
  log("Not initialized, call init() first");
2069
2329
  return;
2070
2330
  }
2331
+ if (!config.apiKey) {
2332
+ log("Missing apiKey, event skipped:", eventName);
2333
+ return;
2334
+ }
2335
+ const idempotencyKey = buildIdempotencyKey(`event:${eventName}`);
2071
2336
  const payload = {
2072
2337
  visitor_id: visitorId,
2073
2338
  session_id: sessionId,
@@ -2076,14 +2341,19 @@ function track(eventName, options = {}) {
2076
2341
  properties: options.properties || {},
2077
2342
  revenue: options.revenue,
2078
2343
  currency: options.currency || "USD",
2079
- url: window.location.href,
2344
+ page_url: window.location.href,
2345
+ referrer: document.referrer || null,
2080
2346
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2081
- tracker_version: VERSION
2347
+ tracker_version: VERSION,
2348
+ idempotency_key: idempotencyKey
2082
2349
  };
2350
+ if (workspaceId) {
2351
+ payload.workspace_id = workspaceId;
2352
+ }
2083
2353
  log("Event:", eventName, payload);
2084
2354
  safeFetch(endpoint("/api/ingest/event"), {
2085
2355
  method: "POST",
2086
- headers: { "Content-Type": "application/json" },
2356
+ headers: buildHeaders(idempotencyKey),
2087
2357
  body: JSON.stringify(payload)
2088
2358
  });
2089
2359
  }
@@ -2095,17 +2365,26 @@ function identify(userId, traits = {}) {
2095
2365
  log("Not initialized, call init() first");
2096
2366
  return;
2097
2367
  }
2368
+ if (!config.apiKey) {
2369
+ log("Missing apiKey, identify skipped");
2370
+ return;
2371
+ }
2098
2372
  log("Identify:", userId, traits);
2373
+ const idempotencyKey = buildIdempotencyKey("identify");
2099
2374
  const payload = {
2100
2375
  visitor_id: visitorId,
2101
2376
  session_id: sessionId,
2102
2377
  user_id: userId,
2103
2378
  traits,
2104
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2379
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2380
+ idempotency_key: idempotencyKey
2105
2381
  };
2382
+ if (workspaceId) {
2383
+ payload.workspace_id = workspaceId;
2384
+ }
2106
2385
  safeFetch(endpoint("/api/ingest/identify"), {
2107
2386
  method: "POST",
2108
- headers: { "Content-Type": "application/json" },
2387
+ headers: buildHeaders(idempotencyKey),
2109
2388
  body: JSON.stringify(payload)
2110
2389
  });
2111
2390
  }
@@ -2236,14 +2515,15 @@ function isTrackerInitialized() {
2236
2515
  return initialized;
2237
2516
  }
2238
2517
  function reportHealth(status, errorMessage) {
2239
- if (!config.apiKey) return;
2240
2518
  try {
2241
2519
  const healthData = {
2242
- workspace_id: config.apiKey,
2520
+ workspace_id: workspaceId,
2521
+ visitor_id: visitorId,
2522
+ session_id: sessionId,
2243
2523
  status,
2244
2524
  error_message: errorMessage || null,
2245
- version: VERSION,
2246
- url: typeof window !== "undefined" ? window.location.href : null,
2525
+ tracker_version: VERSION,
2526
+ page_url: typeof window !== "undefined" ? window.location.href : null,
2247
2527
  user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null,
2248
2528
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2249
2529
  features: {
@@ -2258,7 +2538,7 @@ function reportHealth(status, errorMessage) {
2258
2538
  event_queue: !!eventQueue
2259
2539
  }
2260
2540
  };
2261
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.health), {
2541
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.health), {
2262
2542
  method: "POST",
2263
2543
  headers: { "Content-Type": "application/json" },
2264
2544
  body: JSON.stringify(healthData)
@@ -2302,14 +2582,28 @@ function setDebug(enabled) {
2302
2582
  debugMode = enabled;
2303
2583
  log("Debug mode:", enabled ? "enabled" : "disabled");
2304
2584
  }
2585
+ function trackBehavioral(eventType, eventData) {
2586
+ if (!initialized) {
2587
+ log("Not initialized, trackBehavioral skipped:", eventType);
2588
+ return;
2589
+ }
2590
+ queueEvent(eventType, eventData);
2591
+ }
2592
+ function getCurrentWorkspaceId() {
2593
+ return workspaceId;
2594
+ }
2305
2595
  var loamly = {
2306
2596
  init,
2307
2597
  pageview,
2308
2598
  track,
2599
+ trackBehavioral,
2600
+ // NEW: For secondary modules/plugins
2309
2601
  conversion,
2310
2602
  identify,
2311
2603
  getSessionId: getCurrentSessionId,
2312
2604
  getVisitorId: getCurrentVisitorId,
2605
+ getWorkspaceId: getCurrentWorkspaceId,
2606
+ // NEW: For debugging/introspection
2313
2607
  getAIDetection: getAIDetectionResult,
2314
2608
  getNavigationTiming: getNavigationTimingResult,
2315
2609
  getBehavioralML: getBehavioralMLResult,
@@ -2361,7 +2655,7 @@ var loamly = {
2361
2655
  * See what AI tells your customers — and track when they click.
2362
2656
  *
2363
2657
  * @module @loamly/tracker
2364
- * @version 1.8.0
2658
+ * @version 2.1.0
2365
2659
  * @license MIT
2366
2660
  * @see https://github.com/loamly/loamly
2367
2661
  * @see https://loamly.ai