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