@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/vue.cjs.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
|
*/
|
|
@@ -13,7 +13,7 @@ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentS
|
|
|
13
13
|
* @see SDK_VERSION in core/config.ts
|
|
14
14
|
*/
|
|
15
15
|
/** SDK Version */
|
|
16
|
-
const SDK_VERSION = '1.
|
|
16
|
+
const SDK_VERSION = '1.7.0';
|
|
17
17
|
/** Default API endpoint — reads from env or falls back to localhost */
|
|
18
18
|
const getDefaultApiEndpoint = () => {
|
|
19
19
|
// Next.js (process.env)
|
|
@@ -39,6 +39,15 @@ const getDefaultApiEndpoint = () => {
|
|
|
39
39
|
if (typeof process !== 'undefined' && process.env?.CLIANTA_API_ENDPOINT) {
|
|
40
40
|
return process.env.CLIANTA_API_ENDPOINT;
|
|
41
41
|
}
|
|
42
|
+
// No env var found — warn if we're not on localhost (likely a production misconfiguration)
|
|
43
|
+
const isLocalhost = typeof window !== 'undefined' &&
|
|
44
|
+
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
|
45
|
+
if (!isLocalhost && typeof console !== 'undefined') {
|
|
46
|
+
console.warn('[Clianta] No API endpoint configured. ' +
|
|
47
|
+
'Set NEXT_PUBLIC_CLIANTA_API_ENDPOINT (Next.js), VITE_CLIANTA_API_ENDPOINT (Vite), ' +
|
|
48
|
+
'or pass apiEndpoint directly to clianta(). ' +
|
|
49
|
+
'Falling back to localhost — tracking will not work in production.');
|
|
50
|
+
}
|
|
42
51
|
return 'http://localhost:5000';
|
|
43
52
|
};
|
|
44
53
|
/** Core plugins enabled by default — all auto-track with zero config */
|
|
@@ -184,7 +193,9 @@ const logger = createLogger(false);
|
|
|
184
193
|
*/
|
|
185
194
|
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
|
186
195
|
const DEFAULT_MAX_RETRIES = 3;
|
|
187
|
-
const DEFAULT_RETRY_DELAY = 1000; // 1 second
|
|
196
|
+
const DEFAULT_RETRY_DELAY = 1000; // 1 second base — doubles each attempt (exponential backoff)
|
|
197
|
+
/** fetch keepalive hard limit in browsers (64KB) */
|
|
198
|
+
const KEEPALIVE_SIZE_LIMIT = 60000; // leave 4KB margin
|
|
188
199
|
/**
|
|
189
200
|
* Transport class for sending data to the backend
|
|
190
201
|
*/
|
|
@@ -203,6 +214,11 @@ class Transport {
|
|
|
203
214
|
async sendEvents(events) {
|
|
204
215
|
const url = `${this.config.apiEndpoint}/api/public/track/event`;
|
|
205
216
|
const payload = JSON.stringify({ events });
|
|
217
|
+
// keepalive has a 64KB hard limit — fall back to beacon if too large
|
|
218
|
+
if (payload.length > KEEPALIVE_SIZE_LIMIT) {
|
|
219
|
+
const sent = this.sendBeacon(events);
|
|
220
|
+
return sent ? { success: true } : this.send(url, payload, 1, false);
|
|
221
|
+
}
|
|
206
222
|
return this.send(url, payload);
|
|
207
223
|
}
|
|
208
224
|
/**
|
|
@@ -267,6 +283,15 @@ class Transport {
|
|
|
267
283
|
return false;
|
|
268
284
|
}
|
|
269
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Send an arbitrary POST request through the transport (with timeout + retry).
|
|
288
|
+
* Used for one-off calls like alias() that don't fit the event-batch or identify shapes.
|
|
289
|
+
*/
|
|
290
|
+
async sendPost(path, body) {
|
|
291
|
+
const url = `${this.config.apiEndpoint}${path}`;
|
|
292
|
+
const payload = JSON.stringify(body);
|
|
293
|
+
return this.send(url, payload);
|
|
294
|
+
}
|
|
270
295
|
/**
|
|
271
296
|
* Fetch data from the tracking API (GET request)
|
|
272
297
|
* Used for read-back APIs (visitor profile, activity, etc.)
|
|
@@ -301,38 +326,44 @@ class Transport {
|
|
|
301
326
|
}
|
|
302
327
|
}
|
|
303
328
|
/**
|
|
304
|
-
* Internal send with retry logic
|
|
329
|
+
* Internal send with exponential backoff retry logic
|
|
305
330
|
*/
|
|
306
|
-
async send(url, payload, attempt = 1) {
|
|
331
|
+
async send(url, payload, attempt = 1, useKeepalive = true) {
|
|
332
|
+
// Don't bother sending when offline — caller should re-queue
|
|
333
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
334
|
+
logger.warn('Device offline, skipping send');
|
|
335
|
+
return { success: false, error: new Error('offline') };
|
|
336
|
+
}
|
|
307
337
|
try {
|
|
308
338
|
const response = await this.fetchWithTimeout(url, {
|
|
309
339
|
method: 'POST',
|
|
310
|
-
headers: {
|
|
311
|
-
'Content-Type': 'application/json',
|
|
312
|
-
},
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
313
341
|
body: payload,
|
|
314
|
-
keepalive:
|
|
342
|
+
keepalive: useKeepalive,
|
|
315
343
|
});
|
|
316
344
|
if (response.ok) {
|
|
317
345
|
logger.debug('Request successful:', url);
|
|
318
346
|
return { success: true, status: response.status };
|
|
319
347
|
}
|
|
320
|
-
// Server error
|
|
348
|
+
// Server error — retry with exponential backoff
|
|
321
349
|
if (response.status >= 500 && attempt < this.config.maxRetries) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
350
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
351
|
+
logger.warn(`Server error (${response.status}), retrying in ${backoff}ms...`);
|
|
352
|
+
await this.delay(backoff);
|
|
353
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
325
354
|
}
|
|
326
|
-
//
|
|
355
|
+
// 4xx — don't retry (bad payload, auth failure, etc.)
|
|
327
356
|
logger.error(`Request failed with status ${response.status}`);
|
|
328
357
|
return { success: false, status: response.status };
|
|
329
358
|
}
|
|
330
359
|
catch (error) {
|
|
331
|
-
// Network error
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
360
|
+
// Network error — retry with exponential backoff if still online
|
|
361
|
+
const isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
362
|
+
if (isOnline && attempt < this.config.maxRetries) {
|
|
363
|
+
const backoff = this.config.retryDelay * Math.pow(2, attempt - 1);
|
|
364
|
+
logger.warn(`Network error, retrying in ${backoff}ms (${attempt}/${this.config.maxRetries})...`);
|
|
365
|
+
await this.delay(backoff);
|
|
366
|
+
return this.send(url, payload, attempt + 1, useKeepalive);
|
|
336
367
|
}
|
|
337
368
|
logger.error('Request failed after retries:', error);
|
|
338
369
|
return { success: false, error: error };
|
|
@@ -681,12 +712,17 @@ class EventQueue {
|
|
|
681
712
|
this.queue = [];
|
|
682
713
|
this.flushTimer = null;
|
|
683
714
|
this.isFlushing = false;
|
|
715
|
+
this.isOnline = true;
|
|
684
716
|
/** Rate limiting: timestamps of recent events */
|
|
685
717
|
this.eventTimestamps = [];
|
|
686
718
|
/** Unload handler references for cleanup */
|
|
687
719
|
this.boundBeforeUnload = null;
|
|
688
720
|
this.boundVisibilityChange = null;
|
|
689
721
|
this.boundPageHide = null;
|
|
722
|
+
this.boundOnline = null;
|
|
723
|
+
this.boundOffline = null;
|
|
724
|
+
/** Guards against double-flush on unload (beforeunload + pagehide + visibilitychange all fire) */
|
|
725
|
+
this.unloadFlushed = false;
|
|
690
726
|
this.transport = transport;
|
|
691
727
|
this.config = {
|
|
692
728
|
batchSize: config.batchSize ?? 10,
|
|
@@ -695,6 +731,7 @@ class EventQueue {
|
|
|
695
731
|
storageKey: config.storageKey ?? STORAGE_KEYS.EVENT_QUEUE,
|
|
696
732
|
};
|
|
697
733
|
this.persistMode = config.persistMode || 'session';
|
|
734
|
+
this.isOnline = typeof navigator === 'undefined' || navigator.onLine;
|
|
698
735
|
// Restore persisted queue
|
|
699
736
|
this.restoreQueue();
|
|
700
737
|
// Start auto-flush timer
|
|
@@ -743,7 +780,7 @@ class EventQueue {
|
|
|
743
780
|
* Flush the queue (send all events)
|
|
744
781
|
*/
|
|
745
782
|
async flush() {
|
|
746
|
-
if (this.isFlushing || this.queue.length === 0) {
|
|
783
|
+
if (this.isFlushing || this.queue.length === 0 || !this.isOnline) {
|
|
747
784
|
return;
|
|
748
785
|
}
|
|
749
786
|
this.isFlushing = true;
|
|
@@ -774,11 +811,14 @@ class EventQueue {
|
|
|
774
811
|
}
|
|
775
812
|
}
|
|
776
813
|
/**
|
|
777
|
-
* Flush synchronously using sendBeacon (for page unload)
|
|
814
|
+
* Flush synchronously using sendBeacon (for page unload).
|
|
815
|
+
* Guarded: no-ops after the first call per navigation to prevent
|
|
816
|
+
* triple-flush from beforeunload + visibilitychange + pagehide.
|
|
778
817
|
*/
|
|
779
818
|
flushSync() {
|
|
780
|
-
if (this.queue.length === 0)
|
|
819
|
+
if (this.unloadFlushed || this.queue.length === 0)
|
|
781
820
|
return;
|
|
821
|
+
this.unloadFlushed = true;
|
|
782
822
|
const events = this.queue.splice(0, this.queue.length);
|
|
783
823
|
logger.debug(`Sync flushing ${events.length} events via beacon`);
|
|
784
824
|
const success = this.transport.sendBeacon(events);
|
|
@@ -816,17 +856,17 @@ class EventQueue {
|
|
|
816
856
|
clearInterval(this.flushTimer);
|
|
817
857
|
this.flushTimer = null;
|
|
818
858
|
}
|
|
819
|
-
// Remove unload handlers
|
|
820
859
|
if (typeof window !== 'undefined') {
|
|
821
|
-
if (this.boundBeforeUnload)
|
|
860
|
+
if (this.boundBeforeUnload)
|
|
822
861
|
window.removeEventListener('beforeunload', this.boundBeforeUnload);
|
|
823
|
-
|
|
824
|
-
if (this.boundVisibilityChange) {
|
|
862
|
+
if (this.boundVisibilityChange)
|
|
825
863
|
window.removeEventListener('visibilitychange', this.boundVisibilityChange);
|
|
826
|
-
|
|
827
|
-
if (this.boundPageHide) {
|
|
864
|
+
if (this.boundPageHide)
|
|
828
865
|
window.removeEventListener('pagehide', this.boundPageHide);
|
|
829
|
-
|
|
866
|
+
if (this.boundOnline)
|
|
867
|
+
window.removeEventListener('online', this.boundOnline);
|
|
868
|
+
if (this.boundOffline)
|
|
869
|
+
window.removeEventListener('offline', this.boundOffline);
|
|
830
870
|
}
|
|
831
871
|
}
|
|
832
872
|
/**
|
|
@@ -841,24 +881,38 @@ class EventQueue {
|
|
|
841
881
|
}, this.config.flushInterval);
|
|
842
882
|
}
|
|
843
883
|
/**
|
|
844
|
-
* Setup page unload handlers
|
|
884
|
+
* Setup page unload handlers and online/offline listeners
|
|
845
885
|
*/
|
|
846
886
|
setupUnloadHandlers() {
|
|
847
887
|
if (typeof window === 'undefined')
|
|
848
888
|
return;
|
|
849
|
-
//
|
|
889
|
+
// All three unload events share the same guarded flushSync()
|
|
850
890
|
this.boundBeforeUnload = () => this.flushSync();
|
|
851
891
|
window.addEventListener('beforeunload', this.boundBeforeUnload);
|
|
852
|
-
// Flush when page becomes hidden
|
|
853
892
|
this.boundVisibilityChange = () => {
|
|
854
893
|
if (document.visibilityState === 'hidden') {
|
|
855
894
|
this.flushSync();
|
|
856
895
|
}
|
|
896
|
+
else {
|
|
897
|
+
// Page became visible again (e.g. tab switch back) — reset guard
|
|
898
|
+
this.unloadFlushed = false;
|
|
899
|
+
}
|
|
857
900
|
};
|
|
858
901
|
window.addEventListener('visibilitychange', this.boundVisibilityChange);
|
|
859
|
-
// Flush on page hide (iOS Safari)
|
|
860
902
|
this.boundPageHide = () => this.flushSync();
|
|
861
903
|
window.addEventListener('pagehide', this.boundPageHide);
|
|
904
|
+
// Pause queue when offline, resume + flush when back online
|
|
905
|
+
this.boundOnline = () => {
|
|
906
|
+
logger.info('Connection restored — flushing queued events');
|
|
907
|
+
this.isOnline = true;
|
|
908
|
+
this.flush();
|
|
909
|
+
};
|
|
910
|
+
this.boundOffline = () => {
|
|
911
|
+
logger.warn('Connection lost — pausing event queue');
|
|
912
|
+
this.isOnline = false;
|
|
913
|
+
};
|
|
914
|
+
window.addEventListener('online', this.boundOnline);
|
|
915
|
+
window.addEventListener('offline', this.boundOffline);
|
|
862
916
|
}
|
|
863
917
|
/**
|
|
864
918
|
* Persist queue to storage based on persistMode
|
|
@@ -941,6 +995,8 @@ class BasePlugin {
|
|
|
941
995
|
* Clianta SDK - Page View Plugin
|
|
942
996
|
* @see SDK_VERSION in core/config.ts
|
|
943
997
|
*/
|
|
998
|
+
/** Sentinel flag to prevent double-wrapping history methods across multiple SDK instances */
|
|
999
|
+
const WRAPPED_FLAG = '__clianta_pv_wrapped__';
|
|
944
1000
|
/**
|
|
945
1001
|
* Page View Plugin - Tracks page views
|
|
946
1002
|
*/
|
|
@@ -950,50 +1006,64 @@ class PageViewPlugin extends BasePlugin {
|
|
|
950
1006
|
this.name = 'pageView';
|
|
951
1007
|
this.originalPushState = null;
|
|
952
1008
|
this.originalReplaceState = null;
|
|
1009
|
+
this.navHandler = null;
|
|
953
1010
|
this.popstateHandler = null;
|
|
954
1011
|
}
|
|
955
1012
|
init(tracker) {
|
|
956
1013
|
super.init(tracker);
|
|
957
1014
|
// Track initial page view
|
|
958
1015
|
this.trackPageView();
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1016
|
+
if (typeof window === 'undefined')
|
|
1017
|
+
return;
|
|
1018
|
+
// Only wrap history methods once — guard against multiple SDK instances (e.g. microfrontends)
|
|
1019
|
+
// wrapping them repeatedly, which would cause duplicate navigation events and broken cleanup.
|
|
1020
|
+
if (!history.pushState[WRAPPED_FLAG]) {
|
|
962
1021
|
this.originalPushState = history.pushState;
|
|
963
1022
|
this.originalReplaceState = history.replaceState;
|
|
964
|
-
|
|
965
|
-
const
|
|
1023
|
+
const originalPush = this.originalPushState;
|
|
1024
|
+
const originalReplace = this.originalReplaceState;
|
|
966
1025
|
history.pushState = function (...args) {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
// Notify other plugins (e.g. ScrollPlugin) about navigation
|
|
1026
|
+
originalPush.apply(history, args);
|
|
1027
|
+
// Dispatch event so all listening instances track the navigation
|
|
970
1028
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
971
1029
|
};
|
|
1030
|
+
history.pushState[WRAPPED_FLAG] = true;
|
|
972
1031
|
history.replaceState = function (...args) {
|
|
973
|
-
|
|
974
|
-
self.trackPageView();
|
|
1032
|
+
originalReplace.apply(history, args);
|
|
975
1033
|
window.dispatchEvent(new Event('clianta:navigation'));
|
|
976
1034
|
};
|
|
977
|
-
|
|
978
|
-
this.popstateHandler = () => this.trackPageView();
|
|
979
|
-
window.addEventListener('popstate', this.popstateHandler);
|
|
1035
|
+
history.replaceState[WRAPPED_FLAG] = true;
|
|
980
1036
|
}
|
|
1037
|
+
// Each instance listens to the shared navigation event rather than embedding
|
|
1038
|
+
// tracking directly in the pushState wrapper — decouples tracking from wrapping.
|
|
1039
|
+
this.navHandler = () => this.trackPageView();
|
|
1040
|
+
window.addEventListener('clianta:navigation', this.navHandler);
|
|
1041
|
+
// Handle back/forward navigation
|
|
1042
|
+
this.popstateHandler = () => this.trackPageView();
|
|
1043
|
+
window.addEventListener('popstate', this.popstateHandler);
|
|
981
1044
|
}
|
|
982
1045
|
destroy() {
|
|
983
|
-
|
|
1046
|
+
if (typeof window !== 'undefined') {
|
|
1047
|
+
if (this.navHandler) {
|
|
1048
|
+
window.removeEventListener('clianta:navigation', this.navHandler);
|
|
1049
|
+
this.navHandler = null;
|
|
1050
|
+
}
|
|
1051
|
+
if (this.popstateHandler) {
|
|
1052
|
+
window.removeEventListener('popstate', this.popstateHandler);
|
|
1053
|
+
this.popstateHandler = null;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Restore original history methods only if this instance was the one that wrapped them
|
|
984
1057
|
if (this.originalPushState) {
|
|
985
1058
|
history.pushState = this.originalPushState;
|
|
1059
|
+
delete history.pushState[WRAPPED_FLAG];
|
|
986
1060
|
this.originalPushState = null;
|
|
987
1061
|
}
|
|
988
1062
|
if (this.originalReplaceState) {
|
|
989
1063
|
history.replaceState = this.originalReplaceState;
|
|
1064
|
+
delete history.replaceState[WRAPPED_FLAG];
|
|
990
1065
|
this.originalReplaceState = null;
|
|
991
1066
|
}
|
|
992
|
-
// Remove popstate listener
|
|
993
|
-
if (this.popstateHandler && typeof window !== 'undefined') {
|
|
994
|
-
window.removeEventListener('popstate', this.popstateHandler);
|
|
995
|
-
this.popstateHandler = null;
|
|
996
|
-
}
|
|
997
1067
|
super.destroy();
|
|
998
1068
|
}
|
|
999
1069
|
trackPageView() {
|
|
@@ -1122,6 +1192,7 @@ class FormsPlugin extends BasePlugin {
|
|
|
1122
1192
|
this.trackedForms = new WeakSet();
|
|
1123
1193
|
this.formInteractions = new Set();
|
|
1124
1194
|
this.observer = null;
|
|
1195
|
+
this.observerTimer = null;
|
|
1125
1196
|
this.listeners = [];
|
|
1126
1197
|
}
|
|
1127
1198
|
init(tracker) {
|
|
@@ -1130,13 +1201,21 @@ class FormsPlugin extends BasePlugin {
|
|
|
1130
1201
|
return;
|
|
1131
1202
|
// Track existing forms
|
|
1132
1203
|
this.trackAllForms();
|
|
1133
|
-
// Watch for dynamically added forms
|
|
1204
|
+
// Watch for dynamically added forms — debounced to avoid O(DOM) cost on every mutation
|
|
1134
1205
|
if (typeof MutationObserver !== 'undefined') {
|
|
1135
|
-
this.observer = new MutationObserver(() =>
|
|
1206
|
+
this.observer = new MutationObserver(() => {
|
|
1207
|
+
if (this.observerTimer)
|
|
1208
|
+
clearTimeout(this.observerTimer);
|
|
1209
|
+
this.observerTimer = setTimeout(() => this.trackAllForms(), 100);
|
|
1210
|
+
});
|
|
1136
1211
|
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
1137
1212
|
}
|
|
1138
1213
|
}
|
|
1139
1214
|
destroy() {
|
|
1215
|
+
if (this.observerTimer) {
|
|
1216
|
+
clearTimeout(this.observerTimer);
|
|
1217
|
+
this.observerTimer = null;
|
|
1218
|
+
}
|
|
1140
1219
|
if (this.observer) {
|
|
1141
1220
|
this.observer.disconnect();
|
|
1142
1221
|
this.observer = null;
|
|
@@ -1258,8 +1337,13 @@ class ClicksPlugin extends BasePlugin {
|
|
|
1258
1337
|
super.destroy();
|
|
1259
1338
|
}
|
|
1260
1339
|
handleClick(e) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1340
|
+
// Walk up the DOM to find the nearest trackable ancestor.
|
|
1341
|
+
// Without this, clicks on <span> or <img> inside a <button> are silently dropped.
|
|
1342
|
+
let target = e.target;
|
|
1343
|
+
while (target && !isTrackableClickElement(target)) {
|
|
1344
|
+
target = target.parentElement;
|
|
1345
|
+
}
|
|
1346
|
+
if (!target)
|
|
1263
1347
|
return;
|
|
1264
1348
|
const buttonText = getElementText(target, 100);
|
|
1265
1349
|
const elementInfo = getElementInfo(target);
|
|
@@ -1292,6 +1376,8 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1292
1376
|
this.engagementStartTime = 0;
|
|
1293
1377
|
this.isEngaged = false;
|
|
1294
1378
|
this.engagementTimeout = null;
|
|
1379
|
+
/** Guard: beforeunload + visibilitychange:hidden both fire on tab close — only report once */
|
|
1380
|
+
this.unloadReported = false;
|
|
1295
1381
|
this.boundMarkEngaged = null;
|
|
1296
1382
|
this.boundTrackTimeOnPage = null;
|
|
1297
1383
|
this.boundVisibilityHandler = null;
|
|
@@ -1313,8 +1399,9 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1313
1399
|
this.trackTimeOnPage();
|
|
1314
1400
|
}
|
|
1315
1401
|
else {
|
|
1316
|
-
//
|
|
1402
|
+
// Page is visible again — reset both the time counter and the unload guard
|
|
1317
1403
|
this.engagementStartTime = Date.now();
|
|
1404
|
+
this.unloadReported = false;
|
|
1318
1405
|
}
|
|
1319
1406
|
};
|
|
1320
1407
|
['mousemove', 'keydown', 'touchstart', 'scroll'].forEach((event) => {
|
|
@@ -1360,6 +1447,7 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1360
1447
|
this.pageLoadTime = Date.now();
|
|
1361
1448
|
this.engagementStartTime = Date.now();
|
|
1362
1449
|
this.isEngaged = false;
|
|
1450
|
+
this.unloadReported = false;
|
|
1363
1451
|
if (this.engagementTimeout) {
|
|
1364
1452
|
clearTimeout(this.engagementTimeout);
|
|
1365
1453
|
this.engagementTimeout = null;
|
|
@@ -1381,6 +1469,10 @@ class EngagementPlugin extends BasePlugin {
|
|
|
1381
1469
|
}, 30000); // 30 seconds of inactivity
|
|
1382
1470
|
}
|
|
1383
1471
|
trackTimeOnPage() {
|
|
1472
|
+
// Guard: beforeunload and visibilitychange:hidden both fire on tab close — only report once
|
|
1473
|
+
if (this.unloadReported)
|
|
1474
|
+
return;
|
|
1475
|
+
this.unloadReported = true;
|
|
1384
1476
|
const timeSpent = Math.floor((Date.now() - this.engagementStartTime) / 1000);
|
|
1385
1477
|
if (timeSpent > 0) {
|
|
1386
1478
|
this.track('time_on_page', 'Time Spent', {
|
|
@@ -1539,12 +1631,16 @@ class ExitIntentPlugin extends BasePlugin {
|
|
|
1539
1631
|
/**
|
|
1540
1632
|
* Error Tracking Plugin - Tracks JavaScript errors
|
|
1541
1633
|
*/
|
|
1634
|
+
/** Max unique errors to track per page (prevents queue flooding from error loops) */
|
|
1635
|
+
const MAX_UNIQUE_ERRORS = 20;
|
|
1542
1636
|
class ErrorsPlugin extends BasePlugin {
|
|
1543
1637
|
constructor() {
|
|
1544
1638
|
super(...arguments);
|
|
1545
1639
|
this.name = 'errors';
|
|
1546
1640
|
this.boundErrorHandler = null;
|
|
1547
1641
|
this.boundRejectionHandler = null;
|
|
1642
|
+
/** Seen error fingerprints — deduplicates repeated identical errors */
|
|
1643
|
+
this.seenErrors = new Set();
|
|
1548
1644
|
}
|
|
1549
1645
|
init(tracker) {
|
|
1550
1646
|
super.init(tracker);
|
|
@@ -1567,6 +1663,9 @@ class ErrorsPlugin extends BasePlugin {
|
|
|
1567
1663
|
super.destroy();
|
|
1568
1664
|
}
|
|
1569
1665
|
handleError(e) {
|
|
1666
|
+
const fingerprint = `${e.message}:${e.filename}:${e.lineno}`;
|
|
1667
|
+
if (!this.dedup(fingerprint))
|
|
1668
|
+
return;
|
|
1570
1669
|
this.track('error', 'JavaScript Error', {
|
|
1571
1670
|
message: e.message,
|
|
1572
1671
|
filename: e.filename,
|
|
@@ -1576,9 +1675,22 @@ class ErrorsPlugin extends BasePlugin {
|
|
|
1576
1675
|
});
|
|
1577
1676
|
}
|
|
1578
1677
|
handleRejection(e) {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1678
|
+
const reason = String(e.reason).substring(0, 200);
|
|
1679
|
+
if (!this.dedup(reason))
|
|
1680
|
+
return;
|
|
1681
|
+
this.track('error', 'Unhandled Promise Rejection', { reason });
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Returns true if this error fingerprint is new (should be tracked).
|
|
1685
|
+
* Caps at MAX_UNIQUE_ERRORS to prevent queue flooding from error loops.
|
|
1686
|
+
*/
|
|
1687
|
+
dedup(fingerprint) {
|
|
1688
|
+
if (this.seenErrors.has(fingerprint))
|
|
1689
|
+
return false;
|
|
1690
|
+
if (this.seenErrors.size >= MAX_UNIQUE_ERRORS)
|
|
1691
|
+
return false;
|
|
1692
|
+
this.seenErrors.add(fingerprint);
|
|
1693
|
+
return true;
|
|
1582
1694
|
}
|
|
1583
1695
|
}
|
|
1584
1696
|
|
|
@@ -3517,28 +3629,17 @@ class Tracker {
|
|
|
3517
3629
|
}
|
|
3518
3630
|
const prevId = previousId || this.visitorId;
|
|
3519
3631
|
logger.info('Aliasing visitor:', { from: prevId, to: newId });
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
newId,
|
|
3529
|
-
}),
|
|
3530
|
-
});
|
|
3531
|
-
if (response.ok) {
|
|
3532
|
-
logger.info('Alias successful');
|
|
3533
|
-
return true;
|
|
3534
|
-
}
|
|
3535
|
-
logger.error('Alias failed:', response.status);
|
|
3536
|
-
return false;
|
|
3537
|
-
}
|
|
3538
|
-
catch (error) {
|
|
3539
|
-
logger.error('Alias request failed:', error);
|
|
3540
|
-
return false;
|
|
3632
|
+
const result = await this.transport.sendPost('/api/public/track/alias', {
|
|
3633
|
+
workspaceId: this.workspaceId,
|
|
3634
|
+
previousId: prevId,
|
|
3635
|
+
newId,
|
|
3636
|
+
});
|
|
3637
|
+
if (result.success) {
|
|
3638
|
+
logger.info('Alias successful');
|
|
3639
|
+
return true;
|
|
3541
3640
|
}
|
|
3641
|
+
logger.error('Alias failed:', result.error ?? result.status);
|
|
3642
|
+
return false;
|
|
3542
3643
|
}
|
|
3543
3644
|
/**
|
|
3544
3645
|
* Track a screen view (for mobile-first PWAs and SPAs).
|
|
@@ -3901,13 +4002,24 @@ let globalInstance = null;
|
|
|
3901
4002
|
* });
|
|
3902
4003
|
*/
|
|
3903
4004
|
function clianta(workspaceId, config) {
|
|
3904
|
-
// Return existing instance if same workspace
|
|
4005
|
+
// Return existing instance if same workspace and no config change
|
|
3905
4006
|
if (globalInstance && globalInstance.getWorkspaceId() === workspaceId) {
|
|
4007
|
+
if (config && Object.keys(config).length > 0) {
|
|
4008
|
+
// Config was passed to an already-initialized instance — warn the developer
|
|
4009
|
+
// because the new config is ignored. They must call destroy() first to reconfigure.
|
|
4010
|
+
if (typeof console !== 'undefined') {
|
|
4011
|
+
console.warn('[Clianta] clianta() called with config on an already-initialized instance ' +
|
|
4012
|
+
'for workspace "' + workspaceId + '". The new config was ignored. ' +
|
|
4013
|
+
'Call tracker.destroy() first if you need to reconfigure.');
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
3906
4016
|
return globalInstance;
|
|
3907
4017
|
}
|
|
3908
|
-
// Destroy existing instance if workspace changed
|
|
4018
|
+
// Destroy existing instance if workspace changed (fire-and-forget flush, then destroy)
|
|
3909
4019
|
if (globalInstance) {
|
|
3910
|
-
|
|
4020
|
+
// Kick off async flush+destroy without blocking the new instance creation.
|
|
4021
|
+
// Using void to make the intentional fire-and-forget explicit.
|
|
4022
|
+
void globalInstance.destroy();
|
|
3911
4023
|
}
|
|
3912
4024
|
// Create new instance
|
|
3913
4025
|
globalInstance = new Tracker(workspaceId, config);
|
|
@@ -3935,8 +4047,21 @@ if (typeof window !== 'undefined') {
|
|
|
3935
4047
|
const projectId = script.getAttribute('data-project-id');
|
|
3936
4048
|
if (!projectId)
|
|
3937
4049
|
return;
|
|
3938
|
-
const
|
|
3939
|
-
|
|
4050
|
+
const initConfig = {
|
|
4051
|
+
debug: script.hasAttribute('data-debug'),
|
|
4052
|
+
};
|
|
4053
|
+
// Support additional config via script tag attributes:
|
|
4054
|
+
// data-api-endpoint="https://api.yourhost.com"
|
|
4055
|
+
// data-cookieless (boolean flag)
|
|
4056
|
+
// data-use-cookies (boolean flag)
|
|
4057
|
+
const apiEndpoint = script.getAttribute('data-api-endpoint');
|
|
4058
|
+
if (apiEndpoint)
|
|
4059
|
+
initConfig.apiEndpoint = apiEndpoint;
|
|
4060
|
+
if (script.hasAttribute('data-cookieless'))
|
|
4061
|
+
initConfig.cookielessMode = true;
|
|
4062
|
+
if (script.hasAttribute('data-use-cookies'))
|
|
4063
|
+
initConfig.useCookies = true;
|
|
4064
|
+
const instance = clianta(projectId, initConfig);
|
|
3940
4065
|
// Expose the auto-initialized instance globally
|
|
3941
4066
|
window.__clianta = instance;
|
|
3942
4067
|
};
|