@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/README.md +4 -3
- package/dist/index.cjs +1580 -1286
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.mjs +1580 -1286
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1578 -1270
- 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 +107 -1
- package/src/browser.ts +25 -6
- package/src/config.ts +1 -1
- package/src/core.ts +386 -132
- package/src/index.ts +1 -1
- package/src/infrastructure/event-queue.ts +58 -32
- package/src/infrastructure/ping.ts +19 -3
- package/src/types.ts +23 -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
|
|
@@ -21,6 +29,11 @@ export interface FormEvent {
|
|
|
21
29
|
field_type?: string
|
|
22
30
|
time_to_submit_ms?: number
|
|
23
31
|
is_conversion?: boolean
|
|
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
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
export interface FormTrackerConfig {
|
|
@@ -30,6 +43,10 @@ export interface FormTrackerConfig {
|
|
|
30
43
|
trackableFields: string[]
|
|
31
44
|
// Patterns for thank-you page detection
|
|
32
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
|
|
33
50
|
// Callback for form events
|
|
34
51
|
onFormEvent?: (event: FormEvent) => void
|
|
35
52
|
}
|
|
@@ -40,10 +57,12 @@ const DEFAULT_CONFIG: FormTrackerConfig = {
|
|
|
40
57
|
'credit', 'card', 'cvv', 'cvc',
|
|
41
58
|
'ssn', 'social',
|
|
42
59
|
'secret', 'token', 'key',
|
|
60
|
+
'pin', 'security', 'answer',
|
|
43
61
|
],
|
|
44
62
|
trackableFields: [
|
|
45
63
|
'email', 'name', 'phone', 'company',
|
|
46
64
|
'first', 'last', 'city', 'country',
|
|
65
|
+
'domain', 'website', 'url', 'organization',
|
|
47
66
|
],
|
|
48
67
|
thankYouPatterns: [
|
|
49
68
|
/thank[-_]?you/i,
|
|
@@ -52,6 +71,9 @@ const DEFAULT_CONFIG: FormTrackerConfig = {
|
|
|
52
71
|
/submitted/i,
|
|
53
72
|
/complete/i,
|
|
54
73
|
],
|
|
74
|
+
// LOA-482: Enable field value capture by default
|
|
75
|
+
captureFieldValues: true,
|
|
76
|
+
maxFieldValueLength: 200,
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export class FormTracker {
|
|
@@ -145,6 +167,9 @@ export class FormTracker {
|
|
|
145
167
|
|
|
146
168
|
const formId = this.getFormId(form)
|
|
147
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)
|
|
148
173
|
|
|
149
174
|
this.emitEvent({
|
|
150
175
|
event_type: 'form_submit',
|
|
@@ -152,18 +177,94 @@ export class FormTracker {
|
|
|
152
177
|
form_type: this.detectFormType(form),
|
|
153
178
|
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
154
179
|
is_conversion: true,
|
|
180
|
+
submit_source: 'submit',
|
|
181
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
182
|
+
email_submitted: emailSubmitted,
|
|
155
183
|
})
|
|
156
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
|
+
}
|
|
157
255
|
|
|
158
256
|
private handleClick = (e: Event): void => {
|
|
159
257
|
const target = e.target as HTMLElement
|
|
160
258
|
|
|
161
259
|
// Check for HubSpot submit button
|
|
162
260
|
if (target.closest('.hs-button') || target.closest('[type="submit"]')) {
|
|
163
|
-
const form = target.closest('form')
|
|
261
|
+
const form = target.closest('form') as HTMLFormElement | null
|
|
164
262
|
if (form && form.classList.contains('hs-form')) {
|
|
165
263
|
const formId = this.getFormId(form)
|
|
166
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)
|
|
167
268
|
|
|
168
269
|
this.emitEvent({
|
|
169
270
|
event_type: 'form_submit',
|
|
@@ -171,6 +272,9 @@ export class FormTracker {
|
|
|
171
272
|
form_type: 'hubspot',
|
|
172
273
|
time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
|
|
173
274
|
is_conversion: true,
|
|
275
|
+
submit_source: 'click',
|
|
276
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
277
|
+
email_submitted: emailSubmitted,
|
|
174
278
|
})
|
|
175
279
|
}
|
|
176
280
|
}
|
|
@@ -182,6 +286,7 @@ export class FormTracker {
|
|
|
182
286
|
form_id: 'typeform_embed',
|
|
183
287
|
form_type: 'typeform',
|
|
184
288
|
is_conversion: true,
|
|
289
|
+
submit_source: 'click',
|
|
185
290
|
})
|
|
186
291
|
}
|
|
187
292
|
}
|
|
@@ -269,6 +374,7 @@ export class FormTracker {
|
|
|
269
374
|
form_id: 'page_conversion',
|
|
270
375
|
form_type: 'unknown',
|
|
271
376
|
is_conversion: true,
|
|
377
|
+
submit_source: 'thank_you',
|
|
272
378
|
})
|
|
273
379
|
break
|
|
274
380
|
}
|
package/src/browser.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* <script src="https://app.loamly.ai/t.js?d=your-domain.com"></script>
|
|
10
10
|
*
|
|
11
11
|
* 2. npm with data attributes:
|
|
12
|
-
* <script src="https://cdn.jsdelivr.net/npm/@loamly/tracker"
|
|
12
|
+
* <script src="https://cdn.jsdelivr.net/npm/@loamly/tracker"
|
|
13
|
+
* data-api-key="your-key"
|
|
14
|
+
* data-workspace-id="your-workspace-uuid"></script>
|
|
13
15
|
*
|
|
14
16
|
* 3. Self-hosted with manual init:
|
|
15
17
|
* <script src="/tracker.js"></script>
|
|
@@ -49,7 +51,7 @@ function extractDomainFromScriptUrl(): string | null {
|
|
|
49
51
|
*/
|
|
50
52
|
async function resolveWorkspaceConfig(domain: string): Promise<LoamlyConfig | null> {
|
|
51
53
|
try {
|
|
52
|
-
const response = await fetch(`${DEFAULT_CONFIG.apiHost}${DEFAULT_CONFIG.endpoints.resolve}?
|
|
54
|
+
const response = await fetch(`${DEFAULT_CONFIG.apiHost}${DEFAULT_CONFIG.endpoints.resolve}?d=${encodeURIComponent(domain)}`)
|
|
53
55
|
|
|
54
56
|
if (!response.ok) {
|
|
55
57
|
console.warn('[Loamly] Failed to resolve workspace for domain:', domain)
|
|
@@ -58,9 +60,10 @@ async function resolveWorkspaceConfig(domain: string): Promise<LoamlyConfig | nu
|
|
|
58
60
|
|
|
59
61
|
const data = await response.json()
|
|
60
62
|
|
|
61
|
-
if (data.workspace_id) {
|
|
63
|
+
if (data.workspace_id && data.public_key) {
|
|
62
64
|
return {
|
|
63
|
-
apiKey: data.
|
|
65
|
+
apiKey: data.public_key,
|
|
66
|
+
workspaceId: data.workspace_id,
|
|
64
67
|
apiHost: DEFAULT_CONFIG.apiHost,
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -85,6 +88,10 @@ function extractConfigFromDataAttributes(): LoamlyConfig | null {
|
|
|
85
88
|
if (script.dataset.apiKey) {
|
|
86
89
|
config.apiKey = script.dataset.apiKey
|
|
87
90
|
}
|
|
91
|
+
|
|
92
|
+
if (script.dataset.workspaceId) {
|
|
93
|
+
config.workspaceId = script.dataset.workspaceId
|
|
94
|
+
}
|
|
88
95
|
|
|
89
96
|
if (script.dataset.apiHost) {
|
|
90
97
|
config.apiHost = script.dataset.apiHost
|
|
@@ -102,7 +109,7 @@ function extractConfigFromDataAttributes(): LoamlyConfig | null {
|
|
|
102
109
|
config.disableBehavioral = true
|
|
103
110
|
}
|
|
104
111
|
|
|
105
|
-
if (config.apiKey) {
|
|
112
|
+
if (config.apiKey || config.workspaceId) {
|
|
106
113
|
return config
|
|
107
114
|
}
|
|
108
115
|
}
|
|
@@ -132,8 +139,20 @@ async function autoInit(): Promise<void> {
|
|
|
132
139
|
loamly.init(dataConfig)
|
|
133
140
|
return
|
|
134
141
|
}
|
|
142
|
+
|
|
143
|
+
// Priority 3: URL params (api_key + workspace_id)
|
|
144
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
145
|
+
const apiKeyParam = urlParams.get('api_key')
|
|
146
|
+
const workspaceIdParam = urlParams.get('workspace_id')
|
|
147
|
+
if (apiKeyParam || workspaceIdParam) {
|
|
148
|
+
loamly.init({
|
|
149
|
+
apiKey: apiKeyParam || undefined,
|
|
150
|
+
workspaceId: workspaceIdParam || undefined,
|
|
151
|
+
})
|
|
152
|
+
return
|
|
153
|
+
}
|
|
135
154
|
|
|
136
|
-
// Priority
|
|
155
|
+
// Priority 4: Current domain auto-detection (for app.loamly.ai hosted script)
|
|
137
156
|
const currentDomain = window.location.hostname
|
|
138
157
|
if (currentDomain && currentDomain !== 'localhost') {
|
|
139
158
|
const resolvedConfig = await resolveWorkspaceConfig(currentDomain)
|