@loamly/tracker 1.6.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/src/core.ts ADDED
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Loamly Tracker Core
3
+ *
4
+ * Cookie-free, privacy-first analytics with AI traffic detection.
5
+ *
6
+ * @module @loamly/tracker
7
+ */
8
+
9
+ import { VERSION, DEFAULT_CONFIG } from './config'
10
+ import { detectNavigationType } from './detection/navigation-timing'
11
+ import { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
12
+ import {
13
+ getVisitorId,
14
+ getSessionId,
15
+ extractUTMParams,
16
+ truncateText,
17
+ safeFetch,
18
+ sendBeacon
19
+ } from './utils'
20
+ import type {
21
+ LoamlyConfig,
22
+ LoamlyTracker,
23
+ TrackEventOptions,
24
+ NavigationTiming,
25
+ AIDetectionResult
26
+ } from './types'
27
+
28
+ // State
29
+ let config: LoamlyConfig & { apiHost: string } = { apiHost: DEFAULT_CONFIG.apiHost }
30
+ let initialized = false
31
+ let debugMode = false
32
+ let visitorId: string | null = null
33
+ let sessionId: string | null = null
34
+ let sessionStartTime: number | null = null
35
+ let navigationTiming: NavigationTiming | null = null
36
+ let aiDetection: AIDetectionResult | null = null
37
+
38
+ /**
39
+ * Debug logger
40
+ */
41
+ function log(...args: unknown[]): void {
42
+ if (debugMode) {
43
+ console.log('[Loamly]', ...args)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Build API endpoint URL
49
+ */
50
+ function endpoint(path: string): string {
51
+ return `${config.apiHost}${path}`
52
+ }
53
+
54
+ /**
55
+ * Initialize the tracker
56
+ */
57
+ function init(userConfig: LoamlyConfig = {}): void {
58
+ if (initialized) {
59
+ log('Already initialized')
60
+ return
61
+ }
62
+
63
+ config = {
64
+ ...config,
65
+ ...userConfig,
66
+ apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost,
67
+ }
68
+
69
+ debugMode = userConfig.debug ?? false
70
+
71
+ log('Initializing Loamly Tracker v' + VERSION)
72
+
73
+ // Get/create visitor ID
74
+ visitorId = getVisitorId()
75
+ log('Visitor ID:', visitorId)
76
+
77
+ // Get/create session
78
+ const session = getSessionId()
79
+ sessionId = session.sessionId
80
+ sessionStartTime = Date.now()
81
+ log('Session ID:', sessionId, session.isNew ? '(new)' : '(existing)')
82
+
83
+ // Detect navigation timing (paste vs click)
84
+ navigationTiming = detectNavigationType()
85
+ log('Navigation timing:', navigationTiming)
86
+
87
+ // Detect AI from referrer
88
+ aiDetection = detectAIFromReferrer(document.referrer) || detectAIFromUTM(window.location.href)
89
+ if (aiDetection) {
90
+ log('AI detected:', aiDetection)
91
+ }
92
+
93
+ initialized = true
94
+
95
+ // Auto pageview unless disabled
96
+ if (!userConfig.disableAutoPageview) {
97
+ pageview()
98
+ }
99
+
100
+ // Set up behavioral tracking unless disabled
101
+ if (!userConfig.disableBehavioral) {
102
+ setupBehavioralTracking()
103
+ }
104
+
105
+ log('Initialization complete')
106
+ }
107
+
108
+ /**
109
+ * Track a page view
110
+ */
111
+ function pageview(customUrl?: string): void {
112
+ if (!initialized) {
113
+ log('Not initialized, call init() first')
114
+ return
115
+ }
116
+
117
+ const url = customUrl || window.location.href
118
+ const payload = {
119
+ visitor_id: visitorId,
120
+ session_id: sessionId,
121
+ url,
122
+ referrer: document.referrer || null,
123
+ title: document.title || null,
124
+ utm_source: extractUTMParams(url).utm_source || null,
125
+ utm_medium: extractUTMParams(url).utm_medium || null,
126
+ utm_campaign: extractUTMParams(url).utm_campaign || null,
127
+ user_agent: navigator.userAgent,
128
+ screen_width: window.screen?.width,
129
+ screen_height: window.screen?.height,
130
+ language: navigator.language,
131
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
132
+ tracker_version: VERSION,
133
+ navigation_timing: navigationTiming,
134
+ ai_platform: aiDetection?.platform || null,
135
+ is_ai_referrer: aiDetection?.isAI || false,
136
+ }
137
+
138
+ log('Pageview:', payload)
139
+
140
+ safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: JSON.stringify(payload),
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Track a custom event
149
+ */
150
+ function track(eventName: string, options: TrackEventOptions = {}): void {
151
+ if (!initialized) {
152
+ log('Not initialized, call init() first')
153
+ return
154
+ }
155
+
156
+ const payload = {
157
+ visitor_id: visitorId,
158
+ session_id: sessionId,
159
+ event_name: eventName,
160
+ event_type: 'custom',
161
+ properties: options.properties || {},
162
+ revenue: options.revenue,
163
+ currency: options.currency || 'USD',
164
+ url: window.location.href,
165
+ timestamp: new Date().toISOString(),
166
+ tracker_version: VERSION,
167
+ }
168
+
169
+ log('Event:', eventName, payload)
170
+
171
+ safeFetch(endpoint('/api/ingest/event'), {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify(payload),
175
+ })
176
+ }
177
+
178
+ /**
179
+ * Track a conversion/revenue event
180
+ */
181
+ function conversion(eventName: string, revenue: number, currency = 'USD'): void {
182
+ track(eventName, { revenue, currency, properties: { type: 'conversion' } })
183
+ }
184
+
185
+ /**
186
+ * Identify a user
187
+ */
188
+ function identify(userId: string, traits: Record<string, unknown> = {}): void {
189
+ if (!initialized) {
190
+ log('Not initialized, call init() first')
191
+ return
192
+ }
193
+
194
+ log('Identify:', userId, traits)
195
+
196
+ const payload = {
197
+ visitor_id: visitorId,
198
+ session_id: sessionId,
199
+ user_id: userId,
200
+ traits,
201
+ timestamp: new Date().toISOString(),
202
+ }
203
+
204
+ safeFetch(endpoint('/api/ingest/identify'), {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify(payload),
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Set up behavioral tracking (scroll, time spent, etc.)
213
+ */
214
+ function setupBehavioralTracking(): void {
215
+ let maxScrollDepth = 0
216
+ let lastScrollUpdate = 0
217
+ let lastTimeUpdate = Date.now()
218
+
219
+ // Scroll tracking with requestAnimationFrame throttling
220
+ let scrollTicking = false
221
+
222
+ window.addEventListener('scroll', () => {
223
+ if (!scrollTicking) {
224
+ requestAnimationFrame(() => {
225
+ const scrollPercent = Math.round(
226
+ ((window.scrollY + window.innerHeight) / document.documentElement.scrollHeight) * 100
227
+ )
228
+
229
+ if (scrollPercent > maxScrollDepth) {
230
+ maxScrollDepth = scrollPercent
231
+
232
+ // Report at milestones (25%, 50%, 75%, 100%)
233
+ const milestones = [25, 50, 75, 100]
234
+ for (const milestone of milestones) {
235
+ if (scrollPercent >= milestone && lastScrollUpdate < milestone) {
236
+ lastScrollUpdate = milestone
237
+ sendBehavioralEvent('scroll_depth', { depth: milestone })
238
+ }
239
+ }
240
+ }
241
+
242
+ scrollTicking = false
243
+ })
244
+ scrollTicking = true
245
+ }
246
+ })
247
+
248
+ // Time spent tracking (every 5 seconds minimum)
249
+ const trackTimeSpent = (): void => {
250
+ const now = Date.now()
251
+ const delta = now - lastTimeUpdate
252
+
253
+ if (delta >= DEFAULT_CONFIG.timeSpentThresholdMs) {
254
+ lastTimeUpdate = now
255
+ sendBehavioralEvent('time_spent', {
256
+ seconds: Math.round(delta / 1000),
257
+ total_seconds: Math.round((now - (sessionStartTime || now)) / 1000)
258
+ })
259
+ }
260
+ }
261
+
262
+ // Track on visibility change
263
+ document.addEventListener('visibilitychange', () => {
264
+ if (document.visibilityState === 'hidden') {
265
+ trackTimeSpent()
266
+ }
267
+ })
268
+
269
+ // Track on page unload
270
+ window.addEventListener('beforeunload', () => {
271
+ trackTimeSpent()
272
+
273
+ // Send final scroll depth
274
+ if (maxScrollDepth > 0) {
275
+ sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
276
+ visitor_id: visitorId,
277
+ session_id: sessionId,
278
+ event_type: 'scroll_depth_final',
279
+ data: { depth: maxScrollDepth },
280
+ url: window.location.href,
281
+ })
282
+ }
283
+ })
284
+
285
+ // Form interaction tracking
286
+ document.addEventListener('focusin', (e) => {
287
+ const target = e.target as HTMLElement
288
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
289
+ sendBehavioralEvent('form_focus', {
290
+ field_type: target.tagName.toLowerCase(),
291
+ field_name: (target as HTMLInputElement).name || (target as HTMLInputElement).id || 'unknown',
292
+ })
293
+ }
294
+ })
295
+
296
+ // Form submit tracking
297
+ document.addEventListener('submit', (e) => {
298
+ const form = e.target as HTMLFormElement
299
+ sendBehavioralEvent('form_submit', {
300
+ form_id: form.id || form.name || 'unknown',
301
+ form_action: form.action ? new URL(form.action).pathname : 'unknown',
302
+ })
303
+ })
304
+
305
+ // Click tracking for links
306
+ document.addEventListener('click', (e) => {
307
+ const target = e.target as HTMLElement
308
+ const link = target.closest('a')
309
+
310
+ if (link && link.href) {
311
+ const isExternal = link.hostname !== window.location.hostname
312
+ sendBehavioralEvent('click', {
313
+ element: 'link',
314
+ href: truncateText(link.href, 200),
315
+ text: truncateText(link.textContent || '', 100),
316
+ is_external: isExternal,
317
+ })
318
+ }
319
+ })
320
+ }
321
+
322
+ /**
323
+ * Send a behavioral event
324
+ */
325
+ function sendBehavioralEvent(eventType: string, data: Record<string, unknown>): void {
326
+ const payload = {
327
+ visitor_id: visitorId,
328
+ session_id: sessionId,
329
+ event_type: eventType,
330
+ data,
331
+ url: window.location.href,
332
+ timestamp: new Date().toISOString(),
333
+ tracker_version: VERSION,
334
+ }
335
+
336
+ log('Behavioral:', eventType, data)
337
+
338
+ safeFetch(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json' },
341
+ body: JSON.stringify(payload),
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Get current session ID
347
+ */
348
+ function getCurrentSessionId(): string | null {
349
+ return sessionId
350
+ }
351
+
352
+ /**
353
+ * Get current visitor ID
354
+ */
355
+ function getCurrentVisitorId(): string | null {
356
+ return visitorId
357
+ }
358
+
359
+ /**
360
+ * Get AI detection result
361
+ */
362
+ function getAIDetectionResult(): AIDetectionResult | null {
363
+ return aiDetection
364
+ }
365
+
366
+ /**
367
+ * Get navigation timing result
368
+ */
369
+ function getNavigationTimingResult(): NavigationTiming | null {
370
+ return navigationTiming
371
+ }
372
+
373
+ /**
374
+ * Check if initialized
375
+ */
376
+ function isTrackerInitialized(): boolean {
377
+ return initialized
378
+ }
379
+
380
+ /**
381
+ * Reset the tracker
382
+ */
383
+ function reset(): void {
384
+ log('Resetting tracker')
385
+ initialized = false
386
+ visitorId = null
387
+ sessionId = null
388
+ sessionStartTime = null
389
+ navigationTiming = null
390
+ aiDetection = null
391
+
392
+ try {
393
+ sessionStorage.removeItem('loamly_session')
394
+ sessionStorage.removeItem('loamly_start')
395
+ } catch {
396
+ // Ignore
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Enable/disable debug mode
402
+ */
403
+ function setDebug(enabled: boolean): void {
404
+ debugMode = enabled
405
+ log('Debug mode:', enabled ? 'enabled' : 'disabled')
406
+ }
407
+
408
+ /**
409
+ * The Loamly Tracker instance
410
+ */
411
+ export const loamly: LoamlyTracker = {
412
+ init,
413
+ pageview,
414
+ track,
415
+ conversion,
416
+ identify,
417
+ getSessionId: getCurrentSessionId,
418
+ getVisitorId: getCurrentVisitorId,
419
+ getAIDetection: getAIDetectionResult,
420
+ getNavigationTiming: getNavigationTimingResult,
421
+ isInitialized: isTrackerInitialized,
422
+ reset,
423
+ debug: setDebug,
424
+ }
425
+
426
+ export default loamly
427
+
428
+
@@ -0,0 +1,13 @@
1
+ /**
2
+ * AI Traffic Detection Module
3
+ *
4
+ * Revolutionary methods for detecting "dark" AI traffic that
5
+ * traditional analytics miss.
6
+ *
7
+ * @module @loamly/tracker/detection
8
+ */
9
+
10
+ export { detectNavigationType } from './navigation-timing'
11
+ export { detectAIFromReferrer, detectAIFromUTM } from './referrer'
12
+
13
+
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Navigation Timing API Detection
3
+ *
4
+ * Detects whether the user arrived via paste (from AI chat) vs click
5
+ * by analyzing Navigation Timing API patterns.
6
+ *
7
+ * @module @loamly/tracker/detection
8
+ */
9
+
10
+ import type { NavigationTiming } from '../types'
11
+
12
+ /**
13
+ * Analyze Navigation Timing API to detect paste vs click navigation
14
+ *
15
+ * When users paste a URL (common after copying from AI chat),
16
+ * the timing patterns are distinctive:
17
+ * 1. fetchStart is virtually immediate after navigationStart
18
+ * 2. DNS/connect times are often 0 (cached or direct)
19
+ * 3. No redirect chain
20
+ * 4. Uniform timing patterns
21
+ *
22
+ * @returns NavigationTiming result with type and confidence
23
+ */
24
+ export function detectNavigationType(): NavigationTiming {
25
+ try {
26
+ const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]
27
+
28
+ if (!entries || entries.length === 0) {
29
+ return { nav_type: 'unknown', confidence: 0, signals: ['no_timing_data'] }
30
+ }
31
+
32
+ const nav = entries[0]
33
+ const signals: string[] = []
34
+ let pasteScore = 0
35
+
36
+ // Signal 1: fetchStart delta from navigationStart
37
+ // Paste navigation typically has very small delta (< 5ms)
38
+ const fetchStartDelta = nav.fetchStart - nav.startTime
39
+ if (fetchStartDelta < 5) {
40
+ pasteScore += 0.25
41
+ signals.push('instant_fetch_start')
42
+ } else if (fetchStartDelta < 20) {
43
+ pasteScore += 0.15
44
+ signals.push('fast_fetch_start')
45
+ }
46
+
47
+ // Signal 2: DNS lookup time
48
+ // Paste = likely 0 (direct URL entry, no link warmup)
49
+ const dnsTime = nav.domainLookupEnd - nav.domainLookupStart
50
+ if (dnsTime === 0) {
51
+ pasteScore += 0.15
52
+ signals.push('no_dns_lookup')
53
+ }
54
+
55
+ // Signal 3: TCP connect time
56
+ // Paste = likely 0 (no preconnect from previous page)
57
+ const connectTime = nav.connectEnd - nav.connectStart
58
+ if (connectTime === 0) {
59
+ pasteScore += 0.15
60
+ signals.push('no_tcp_connect')
61
+ }
62
+
63
+ // Signal 4: No redirects
64
+ // Paste URLs are typically direct, no redirects
65
+ if (nav.redirectCount === 0) {
66
+ pasteScore += 0.1
67
+ signals.push('no_redirects')
68
+ }
69
+
70
+ // Signal 5: Timing uniformity check
71
+ // Paste navigation tends to have more uniform patterns
72
+ const timingVariance = calculateTimingVariance(nav)
73
+ if (timingVariance < 10) {
74
+ pasteScore += 0.15
75
+ signals.push('uniform_timing')
76
+ }
77
+
78
+ // Signal 6: No referrer (common for paste)
79
+ if (!document.referrer || document.referrer === '') {
80
+ pasteScore += 0.1
81
+ signals.push('no_referrer')
82
+ }
83
+
84
+ // Determine navigation type based on score
85
+ const confidence = Math.min(pasteScore, 1)
86
+ const nav_type = pasteScore >= 0.5 ? 'likely_paste' : 'likely_click'
87
+
88
+ return {
89
+ nav_type,
90
+ confidence: Math.round(confidence * 1000) / 1000,
91
+ signals,
92
+ }
93
+ } catch {
94
+ return { nav_type: 'unknown', confidence: 0, signals: ['detection_error'] }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Calculate timing variance to detect paste patterns
100
+ */
101
+ function calculateTimingVariance(nav: PerformanceNavigationTiming): number {
102
+ const timings = [
103
+ nav.fetchStart - nav.startTime,
104
+ nav.domainLookupEnd - nav.domainLookupStart,
105
+ nav.connectEnd - nav.connectStart,
106
+ nav.responseStart - nav.requestStart,
107
+ ].filter((t) => t >= 0)
108
+
109
+ if (timings.length === 0) return 100
110
+
111
+ const mean = timings.reduce((a, b) => a + b, 0) / timings.length
112
+ const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length
113
+
114
+ return Math.sqrt(variance)
115
+ }
116
+
117
+
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Referrer-based AI Detection
3
+ *
4
+ * Detects when users arrive from known AI platforms
5
+ * based on the document.referrer header.
6
+ *
7
+ * @module @loamly/tracker/detection
8
+ */
9
+
10
+ import { AI_PLATFORMS } from '../config'
11
+ import type { AIDetectionResult } from '../types'
12
+
13
+ /**
14
+ * Detect AI platform from referrer URL
15
+ *
16
+ * @param referrer - The document.referrer value
17
+ * @returns AI detection result or null if no AI detected
18
+ */
19
+ export function detectAIFromReferrer(referrer: string): AIDetectionResult | null {
20
+ if (!referrer) {
21
+ return null
22
+ }
23
+
24
+ try {
25
+ const url = new URL(referrer)
26
+ const hostname = url.hostname.toLowerCase()
27
+
28
+ // Check against known AI platforms
29
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
30
+ if (hostname.includes(pattern) || referrer.includes(pattern)) {
31
+ return {
32
+ isAI: true,
33
+ platform,
34
+ confidence: 0.95, // High confidence when referrer matches
35
+ method: 'referrer',
36
+ }
37
+ }
38
+ }
39
+
40
+ return null
41
+ } catch {
42
+ // Invalid URL, try pattern matching on raw string
43
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
44
+ if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
45
+ return {
46
+ isAI: true,
47
+ platform,
48
+ confidence: 0.85,
49
+ method: 'referrer',
50
+ }
51
+ }
52
+ }
53
+ return null
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Extract AI platform from UTM parameters
59
+ *
60
+ * @param url - The current page URL
61
+ * @returns AI platform name or null
62
+ */
63
+ export function detectAIFromUTM(url: string): AIDetectionResult | null {
64
+ try {
65
+ const params = new URL(url).searchParams
66
+
67
+ // Check utm_source for AI platforms
68
+ const utmSource = params.get('utm_source')?.toLowerCase()
69
+ if (utmSource) {
70
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
71
+ if (utmSource.includes(pattern.split('.')[0])) {
72
+ return {
73
+ isAI: true,
74
+ platform,
75
+ confidence: 0.99, // Very high confidence from explicit UTM
76
+ method: 'referrer',
77
+ }
78
+ }
79
+ }
80
+
81
+ // Generic AI source patterns
82
+ if (utmSource.includes('ai') || utmSource.includes('llm') || utmSource.includes('chatbot')) {
83
+ return {
84
+ isAI: true,
85
+ platform: utmSource,
86
+ confidence: 0.9,
87
+ method: 'referrer',
88
+ }
89
+ }
90
+ }
91
+
92
+ return null
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Loamly Tracker
3
+ *
4
+ * Open-source AI traffic detection for websites.
5
+ * See what AI tells your customers — and track when they click.
6
+ *
7
+ * @module @loamly/tracker
8
+ * @license MIT
9
+ * @see https://loamly.ai
10
+ */
11
+
12
+ // Core tracker
13
+ export { loamly, loamly as default } from './core'
14
+
15
+ // Types
16
+ export type {
17
+ LoamlyConfig,
18
+ LoamlyTracker,
19
+ TrackEventOptions,
20
+ NavigationTiming,
21
+ AIDetectionResult,
22
+ } from './types'
23
+
24
+ // Detection utilities (for advanced usage)
25
+ export { detectNavigationType } from './detection/navigation-timing'
26
+ export { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
27
+
28
+ // Configuration
29
+ export { VERSION, AI_PLATFORMS, AI_BOT_PATTERNS } from './config'
30
+
31
+