@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loamly/tracker",
3
- "version": "2.1.1",
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
@@ -6,7 +6,7 @@
6
6
  * @see https://github.com/loamly/loamly
7
7
  */
8
8
 
9
- export const VERSION = '2.1.1'
9
+ export const VERSION = '2.4.0'
10
10
 
11
11
  export const DEFAULT_CONFIG = {
12
12
  apiHost: 'https://app.loamly.ai',
package/src/core.ts CHANGED
@@ -17,44 +17,44 @@
17
17
  * @module @loamly/tracker
18
18
  */
19
19
 
20
- import { VERSION, DEFAULT_CONFIG } from './config'
21
- import { detectNavigationType } from './detection/navigation-timing'
22
- import { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
23
- import {
24
- BehavioralClassifier,
25
- type BehavioralClassificationResult
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
- FocusBlurAnalyzer,
29
- type FocusBlurResult
33
+ FocusBlurAnalyzer,
34
+ type FocusBlurResult
30
35
  } from './detection/focus-blur'
31
- import {
32
- AgenticBrowserAnalyzer,
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
- getVisitorId,
43
- getSessionId,
44
- extractUTMParams,
45
- truncateText,
46
- safeFetch,
47
- sendBeacon
48
- } from './utils'
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