@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/README.md +82 -0
- package/dist/index.cjs +584 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +146 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.mjs +551 -0
- package/dist/index.mjs.map +1 -0
- package/dist/loamly.iife.global.js +594 -0
- package/dist/loamly.iife.global.js.map +1 -0
- package/dist/loamly.iife.min.global.js +2 -0
- package/dist/loamly.iife.min.global.js.map +1 -0
- package/package.json +68 -0
- package/src/browser.ts +81 -0
- package/src/config.ts +59 -0
- package/src/core.ts +428 -0
- package/src/detection/index.ts +13 -0
- package/src/detection/navigation-timing.ts +117 -0
- package/src/detection/referrer.ts +98 -0
- package/src/index.ts +31 -0
- package/src/types.ts +100 -0
- package/src/utils.ts +130 -0
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
|
+
|