@loamly/tracker 2.1.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1314 -1233
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.mjs +1314 -1233
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1313 -1232
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +1 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +1 -1
- package/src/behavioral/form-tracker.ts +102 -1
- package/src/config.ts +1 -1
- package/src/core.ts +73 -31
- package/src/types.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loamly/tracker",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "See every AI bot that visits your website. ChatGPT, Claude, Perplexity, Gemini — know when they crawl or refer traffic.",
|
|
5
5
|
"author": "Loamly <hello@loamly.ai>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
* @module @loamly/tracker
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// LOA-482: Form field data (privacy-safe)
|
|
17
|
+
export interface FormFieldData {
|
|
18
|
+
name: string
|
|
19
|
+
type: string
|
|
20
|
+
// Value is included only for non-sensitive fields, truncated for privacy
|
|
21
|
+
value?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export interface FormEvent {
|
|
17
25
|
event_type: 'form_start' | 'form_field' | 'form_submit' | 'form_success'
|
|
18
26
|
form_id: string
|
|
@@ -22,6 +30,10 @@ export interface FormEvent {
|
|
|
22
30
|
time_to_submit_ms?: number
|
|
23
31
|
is_conversion?: boolean
|
|
24
32
|
submit_source?: 'submit' | 'click' | 'thank_you'
|
|
33
|
+
// LOA-482: Captured form field data (privacy-safe)
|
|
34
|
+
fields?: FormFieldData[]
|
|
35
|
+
// LOA-482: Extracted email if found in form
|
|
36
|
+
email_submitted?: string
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
export interface FormTrackerConfig {
|
|
@@ -31,6 +43,10 @@ export interface FormTrackerConfig {
|
|
|
31
43
|
trackableFields: string[]
|
|
32
44
|
// Patterns for thank-you page detection
|
|
33
45
|
thankYouPatterns: RegExp[]
|
|
46
|
+
// LOA-482: Whether to capture form field values on submission
|
|
47
|
+
captureFieldValues: boolean
|
|
48
|
+
// LOA-482: Maximum length for field values (truncate for privacy)
|
|
49
|
+
maxFieldValueLength: number
|
|
34
50
|
// Callback for form events
|
|
35
51
|
onFormEvent?: (event: FormEvent) => void
|
|
36
52
|
}
|
|
@@ -41,10 +57,12 @@ const DEFAULT_CONFIG: FormTrackerConfig = {
|
|
|
41
57
|
'credit', 'card', 'cvv', 'cvc',
|
|
42
58
|
'ssn', 'social',
|
|
43
59
|
'secret', 'token', 'key',
|
|
60
|
+
'pin', 'security', 'answer',
|
|
44
61
|
],
|
|
45
62
|
trackableFields: [
|
|
46
63
|
'email', 'name', 'phone', 'company',
|
|
47
64
|
'first', 'last', 'city', 'country',
|
|
65
|
+
'domain', 'website', 'url', 'organization',
|
|
48
66
|
],
|
|
49
67
|
thankYouPatterns: [
|
|
50
68
|
/thank[-_]?you/i,
|
|
@@ -53,6 +71,9 @@ const DEFAULT_CONFIG: FormTrackerConfig = {
|
|
|
53
71
|
/submitted/i,
|
|
54
72
|
/complete/i,
|
|
55
73
|
],
|
|
74
|
+
// LOA-482: Enable field value capture by default
|
|
75
|
+
captureFieldValues: true,
|
|
76
|
+
maxFieldValueLength: 200,
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
export class FormTracker {
|
|
@@ -146,6 +167,9 @@ export class FormTracker {
|
|
|
146
167
|
|
|
147
168
|
const formId = this.getFormId(form)
|
|
148
169
|
const startTime = this.formStartTimes.get(formId)
|
|
170
|
+
|
|
171
|
+
// LOA-482: Capture form field values with privacy sanitization
|
|
172
|
+
const { fields, emailSubmitted } = this.captureFormFields(form)
|
|
149
173
|
|
|
150
174
|
this.emitEvent({
|
|
151
175
|
event_type: 'form_submit',
|
|
@@ -154,18 +178,93 @@ export class FormTracker {
|
|
|
154
178
|
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
155
179
|
is_conversion: true,
|
|
156
180
|
submit_source: 'submit',
|
|
181
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
182
|
+
email_submitted: emailSubmitted,
|
|
157
183
|
})
|
|
158
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* LOA-482: Capture form field values with privacy-safe sanitization
|
|
188
|
+
* - Never captures sensitive fields (password, credit card, etc.)
|
|
189
|
+
* - Truncates values to maxFieldValueLength
|
|
190
|
+
* - Extracts email if found
|
|
191
|
+
*/
|
|
192
|
+
private captureFormFields(form: HTMLFormElement): { fields: FormFieldData[], emailSubmitted?: string } {
|
|
193
|
+
const fields: FormFieldData[] = []
|
|
194
|
+
let emailSubmitted: string | undefined
|
|
195
|
+
|
|
196
|
+
if (!this.config.captureFieldValues) {
|
|
197
|
+
return { fields, emailSubmitted }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const formData = new FormData(form)
|
|
202
|
+
|
|
203
|
+
for (const [name, value] of formData.entries()) {
|
|
204
|
+
// Skip sensitive fields entirely
|
|
205
|
+
if (this.isSensitiveField(name)) {
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Get the input element to determine type
|
|
210
|
+
const input = form.elements.namedItem(name) as HTMLInputElement | null
|
|
211
|
+
const inputType = input?.type || 'text'
|
|
212
|
+
|
|
213
|
+
// Skip file inputs
|
|
214
|
+
if (inputType === 'file' || value instanceof File) {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stringValue = String(value)
|
|
219
|
+
|
|
220
|
+
// Check for email field
|
|
221
|
+
if (this.isEmailField(name, stringValue)) {
|
|
222
|
+
emailSubmitted = stringValue.substring(0, 254) // Max email length
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Truncate value for privacy
|
|
226
|
+
const truncatedValue = stringValue.length > this.config.maxFieldValueLength
|
|
227
|
+
? stringValue.substring(0, this.config.maxFieldValueLength) + '...'
|
|
228
|
+
: stringValue
|
|
229
|
+
|
|
230
|
+
fields.push({
|
|
231
|
+
name: this.sanitizeFieldName(name),
|
|
232
|
+
type: inputType,
|
|
233
|
+
value: truncatedValue,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Silent fail - don't break form submission
|
|
238
|
+
console.warn('[Loamly] Failed to capture form fields:', err)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { fields, emailSubmitted }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if a field contains an email
|
|
246
|
+
*/
|
|
247
|
+
private isEmailField(fieldName: string, value: string): boolean {
|
|
248
|
+
const lowerName = fieldName.toLowerCase()
|
|
249
|
+
// Check if field name suggests email
|
|
250
|
+
const isEmailName = lowerName.includes('email') || lowerName === 'e-mail'
|
|
251
|
+
// Simple email validation regex
|
|
252
|
+
const isEmailValue = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
253
|
+
return isEmailName && isEmailValue
|
|
254
|
+
}
|
|
159
255
|
|
|
160
256
|
private handleClick = (e: Event): void => {
|
|
161
257
|
const target = e.target as HTMLElement
|
|
162
258
|
|
|
163
259
|
// Check for HubSpot submit button
|
|
164
260
|
if (target.closest('.hs-button') || target.closest('[type="submit"]')) {
|
|
165
|
-
const form = target.closest('form')
|
|
261
|
+
const form = target.closest('form') as HTMLFormElement | null
|
|
166
262
|
if (form && form.classList.contains('hs-form')) {
|
|
167
263
|
const formId = this.getFormId(form)
|
|
168
264
|
const startTime = this.formStartTimes.get(formId)
|
|
265
|
+
|
|
266
|
+
// LOA-482: Capture form field values for HubSpot forms
|
|
267
|
+
const { fields, emailSubmitted } = this.captureFormFields(form)
|
|
169
268
|
|
|
170
269
|
this.emitEvent({
|
|
171
270
|
event_type: 'form_submit',
|
|
@@ -174,6 +273,8 @@ export class FormTracker {
|
|
|
174
273
|
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
175
274
|
is_conversion: true,
|
|
176
275
|
submit_source: 'click',
|
|
276
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
277
|
+
email_submitted: emailSubmitted,
|
|
177
278
|
})
|
|
178
279
|
}
|
|
179
280
|
}
|
package/src/config.ts
CHANGED
package/src/core.ts
CHANGED
|
@@ -17,44 +17,44 @@
|
|
|
17
17
|
* @module @loamly/tracker
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
import { FormTracker, type FormEvent } from './behavioral/form-tracker'
|
|
21
|
+
import { ScrollTracker, type ScrollEvent } from './behavioral/scroll-tracker'
|
|
22
|
+
import { TimeTracker, type TimeEvent } from './behavioral/time-tracker'
|
|
23
|
+
import { DEFAULT_CONFIG, VERSION } from './config'
|
|
24
|
+
import {
|
|
25
|
+
AgenticBrowserAnalyzer,
|
|
26
|
+
type AgenticDetectionResult
|
|
27
|
+
} from './detection/agentic-browser'
|
|
28
|
+
import {
|
|
29
|
+
BehavioralClassifier,
|
|
30
|
+
type BehavioralClassificationResult
|
|
26
31
|
} from './detection/behavioral-classifier'
|
|
27
32
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
FocusBlurAnalyzer,
|
|
34
|
+
type FocusBlurResult
|
|
30
35
|
} from './detection/focus-blur'
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
type AgenticDetectionResult
|
|
34
|
-
} from './detection/agentic-browser'
|
|
36
|
+
import { detectNavigationType } from './detection/navigation-timing'
|
|
37
|
+
import { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
|
|
35
38
|
import { EventQueue } from './infrastructure/event-queue'
|
|
36
39
|
import { PingService } from './infrastructure/ping'
|
|
37
|
-
import { ScrollTracker, type ScrollEvent } from './behavioral/scroll-tracker'
|
|
38
|
-
import { TimeTracker, type TimeEvent } from './behavioral/time-tracker'
|
|
39
|
-
import { FormTracker, type FormEvent } from './behavioral/form-tracker'
|
|
40
40
|
import { SPARouter, type NavigationEvent } from './spa/router'
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
import type {
|
|
50
|
-
LoamlyConfig,
|
|
51
|
-
LoamlyTracker,
|
|
52
|
-
TrackEventOptions,
|
|
53
|
-
NavigationTiming,
|
|
54
|
-
AIDetectionResult,
|
|
55
|
-
BehavioralMLResult,
|
|
56
|
-
FocusBlurMLResult
|
|
41
|
+
import type {
|
|
42
|
+
AIDetectionResult,
|
|
43
|
+
BehavioralMLResult,
|
|
44
|
+
FocusBlurMLResult,
|
|
45
|
+
LoamlyConfig,
|
|
46
|
+
LoamlyTracker,
|
|
47
|
+
NavigationTiming,
|
|
48
|
+
TrackEventOptions
|
|
57
49
|
} from './types'
|
|
50
|
+
import {
|
|
51
|
+
extractUTMParams,
|
|
52
|
+
getSessionId,
|
|
53
|
+
getVisitorId,
|
|
54
|
+
safeFetch,
|
|
55
|
+
sendBeacon,
|
|
56
|
+
truncateText
|
|
57
|
+
} from './utils'
|
|
58
58
|
|
|
59
59
|
// State
|
|
60
60
|
let config: LoamlyConfig & { apiHost: string } = { apiHost: DEFAULT_CONFIG.apiHost }
|
|
@@ -423,6 +423,9 @@ function setupAdvancedBehavioralTracking(features: FeatureFlags): void {
|
|
|
423
423
|
submit_source: submitSource,
|
|
424
424
|
is_inferred: isSuccessEvent,
|
|
425
425
|
time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1000) : null,
|
|
426
|
+
// LOA-482: Include captured form field values
|
|
427
|
+
fields: event.fields || null,
|
|
428
|
+
email_submitted: event.email_submitted || null,
|
|
426
429
|
})
|
|
427
430
|
},
|
|
428
431
|
})
|
|
@@ -1037,20 +1040,59 @@ function setDebug(enabled: boolean): void {
|
|
|
1037
1040
|
log('Debug mode:', enabled ? 'enabled' : 'disabled')
|
|
1038
1041
|
}
|
|
1039
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* Track a behavioral event (for use by secondary modules/plugins)
|
|
1045
|
+
*
|
|
1046
|
+
* This is the recommended way for secondary tracking modules to send events.
|
|
1047
|
+
* It automatically includes workspace_id, visitor_id, session_id, and handles
|
|
1048
|
+
* batching, queueing, and retry logic.
|
|
1049
|
+
*
|
|
1050
|
+
* @param eventType - The type of event (e.g., 'outbound_click', 'button_click')
|
|
1051
|
+
* @param eventData - Additional data for the event
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```js
|
|
1055
|
+
* Loamly.trackBehavioral('outbound_click', {
|
|
1056
|
+
* url: 'https://example.com',
|
|
1057
|
+
* text: 'Click here',
|
|
1058
|
+
* hostname: 'example.com'
|
|
1059
|
+
* });
|
|
1060
|
+
* ```
|
|
1061
|
+
*/
|
|
1062
|
+
function trackBehavioral(eventType: string, eventData: Record<string, unknown>): void {
|
|
1063
|
+
if (!initialized) {
|
|
1064
|
+
log('Not initialized, trackBehavioral skipped:', eventType)
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
queueEvent(eventType, eventData)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Get the current workspace ID
|
|
1072
|
+
* For internal use by secondary modules
|
|
1073
|
+
*/
|
|
1074
|
+
function getCurrentWorkspaceId(): string | null {
|
|
1075
|
+
return workspaceId
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1040
1078
|
/**
|
|
1041
1079
|
* The Loamly Tracker instance
|
|
1042
1080
|
*/
|
|
1043
1081
|
export const loamly: LoamlyTracker & {
|
|
1044
1082
|
getAgentic: () => AgenticDetectionResult | null
|
|
1045
1083
|
reportHealth: (status: 'initialized' | 'error' | 'ready', errorMessage?: string) => void
|
|
1084
|
+
trackBehavioral: (eventType: string, eventData: Record<string, unknown>) => void
|
|
1085
|
+
getWorkspaceId: () => string | null
|
|
1046
1086
|
} = {
|
|
1047
1087
|
init,
|
|
1048
1088
|
pageview,
|
|
1049
1089
|
track,
|
|
1090
|
+
trackBehavioral, // NEW: For secondary modules/plugins
|
|
1050
1091
|
conversion,
|
|
1051
1092
|
identify,
|
|
1052
1093
|
getSessionId: getCurrentSessionId,
|
|
1053
1094
|
getVisitorId: getCurrentVisitorId,
|
|
1095
|
+
getWorkspaceId: getCurrentWorkspaceId, // NEW: For debugging/introspection
|
|
1054
1096
|
getAIDetection: getAIDetectionResult,
|
|
1055
1097
|
getNavigationTiming: getNavigationTimingResult,
|
|
1056
1098
|
getBehavioralML: getBehavioralMLResult,
|
package/src/types.ts
CHANGED
|
@@ -129,6 +129,23 @@ export interface LoamlyTracker {
|
|
|
129
129
|
/** Track a custom event */
|
|
130
130
|
track: (eventName: string, options?: TrackEventOptions) => void
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Track a behavioral event (for secondary modules/plugins)
|
|
134
|
+
*
|
|
135
|
+
* This is the recommended way for secondary tracking modules to send events.
|
|
136
|
+
* It automatically includes workspace_id, visitor_id, session_id, and handles
|
|
137
|
+
* batching, queueing, and retry logic.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```js
|
|
141
|
+
* Loamly.trackBehavioral('outbound_click', {
|
|
142
|
+
* url: 'https://example.com',
|
|
143
|
+
* text: 'Click here'
|
|
144
|
+
* });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
trackBehavioral: (eventType: string, eventData: Record<string, unknown>) => void
|
|
148
|
+
|
|
132
149
|
/** Track a conversion/revenue event */
|
|
133
150
|
conversion: (eventName: string, revenue: number, currency?: string) => void
|
|
134
151
|
|
|
@@ -141,6 +158,9 @@ export interface LoamlyTracker {
|
|
|
141
158
|
/** Get the current visitor ID */
|
|
142
159
|
getVisitorId: () => string | null
|
|
143
160
|
|
|
161
|
+
/** Get the current workspace ID */
|
|
162
|
+
getWorkspaceId: () => string | null
|
|
163
|
+
|
|
144
164
|
/** Get AI detection result for current page */
|
|
145
165
|
getAIDetection: () => AIDetectionResult | null
|
|
146
166
|
|