@clianta/sdk 1.6.8 → 1.7.1
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/angular.cjs.js +211 -86
- package/dist/angular.cjs.js.map +1 -1
- package/dist/angular.d.ts +1 -1
- package/dist/angular.esm.js +211 -86
- package/dist/angular.esm.js.map +1 -1
- package/dist/clianta.cjs.js +211 -86
- package/dist/clianta.cjs.js.map +1 -1
- package/dist/clianta.esm.js +211 -86
- package/dist/clianta.esm.js.map +1 -1
- package/dist/clianta.umd.js +211 -86
- package/dist/clianta.umd.js.map +1 -1
- package/dist/clianta.umd.min.js +2 -2
- package/dist/clianta.umd.min.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/react.cjs.js +218 -93
- package/dist/react.cjs.js.map +1 -1
- package/dist/react.d.ts +2 -1
- package/dist/react.esm.js +219 -94
- package/dist/react.esm.js.map +1 -1
- package/dist/svelte.cjs.js +211 -86
- package/dist/svelte.cjs.js.map +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/svelte.esm.js +211 -86
- package/dist/svelte.esm.js.map +1 -1
- package/dist/vue.cjs.js +211 -86
- package/dist/vue.cjs.js.map +1 -1
- package/dist/vue.d.ts +1 -1
- package/dist/vue.esm.js +211 -86
- package/dist/vue.esm.js.map +1 -1
- package/package.json +1 -1
package/dist/angular.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Clianta SDK v1.
|
|
2
|
+
* Clianta SDK v1.7.1
|
|
3
3
|
* (c) 2026 Clianta
|
|
4
4
|
* Released under the MIT License.
|
|
5
5
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @see SDK_VERSION in core/config.ts
|
|
9
9
|
*/
|
|
10
10
|
/** SDK Version */
|
|
11
|
-
const SDK_VERSION = '1.
|
|
11
|
+
const SDK_VERSION = '1.7.0';
|
|
12
12
|
/** Default API endpoint — reads from env or falls back to localhost */
|
|
13
13
|
const getDefaultApiEndpoint = () => {
|
|
14
14
|
// Next.js (process.env)
|
|
@@ -34,6 +34,15 @@ const getDefaultApiEndpoint = () => {
|
|
|
34
34
|
if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
|
|
35
35
|
return process.env.CLIANTA_API_ENDPOINT;
|
|
36
36
|
}
|
|
37
|
+
// No env var found — warn if we're not on localhost (likely a production misconfiguration)
|
|
38
|
+
const isLocalhost = typeof window !== 'undefined' &&
|
|
39
|
+
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
|
40
|
+
if (!isLocalhost && typeof console !== 'undefined') {
|
|
41
|
+
console.warn('[Clianta] No API endpoint configured. ' +
|
|
42
|
+
'Set NEXT_PUBLIC_CLIANTA_API_ENDPOINT (Next.js), VITE_CLIANTA_API_ENDPOINT (Vite), ' +
|
|
43
|
+
'or pass apiEndpoint directly to clianta(). ' +
|
|
44
|
+
'Falling back to localhost — tracking will not work in production.');
|
|
45
|
+
}
|
|
37
46
|
return 'http://localhost:5000';
|
|
38
47
|
};
|
|
39
48
|
/** Core plugins enabled by default — all auto-track with zero config */
|
|
@@ -179,7 +188,9 @@ const logger = createLogger(false);
|
|
|
179
188
|
*/
|
|
180
189
|
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
|
181
190
|
const DEFAULT_MAX_RETRIES = 3;
|
|
182
|
-
const DEFAULT_RETRY_DELAY = 1000; // 1 second
|
|
191
|
+
const DEFAULT_RETRY_DELAY = 1000; // 1 second base — doubles each attempt (exponential backoff)
|
|
192
|
+
/** fetch keepalive hard limit in browsers (64KB) */
|
|
193
|
+
const KEEPALIVE_SIZE_LIMIT = 60000; // leave 4KB margin
|
|
183
194
|
/**
|
|
184
195
|
* Transport class for sending data to the backend
|
|
185
196
|
*/
|
|
@@ -198,6 +209,11 @@ class Transport {
|
|
|
198
209
|
async sendEvents(events) {
|
|
199
210
|
const url = `${this.config.apiEndpoint}/api/public/track/event`;
|
|
200
211
|
const payload = JSON.stringify({ events });
|
|
212
|
+
// keepalive has a 64KB hard limit — fall back to beacon if too large
|
|
213
|
+
if (payload.length > KEEPALIVE_SIZE_LIMIT) {
|
|
214
|
+
const sent = this.sendBeacon(events);
|
|
215
|
+
return sent ? { success: true } : this.send(url, payload, 1, false);
|
|
216
|
+
}
|
|
201
217
|
return this.send(url, payload);
|
|
202
218
|
}
|
|
203
219
|
/**
|
|
@@ -262,6 +278,15 @@ class Transport {
|
|
|
262
278
|
return false;
|
|
263
279
|
}
|
|
264
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Send an arbitrary POST request through the transport (with timeout + retry).
|
|
283
|
+
* Used for one-off calls like alias() that don't fit the event-batch or identify shapes.
|
|
284
|
+
*/
|
|
285
|
+
async sendPost(path, body) {
|
|
286
|
+
const url = `${this.config.apiEndpoint}${path}`;
|
|
287
|
+
const payload = JSON.stringify(body);
|
|
288
|
+
return this.send(url, payload);
|
|
289
|
+
}
|
|
265
290
|
/**
|
|
266
291
|
* Fetch data from the tracking API (GET request)
|
|
267
292
|
* Used for read-back APIs (visitor profile, activity, etc.)
|
|
@@ -296,38 +321,44 @@ class Transport {
|
|
|
296
321
|
}
|
|
297
322
|
}
|
|
298
323
|
/**
|
|
299
|
-
* Internal send with retry logic
|
|
324
|
+
* Internal send with exponential backoff retry logic
|
|
300
325
|
*/
|
|
301
|
-
async send(url, payload, attempt = 1) {
|
|
326
|
+
async send(url, payload, attempt = 1, useKeepalive = true) {
|
|
327
|
+
// Don't bother sending when offline — caller should re-queue
|
|
328
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
329
|
+
logger.warn('Device offline, skipping send');
|
|
330
|
+
return { success: false, error: new Error('offline') };
|
|
331
|
+
}
|
|
302
332
|
try {
|
|
303
333
|
const response = await this.fetchWithTimeout(url, {
|
|
304
334
|
method: 'POST',
|
|
305
|
-
headers: {
|
|
306
|
-
'Content-Type': 'application/json',
|
|
307
|
-
},
|
|
335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
336
|
body: payload,
|
|
309
|
-
keepalive:
|
|
337
|
+
keepalive: useKeepalive,
|
|
310
338
|
});
|
|
311
339
|
if (response.ok) {
|
|
312
340
|
logger.debug('Request successful:', url);
|
|
313
341
|
return { success: true, status: response.status };
|
|
314
342
|
}
|
|
315
|
-
// Server error
|
|
343
|
+
// Server error — retry with exponential backoff
|
|
316
344
|
if (response.status >= 500 && attempt < this.config.maxRetries) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
345
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
346
|
+
logger.warn(`Server error (${response.status}), retrying in ${backoff}ms...`);
|
|
347
|
+
await this.delay(backoff);
|
|
348
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
320
349
|
}
|
|
321
|
-
//
|
|
350
|
+
// 4xx — don't retry (bad payload, auth failure, etc.)
|
|
322
351
|
logger.error(`Request failed with status ${response.status}`);
|
|
323
352
|
return { success: false, status: response.status };
|
|
324
353
|
}
|
|
325
354
|
catch (error) {
|
|
326
|
-
// Network error
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
355
|
+
// Network error — retry with exponential backoff if still online
|
|
356
|
+
const isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
357
|
+
if (isOnline && attempt < this.config.maxRetries) {
|
|
358
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
359
|
+
logger.warn(`Network error, retrying in ${backoff}ms (${attempt}/${this.config.maxRetries})...`);
|
|
360
|
+
await this.delay(backoff);
|
|
361
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
331
362
|
}
|
|
332
363
|
logger.error('Request failed after retries:', error);
|
|
333
364
|
return { success: false, error: error };
|
|
@@ -676,12 +707,17 @@ class EventQueue {
|
|
|
676
707
|
this.queue = [];
|
|
677
708
|
this.flushTimer = null;
|
|
678
709
|
this.isFlushing = false;
|
|
710
|
+
this.isOnline = true;
|
|
679
711
|
/** Rate limiting: timestamps of recent events */
|
|
680
712
|
this.eventTimestamps = [];
|
|
681
713
|
/** Unload handler references for cleanup */
|
|
682
714
|
this.boundBeforeUnload = null;
|
|
683
715
|
this.boundVisibilityChange = null;
|
|
684
716
|
this.boundPageHide = null;
|
|
717
|
+
this.boundOnline = null;
|
|
718
|
+
this.boundOffline = null;
|
|
719
|
+
/** Guards against double-flush on unload (beforeunload + pagehide + visibilitychange all fire) */
|
|
720
|
+
this.unloadFlushed = false;
|
|
685
721
|
this.transport = transport;
|
|
686
722
|
this.config = {
|
|
687
723
|
batchSize: config.batchSize ?? 10,
|
|
@@ -690,6 +726,7 @@ class EventQueue {
|
|
|
690
726
|
storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
|
|
691
727
|
};
|
|
692
728
|
this.persistMode = config.persistMode || 'session';
|
|
729
|
+
this.isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
693
730
|
// Restore persisted queue
|
|
694
731
|
this.restoreQueue();
|
|
695
732
|
// Start auto-flush timer
|
|
@@ -738,7 +775,7 @@ class EventQueue {
|
|
|
738
775
|
* Flush the queue (send all events)
|
|
739
776
|
*/
|
|
740
777
|
async flush() {
|
|
741
|
-
if (this.isFlushing || this.queue.length === 0) {
|
|
778
|
+
if (this.isFlushing || this.queue.length === 0 || !this.isOnline) {
|
|
742
779
|
return;
|
|
743
780
|
}
|
|
744
781
|
this.isFlushing = true;
|
|
@@ -769,11 +806,14 @@ class EventQueue {
|
|
|
769
806
|
}
|
|
770
807
|
}
|
|
771
808
|
/**
|
|
772
|
-
* Flush synchronously using sendBeacon (for page unload)
|
|
809
|
+
* Flush synchronously using sendBeacon (for page unload).
|
|
810
|
+
* Guarded: no-ops after the first call per navigation to prevent
|
|
811
|
+
* triple-flush from beforeunload + visibilitychange + pagehide.
|
|
773
812
|
*/
|
|
774
813
|
flushSync() {
|
|
775
|
-
if (this.queue.length === 0)
|
|
814
|
+
if (this.unloadFlushed || this.queue.length === 0)
|
|
776
815
|
return;
|
|
816
|
+
this.unloadFlushed = true;
|
|
777
817
|
const events = this.queue.splice(0, this.queue.length);
|
|
778
818
|
logger.debug(`Sync flushing ${events.length} events via beacon`);
|
|
779
819
|
const success = this.transport.sendBeacon(events);
|
|
@@ -811,17 +851,17 @@ class EventQueue {
|
|
|
811
851
|
clearInterval(this.flushTimer);
|
|
812
852
|
this.flushTimer = null;
|
|
813
853
|
}
|
|
814
|
-
// Remove unload handlers
|
|
815
854
|
if (typeof window !== 'undefined') {
|
|
816
|
-
if (this.boundBeforeUnload)
|
|
855
|
+
if (this.boundBeforeUnload)
|
|
817
856
|
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
818
|
-
|
|
819
|
-
if (this.boundVisibilityChange) {
|
|
857
|
+
if (this.boundVisibilityChange)
|
|
820
858
|
window.removeEventListener('visibilitychange', this.boundVisibilityChange);
|
|
821
|
-
|
|
822
|
-
if (this.boundPageHide) {
|
|
859
|
+
if (this.boundPageHide)
|
|
823
860
|
window.removeEventListener('pagehide', this.boundPageHide);
|
|
824
|
-
|
|
861
|
+
if (this.boundOnline)
|
|
862
|
+
window.removeEventListener('online', this.boundOnline);
|
|
863
|
+
if (this.boundOffline)
|
|
864
|
+
window.removeEventListener('offline', this.boundOffline);
|
|
825
865
|
}
|
|
826
866
|
}
|
|
827
867
|
/**
|
|
@@ -836,24 +876,38 @@ class EventQueue {
|
|
|
836
876
|
}, this.config.flushInterval);
|
|
837
877
|
}
|
|
838
878
|
/**
|
|
839
|
-
* Setup page unload handlers
|
|
879
|
+
* Setup page unload handlers and online/offline listeners
|
|
840
880
|
*/
|
|
841
881
|
setupUnloadHandlers() {
|
|
842
882
|
if (typeof window === 'undefined')
|
|
843
883
|
return;
|
|
844
|
-
//
|
|
884
|
+
// All three unload events share the same guarded flushSync()
|
|
845
885
|
this.boundBeforeUnload = () => this.flushSync();
|
|
846
886
|
window.addEventListener('beforeunload', this.boundBeforeUnload);
|
|
847
|
-
// Flush when page becomes hidden
|
|
848
887
|
this.boundVisibilityChange = () => {
|
|
849
888
|
if (document.visibilityState === 'hidden') {
|
|
850
889
|
this.flushSync();
|
|
851
890
|
}
|
|
891
|
+
else {
|
|
892
|
+
// Page became visible again (e.g. tab switch back) — reset guard
|
|
893
|
+
this.unloadFlushed = false;
|
|
894
|
+
}
|
|
852
895
|
};
|
|
853
896
|
window.addEventListener('visibilitychange', this.boundVisibilityChange);
|
|
854
|
-
// Flush on page hide (iOS Safari)
|
|
855
897
|
this.boundPageHide = () => this.flushSync();
|
|
856
898
|
window.addEventListener('pagehide', this.boundPageHide);
|
|
899
|
+
// Pause queue when offline, resume + flush when back online
|
|
900
|
+
this.boundOnline = () => {
|
|
901
|
+
logger.info('Connection restored — flushing queued events');
|
|
902
|
+
this.isOnline = true;
|
|
903
|
+
this.flush();
|
|
904
|
+
};
|
|
905
|
+
this.boundOffline = () => {
|
|
906
|
+
logger.warn('Connection lost — pausing event queue');
|
|
907
|
+
this.isOnline = false;
|
|
908
|
+
};
|
|
909
|
+
window.addEventListener('online', this.boundOnline);
|
|
910
|
+
window.addEventListener('offline', this.boundOffline);
|
|
857
911
|
}
|
|
858
912
|
/**
|
|
859
913
|
* Persist queue to storage based on persistMode
|
|
@@ -936,6 +990,8 @@ class BasePlugin {
|
|
|
936
990
|
* Clianta SDK - Page View Plugin
|
|
937
991
|
* @see SDK_VERSION in core/config.ts
|
|
938
992
|
*/
|
|
993
|
+
/** Sentinel flag to prevent double-wrapping history methods across multiple SDK instances */
|
|
994
|
+
const WRAPPED_FLAG = '__clianta_pv_wrapped__';
|
|
939
995
|
/**
|
|
940
996
|
* Page View Plugin - Tracks page views
|
|
941
997
|
*/
|
|
@@ -945,50 +1001,64 @@ class PageViewPlugin extends BasePlugin {
|
|
|
945
1001
|
this.name = 'pageView';
|
|
946
1002
|
this.originalPushState = null;
|
|
947
1003
|
this.originalReplaceState = null;
|
|
1004
|
+
this.navHandler = null;
|
|
948
1005
|
this.popstateHandler = null;
|
|
949
1006
|
}
|
|
950
1007
|
init(tracker) {
|
|
951
1008
|
super.init(tracker);
|
|
952
1009
|
// Track initial page view
|
|
953
1010
|
this.trackPageView();
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1011
|
+
if (typeof window === 'undefined')
|
|
1012
|
+
return;
|
|
1013
|
+
// Only wrap history methods once — guard against multiple SDK instances (e.g. microfrontends)
|
|
1014
|
+
// wrapping them repeatedly, which would cause duplicate navigation events and broken cleanup.
|
|
1015
|
+
if (!history.pushState[WRAPPED_FLAG]) {
|
|
957
1016
|
this.originalPushState = history.pushState;
|
|
958
1017
|
this.originalReplaceState = history.replaceState;
|
|
959
|
-
|
|
960
|
-
const
|
|
1018
|
+
const originalPush = this.originalPushState;
|
|
1019
|
+
const originalReplace = this.originalReplaceState;
|
|
961
1020
|
history.pushState = function (...args) {
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
// Notify other plugins (e.g. ScrollPlugin) about navigation
|
|
1021
|
+
originalPush.apply(history, args);
|
|
1022
|
+
// Dispatch event so all listening instances track the navigation
|
|
965
1023
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
966
1024
|
};
|
|
1025
|
+
history.pushState[WRAPPED_FLAG] = true;
|
|
967
1026
|
history.replaceState = function (...args) {
|
|
968
|
-
|
|
969
|
-
self.trackPageView();
|
|
1027
|
+
originalReplace.apply(history, args);
|
|
970
1028
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
971
1029
|
};
|
|
972
|
-
|
|
973
|
-
this.popstateHandler = () => this.trackPageView();
|
|
974
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
1030
|
+
history.replaceState[WRAPPED_FLAG] = true;
|
|
975
1031
|
}
|
|
1032
|
+
// Each instance listens to the shared navigation event rather than embedding
|
|
1033
|
+
// tracking directly in the pushState wrapper — decouples tracking from wrapping.
|
|
1034
|
+
this.navHandler = () => this.trackPageView();
|
|
1035
|
+
window.addEventListener('clianta:navigation', this.navHandler);
|
|
1036
|
+
// Handle back/forward navigation
|
|
1037
|
+
this.popstateHandler = () => this.trackPageView();
|
|
1038
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
976
1039
|
}
|
|
977
1040
|
destroy() {
|
|
978
|
-
|
|
1041
|
+
if (typeof window !== 'undefined') {
|
|
1042
|
+
if (this.navHandler) {
|
|
1043
|
+
window.removeEventListener('clianta:navigation', this.navHandler);
|
|
1044
|
+
this.navHandler = null;
|
|
1045
|
+
}
|
|
1046
|
+
if (this.popstateHandler) {
|
|
1047
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1048
|
+
this.popstateHandler = null;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
// Restore original history methods only if this instance was the one that wrapped them
|
|
979
1052
|
if (this.originalPushState) {
|
|
980
1053
|
history.pushState = this.originalPushState;
|
|
1054
|
+
delete history.pushState[WRAPPED_FLAG];
|
|
981
1055
|
this.originalPushState = null;
|
|
982
1056
|
}
|
|
983
1057
|
if (this.originalReplaceState) {
|
|
984
1058
|
history.replaceState = this.originalReplaceState;
|
|
1059
|
+
delete history.replaceState[WRAPPED_FLAG];
|
|
985
1060
|
this.originalReplaceState = null;
|
|
986
1061
|
}
|
|
987
|
-
// Remove popstate listener
|
|
988
|
-
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
989
|
-
window.removeEventListener('popstate', this.popstateHandler);
|
|
990
|
-
this.popstateHandler = null;
|
|
991
|
-
}
|
|
992
1062
|
super.destroy();
|
|
993
1063
|
}
|
|
994
1064
|
trackPageView() {
|
|
@@ -1117,6 +1187,7 @@ class FormsPlugin extends BasePlugin {
|
|
|
1117
1187
|
this.trackedForms = new WeakSet();
|
|
1118
1188
|
this.formInteractions = new Set();
|
|
1119
1189
|
this.observer = null;
|
|
1190
|
+
this.observerTimer = null;
|
|
1120
1191
|
this.listeners = [];
|
|
1121
1192
|
}
|
|
1122
1193
|
init(tracker) {
|
|
@@ -1125,13 +1196,21 @@ class FormsPlugin extends BasePlugin {
|
|
|
1125
1196
|
return;
|
|
1126
1197
|
// Track existing forms
|
|
1127
1198
|
this.trackAllForms();
|
|
1128
|
-
// Watch for dynamically added forms
|
|
1199
|
+
// Watch for dynamically added forms — debounced to avoid O(DOM) cost on every mutation
|
|
1129
1200
|
if (typeof MutationObserver !== 'undefined') {
|
|
1130
|
-
this.observer = new MutationObserver(() =>
|
|
1201
|
+
this.observer = new MutationObserver(() => {
|
|
1202
|
+
if (this.observerTimer)
|
|
1203
|
+
clearTimeout(this.observerTimer);
|
|
1204
|
+
this.observerTimer = setTimeout(() => this.trackAllForms(), 100);
|
|
1205
|
+
});
|
|
1131
1206
|
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
1132
1207
|
}
|
|
1133
1208
|
}
|
|
1134
1209
|
destroy() {
|
|
1210
|
+
if (this.observerTimer) {
|
|
1211
|
+
clearTimeout(this.observerTimer);
|
|
1212
|
+
this.observerTimer = null;
|
|
1213
|
+
}
|
|
1135
1214
|
if (this.observer) {
|
|
1136
1215
|
this.observer.disconnect();
|
|
1137
1216
|
this.observer = null;
|
|
@@ -1253,8 +1332,13 @@ class ClicksPlugin extends BasePlugin {
|
|
|
1253
1332
|
super.destroy();
|
|
1254
1333
|
}
|
|
1255
1334
|
handleClick(e) {
|
|
1256
|
-
|
|
1257
|
-
|
|
1335
|
+
// Walk up the DOM to find the nearest trackable ancestor.
|
|
1336
|
+
// Without this, clicks on <span> or <img> inside a <button> are silently dropped.
|
|
1337
|
+
let target = e.target;
|
|
1338
|
+
while (target && !isTrackableClickElement(target)) {
|
|
1339
|
+
target = target.parentElement;
|
|
1340
|
+
}
|
|
1341
|
+
if (!target)
|
|
1258
1342
|
return;
|
|
1259
1343
|
const buttonText = getElementText(target, 100);
|
|
1260
1344
|
const elementInfo = getElementInfo(target);
|
|
@@ -1287,6 +1371,8 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1287
1371
|
this.engagementStartTime = 0;
|
|
1288
1372
|
this.isEngaged = false;
|
|
1289
1373
|
this.engagementTimeout = null;
|
|
1374
|
+
/** Guard: beforeunload + visibilitychange:hidden both fire on tab close — only report once */
|
|
1375
|
+
this.unloadReported = false;
|
|
1290
1376
|
this.boundMarkEngaged = null;
|
|
1291
1377
|
this.boundTrackTimeOnPage = null;
|
|
1292
1378
|
this.boundVisibilityHandler = null;
|
|
@@ -1308,8 +1394,9 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1308
1394
|
this.trackTimeOnPage();
|
|
1309
1395
|
}
|
|
1310
1396
|
else {
|
|
1311
|
-
//
|
|
1397
|
+
// Page is visible again — reset both the time counter and the unload guard
|
|
1312
1398
|
this.engagementStartTime = Date.now();
|
|
1399
|
+
this.unloadReported = false;
|
|
1313
1400
|
}
|
|
1314
1401
|
};
|
|
1315
1402
|
['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
|
|
@@ -1355,6 +1442,7 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1355
1442
|
this.pageLoadTime = Date.now();
|
|
1356
1443
|
this.engagementStartTime = Date.now();
|
|
1357
1444
|
this.isEngaged = false;
|
|
1445
|
+
this.unloadReported = false;
|
|
1358
1446
|
if (this.engagementTimeout) {
|
|
1359
1447
|
clearTimeout(this.engagementTimeout);
|
|
1360
1448
|
this.engagementTimeout = null;
|
|
@@ -1376,6 +1464,10 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1376
1464
|
}, 30000); // 30 seconds of inactivity
|
|
1377
1465
|
}
|
|
1378
1466
|
trackTimeOnPage() {
|
|
1467
|
+
// Guard: beforeunload and visibilitychange:hidden both fire on tab close — only report once
|
|
1468
|
+
if (this.unloadReported)
|
|
1469
|
+
return;
|
|
1470
|
+
this.unloadReported = true;
|
|
1379
1471
|
const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
|
|
1380
1472
|
if (timeSpent > 0) {
|
|
1381
1473
|
this.track('time_on_page', 'Time Spent', {
|
|
@@ -1534,12 +1626,16 @@ class ExitIntentPlugin extends BasePlugin {
|
|
|
1534
1626
|
/**
|
|
1535
1627
|
* Error Tracking Plugin - Tracks JavaScript errors
|
|
1536
1628
|
*/
|
|
1629
|
+
/** Max unique errors to track per page (prevents queue flooding from error loops) */
|
|
1630
|
+
const MAX_UNIQUE_ERRORS = 20;
|
|
1537
1631
|
class ErrorsPlugin extends BasePlugin {
|
|
1538
1632
|
constructor() {
|
|
1539
1633
|
super(...arguments);
|
|
1540
1634
|
this.name = 'errors';
|
|
1541
1635
|
this.boundErrorHandler = null;
|
|
1542
1636
|
this.boundRejectionHandler = null;
|
|
1637
|
+
/** Seen error fingerprints — deduplicates repeated identical errors */
|
|
1638
|
+
this.seenErrors = new Set();
|
|
1543
1639
|
}
|
|
1544
1640
|
init(tracker) {
|
|
1545
1641
|
super.init(tracker);
|
|
@@ -1562,6 +1658,9 @@ class ErrorsPlugin extends BasePlugin {
|
|
|
1562
1658
|
super.destroy();
|
|
1563
1659
|
}
|
|
1564
1660
|
handleError(e) {
|
|
1661
|
+
const fingerprint = `${e.message}:${e.filename}:${e.lineno}`;
|
|
1662
|
+
if (!this.dedup(fingerprint))
|
|
1663
|
+
return;
|
|
1565
1664
|
this.track('error', 'JavaScript Error', {
|
|
1566
1665
|
message: e.message,
|
|
1567
1666
|
filename: e.filename,
|
|
@@ -1571,9 +1670,22 @@ class ErrorsPlugin extends BasePlugin {
|
|
|
1571
1670
|
});
|
|
1572
1671
|
}
|
|
1573
1672
|
handleRejection(e) {
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1673
|
+
const reason = String(e.reason).substring(0, 200);
|
|
1674
|
+
if (!this.dedup(reason))
|
|
1675
|
+
return;
|
|
1676
|
+
this.track('error', 'Unhandled Promise Rejection', { reason });
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Returns true if this error fingerprint is new (should be tracked).
|
|
1680
|
+
* Caps at MAX_UNIQUE_ERRORS to prevent queue flooding from error loops.
|
|
1681
|
+
*/
|
|
1682
|
+
dedup(fingerprint) {
|
|
1683
|
+
if (this.seenErrors.has(fingerprint))
|
|
1684
|
+
return false;
|
|
1685
|
+
if (this.seenErrors.size >= MAX_UNIQUE_ERRORS)
|
|
1686
|
+
return false;
|
|
1687
|
+
this.seenErrors.add(fingerprint);
|
|
1688
|
+
return true;
|
|
1577
1689
|
}
|
|
1578
1690
|
}
|
|
1579
1691
|
|
|
@@ -3512,28 +3624,17 @@ class Tracker {
|
|
|
3512
3624
|
}
|
|
3513
3625
|
const prevId = previousId || this.visitorId;
|
|
3514
3626
|
logger.info('Aliasing visitor:', { from: prevId, to: newId });
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
newId,
|
|
3524
|
-
}),
|
|
3525
|
-
});
|
|
3526
|
-
if (response.ok) {
|
|
3527
|
-
logger.info('Alias successful');
|
|
3528
|
-
return true;
|
|
3529
|
-
}
|
|
3530
|
-
logger.error('Alias failed:', response.status);
|
|
3531
|
-
return false;
|
|
3532
|
-
}
|
|
3533
|
-
catch (error) {
|
|
3534
|
-
logger.error('Alias request failed:', error);
|
|
3535
|
-
return false;
|
|
3627
|
+
const result = await this.transport.sendPost('/api/public/track/alias', {
|
|
3628
|
+
workspaceId: this.workspaceId,
|
|
3629
|
+
previousId: prevId,
|
|
3630
|
+
newId,
|
|
3631
|
+
});
|
|
3632
|
+
if (result.success) {
|
|
3633
|
+
logger.info('Alias successful');
|
|
3634
|
+
return true;
|
|
3536
3635
|
}
|
|
3636
|
+
logger.error('Alias failed:', result.error ?? result.status);
|
|
3637
|
+
return false;
|
|
3537
3638
|
}
|
|
3538
3639
|
/**
|
|
3539
3640
|
* Track a screen view (for mobile-first PWAs and SPAs).
|
|
@@ -3896,13 +3997,24 @@ let globalInstance = null;
|
|
|
3896
3997
|
* });
|
|
3897
3998
|
*/
|
|
3898
3999
|
function clianta(workspaceId, config) {
|
|
3899
|
-
// Return existing instance if same workspace
|
|
4000
|
+
// Return existing instance if same workspace and no config change
|
|
3900
4001
|
if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
|
|
4002
|
+
if (config && Object.keys(config).length > 0) {
|
|
4003
|
+
// Config was passed to an already-initialized instance — warn the developer
|
|
4004
|
+
// because the new config is ignored. They must call destroy() first to reconfigure.
|
|
4005
|
+
if (typeof console !== 'undefined') {
|
|
4006
|
+
console.warn('[Clianta] clianta() called with config on an already-initialized instance ' +
|
|
4007
|
+
'for workspace "' + workspaceId + '". The new config was ignored. ' +
|
|
4008
|
+
'Call tracker.destroy() first if you need to reconfigure.');
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
3901
4011
|
return globalInstance;
|
|
3902
4012
|
}
|
|
3903
|
-
// Destroy existing instance if workspace changed
|
|
4013
|
+
// Destroy existing instance if workspace changed (fire-and-forget flush, then destroy)
|
|
3904
4014
|
if (globalInstance) {
|
|
3905
|
-
|
|
4015
|
+
// Kick off async flush+destroy without blocking the new instance creation.
|
|
4016
|
+
// Using void to make the intentional fire-and-forget explicit.
|
|
4017
|
+
void globalInstance.destroy();
|
|
3906
4018
|
}
|
|
3907
4019
|
// Create new instance
|
|
3908
4020
|
globalInstance = new Tracker(workspaceId, config);
|
|
@@ -3930,8 +4042,21 @@ if (typeof window !== 'undefined') {
|
|
|
3930
4042
|
const projectId = script.getAttribute('data-project-id');
|
|
3931
4043
|
if (!projectId)
|
|
3932
4044
|
return;
|
|
3933
|
-
const
|
|
3934
|
-
|
|
4045
|
+
const initConfig = {
|
|
4046
|
+
debug: script.hasAttribute('data-debug'),
|
|
4047
|
+
};
|
|
4048
|
+
// Support additional config via script tag attributes:
|
|
4049
|
+
// data-api-endpoint="https://api.yourhost.com"
|
|
4050
|
+
// data-cookieless (boolean flag)
|
|
4051
|
+
// data-use-cookies (boolean flag)
|
|
4052
|
+
const apiEndpoint = script.getAttribute('data-api-endpoint');
|
|
4053
|
+
if (apiEndpoint)
|
|
4054
|
+
initConfig.apiEndpoint = apiEndpoint;
|
|
4055
|
+
if (script.hasAttribute('data-cookieless'))
|
|
4056
|
+
initConfig.cookielessMode = true;
|
|
4057
|
+
if (script.hasAttribute('data-use-cookies'))
|
|
4058
|
+
initConfig.useCookies = true;
|
|
4059
|
+
const instance = clianta(projectId, initConfig);
|
|
3935
4060
|
// Expose the auto-initialized instance globally
|
|
3936
4061
|
window.__clianta = instance;
|
|
3937
4062
|
};
|