@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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Clianta SDK v1.6.8
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.6.7';
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: true,
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 - may retry
343
+ // Server error retry with exponential backoff
316
344
  if (response.status >= 500 && attempt < this.config.maxRetries) {
317
- logger.warn(`Server error (${response.status}), retrying...`);
318
- await this.delay(this.config.retryDelay * attempt);
319
- return this.send(url, payload, attempt + 1);
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
- // Client error - don't retry
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 - retry if possible
327
- if (attempt < this.config.maxRetries) {
328
- logger.warn(`Network error, retrying (${attempt}/${this.config.maxRetries})...`);
329
- await this.delay(this.config.retryDelay * attempt);
330
- return this.send(url, payload, attempt + 1);
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
- // Flush on page unload
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
- // Track SPA navigation (History API)
955
- if (typeof window !== 'undefined') {
956
- // Store originals for cleanup
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
- // Intercept pushState and replaceState
960
- const self = this;
1018
+ const originalPush = this.originalPushState;
1019
+ const originalReplace = this.originalReplaceState;
961
1020
  history.pushState = function (...args) {
962
- self.originalPushState.apply(history, args);
963
- self.trackPageView();
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
- self.originalReplaceState.apply(history, args);
969
- self.trackPageView();
1027
+ originalReplace.apply(history, args);
970
1028
  window.dispatchEvent(new Event('clianta:navigation'));
971
1029
  };
972
- // Handle back/forward navigation
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
- // Restore original history methods
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(() => this.trackAllForms());
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
- const target = e.target;
1257
- if (!target || !isTrackableClickElement(target))
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
- // Reset engagement timer when page becomes visible again
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
- this.track('error', 'Unhandled Promise Rejection', {
1575
- reason: String(e.reason).substring(0, 200),
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
- try {
3516
- const url = `${this.config.apiEndpoint}/api/public/track/alias`;
3517
- const response = await fetch(url, {
3518
- method: 'POST',
3519
- headers: { 'Content-Type': 'application/json' },
3520
- body: JSON.stringify({
3521
- workspaceId: this.workspaceId,
3522
- previousId: prevId,
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
- globalInstance.destroy();
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 debug = script.hasAttribute('data-debug');
3934
- const instance = clianta(projectId, { debug });
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
  };