@loamly/tracker 1.8.0 → 2.0.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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Universal Form Tracker
3
+ *
4
+ * Comprehensive form tracking with support for:
5
+ * - Native HTML forms
6
+ * - HubSpot forms
7
+ * - Typeform embeds
8
+ * - JotForm embeds
9
+ * - Gravity Forms
10
+ * - Thank-you page detection
11
+ * - Privacy-preserving field capture
12
+ *
13
+ * @module @loamly/tracker
14
+ */
15
+
16
+ export interface FormEvent {
17
+ event_type: 'form_start' | 'form_field' | 'form_submit' | 'form_success'
18
+ form_id: string
19
+ form_type: 'native' | 'hubspot' | 'typeform' | 'jotform' | 'gravity' | 'unknown'
20
+ field_name?: string
21
+ field_type?: string
22
+ time_to_submit_ms?: number
23
+ is_conversion?: boolean
24
+ }
25
+
26
+ export interface FormTrackerConfig {
27
+ // Privacy: Never capture these field values
28
+ sensitiveFields: string[]
29
+ // Fields to track interaction (not values)
30
+ trackableFields: string[]
31
+ // Patterns for thank-you page detection
32
+ thankYouPatterns: RegExp[]
33
+ // Callback for form events
34
+ onFormEvent?: (event: FormEvent) => void
35
+ }
36
+
37
+ const DEFAULT_CONFIG: FormTrackerConfig = {
38
+ sensitiveFields: [
39
+ 'password', 'pwd', 'pass',
40
+ 'credit', 'card', 'cvv', 'cvc',
41
+ 'ssn', 'social',
42
+ 'secret', 'token', 'key',
43
+ ],
44
+ trackableFields: [
45
+ 'email', 'name', 'phone', 'company',
46
+ 'first', 'last', 'city', 'country',
47
+ ],
48
+ thankYouPatterns: [
49
+ /thank[-_]?you/i,
50
+ /success/i,
51
+ /confirmation/i,
52
+ /submitted/i,
53
+ /complete/i,
54
+ ],
55
+ }
56
+
57
+ export class FormTracker {
58
+ private config: FormTrackerConfig
59
+ private formStartTimes = new Map<string, number>()
60
+ private interactedForms = new Set<string>()
61
+ private mutationObserver: MutationObserver | null = null
62
+
63
+ constructor(config: Partial<FormTrackerConfig> = {}) {
64
+ this.config = {
65
+ ...DEFAULT_CONFIG,
66
+ ...config,
67
+ sensitiveFields: [
68
+ ...DEFAULT_CONFIG.sensitiveFields,
69
+ ...(config.sensitiveFields || []),
70
+ ],
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Start tracking forms
76
+ */
77
+ start(): void {
78
+ // Track native form interactions
79
+ document.addEventListener('focusin', this.handleFocusIn, { passive: true })
80
+ document.addEventListener('submit', this.handleSubmit)
81
+ document.addEventListener('click', this.handleClick, { passive: true })
82
+
83
+ // Observe DOM for dynamically added forms (HubSpot, Typeform, etc.)
84
+ this.startMutationObserver()
85
+
86
+ // Check for thank-you page on load
87
+ this.checkThankYouPage()
88
+
89
+ // Scan for existing embedded forms
90
+ this.scanForEmbeddedForms()
91
+ }
92
+
93
+ /**
94
+ * Stop tracking
95
+ */
96
+ stop(): void {
97
+ document.removeEventListener('focusin', this.handleFocusIn)
98
+ document.removeEventListener('submit', this.handleSubmit)
99
+ document.removeEventListener('click', this.handleClick)
100
+ this.mutationObserver?.disconnect()
101
+ }
102
+
103
+ /**
104
+ * Get forms that had interaction
105
+ */
106
+ getInteractedForms(): string[] {
107
+ return Array.from(this.interactedForms)
108
+ }
109
+
110
+ private handleFocusIn = (e: FocusEvent): void => {
111
+ const target = e.target as HTMLElement
112
+ if (!this.isFormField(target)) return
113
+
114
+ const form = target.closest('form')
115
+ const formId = this.getFormId(form || target)
116
+
117
+ // Track form start
118
+ if (!this.formStartTimes.has(formId)) {
119
+ this.formStartTimes.set(formId, Date.now())
120
+ this.interactedForms.add(formId)
121
+
122
+ this.emitEvent({
123
+ event_type: 'form_start',
124
+ form_id: formId,
125
+ form_type: this.detectFormType(form || target),
126
+ })
127
+ }
128
+
129
+ // Track field interaction (privacy-safe)
130
+ const fieldName = this.getFieldName(target as HTMLInputElement)
131
+ if (fieldName && !this.isSensitiveField(fieldName)) {
132
+ this.emitEvent({
133
+ event_type: 'form_field',
134
+ form_id: formId,
135
+ form_type: this.detectFormType(form || target),
136
+ field_name: this.sanitizeFieldName(fieldName),
137
+ field_type: (target as HTMLInputElement).type || target.tagName.toLowerCase(),
138
+ })
139
+ }
140
+ }
141
+
142
+ private handleSubmit = (e: Event): void => {
143
+ const form = e.target as HTMLFormElement
144
+ if (!form || form.tagName !== 'FORM') return
145
+
146
+ const formId = this.getFormId(form)
147
+ const startTime = this.formStartTimes.get(formId)
148
+
149
+ this.emitEvent({
150
+ event_type: 'form_submit',
151
+ form_id: formId,
152
+ form_type: this.detectFormType(form),
153
+ time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
154
+ is_conversion: true,
155
+ })
156
+ }
157
+
158
+ private handleClick = (e: Event): void => {
159
+ const target = e.target as HTMLElement
160
+
161
+ // Check for HubSpot submit button
162
+ if (target.closest('.hs-button') || target.closest('[type="submit"]')) {
163
+ const form = target.closest('form')
164
+ if (form && form.classList.contains('hs-form')) {
165
+ const formId = this.getFormId(form)
166
+ const startTime = this.formStartTimes.get(formId)
167
+
168
+ this.emitEvent({
169
+ event_type: 'form_submit',
170
+ form_id: formId,
171
+ form_type: 'hubspot',
172
+ time_to_submit_ms: startTime ? Date.now() - startTime : undefined,
173
+ is_conversion: true,
174
+ })
175
+ }
176
+ }
177
+
178
+ // Check for Typeform submit
179
+ if (target.closest('[data-qa="submit-button"]')) {
180
+ this.emitEvent({
181
+ event_type: 'form_submit',
182
+ form_id: 'typeform_embed',
183
+ form_type: 'typeform',
184
+ is_conversion: true,
185
+ })
186
+ }
187
+ }
188
+
189
+ private startMutationObserver(): void {
190
+ this.mutationObserver = new MutationObserver((mutations) => {
191
+ for (const mutation of mutations) {
192
+ for (const node of mutation.addedNodes) {
193
+ if (node instanceof HTMLElement) {
194
+ // Check for HubSpot form
195
+ if (node.classList?.contains('hs-form') || node.querySelector?.('.hs-form')) {
196
+ this.trackEmbeddedForm(node, 'hubspot')
197
+ }
198
+ // Check for Typeform
199
+ if (node.classList?.contains('typeform-widget') || node.querySelector?.('[data-tf-widget]')) {
200
+ this.trackEmbeddedForm(node, 'typeform')
201
+ }
202
+ // Check for JotForm
203
+ if (node.classList?.contains('jotform-form') || node.querySelector?.('.jotform-form')) {
204
+ this.trackEmbeddedForm(node, 'jotform')
205
+ }
206
+ // Check for Gravity Forms
207
+ if (node.classList?.contains('gform_wrapper') || node.querySelector?.('.gform_wrapper')) {
208
+ this.trackEmbeddedForm(node, 'gravity')
209
+ }
210
+ }
211
+ }
212
+ }
213
+ })
214
+
215
+ this.mutationObserver.observe(document.body, {
216
+ childList: true,
217
+ subtree: true,
218
+ })
219
+ }
220
+
221
+ private scanForEmbeddedForms(): void {
222
+ // HubSpot
223
+ document.querySelectorAll('.hs-form').forEach(form => {
224
+ this.trackEmbeddedForm(form as HTMLElement, 'hubspot')
225
+ })
226
+
227
+ // Typeform
228
+ document.querySelectorAll('[data-tf-widget], .typeform-widget').forEach(form => {
229
+ this.trackEmbeddedForm(form as HTMLElement, 'typeform')
230
+ })
231
+
232
+ // JotForm
233
+ document.querySelectorAll('.jotform-form').forEach(form => {
234
+ this.trackEmbeddedForm(form as HTMLElement, 'jotform')
235
+ })
236
+
237
+ // Gravity Forms
238
+ document.querySelectorAll('.gform_wrapper').forEach(form => {
239
+ this.trackEmbeddedForm(form as HTMLElement, 'gravity')
240
+ })
241
+ }
242
+
243
+ private trackEmbeddedForm(element: HTMLElement, type: FormEvent['form_type']): void {
244
+ const formId = `${type}_${this.getFormId(element)}`
245
+
246
+ // Add event listeners for embedded form interactions
247
+ element.addEventListener('focusin', () => {
248
+ if (!this.formStartTimes.has(formId)) {
249
+ this.formStartTimes.set(formId, Date.now())
250
+ this.interactedForms.add(formId)
251
+
252
+ this.emitEvent({
253
+ event_type: 'form_start',
254
+ form_id: formId,
255
+ form_type: type,
256
+ })
257
+ }
258
+ }, { passive: true })
259
+ }
260
+
261
+ private checkThankYouPage(): void {
262
+ const url = window.location.href.toLowerCase()
263
+ const title = document.title.toLowerCase()
264
+
265
+ for (const pattern of this.config.thankYouPatterns) {
266
+ if (pattern.test(url) || pattern.test(title)) {
267
+ this.emitEvent({
268
+ event_type: 'form_success',
269
+ form_id: 'page_conversion',
270
+ form_type: 'unknown',
271
+ is_conversion: true,
272
+ })
273
+ break
274
+ }
275
+ }
276
+ }
277
+
278
+ private isFormField(element: HTMLElement): boolean {
279
+ const tagName = element.tagName
280
+ return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
281
+ }
282
+
283
+ private getFormId(element: HTMLElement | null): string {
284
+ if (!element) return 'unknown'
285
+ return element.id || element.getAttribute('name') || element.getAttribute('data-form-id') || 'form_' + Math.random().toString(36).substring(2, 8)
286
+ }
287
+
288
+ private getFieldName(input: HTMLInputElement): string {
289
+ return input.name || input.id || input.getAttribute('data-name') || ''
290
+ }
291
+
292
+ private isSensitiveField(fieldName: string): boolean {
293
+ const lowerName = fieldName.toLowerCase()
294
+ return this.config.sensitiveFields.some(sensitive => lowerName.includes(sensitive))
295
+ }
296
+
297
+ private sanitizeFieldName(fieldName: string): string {
298
+ // Remove any potential PII from field names
299
+ return fieldName.replace(/[0-9]+/g, '*').substring(0, 50)
300
+ }
301
+
302
+ private detectFormType(element: HTMLElement): FormEvent['form_type'] {
303
+ if (element.classList.contains('hs-form') || element.closest('.hs-form')) {
304
+ return 'hubspot'
305
+ }
306
+ if (element.classList.contains('typeform-widget') || element.closest('[data-tf-widget]')) {
307
+ return 'typeform'
308
+ }
309
+ if (element.classList.contains('jotform-form') || element.closest('.jotform-form')) {
310
+ return 'jotform'
311
+ }
312
+ if (element.classList.contains('gform_wrapper') || element.closest('.gform_wrapper')) {
313
+ return 'gravity'
314
+ }
315
+ if (element.tagName === 'FORM') {
316
+ return 'native'
317
+ }
318
+ return 'unknown'
319
+ }
320
+
321
+ private emitEvent(event: FormEvent): void {
322
+ this.config.onFormEvent?.(event)
323
+ }
324
+ }
325
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Behavioral Tracking Modules
3
+ * @module @loamly/tracker
4
+ */
5
+
6
+ export { ScrollTracker, type ScrollEvent, type ScrollTrackerConfig } from './scroll-tracker'
7
+ export { TimeTracker, type TimeEvent, type TimeTrackerConfig } from './time-tracker'
8
+ export { FormTracker, type FormEvent, type FormTrackerConfig } from './form-tracker'
9
+
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Advanced Scroll Depth Tracker
3
+ *
4
+ * Production-grade scroll tracking with:
5
+ * - 30% chunk reporting (30%, 60%, 90%, 100%)
6
+ * - requestAnimationFrame throttling for performance
7
+ * - Visibility-aware (only tracks when visible)
8
+ * - Max depth tracking
9
+ *
10
+ * @module @loamly/tracker
11
+ */
12
+
13
+ export interface ScrollEvent {
14
+ depth: number
15
+ chunk: number // 30, 60, 90, 100
16
+ time_to_reach_ms: number
17
+ total_height: number
18
+ viewport_height: number
19
+ }
20
+
21
+ export interface ScrollTrackerConfig {
22
+ chunks: number[] // Default: [30, 60, 90, 100]
23
+ onChunkReached?: (event: ScrollEvent) => void
24
+ onDepthChange?: (depth: number) => void
25
+ }
26
+
27
+ const DEFAULT_CHUNKS = [30, 60, 90, 100]
28
+
29
+ export class ScrollTracker {
30
+ private config: ScrollTrackerConfig
31
+ private maxDepth = 0
32
+ private reportedChunks = new Set<number>()
33
+ private startTime: number
34
+ private ticking = false
35
+ private isVisible = true
36
+
37
+ constructor(config: Partial<ScrollTrackerConfig> = {}) {
38
+ this.config = {
39
+ chunks: DEFAULT_CHUNKS,
40
+ ...config,
41
+ }
42
+ this.startTime = Date.now()
43
+ }
44
+
45
+ /**
46
+ * Start tracking scroll depth
47
+ */
48
+ start(): void {
49
+ window.addEventListener('scroll', this.handleScroll, { passive: true })
50
+ document.addEventListener('visibilitychange', this.handleVisibility)
51
+
52
+ // Check initial scroll position (for page refresh)
53
+ this.checkScrollDepth()
54
+ }
55
+
56
+ /**
57
+ * Stop tracking
58
+ */
59
+ stop(): void {
60
+ window.removeEventListener('scroll', this.handleScroll)
61
+ document.removeEventListener('visibilitychange', this.handleVisibility)
62
+ }
63
+
64
+ /**
65
+ * Get current max scroll depth
66
+ */
67
+ getMaxDepth(): number {
68
+ return this.maxDepth
69
+ }
70
+
71
+ /**
72
+ * Get reported chunks
73
+ */
74
+ getReportedChunks(): number[] {
75
+ return Array.from(this.reportedChunks).sort((a, b) => a - b)
76
+ }
77
+
78
+ /**
79
+ * Get final scroll event (for unload)
80
+ */
81
+ getFinalEvent(): ScrollEvent {
82
+ const docHeight = document.documentElement.scrollHeight
83
+ const viewportHeight = window.innerHeight
84
+
85
+ return {
86
+ depth: this.maxDepth,
87
+ chunk: this.getChunkForDepth(this.maxDepth),
88
+ time_to_reach_ms: Date.now() - this.startTime,
89
+ total_height: docHeight,
90
+ viewport_height: viewportHeight,
91
+ }
92
+ }
93
+
94
+ private handleScroll = (): void => {
95
+ if (!this.ticking && this.isVisible) {
96
+ requestAnimationFrame(() => {
97
+ this.checkScrollDepth()
98
+ this.ticking = false
99
+ })
100
+ this.ticking = true
101
+ }
102
+ }
103
+
104
+ private handleVisibility = (): void => {
105
+ this.isVisible = document.visibilityState === 'visible'
106
+ }
107
+
108
+ private checkScrollDepth(): void {
109
+ const scrollY = window.scrollY
110
+ const viewportHeight = window.innerHeight
111
+ const docHeight = document.documentElement.scrollHeight
112
+
113
+ // Avoid division by zero
114
+ if (docHeight <= viewportHeight) {
115
+ this.updateDepth(100)
116
+ return
117
+ }
118
+
119
+ const scrollableHeight = docHeight - viewportHeight
120
+ const currentDepth = Math.min(100, Math.round((scrollY / scrollableHeight) * 100))
121
+
122
+ this.updateDepth(currentDepth)
123
+ }
124
+
125
+ private updateDepth(depth: number): void {
126
+ if (depth <= this.maxDepth) return
127
+
128
+ this.maxDepth = depth
129
+ this.config.onDepthChange?.(depth)
130
+
131
+ // Check for chunk milestones
132
+ for (const chunk of this.config.chunks!) {
133
+ if (depth >= chunk && !this.reportedChunks.has(chunk)) {
134
+ this.reportedChunks.add(chunk)
135
+ this.reportChunk(chunk)
136
+ }
137
+ }
138
+ }
139
+
140
+ private reportChunk(chunk: number): void {
141
+ const docHeight = document.documentElement.scrollHeight
142
+ const viewportHeight = window.innerHeight
143
+
144
+ const event: ScrollEvent = {
145
+ depth: this.maxDepth,
146
+ chunk,
147
+ time_to_reach_ms: Date.now() - this.startTime,
148
+ total_height: docHeight,
149
+ viewport_height: viewportHeight,
150
+ }
151
+
152
+ this.config.onChunkReached?.(event)
153
+ }
154
+
155
+ private getChunkForDepth(depth: number): number {
156
+ const chunks = this.config.chunks!.sort((a, b) => b - a)
157
+ for (const chunk of chunks) {
158
+ if (depth >= chunk) return chunk
159
+ }
160
+ return 0
161
+ }
162
+ }
163
+
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Time Spent Tracker
3
+ *
4
+ * Accurate time-on-page tracking with:
5
+ * - Visibility-aware (pauses when tab hidden)
6
+ * - Heartbeat updates
7
+ * - Engagement detection (active vs idle)
8
+ *
9
+ * @module @loamly/tracker
10
+ */
11
+
12
+ export interface TimeEvent {
13
+ active_time_ms: number
14
+ total_time_ms: number
15
+ idle_time_ms: number
16
+ is_engaged: boolean
17
+ }
18
+
19
+ export interface TimeTrackerConfig {
20
+ idleThresholdMs: number // Time without interaction to consider idle
21
+ updateIntervalMs: number // How often to report time
22
+ onUpdate?: (event: TimeEvent) => void
23
+ }
24
+
25
+ const DEFAULT_CONFIG: TimeTrackerConfig = {
26
+ idleThresholdMs: 30000, // 30 seconds
27
+ updateIntervalMs: 5000, // 5 seconds
28
+ }
29
+
30
+ export class TimeTracker {
31
+ private config: TimeTrackerConfig
32
+ private startTime: number
33
+ private activeTime = 0
34
+ private idleTime = 0
35
+ private lastActivityTime: number
36
+ private lastUpdateTime: number
37
+ private isVisible = true
38
+ private isIdle = false
39
+ private updateInterval: ReturnType<typeof setInterval> | null = null
40
+ private idleCheckInterval: ReturnType<typeof setInterval> | null = null
41
+
42
+ constructor(config: Partial<TimeTrackerConfig> = {}) {
43
+ this.config = { ...DEFAULT_CONFIG, ...config }
44
+ this.startTime = Date.now()
45
+ this.lastActivityTime = this.startTime
46
+ this.lastUpdateTime = this.startTime
47
+ }
48
+
49
+ /**
50
+ * Start tracking time
51
+ */
52
+ start(): void {
53
+ // Listen for visibility changes
54
+ document.addEventListener('visibilitychange', this.handleVisibility)
55
+
56
+ // Listen for user activity
57
+ const activityEvents = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart']
58
+ activityEvents.forEach(event => {
59
+ document.addEventListener(event, this.handleActivity, { passive: true })
60
+ })
61
+
62
+ // Start update interval
63
+ this.updateInterval = setInterval(() => {
64
+ this.update()
65
+ }, this.config.updateIntervalMs)
66
+
67
+ // Start idle check
68
+ this.idleCheckInterval = setInterval(() => {
69
+ this.checkIdle()
70
+ }, 1000)
71
+ }
72
+
73
+ /**
74
+ * Stop tracking
75
+ */
76
+ stop(): void {
77
+ document.removeEventListener('visibilitychange', this.handleVisibility)
78
+
79
+ const activityEvents = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart']
80
+ activityEvents.forEach(event => {
81
+ document.removeEventListener(event, this.handleActivity)
82
+ })
83
+
84
+ if (this.updateInterval) {
85
+ clearInterval(this.updateInterval)
86
+ this.updateInterval = null
87
+ }
88
+
89
+ if (this.idleCheckInterval) {
90
+ clearInterval(this.idleCheckInterval)
91
+ this.idleCheckInterval = null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get current time metrics
97
+ */
98
+ getMetrics(): TimeEvent {
99
+ this.updateTimes()
100
+
101
+ return {
102
+ active_time_ms: this.activeTime,
103
+ total_time_ms: Date.now() - this.startTime,
104
+ idle_time_ms: this.idleTime,
105
+ is_engaged: !this.isIdle && this.isVisible,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get final metrics (for unload)
111
+ */
112
+ getFinalMetrics(): TimeEvent {
113
+ this.updateTimes()
114
+ return this.getMetrics()
115
+ }
116
+
117
+ private handleVisibility = (): void => {
118
+ const wasVisible = this.isVisible
119
+ this.isVisible = document.visibilityState === 'visible'
120
+
121
+ if (wasVisible && !this.isVisible) {
122
+ // Tab hidden - update times before stopping
123
+ this.updateTimes()
124
+ } else if (!wasVisible && this.isVisible) {
125
+ // Tab shown - resume tracking
126
+ this.lastUpdateTime = Date.now()
127
+ this.lastActivityTime = Date.now()
128
+ }
129
+ }
130
+
131
+ private handleActivity = (): void => {
132
+ const now = Date.now()
133
+
134
+ if (this.isIdle) {
135
+ // Was idle, now active
136
+ this.isIdle = false
137
+ }
138
+
139
+ this.lastActivityTime = now
140
+ }
141
+
142
+ private checkIdle(): void {
143
+ const now = Date.now()
144
+ const timeSinceActivity = now - this.lastActivityTime
145
+
146
+ if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
147
+ // User went idle
148
+ this.isIdle = true
149
+ }
150
+ }
151
+
152
+ private updateTimes(): void {
153
+ const now = Date.now()
154
+ const elapsed = now - this.lastUpdateTime
155
+
156
+ if (this.isVisible) {
157
+ if (this.isIdle) {
158
+ this.idleTime += elapsed
159
+ } else {
160
+ this.activeTime += elapsed
161
+ }
162
+ }
163
+
164
+ this.lastUpdateTime = now
165
+ }
166
+
167
+ private update(): void {
168
+ if (!this.isVisible) return
169
+
170
+ this.updateTimes()
171
+ this.config.onUpdate?.(this.getMetrics())
172
+ }
173
+ }
174
+