@productbet/tracker-core 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,1148 @@
1
+ const DEFAULT_OPTIONS = {
2
+ ingestionUrl: "https://productbet.io/tracking",
3
+ enableSessionRecording: false,
4
+ enableAutoTracking: true,
5
+ autoTrackingOptions: {
6
+ trackClicks: true,
7
+ trackForms: true,
8
+ trackNavigation: true,
9
+ trackScrollDepth: true,
10
+ detectRageClicks: true,
11
+ detectDeadClicks: true,
12
+ includeText: true,
13
+ includeClasses: true,
14
+ },
15
+ redaction: {
16
+ mode: "privacy-first",
17
+ unredactFields: [],
18
+ },
19
+ maxQueueSize: 1000,
20
+ flushIntervalMs: 3000,
21
+ enableConsoleTracking: true,
22
+ enableNetworkTracking: true,
23
+ recordCanvas: false,
24
+ sessionTimeoutMs: 30 * 60 * 1000,
25
+ debug: false,
26
+ };
27
+ const SDK_VERSION = "0.1.0";
28
+
29
+ let debugEnabled = false;
30
+ function setDebug(enabled) {
31
+ debugEnabled = enabled;
32
+ }
33
+ function debug(...args) {
34
+ if (debugEnabled) {
35
+ console.log("[ProductBet]", ...args);
36
+ }
37
+ }
38
+ function warn(...args) {
39
+ console.warn("[ProductBet]", ...args);
40
+ }
41
+ function error(...args) {
42
+ console.error("[ProductBet]", ...args);
43
+ }
44
+
45
+ const STORAGE_KEY = "pb_session";
46
+ const WINDOW_ID_KEY = "pb_window_id";
47
+ function generateId() {
48
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
49
+ const segments = [8, 4, 4, 12];
50
+ return segments
51
+ .map((len) => Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join(""))
52
+ .join("-");
53
+ }
54
+ class SessionManager {
55
+ constructor(timeoutMs) {
56
+ this.timeoutMs = timeoutMs;
57
+ this.windowId = this.getOrCreateWindowId();
58
+ const restored = this.restore();
59
+ if (restored) {
60
+ this.sessionId = restored.sessionId;
61
+ this.startedAt = restored.startedAt;
62
+ this.lastActivityAt = restored.lastActivityAt;
63
+ debug("Restored session", this.sessionId);
64
+ }
65
+ else {
66
+ this.sessionId = generateId();
67
+ this.startedAt = Date.now();
68
+ this.lastActivityAt = Date.now();
69
+ this.persist();
70
+ debug("Created new session", this.sessionId);
71
+ }
72
+ }
73
+ getSessionId() {
74
+ return this.sessionId;
75
+ }
76
+ getWindowId() {
77
+ return this.windowId;
78
+ }
79
+ getStartedAt() {
80
+ return this.startedAt;
81
+ }
82
+ touch() {
83
+ this.lastActivityAt = Date.now();
84
+ this.persist();
85
+ }
86
+ isExpired() {
87
+ return Date.now() - this.lastActivityAt > this.timeoutMs;
88
+ }
89
+ rotate() {
90
+ this.sessionId = generateId();
91
+ this.startedAt = Date.now();
92
+ this.lastActivityAt = Date.now();
93
+ this.persist();
94
+ debug("Rotated session", this.sessionId);
95
+ return this.sessionId;
96
+ }
97
+ persist() {
98
+ try {
99
+ const data = {
100
+ sessionId: this.sessionId,
101
+ lastActivityAt: this.lastActivityAt,
102
+ startedAt: this.startedAt,
103
+ };
104
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
105
+ }
106
+ catch {
107
+ // localStorage may be unavailable (SSR, private browsing)
108
+ }
109
+ }
110
+ restore() {
111
+ try {
112
+ const raw = localStorage.getItem(STORAGE_KEY);
113
+ if (!raw)
114
+ return null;
115
+ const data = JSON.parse(raw);
116
+ if (Date.now() - data.lastActivityAt > this.timeoutMs) {
117
+ localStorage.removeItem(STORAGE_KEY);
118
+ return null;
119
+ }
120
+ return data;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ getOrCreateWindowId() {
127
+ try {
128
+ let id = sessionStorage.getItem(WINDOW_ID_KEY);
129
+ if (!id) {
130
+ id = generateId();
131
+ sessionStorage.setItem(WINDOW_ID_KEY, id);
132
+ }
133
+ return id;
134
+ }
135
+ catch {
136
+ return generateId();
137
+ }
138
+ }
139
+ }
140
+
141
+ const MAX_PAYLOAD_BYTES = 1000000;
142
+ const RETRY_DELAYS = [1000, 2000, 4000, 8000];
143
+ const QUEUE_STORAGE_KEY = "pb_event_queue";
144
+ const MIN_SESSION_DURATION_MS = 5000;
145
+ class ApiClient {
146
+ constructor(apiKey, ingestionUrl, sessionId, maxQueueSize, flushIntervalMs, windowId) {
147
+ this.queue = [];
148
+ this.replayQueue = [];
149
+ this.flushTimer = null;
150
+ this.endUserId = null;
151
+ this.isFlushing = false;
152
+ this.monthlyLimitReached = false;
153
+ this.startedAt = Date.now();
154
+ this.sessionMatureEnough = false;
155
+ this.apiKey = apiKey;
156
+ this.ingestionUrl = ingestionUrl.replace(/\/$/, "");
157
+ this.sessionId = sessionId;
158
+ this.windowId = windowId;
159
+ this.maxQueueSize = maxQueueSize;
160
+ this.flushIntervalMs = flushIntervalMs;
161
+ this.restoreQueue();
162
+ }
163
+ start() {
164
+ this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
165
+ if (typeof window !== "undefined") {
166
+ window.addEventListener("beforeunload", () => this.onUnload());
167
+ document.addEventListener("visibilitychange", () => {
168
+ if (document.visibilityState === "hidden")
169
+ this.onUnload();
170
+ });
171
+ }
172
+ debug("ApiClient started, flushing every", this.flushIntervalMs, "ms");
173
+ }
174
+ stop() {
175
+ if (this.flushTimer) {
176
+ clearInterval(this.flushTimer);
177
+ this.flushTimer = null;
178
+ }
179
+ this.flush();
180
+ }
181
+ updateSessionId(sessionId) {
182
+ this.sessionId = sessionId;
183
+ }
184
+ setEndUserId(userId) {
185
+ this.endUserId = userId;
186
+ }
187
+ enqueueEvent(event) {
188
+ if (this.monthlyLimitReached)
189
+ return;
190
+ this.queue.push(event);
191
+ if (this.queue.length >= this.maxQueueSize) {
192
+ this.flush();
193
+ }
194
+ }
195
+ enqueueReplayEvent(event) {
196
+ if (this.monthlyLimitReached)
197
+ return;
198
+ this.replayQueue.push(event);
199
+ }
200
+ async sendIdentify(payload) {
201
+ try {
202
+ await this.post("/identify", payload);
203
+ debug("Identify sent", payload.userId);
204
+ }
205
+ catch (err) {
206
+ error("Failed to send identify", err);
207
+ }
208
+ }
209
+ async sendCustomEvent(payload) {
210
+ try {
211
+ await this.post("/custom", payload);
212
+ debug("Custom event sent", payload.eventName);
213
+ }
214
+ catch (err) {
215
+ error("Failed to send custom event", err);
216
+ }
217
+ }
218
+ async flush() {
219
+ if (this.isFlushing)
220
+ return;
221
+ if (this.queue.length === 0 && this.replayQueue.length === 0)
222
+ return;
223
+ if (this.monthlyLimitReached)
224
+ return;
225
+ if (!this.sessionMatureEnough) {
226
+ if (Date.now() - this.startedAt < MIN_SESSION_DURATION_MS) {
227
+ debug("Session too short, buffering events");
228
+ return;
229
+ }
230
+ this.sessionMatureEnough = true;
231
+ }
232
+ this.isFlushing = true;
233
+ const events = this.queue.splice(0);
234
+ const replayEvents = this.replayQueue.splice(0);
235
+ const batch = {
236
+ sessionId: this.sessionId,
237
+ windowId: this.windowId,
238
+ events,
239
+ replayEvents: replayEvents.length > 0 ? replayEvents : undefined,
240
+ endUserId: this.endUserId,
241
+ sdkVersion: SDK_VERSION,
242
+ timestamp: Date.now(),
243
+ };
244
+ try {
245
+ const payload = JSON.stringify(batch);
246
+ if (payload.length > MAX_PAYLOAD_BYTES) {
247
+ await this.sendChunked(events, replayEvents);
248
+ }
249
+ else {
250
+ const response = await this.post("/events", batch);
251
+ if (response?.monthlyLimitReached) {
252
+ this.monthlyLimitReached = true;
253
+ warn("Monthly event limit reached. Tracking paused.");
254
+ }
255
+ }
256
+ this.clearPersistedQueue();
257
+ debug("Flushed", events.length, "events +", replayEvents.length, "replay events");
258
+ }
259
+ catch {
260
+ this.queue.unshift(...events);
261
+ this.replayQueue.unshift(...replayEvents);
262
+ this.persistQueue();
263
+ warn("Flush failed, events re-queued");
264
+ }
265
+ finally {
266
+ this.isFlushing = false;
267
+ }
268
+ }
269
+ async sendChunked(events, replayEvents) {
270
+ const chunkSize = Math.ceil(events.length / 2);
271
+ for (let i = 0; i < events.length; i += chunkSize) {
272
+ const chunk = events.slice(i, i + chunkSize);
273
+ const replayChunk = i === 0 ? replayEvents : [];
274
+ const batch = {
275
+ sessionId: this.sessionId,
276
+ windowId: this.windowId,
277
+ events: chunk,
278
+ replayEvents: replayChunk.length > 0 ? replayChunk : undefined,
279
+ endUserId: this.endUserId,
280
+ sdkVersion: SDK_VERSION,
281
+ timestamp: Date.now(),
282
+ };
283
+ await this.post("/events", batch);
284
+ }
285
+ }
286
+ async post(path, body, retryCount = 0) {
287
+ const url = `${this.ingestionUrl}${path}`;
288
+ const response = await fetch(url, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ Authorization: `Bearer ${this.apiKey}`,
293
+ },
294
+ body: JSON.stringify(body),
295
+ credentials: "omit",
296
+ });
297
+ if (response.status === 429) {
298
+ this.monthlyLimitReached = true;
299
+ warn("Rate limited (429). Tracking paused.");
300
+ return { success: false, monthlyLimitReached: true };
301
+ }
302
+ if (response.status === 413 && retryCount === 0) {
303
+ warn("Payload too large, will chunk on next flush");
304
+ return null;
305
+ }
306
+ if (!response.ok) {
307
+ let errorBody = "";
308
+ try {
309
+ errorBody = await response.text();
310
+ }
311
+ catch { }
312
+ warn(`Ingestion error ${response.status}: ${errorBody}`);
313
+ if (retryCount < RETRY_DELAYS.length) {
314
+ const delay = RETRY_DELAYS[retryCount];
315
+ debug("Retrying in", delay, "ms (attempt", retryCount + 1, ")");
316
+ await new Promise((r) => setTimeout(r, delay));
317
+ return this.post(path, body, retryCount + 1);
318
+ }
319
+ throw new Error(`Ingestion failed: ${response.status}`);
320
+ }
321
+ return response.json();
322
+ }
323
+ onUnload() {
324
+ if (this.queue.length === 0 && this.replayQueue.length === 0)
325
+ return;
326
+ const batch = {
327
+ sessionId: this.sessionId,
328
+ windowId: this.windowId,
329
+ events: this.queue.splice(0),
330
+ replayEvents: this.replayQueue.length > 0
331
+ ? this.replayQueue.splice(0)
332
+ : undefined,
333
+ endUserId: this.endUserId,
334
+ sdkVersion: SDK_VERSION,
335
+ timestamp: Date.now(),
336
+ };
337
+ const url = `${this.ingestionUrl}/events`;
338
+ const payload = JSON.stringify(batch);
339
+ if (typeof navigator.sendBeacon === "function") {
340
+ const blob = new Blob([payload], { type: "application/json" });
341
+ const sent = navigator.sendBeacon(url, blob);
342
+ if (sent) {
343
+ debug("Sent via sendBeacon on unload");
344
+ return;
345
+ }
346
+ }
347
+ this.persistQueue();
348
+ }
349
+ persistQueue() {
350
+ try {
351
+ if (this.queue.length > 0) {
352
+ localStorage.setItem(QUEUE_STORAGE_KEY, JSON.stringify(this.queue));
353
+ }
354
+ }
355
+ catch {
356
+ // localStorage unavailable
357
+ }
358
+ }
359
+ restoreQueue() {
360
+ try {
361
+ const raw = localStorage.getItem(QUEUE_STORAGE_KEY);
362
+ if (raw) {
363
+ const events = JSON.parse(raw);
364
+ this.queue.unshift(...events);
365
+ localStorage.removeItem(QUEUE_STORAGE_KEY);
366
+ debug("Restored", events.length, "queued events");
367
+ }
368
+ }
369
+ catch {
370
+ // ignore
371
+ }
372
+ }
373
+ clearPersistedQueue() {
374
+ try {
375
+ localStorage.removeItem(QUEUE_STORAGE_KEY);
376
+ }
377
+ catch {
378
+ // ignore
379
+ }
380
+ }
381
+ }
382
+
383
+ const RAGE_CLICK_THRESHOLD = 3;
384
+ const RAGE_CLICK_WINDOW_MS = 1000;
385
+ const RAGE_CLICK_RADIUS_PX = 30;
386
+ const DEAD_CLICK_TIMEOUT_MS = 2500;
387
+ const INTERACTIVE_TAGS = new Set([
388
+ "A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "LABEL",
389
+ ]);
390
+ const INTERACTIVE_ROLES = new Set([
391
+ "button", "link", "tab", "menuitem", "checkbox", "radio",
392
+ ]);
393
+ function isInteractiveElement(el) {
394
+ let current = el;
395
+ while (current && current !== document.body) {
396
+ if (INTERACTIVE_TAGS.has(current.tagName))
397
+ return true;
398
+ const role = current.getAttribute("role");
399
+ if (role && INTERACTIVE_ROLES.has(role))
400
+ return true;
401
+ if (current.hasAttribute("onclick"))
402
+ return true;
403
+ if (current.hasAttribute("tabindex"))
404
+ return true;
405
+ current = current.parentElement;
406
+ }
407
+ try {
408
+ if (window.getComputedStyle(el).cursor === "pointer")
409
+ return true;
410
+ }
411
+ catch {
412
+ // getComputedStyle can throw on detached elements
413
+ }
414
+ return false;
415
+ }
416
+ class AutoTracker {
417
+ constructor(options, redaction, sessionId, onEvent) {
418
+ this.recentClicks = [];
419
+ this.scrollDepthMax = 0;
420
+ this.scrollTimer = null;
421
+ this.originalPushState = null;
422
+ this.originalReplaceState = null;
423
+ this.cleanupFns = [];
424
+ this.options = options;
425
+ this.redaction = redaction;
426
+ this.sessionId = sessionId;
427
+ this.onEvent = onEvent;
428
+ }
429
+ start() {
430
+ if (typeof window === "undefined")
431
+ return;
432
+ if (this.options.trackClicks !== false)
433
+ this.setupClickTracking();
434
+ if (this.options.trackForms !== false)
435
+ this.setupFormTracking();
436
+ if (this.options.trackNavigation !== false)
437
+ this.setupNavigationTracking();
438
+ if (this.options.trackScrollDepth !== false)
439
+ this.setupScrollTracking();
440
+ debug("AutoTracker started");
441
+ }
442
+ stop() {
443
+ this.cleanupFns.forEach((fn) => fn());
444
+ this.cleanupFns = [];
445
+ if (this.originalPushState) {
446
+ history.pushState = this.originalPushState;
447
+ }
448
+ if (this.originalReplaceState) {
449
+ history.replaceState = this.originalReplaceState;
450
+ }
451
+ debug("AutoTracker stopped");
452
+ }
453
+ updateSessionId(sessionId) {
454
+ this.sessionId = sessionId;
455
+ }
456
+ setupClickTracking() {
457
+ const handler = (e) => {
458
+ const target = e.target;
459
+ if (!target)
460
+ return;
461
+ const info = this.getElementInfo(target);
462
+ this.emit("click", {
463
+ element: info,
464
+ x: e.clientX,
465
+ y: e.clientY,
466
+ pageUrl: window.location.href,
467
+ });
468
+ if (this.options.detectRageClicks !== false) {
469
+ this.checkRageClick(e);
470
+ }
471
+ if (this.options.detectDeadClicks !== false) {
472
+ this.checkDeadClick(e, target);
473
+ }
474
+ };
475
+ document.addEventListener("click", handler, true);
476
+ this.cleanupFns.push(() => document.removeEventListener("click", handler, true));
477
+ }
478
+ setupFormTracking() {
479
+ const submitHandler = (e) => {
480
+ const form = e.target;
481
+ if (!form)
482
+ return;
483
+ this.emit("form_submit", {
484
+ element: this.getElementInfo(form),
485
+ action: form.action,
486
+ method: form.method,
487
+ pageUrl: window.location.href,
488
+ });
489
+ };
490
+ const changeHandler = (e) => {
491
+ const target = e.target;
492
+ if (!target)
493
+ return;
494
+ if (!["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName))
495
+ return;
496
+ const info = this.getElementInfo(target);
497
+ this.emit("form_change", {
498
+ element: info,
499
+ fieldType: target.type || target.tagName.toLowerCase(),
500
+ pageUrl: window.location.href,
501
+ });
502
+ };
503
+ document.addEventListener("submit", submitHandler, true);
504
+ document.addEventListener("change", changeHandler, true);
505
+ this.cleanupFns.push(() => document.removeEventListener("submit", submitHandler, true), () => document.removeEventListener("change", changeHandler, true));
506
+ }
507
+ setupNavigationTracking() {
508
+ this.emitPageView();
509
+ const popstateHandler = () => this.emitPageView();
510
+ window.addEventListener("popstate", popstateHandler);
511
+ this.cleanupFns.push(() => window.removeEventListener("popstate", popstateHandler));
512
+ this.originalPushState = history.pushState.bind(history);
513
+ this.originalReplaceState = history.replaceState.bind(history);
514
+ history.pushState = (...args) => {
515
+ this.originalPushState(...args);
516
+ this.emitPageView();
517
+ };
518
+ history.replaceState = (...args) => {
519
+ this.originalReplaceState(...args);
520
+ this.emitPageView();
521
+ };
522
+ }
523
+ setupScrollTracking() {
524
+ const handler = () => {
525
+ if (this.scrollTimer)
526
+ clearTimeout(this.scrollTimer);
527
+ this.scrollTimer = setTimeout(() => {
528
+ const scrollHeight = document.documentElement.scrollHeight;
529
+ const viewportHeight = window.innerHeight;
530
+ const scrollTop = window.scrollY;
531
+ if (scrollHeight <= viewportHeight)
532
+ return;
533
+ const depth = Math.round(((scrollTop + viewportHeight) / scrollHeight) * 100);
534
+ if (depth > this.scrollDepthMax) {
535
+ this.scrollDepthMax = depth;
536
+ this.emit("scroll_depth", {
537
+ depth,
538
+ pageUrl: window.location.href,
539
+ });
540
+ }
541
+ }, 250);
542
+ };
543
+ window.addEventListener("scroll", handler, { passive: true });
544
+ this.cleanupFns.push(() => window.removeEventListener("scroll", handler));
545
+ }
546
+ checkRageClick(e) {
547
+ const now = Date.now();
548
+ this.recentClicks.push({
549
+ target: e.target,
550
+ timestamp: now,
551
+ x: e.clientX,
552
+ y: e.clientY,
553
+ });
554
+ this.recentClicks = this.recentClicks.filter((c) => now - c.timestamp < RAGE_CLICK_WINDOW_MS);
555
+ if (this.recentClicks.length >= RAGE_CLICK_THRESHOLD) {
556
+ const nearby = this.recentClicks.filter((c) => {
557
+ const dx = Math.abs(c.x - e.clientX);
558
+ const dy = Math.abs(c.y - e.clientY);
559
+ return dx <= RAGE_CLICK_RADIUS_PX && dy <= RAGE_CLICK_RADIUS_PX;
560
+ });
561
+ if (nearby.length >= RAGE_CLICK_THRESHOLD) {
562
+ const target = e.target;
563
+ this.emit("rage_click", {
564
+ element: this.getElementInfo(target),
565
+ clickCount: nearby.length,
566
+ windowMs: RAGE_CLICK_WINDOW_MS,
567
+ pageUrl: window.location.href,
568
+ });
569
+ this.recentClicks = [];
570
+ }
571
+ }
572
+ }
573
+ checkDeadClick(_e, target) {
574
+ if (isInteractiveElement(target))
575
+ return;
576
+ const clickUrl = window.location.href;
577
+ let hadMutation = false;
578
+ let hadScroll = false;
579
+ let hadSelection = false;
580
+ const observer = new MutationObserver(() => {
581
+ hadMutation = true;
582
+ });
583
+ observer.observe(document.body, {
584
+ childList: true,
585
+ subtree: true,
586
+ attributes: true,
587
+ characterData: true,
588
+ });
589
+ const scrollHandler = () => { hadScroll = true; };
590
+ const selectionHandler = () => {
591
+ const sel = window.getSelection();
592
+ if (sel && sel.toString().length > 0)
593
+ hadSelection = true;
594
+ };
595
+ window.addEventListener("scroll", scrollHandler, { passive: true, capture: true });
596
+ document.addEventListener("selectionchange", selectionHandler);
597
+ setTimeout(() => {
598
+ observer.disconnect();
599
+ window.removeEventListener("scroll", scrollHandler, true);
600
+ document.removeEventListener("selectionchange", selectionHandler);
601
+ const urlChanged = window.location.href !== clickUrl;
602
+ if (!hadMutation && !hadScroll && !hadSelection && !urlChanged) {
603
+ const reportTarget = this.findReportableAncestor(target) ?? target;
604
+ this.emit("dead_click", {
605
+ element: this.getElementInfo(reportTarget),
606
+ pageUrl: clickUrl,
607
+ });
608
+ }
609
+ }, DEAD_CLICK_TIMEOUT_MS);
610
+ }
611
+ findReportableAncestor(el) {
612
+ let current = el.parentElement;
613
+ while (current && current !== document.body) {
614
+ if (INTERACTIVE_TAGS.has(current.tagName))
615
+ return current;
616
+ if (current.getAttribute("role") === "button")
617
+ return current;
618
+ current = current.parentElement;
619
+ }
620
+ return null;
621
+ }
622
+ emitPageView() {
623
+ this.scrollDepthMax = 0;
624
+ this.emit("page_view", {
625
+ url: window.location.href,
626
+ path: window.location.pathname,
627
+ referrer: document.referrer,
628
+ title: document.title,
629
+ });
630
+ }
631
+ emit(type, data) {
632
+ this.onEvent({
633
+ type,
634
+ timestamp: Date.now(),
635
+ data,
636
+ sessionId: this.sessionId,
637
+ });
638
+ }
639
+ getElementInfo(el) {
640
+ const info = {
641
+ tag: el.tagName.toLowerCase(),
642
+ selector: this.buildSelector(el),
643
+ };
644
+ if (el.id)
645
+ info.id = el.id;
646
+ if (this.options.includeClasses !== false && el.className) {
647
+ const classes = typeof el.className === "string"
648
+ ? el.className.split(/\s+/).filter(Boolean)
649
+ : [];
650
+ if (classes.length > 0)
651
+ info.classes = classes;
652
+ }
653
+ if (this.options.includeText !== false) {
654
+ const isSensitiveInput = ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
655
+ if (!isSensitiveInput || this.redaction.mode !== "privacy-first") {
656
+ const text = el.textContent?.trim().slice(0, 100);
657
+ if (text)
658
+ info.text = text;
659
+ }
660
+ }
661
+ if (el.tagName === "A") {
662
+ info.href = el.href;
663
+ }
664
+ if ("name" in el && typeof el.name === "string") {
665
+ info.name = el.name;
666
+ }
667
+ if ("type" in el && typeof el.type === "string") {
668
+ info.type = el.type;
669
+ }
670
+ return info;
671
+ }
672
+ buildSelector(el) {
673
+ if (el.id)
674
+ return `#${el.id}`;
675
+ const parts = [];
676
+ let current = el;
677
+ for (let i = 0; i < 4 && current && current !== document.body; i++) {
678
+ let part = current.tagName.toLowerCase();
679
+ if (current.id) {
680
+ part = `#${current.id}`;
681
+ parts.unshift(part);
682
+ break;
683
+ }
684
+ if (current.className && typeof current.className === "string") {
685
+ const cls = current.className
686
+ .split(/\s+/)
687
+ .filter(Boolean)
688
+ .slice(0, 2)
689
+ .join(".");
690
+ if (cls)
691
+ part += `.${cls}`;
692
+ }
693
+ parts.unshift(part);
694
+ current = current.parentElement;
695
+ }
696
+ return parts.join(" > ");
697
+ }
698
+ }
699
+
700
+ class SessionRecorder {
701
+ constructor(redaction, recordCanvas, onEvent) {
702
+ this.recording = false;
703
+ this.stopFn = null;
704
+ this.redaction = redaction;
705
+ this.recordCanvas = recordCanvas;
706
+ this.onEvent = onEvent;
707
+ }
708
+ async start() {
709
+ if (this.recording)
710
+ return;
711
+ if (typeof window === "undefined")
712
+ return;
713
+ try {
714
+ const { record } = await import('rrweb');
715
+ const maskConfig = this.buildMaskConfig();
716
+ const stopFn = record({
717
+ emit: (event) => {
718
+ this.onEvent(event);
719
+ },
720
+ ...maskConfig,
721
+ recordCanvas: this.recordCanvas,
722
+ sampling: {
723
+ mousemove: true,
724
+ mouseInteraction: true,
725
+ scroll: 150,
726
+ input: "last",
727
+ },
728
+ recordCrossOriginIframes: false,
729
+ });
730
+ if (stopFn)
731
+ this.stopFn = stopFn;
732
+ this.recording = true;
733
+ debug("Session recording started");
734
+ }
735
+ catch (err) {
736
+ warn("Failed to start rrweb recording:", err);
737
+ }
738
+ }
739
+ stop() {
740
+ if (this.stopFn) {
741
+ this.stopFn();
742
+ this.stopFn = null;
743
+ }
744
+ this.recording = false;
745
+ debug("Session recording stopped");
746
+ }
747
+ isRecording() {
748
+ return this.recording;
749
+ }
750
+ buildMaskConfig() {
751
+ if (this.redaction.mode === "privacy-first") {
752
+ return {
753
+ maskAllInputs: true,
754
+ maskTextSelector: "*",
755
+ maskInputOptions: {
756
+ password: true,
757
+ email: true,
758
+ text: true,
759
+ tel: true,
760
+ url: true,
761
+ search: true,
762
+ number: true,
763
+ textarea: true,
764
+ select: true,
765
+ },
766
+ ...(this.redaction.unredactFields?.length
767
+ ? {
768
+ unmaskTextSelector: this.redaction.unredactFields
769
+ .map((f) => `[data-pb-unmask="${f}"]`)
770
+ .join(", "),
771
+ }
772
+ : {}),
773
+ };
774
+ }
775
+ return {
776
+ maskAllInputs: false,
777
+ maskInputOptions: {
778
+ password: true,
779
+ },
780
+ };
781
+ }
782
+ }
783
+
784
+ class ConsoleTracker {
785
+ constructor(sessionId, onEvent) {
786
+ this.originalWarn = null;
787
+ this.originalError = null;
788
+ this.sessionId = sessionId;
789
+ this.onEvent = onEvent;
790
+ }
791
+ start() {
792
+ if (typeof window === "undefined")
793
+ return;
794
+ this.originalWarn = console.warn;
795
+ this.originalError = console.error;
796
+ console.warn = (...args) => {
797
+ this.originalWarn?.apply(console, args);
798
+ this.trackConsole("console_warn", args);
799
+ };
800
+ console.error = (...args) => {
801
+ this.originalError?.apply(console, args);
802
+ this.trackConsole("console_error", args);
803
+ };
804
+ window.addEventListener("error", (e) => {
805
+ this.onEvent({
806
+ type: "console_error",
807
+ timestamp: Date.now(),
808
+ data: {
809
+ message: e.message,
810
+ filename: e.filename,
811
+ lineno: e.lineno,
812
+ colno: e.colno,
813
+ stack: e.error?.stack,
814
+ },
815
+ sessionId: this.sessionId,
816
+ });
817
+ });
818
+ window.addEventListener("unhandledrejection", (e) => {
819
+ this.onEvent({
820
+ type: "console_error",
821
+ timestamp: Date.now(),
822
+ data: {
823
+ message: String(e.reason),
824
+ stack: e.reason?.stack,
825
+ source: "unhandledrejection",
826
+ },
827
+ sessionId: this.sessionId,
828
+ });
829
+ });
830
+ debug("ConsoleTracker started");
831
+ }
832
+ stop() {
833
+ if (this.originalWarn)
834
+ console.warn = this.originalWarn;
835
+ if (this.originalError)
836
+ console.error = this.originalError;
837
+ }
838
+ updateSessionId(sessionId) {
839
+ this.sessionId = sessionId;
840
+ }
841
+ trackConsole(type, args) {
842
+ const message = args
843
+ .map((a) => {
844
+ if (typeof a === "string")
845
+ return a;
846
+ try {
847
+ return JSON.stringify(a);
848
+ }
849
+ catch {
850
+ return String(a);
851
+ }
852
+ })
853
+ .join(" ");
854
+ if (message.includes("[ProductBet]"))
855
+ return;
856
+ this.onEvent({
857
+ type,
858
+ timestamp: Date.now(),
859
+ data: {
860
+ message: message.slice(0, 2000),
861
+ url: window.location.href,
862
+ },
863
+ sessionId: this.sessionId,
864
+ });
865
+ }
866
+ }
867
+
868
+ class NetworkTracker {
869
+ constructor(sessionId, ingestionUrl, onEvent) {
870
+ this.originalFetch = null;
871
+ this.sessionId = sessionId;
872
+ this.ingestionUrl = ingestionUrl;
873
+ this.onEvent = onEvent;
874
+ }
875
+ start() {
876
+ if (typeof window === "undefined")
877
+ return;
878
+ if (this.originalFetch)
879
+ return;
880
+ this.originalFetch = window.fetch.bind(window);
881
+ const tracker = this;
882
+ window.fetch = function pbTrackedFetch(input, init) {
883
+ const url = typeof input === "string"
884
+ ? input
885
+ : input instanceof URL
886
+ ? input.href
887
+ : input.url;
888
+ if (url.startsWith(tracker.ingestionUrl)) {
889
+ return tracker.originalFetch(input, init);
890
+ }
891
+ const method = init?.method || "GET";
892
+ const startTime = Date.now();
893
+ return tracker.originalFetch(input, init).then((response) => {
894
+ if (response.status >= 400) {
895
+ tracker.onEvent({
896
+ type: "network_error",
897
+ timestamp: Date.now(),
898
+ data: {
899
+ url: url.slice(0, 500),
900
+ method,
901
+ status: response.status,
902
+ statusText: response.statusText,
903
+ duration: Date.now() - startTime,
904
+ errorType: response.status >= 500
905
+ ? "server_error"
906
+ : "client_error",
907
+ },
908
+ sessionId: tracker.sessionId,
909
+ });
910
+ }
911
+ return response;
912
+ }, (err) => {
913
+ tracker.onEvent({
914
+ type: "network_error",
915
+ timestamp: Date.now(),
916
+ data: {
917
+ url: url.slice(0, 500),
918
+ method,
919
+ status: null,
920
+ duration: Date.now() - startTime,
921
+ errorType: "network_error",
922
+ errorMessage: err instanceof Error ? err.message : String(err),
923
+ },
924
+ sessionId: tracker.sessionId,
925
+ });
926
+ throw err;
927
+ });
928
+ };
929
+ debug("NetworkTracker started");
930
+ }
931
+ stop() {
932
+ if (this.originalFetch) {
933
+ window.fetch = this.originalFetch;
934
+ this.originalFetch = null;
935
+ }
936
+ }
937
+ updateSessionId(sessionId) {
938
+ this.sessionId = sessionId;
939
+ }
940
+ }
941
+
942
+ let globalInstance = null;
943
+ const IDLE_THRESHOLD_MS = 5 * 60000;
944
+ const USER_ACTIVITY_EVENTS = ["click", "keydown", "scroll", "mousemove"];
945
+ class ProductBetTracker {
946
+ constructor(apiKey, options = {}) {
947
+ this.autoTracker = null;
948
+ this.recorder = null;
949
+ this.consoleTracker = null;
950
+ this.networkTracker = null;
951
+ this.started = false;
952
+ this.activityCheckTimer = null;
953
+ this.lastActivityAt = Date.now();
954
+ this.idle = false;
955
+ this.apiKey = apiKey;
956
+ this.options = this.mergeOptions(options);
957
+ setDebug(this.options.debug);
958
+ this.session = new SessionManager(this.options.sessionTimeoutMs);
959
+ this.apiClient = new ApiClient(this.apiKey, this.options.ingestionUrl, this.session.getSessionId(), this.options.maxQueueSize, this.options.flushIntervalMs, this.session.getWindowId());
960
+ if (this.options.enableAutoTracking) {
961
+ this.autoTracker = new AutoTracker(this.options.autoTrackingOptions, this.options.redaction, this.session.getSessionId(), (event) => this.handleEvent(event));
962
+ }
963
+ if (this.options.enableSessionRecording) {
964
+ this.recorder = new SessionRecorder(this.options.redaction, this.options.recordCanvas, (event) => this.apiClient.enqueueReplayEvent(event));
965
+ }
966
+ if (this.options.enableConsoleTracking) {
967
+ this.consoleTracker = new ConsoleTracker(this.session.getSessionId(), (event) => this.handleEvent(event));
968
+ }
969
+ if (this.options.enableNetworkTracking) {
970
+ this.networkTracker = new NetworkTracker(this.session.getSessionId(), this.options.ingestionUrl, (event) => this.handleEvent(event));
971
+ }
972
+ debug("ProductBetTracker initialized");
973
+ }
974
+ static init(apiKey, options) {
975
+ if (!apiKey) {
976
+ throw new Error("ProductBet: apiKey is required");
977
+ }
978
+ if (globalInstance) {
979
+ warn("ProductBetTracker already initialized. Returning existing instance.");
980
+ return globalInstance;
981
+ }
982
+ globalInstance = new ProductBetTracker(apiKey, options);
983
+ return globalInstance;
984
+ }
985
+ static getInstance() {
986
+ return globalInstance;
987
+ }
988
+ start() {
989
+ if (this.started)
990
+ return;
991
+ if (typeof window === "undefined") {
992
+ warn("ProductBetTracker.start() called in non-browser environment. Skipping.");
993
+ return;
994
+ }
995
+ this.started = true;
996
+ this.apiClient.start();
997
+ this.autoTracker?.start();
998
+ this.recorder?.start();
999
+ this.consoleTracker?.start();
1000
+ this.networkTracker?.start();
1001
+ this.handleEvent({
1002
+ type: "session_start",
1003
+ timestamp: Date.now(),
1004
+ data: {
1005
+ url: window.location.href,
1006
+ referrer: document.referrer,
1007
+ screenWidth: window.screen.width,
1008
+ screenHeight: window.screen.height,
1009
+ viewportWidth: window.innerWidth,
1010
+ viewportHeight: window.innerHeight,
1011
+ userAgent: navigator.userAgent,
1012
+ language: navigator.language,
1013
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1014
+ },
1015
+ sessionId: this.session.getSessionId(),
1016
+ });
1017
+ this.activityCheckTimer = setInterval(() => {
1018
+ if (this.session.isExpired()) {
1019
+ this.rotateSession();
1020
+ }
1021
+ this.checkIdle();
1022
+ }, 60000);
1023
+ this.setupActivityListeners();
1024
+ debug("ProductBetTracker started");
1025
+ }
1026
+ stop() {
1027
+ if (!this.started)
1028
+ return;
1029
+ this.handleEvent({
1030
+ type: "session_end",
1031
+ timestamp: Date.now(),
1032
+ data: {},
1033
+ sessionId: this.session.getSessionId(),
1034
+ });
1035
+ this.autoTracker?.stop();
1036
+ this.recorder?.stop();
1037
+ this.consoleTracker?.stop();
1038
+ this.networkTracker?.stop();
1039
+ this.apiClient.stop();
1040
+ if (this.activityCheckTimer) {
1041
+ clearInterval(this.activityCheckTimer);
1042
+ this.activityCheckTimer = null;
1043
+ }
1044
+ this.started = false;
1045
+ debug("ProductBetTracker stopped");
1046
+ }
1047
+ identify(userId, attributes) {
1048
+ const props = { userId, attributes };
1049
+ this.apiClient.setEndUserId(userId);
1050
+ this.apiClient.sendIdentify({
1051
+ userId: props.userId,
1052
+ attributes: props.attributes || {},
1053
+ sessionId: this.session.getSessionId(),
1054
+ });
1055
+ debug("User identified", userId);
1056
+ }
1057
+ track(eventName, properties) {
1058
+ this.apiClient.sendCustomEvent({
1059
+ sessionId: this.session.getSessionId(),
1060
+ eventName,
1061
+ eventProperties: properties || {},
1062
+ endUserId: null,
1063
+ });
1064
+ }
1065
+ getSessionId() {
1066
+ return this.session.getSessionId();
1067
+ }
1068
+ setUnredactedFields(fields) {
1069
+ this.options.redaction.unredactFields = fields;
1070
+ }
1071
+ getUnredactedFields() {
1072
+ return this.options.redaction.unredactFields || [];
1073
+ }
1074
+ handleEvent(event) {
1075
+ const isUserAction = event.type === "click" ||
1076
+ event.type === "rage_click" ||
1077
+ event.type === "form_submit" ||
1078
+ event.type === "form_change" ||
1079
+ event.type === "page_view" ||
1080
+ event.type === "session_start" ||
1081
+ event.type === "session_end";
1082
+ if (isUserAction) {
1083
+ this.lastActivityAt = Date.now();
1084
+ if (this.idle) {
1085
+ this.idle = false;
1086
+ debug("User returned from idle");
1087
+ }
1088
+ }
1089
+ if (this.idle && !isUserAction)
1090
+ return;
1091
+ this.session.touch();
1092
+ this.apiClient.enqueueEvent(event);
1093
+ }
1094
+ setupActivityListeners() {
1095
+ const onActivity = () => {
1096
+ this.lastActivityAt = Date.now();
1097
+ if (this.idle) {
1098
+ this.idle = false;
1099
+ debug("User returned from idle");
1100
+ }
1101
+ };
1102
+ for (const evt of USER_ACTIVITY_EVENTS) {
1103
+ window.addEventListener(evt, onActivity, { passive: true, capture: true });
1104
+ }
1105
+ }
1106
+ checkIdle() {
1107
+ if (this.idle)
1108
+ return;
1109
+ if (Date.now() - this.lastActivityAt > IDLE_THRESHOLD_MS) {
1110
+ this.idle = true;
1111
+ debug("User idle, pausing background event capture");
1112
+ }
1113
+ }
1114
+ rotateSession() {
1115
+ const newSessionId = this.session.rotate();
1116
+ this.apiClient.updateSessionId(newSessionId);
1117
+ this.autoTracker?.updateSessionId(newSessionId);
1118
+ this.consoleTracker?.updateSessionId(newSessionId);
1119
+ this.networkTracker?.updateSessionId(newSessionId);
1120
+ debug("Session rotated to", newSessionId);
1121
+ this.handleEvent({
1122
+ type: "session_start",
1123
+ timestamp: Date.now(),
1124
+ data: {
1125
+ url: window.location.href,
1126
+ rotated: true,
1127
+ },
1128
+ sessionId: newSessionId,
1129
+ });
1130
+ }
1131
+ mergeOptions(partial) {
1132
+ return {
1133
+ ...DEFAULT_OPTIONS,
1134
+ ...partial,
1135
+ autoTrackingOptions: {
1136
+ ...DEFAULT_OPTIONS.autoTrackingOptions,
1137
+ ...partial.autoTrackingOptions,
1138
+ },
1139
+ redaction: {
1140
+ ...DEFAULT_OPTIONS.redaction,
1141
+ ...partial.redaction,
1142
+ },
1143
+ };
1144
+ }
1145
+ }
1146
+
1147
+ export { ProductBetTracker, SDK_VERSION };
1148
+ //# sourceMappingURL=index.mjs.map