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.
- package/README.md +72 -1
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/checksums/sha1-checksums.bin +0 -0
- package/dist/cuoral.d.ts +13 -0
- package/dist/cuoral.d.ts.map +1 -1
- package/dist/cuoral.js +26 -0
- package/dist/index.esm.js +314 -0
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -1
- package/dist/intelligence.d.ts +47 -0
- package/dist/intelligence.d.ts.map +1 -1
- package/dist/intelligence.js +288 -0
- package/package.json +4 -2
- package/src/cuoral.ts +26 -0
- package/src/intelligence.ts +352 -0
package/dist/intelligence.js
CHANGED
|
@@ -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.
|
|
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
|
*/
|