@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loamly/tracker",
3
- "version": "2.1.0",
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" data-api-key="your-key"></script>
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}?domain=${encodeURIComponent(domain)}`)
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.workspace_api_key,
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 3: Current domain auto-detection (for app.loamly.ai hosted script)
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)
package/src/config.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * @see https://github.com/loamly/loamly
7
7
  */
8
8
 
9
- export const VERSION = '2.1.0'
9
+ export const VERSION = '2.4.0'
10
10
 
11
11
  export const DEFAULT_CONFIG = {
12
12
  apiHost: 'https://app.loamly.ai',