cuoral-ionic 0.0.5 → 0.0.6

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.
@@ -1,5 +1,6 @@
1
1
  import { Capacitor } from '@capacitor/core';
2
2
  import { registerPlugin } from '@capacitor/core';
3
+ import * as rrweb from 'rrweb';
3
4
  // Register the Cuoral plugin for native error capture
4
5
  const CuoralPlugin = registerPlugin('CuoralPlugin');
5
6
  export class CuoralIntelligence {
@@ -8,11 +9,13 @@ export class CuoralIntelligence {
8
9
  consoleErrorBackendUrl: 'https://api.cuoral.com/customer-intelligence/console-error',
9
10
  pageViewBackendUrl: 'https://api.cuoral.com/customer-intelligence/page-view',
10
11
  apiResponseBackendUrl: 'https://api.cuoral.com/customer-intelligence/api-response',
12
+ sessionReplayBackendUrl: 'https://api.cuoral.com/customer-intelligence/session-recording/batch',
11
13
  batchSize: 10,
12
14
  batchInterval: 2000,
13
15
  maxQueueSize: 30,
14
16
  retryAttempts: 1,
15
17
  retryDelay: 2000,
18
+ sessionReplayBatchInterval: 10000, // 10 seconds
16
19
  };
17
20
  this.queues = {
18
21
  console_error: [],
@@ -29,6 +32,14 @@ export class CuoralIntelligence {
29
32
  // Network monitoring state
30
33
  this.originalFetch = null;
31
34
  this.originalXMLHttpRequest = null;
35
+ // Session replay state
36
+ this.rrwebStopFn = null;
37
+ this.rrwebEvents = [];
38
+ this.customEvents = [];
39
+ this.sessionReplayTimer = null;
40
+ this.clickTimestamps = new Map();
41
+ this.rageClickThreshold = 5; // 5 rapid clicks
42
+ this.rageClickWindowMs = 2000; // Within 2 seconds
32
43
  this.sessionId = sessionId;
33
44
  }
34
45
  /**
@@ -43,6 +54,7 @@ export class CuoralIntelligence {
43
54
  this.setupNetworkMonitoring();
44
55
  this.setupAppStateListener();
45
56
  this.setupNativeErrorCapture();
57
+ this.setupSessionReplay();
46
58
  this.isInitialized = true;
47
59
  this.flushPendingEvents();
48
60
  }
@@ -90,6 +102,17 @@ export class CuoralIntelligence {
90
102
  delete this.batchTimers[type];
91
103
  }
92
104
  });
105
+ // Stop session replay
106
+ if (this.rrwebStopFn) {
107
+ this.rrwebStopFn();
108
+ this.rrwebStopFn = null;
109
+ }
110
+ if (this.sessionReplayTimer) {
111
+ clearInterval(this.sessionReplayTimer);
112
+ this.sessionReplayTimer = null;
113
+ }
114
+ // Flush remaining session replay data
115
+ this.flushSessionReplayBatch();
93
116
  // Restore original functions
94
117
  if (this.originalFetch) {
95
118
  window.fetch = this.originalFetch;
@@ -505,4 +528,269 @@ export class CuoralIntelligence {
505
528
  // Silently fail if plugin is not available
506
529
  }
507
530
  }
531
+ /**
532
+ * Setup session replay with rrweb
533
+ */
534
+ setupSessionReplay() {
535
+ if (!this.sessionId) {
536
+ console.warn('[Cuoral Intelligence] Session replay requires a session ID');
537
+ return;
538
+ }
539
+ // Start rrweb recording
540
+ this.rrwebStopFn = rrweb.record({
541
+ emit: (event) => {
542
+ this.rrwebEvents.push(event);
543
+ },
544
+ checkoutEveryNms: 60000, // Full snapshot every minute
545
+ sampling: {
546
+ scroll: 150, // Throttle scroll events
547
+ media: 800,
548
+ input: 'last', // Only record final input value (privacy)
549
+ },
550
+ maskAllInputs: true, // Mask sensitive inputs (privacy)
551
+ blockClass: 'cuoral-block',
552
+ ignoreClass: 'cuoral-ignore',
553
+ });
554
+ // Setup custom event tracking
555
+ this.setupClickTracking();
556
+ this.setupScrollTracking();
557
+ this.setupFormTracking();
558
+ // Start batch timer (send every ~10 seconds)
559
+ this.sessionReplayTimer = setInterval(() => {
560
+ this.flushSessionReplayBatch();
561
+ }, this.config.sessionReplayBatchInterval);
562
+ }
563
+ /**
564
+ * Setup click tracking (including rage clicks)
565
+ */
566
+ setupClickTracking() {
567
+ document.addEventListener('click', (event) => {
568
+ const target = event.target;
569
+ if (!target)
570
+ return;
571
+ const selector = this.getElementSelector(target);
572
+ const elementText = target.textContent?.trim().substring(0, 100) || '';
573
+ const url = window.location.href;
574
+ const now = Date.now();
575
+ // Track regular click
576
+ this.addCustomEvent({
577
+ name: 'click',
578
+ category: 'interaction',
579
+ url,
580
+ element_selector: selector,
581
+ element_text: elementText,
582
+ event_timestamp: new Date(now).toISOString(),
583
+ session_id: this.sessionId,
584
+ properties: this.getMetadata(),
585
+ });
586
+ // Track rage click detection
587
+ const clicks = this.clickTimestamps.get(selector) || [];
588
+ clicks.push(now);
589
+ // Remove old clicks outside the time window
590
+ const recentClicks = clicks.filter(timestamp => now - timestamp < this.rageClickWindowMs);
591
+ this.clickTimestamps.set(selector, recentClicks);
592
+ // If 5+ clicks within 2 seconds = rage click
593
+ if (recentClicks.length >= this.rageClickThreshold) {
594
+ this.addCustomEvent({
595
+ name: 'rage_click',
596
+ category: 'frustration',
597
+ url,
598
+ element_selector: selector,
599
+ element_text: elementText,
600
+ event_timestamp: new Date(now).toISOString(),
601
+ session_id: this.sessionId,
602
+ properties: {
603
+ ...this.getMetadata(),
604
+ click_count: recentClicks.length,
605
+ },
606
+ });
607
+ // Clear after detecting rage click
608
+ this.clickTimestamps.delete(selector);
609
+ }
610
+ }, true);
611
+ }
612
+ /**
613
+ * Setup scroll depth tracking
614
+ */
615
+ setupScrollTracking() {
616
+ let scrollDepths = new Set();
617
+ let ticking = false;
618
+ const trackScroll = () => {
619
+ const scrollPercentage = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100);
620
+ // Track milestones: 25%, 50%, 75%, 100%
621
+ const milestones = [25, 50, 75, 100];
622
+ for (const milestone of milestones) {
623
+ if (scrollPercentage >= milestone && !scrollDepths.has(milestone)) {
624
+ scrollDepths.add(milestone);
625
+ this.addCustomEvent({
626
+ name: 'scroll_depth',
627
+ category: 'engagement',
628
+ url: window.location.href,
629
+ event_timestamp: new Date().toISOString(),
630
+ session_id: this.sessionId,
631
+ properties: {
632
+ ...this.getMetadata(),
633
+ percentage: milestone,
634
+ },
635
+ });
636
+ }
637
+ }
638
+ ticking = false;
639
+ };
640
+ window.addEventListener('scroll', () => {
641
+ if (!ticking) {
642
+ window.requestAnimationFrame(trackScroll);
643
+ ticking = true;
644
+ }
645
+ });
646
+ }
647
+ /**
648
+ * Setup form tracking
649
+ */
650
+ setupFormTracking() {
651
+ const trackedForms = new WeakSet();
652
+ // Track form starts
653
+ document.addEventListener('focusin', (event) => {
654
+ const target = event.target;
655
+ if (!target)
656
+ return;
657
+ const form = target.closest('form');
658
+ if (form && !trackedForms.has(form)) {
659
+ trackedForms.add(form);
660
+ this.addCustomEvent({
661
+ name: 'form_started',
662
+ category: 'form',
663
+ url: window.location.href,
664
+ element_selector: this.getElementSelector(form),
665
+ event_timestamp: new Date().toISOString(),
666
+ session_id: this.sessionId,
667
+ properties: this.getMetadata(),
668
+ });
669
+ }
670
+ }, true);
671
+ // Track form submissions
672
+ document.addEventListener('submit', (event) => {
673
+ const form = event.target;
674
+ if (!form)
675
+ return;
676
+ this.addCustomEvent({
677
+ name: 'form_submitted',
678
+ category: 'form',
679
+ url: window.location.href,
680
+ element_selector: this.getElementSelector(form),
681
+ event_timestamp: new Date().toISOString(),
682
+ session_id: this.sessionId,
683
+ properties: this.getMetadata(),
684
+ });
685
+ }, true);
686
+ // Track form abandonment (on page exit with incomplete forms)
687
+ window.addEventListener('beforeunload', () => {
688
+ document.querySelectorAll('form').forEach((form) => {
689
+ const inputs = form.querySelectorAll('input, textarea, select');
690
+ const hasValue = Array.from(inputs).some((input) => input.value);
691
+ if (hasValue && !trackedForms.has(form)) {
692
+ this.addCustomEvent({
693
+ name: 'form_abandoned',
694
+ category: 'form',
695
+ url: window.location.href,
696
+ element_selector: this.getElementSelector(form),
697
+ event_timestamp: new Date().toISOString(),
698
+ session_id: this.sessionId,
699
+ properties: this.getMetadata(),
700
+ });
701
+ }
702
+ });
703
+ });
704
+ }
705
+ /**
706
+ * Track custom business events (flows, features, etc.)
707
+ */
708
+ trackCustomEvent(name, category, properties = {}, elementSelector, elementText) {
709
+ this.addCustomEvent({
710
+ name,
711
+ category,
712
+ url: window.location.href,
713
+ element_selector: elementSelector,
714
+ element_text: elementText,
715
+ event_timestamp: new Date().toISOString(),
716
+ session_id: this.sessionId,
717
+ properties: {
718
+ ...this.getMetadata(),
719
+ ...properties,
720
+ },
721
+ });
722
+ }
723
+ /**
724
+ * Add a custom event to the buffer
725
+ */
726
+ addCustomEvent(event) {
727
+ this.customEvents.push(event);
728
+ }
729
+ /**
730
+ * Flush session replay batch
731
+ */
732
+ flushSessionReplayBatch() {
733
+ if (!this.sessionId)
734
+ return;
735
+ const eventsToSend = [...this.rrwebEvents];
736
+ const customEventsToSend = [...this.customEvents];
737
+ // Clear buffers
738
+ this.rrwebEvents = [];
739
+ this.customEvents = [];
740
+ // Only send if there's data
741
+ if (eventsToSend.length === 0 && customEventsToSend.length === 0) {
742
+ return;
743
+ }
744
+ const batch = {
745
+ session_id: this.sessionId,
746
+ events: eventsToSend,
747
+ custom_events: customEventsToSend,
748
+ };
749
+ this.sendSessionReplayBatch(batch);
750
+ }
751
+ /**
752
+ * Send session replay batch to backend
753
+ */
754
+ async sendSessionReplayBatch(batch) {
755
+ try {
756
+ const response = await fetch(this.config.sessionReplayBackendUrl, {
757
+ method: 'POST',
758
+ headers: {
759
+ 'Content-Type': 'application/json',
760
+ },
761
+ body: JSON.stringify(batch),
762
+ });
763
+ if (!response.ok) {
764
+ console.warn('[Cuoral Intelligence] Failed to send session replay batch');
765
+ }
766
+ }
767
+ catch (error) {
768
+ console.warn('[Cuoral Intelligence] Error sending session replay batch:', error);
769
+ }
770
+ }
771
+ /**
772
+ * Get element selector (CSS selector)
773
+ */
774
+ getElementSelector(element) {
775
+ if (element.id) {
776
+ return `#${element.id}`;
777
+ }
778
+ if (element.className && typeof element.className === 'string') {
779
+ const classes = element.className.split(' ').filter(c => c).join('.');
780
+ if (classes) {
781
+ return `${element.tagName.toLowerCase()}.${classes}`;
782
+ }
783
+ }
784
+ return element.tagName.toLowerCase();
785
+ }
786
+ /**
787
+ * Get metadata (user agent, screen resolution, viewport)
788
+ */
789
+ getMetadata() {
790
+ return {
791
+ user_agent: navigator.userAgent,
792
+ screen_resolution: `${screen.width}x${screen.height}`,
793
+ viewport_size: `${window.innerWidth}x${window.innerHeight}`,
794
+ };
795
+ }
508
796
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuoral-ionic",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Cuoral Ionic Framework Library - Proactive customer success platform with support ticketing, customer intelligence, screen recording, and engagement tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -51,7 +51,9 @@
51
51
  "rollup-plugin-typescript2": "^0.36.0",
52
52
  "typescript": "^5.0.0"
53
53
  },
54
- "dependencies": {},
54
+ "dependencies": {
55
+ "rrweb": "^1.1.3"
56
+ },
55
57
  "repository": {
56
58
  "type": "git",
57
59
  "url": "https://github.com/cuoral/cuoral-ionic.git"
package/src/cuoral.ts CHANGED
@@ -205,6 +205,32 @@ export class Cuoral {
205
205
  }
206
206
  }
207
207
 
208
+ /**
209
+ * Start native screen recording programmatically
210
+ * @returns Promise<boolean> - true if recording started successfully
211
+ */
212
+ public async startRecording(): Promise<boolean> {
213
+ try {
214
+ return await this.recorder.startRecording();
215
+ } catch (error) {
216
+ console.error('[Cuoral] Failed to start recording:', error);
217
+ return false;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Stop native screen recording programmatically
223
+ * @returns Promise<{filePath?: string; duration?: number} | null> - Recording result or null if failed
224
+ */
225
+ public async stopRecording(): Promise<{filePath?: string; duration?: number} | null> {
226
+ try {
227
+ return await this.recorder.stopRecording();
228
+ } catch (error) {
229
+ console.error('[Cuoral] Failed to stop recording:', error);
230
+ return null;
231
+ }
232
+ }
233
+
208
234
  /**
209
235
  * Open the widget modal
210
236
  */