@logspace/sdk 1.0.3 → 1.1.0

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/logspace.esm.js CHANGED
@@ -27,6 +27,20 @@ function safeStringify(value) {
27
27
  if (typeof value === "number" || typeof value === "boolean") return String(value);
28
28
  if (value === null) return "null";
29
29
  if (value === void 0) return "undefined";
30
+ if (typeof value === "function") {
31
+ const name = value.name || "anonymous";
32
+ return `[Function: ${name}]`;
33
+ }
34
+ if (typeof value === "symbol") {
35
+ return value.toString();
36
+ }
37
+ if (value instanceof Error) {
38
+ return JSON.stringify({
39
+ name: value.name,
40
+ message: value.message,
41
+ stack: value.stack
42
+ });
43
+ }
30
44
  if (typeof FormData !== "undefined" && value instanceof FormData) {
31
45
  const formDataObj = {};
32
46
  value.forEach((val, key) => {
@@ -95,6 +109,11 @@ function maskSensitiveData(text) {
95
109
  pattern: /\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
96
110
  replacement: "[PHONE_REDACTED]"
97
111
  },
112
+ // Passwords in plain text logs (e.g., "Password: value", "password: value")
113
+ {
114
+ pattern: /\b(password|pwd|passwd)\s*[:=]\s*[^\s,;}\]]+/gi,
115
+ replacement: "$1: [REDACTED]"
116
+ },
98
117
  // Passwords in JSON (case insensitive)
99
118
  {
100
119
  pattern: /"password"\s*:\s*"[^"]*"/gi,
@@ -104,7 +123,12 @@ function maskSensitiveData(text) {
104
123
  pattern: /'password'\s*:\s*'[^']*'/gi,
105
124
  replacement: "'password': '[REDACTED]'"
106
125
  },
107
- // API keys and tokens
126
+ // API keys (common prefixes: sk_, pk_, api_key_, etc.)
127
+ {
128
+ pattern: /\b(?:sk|pk|api|auth|secret)_(?:live|test|prod|dev)?_[A-Za-z0-9_-]{10,}/g,
129
+ replacement: "[API_KEY_REDACTED]"
130
+ },
131
+ // API keys and tokens in JSON
108
132
  {
109
133
  pattern: /"(?:api_?key|token|secret|auth_?token|access_?token)"\s*:\s*"[^"]*"/gi,
110
134
  replacement: '"$1": "[REDACTED]"'
@@ -122,9 +146,9 @@ function maskSensitiveData(text) {
122
146
  pattern: /Authorization:\s*Basic\s+[^\s]+/gi,
123
147
  replacement: "Authorization: Basic [REDACTED]"
124
148
  },
125
- // JWT tokens (rough pattern)
149
+ // JWT tokens - matches eyJ... pattern (base64url encoded JSON starting with {"alg" or {"typ")
126
150
  {
127
- pattern: /\b[A-Za-z0-9-_]{36,}\.[A-Za-z0-9-_]{36,}\.[A-Za-z0-9-_]{36,}\b/g,
151
+ pattern: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\b/g,
128
152
  replacement: "[JWT_TOKEN_REDACTED]"
129
153
  }
130
154
  ];
@@ -133,6 +157,52 @@ function maskSensitiveData(text) {
133
157
  }
134
158
  return masked;
135
159
  }
160
+ const SENSITIVE_LABELS = /^(password|pwd|passwd|secret|token|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key|secret[_-]?key)\s*[:=]?\s*$/i;
161
+ function maskConsoleArgs(args) {
162
+ const result2 = [];
163
+ for (let i = 0; i < args.length; i++) {
164
+ const arg = args[i];
165
+ const nextArg = args[i + 1];
166
+ if (typeof arg === "string" && SENSITIVE_LABELS.test(arg.trim()) && nextArg !== void 0) {
167
+ result2.push(arg);
168
+ result2.push("[REDACTED]");
169
+ i++;
170
+ } else if (arg !== void 0) {
171
+ result2.push(maskSensitiveData(arg));
172
+ }
173
+ }
174
+ return result2;
175
+ }
176
+ function maskHeaders(headers, additionalHeaders) {
177
+ if (!headers || typeof headers !== "object") {
178
+ return headers;
179
+ }
180
+ const maskedHeaders = {};
181
+ const defaultSensitiveHeaders = [
182
+ "authorization",
183
+ "cookie",
184
+ "set-cookie",
185
+ "x-api-key",
186
+ "x-auth-token",
187
+ "x-access-token",
188
+ "x-csrf-token",
189
+ "csrf-token"
190
+ ];
191
+ const allSensitiveHeaders = [...defaultSensitiveHeaders, ...(additionalHeaders || []).map((h) => h.toLowerCase())];
192
+ for (const [name, value] of Object.entries(headers)) {
193
+ let maskedValue = value;
194
+ const isSensitiveHeader = allSensitiveHeaders.some(
195
+ (sensitiveName) => name.toLowerCase().includes(sensitiveName.toLowerCase())
196
+ );
197
+ if (isSensitiveHeader) {
198
+ maskedValue = "[REDACTED]";
199
+ } else {
200
+ maskedValue = maskSensitiveData(value);
201
+ }
202
+ maskedHeaders[name] = maskedValue;
203
+ }
204
+ return maskedHeaders;
205
+ }
136
206
  function isBrowser() {
137
207
  return typeof window !== "undefined" && typeof document !== "undefined";
138
208
  }
@@ -269,8 +339,59 @@ function createLogEntry(type, data, severity = "info") {
269
339
  severity
270
340
  };
271
341
  }
342
+ function applyPrivacy(log, privacy) {
343
+ if (!privacy.maskSensitiveData) {
344
+ return log;
345
+ }
346
+ const masked = { ...log, data: { ...log.data } };
347
+ switch (log.type) {
348
+ case "network":
349
+ if (masked.data.requestHeaders) {
350
+ masked.data.requestHeaders = maskHeaders(masked.data.requestHeaders, privacy.redactHeaders);
351
+ }
352
+ if (masked.data.responseHeaders) {
353
+ masked.data.responseHeaders = maskHeaders(masked.data.responseHeaders, privacy.redactHeaders);
354
+ }
355
+ if (masked.data.requestBody) {
356
+ masked.data.requestBody = maskSensitiveData(masked.data.requestBody);
357
+ }
358
+ if (masked.data.responseBody) {
359
+ masked.data.responseBody = maskSensitiveData(masked.data.responseBody);
360
+ }
361
+ break;
362
+ case "console":
363
+ if (masked.data.args && Array.isArray(masked.data.args)) {
364
+ masked.data.args = maskConsoleArgs(masked.data.args);
365
+ }
366
+ break;
367
+ case "websocket":
368
+ case "sse":
369
+ if (masked.data.message) {
370
+ masked.data.message = maskSensitiveData(masked.data.message);
371
+ }
372
+ break;
373
+ case "storage":
374
+ if (masked.data.value) {
375
+ masked.data.value = maskSensitiveData(masked.data.value);
376
+ }
377
+ if (masked.data.oldValue) {
378
+ masked.data.oldValue = maskSensitiveData(masked.data.oldValue);
379
+ }
380
+ break;
381
+ case "interaction":
382
+ if (masked.data.value) {
383
+ masked.data.value = maskSensitiveData(masked.data.value);
384
+ }
385
+ if (masked.data.element?.text) {
386
+ masked.data.element.text = maskSensitiveData(masked.data.element.text);
387
+ }
388
+ break;
389
+ }
390
+ return masked;
391
+ }
272
392
  let originalConsole = null;
273
393
  const timerMap = /* @__PURE__ */ new Map();
394
+ const counterMap = /* @__PURE__ */ new Map();
274
395
  function shouldIgnoreMessage(args) {
275
396
  if (!args || args.length === 0) return false;
276
397
  for (const arg of args) {
@@ -309,7 +430,12 @@ const consoleCapture = {
309
430
  debug: console.debug,
310
431
  time: console.time,
311
432
  timeEnd: console.timeEnd,
312
- timeLog: console.timeLog
433
+ timeLog: console.timeLog,
434
+ trace: console.trace,
435
+ table: console.table,
436
+ count: console.count,
437
+ countReset: console.countReset,
438
+ assert: console.assert
313
439
  };
314
440
  levels.forEach((level) => {
315
441
  console[level] = function(...args) {
@@ -381,6 +507,80 @@ const consoleCapture = {
381
507
  );
382
508
  handler2(log);
383
509
  };
510
+ console.trace = function(...args) {
511
+ originalConsole.trace.apply(console, args);
512
+ const serializedArgs = args.map((arg) => safeStringify(arg));
513
+ const stackTrace = new Error().stack || "";
514
+ const log = createLogEntry(
515
+ "console",
516
+ {
517
+ level: "trace",
518
+ args: serializedArgs.length > 0 ? serializedArgs : ["Trace"],
519
+ stackTrace
520
+ },
521
+ "info"
522
+ );
523
+ handler2(log);
524
+ };
525
+ console.table = function(data, columns) {
526
+ originalConsole.table.call(console, data, columns);
527
+ const log = createLogEntry(
528
+ "console",
529
+ {
530
+ level: "table",
531
+ args: [safeStringify(data)],
532
+ columns
533
+ },
534
+ "info"
535
+ );
536
+ handler2(log);
537
+ };
538
+ console.count = function(label = "default") {
539
+ originalConsole.count.call(console, label);
540
+ const count = (counterMap.get(label) || 0) + 1;
541
+ counterMap.set(label, count);
542
+ const log = createLogEntry(
543
+ "console",
544
+ {
545
+ level: "count",
546
+ args: [`${label}: ${count}`],
547
+ counterLabel: label,
548
+ count
549
+ },
550
+ "info"
551
+ );
552
+ handler2(log);
553
+ };
554
+ console.countReset = function(label = "default") {
555
+ originalConsole.countReset.call(console, label);
556
+ counterMap.set(label, 0);
557
+ const log = createLogEntry(
558
+ "console",
559
+ {
560
+ level: "countReset",
561
+ args: [`${label}: 0`],
562
+ counterLabel: label
563
+ },
564
+ "info"
565
+ );
566
+ handler2(log);
567
+ };
568
+ console.assert = function(condition, ...args) {
569
+ originalConsole.assert.apply(console, [condition, ...args]);
570
+ if (!condition) {
571
+ const serializedArgs = args.map((arg) => safeStringify(arg));
572
+ const log = createLogEntry(
573
+ "console",
574
+ {
575
+ level: "assert",
576
+ args: ["Assertion failed:", ...serializedArgs],
577
+ stackTrace: new Error().stack
578
+ },
579
+ "warn"
580
+ );
581
+ handler2(log);
582
+ }
583
+ };
384
584
  },
385
585
  uninstall() {
386
586
  if (!isBrowser() || !originalConsole) return;
@@ -392,11 +592,18 @@ const consoleCapture = {
392
592
  console.time = originalConsole.time;
393
593
  console.timeEnd = originalConsole.timeEnd;
394
594
  console.timeLog = originalConsole.timeLog;
595
+ console.trace = originalConsole.trace;
596
+ console.table = originalConsole.table;
597
+ console.count = originalConsole.count;
598
+ console.countReset = originalConsole.countReset;
599
+ console.assert = originalConsole.assert;
395
600
  timerMap.clear();
601
+ counterMap.clear();
396
602
  originalConsole = null;
397
603
  }
398
604
  };
399
- const MAX_BODY_SIZE = 5e3;
605
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024;
606
+ let maxBodySize$2 = DEFAULT_MAX_BODY_SIZE;
400
607
  let originalFetch = null;
401
608
  let OriginalXHR = null;
402
609
  let excludeUrls$3 = [];
@@ -491,12 +698,13 @@ function interceptSSEStream(response, url, handler2) {
491
698
  }
492
699
  const networkCapture = {
493
700
  name: "network",
494
- install(handler2, privacy) {
701
+ install(handler2, privacy, _onActivity, limits) {
495
702
  if (!isBrowser()) return;
496
703
  if (originalFetch) return;
497
704
  captureHandler$1 = handler2;
498
705
  excludeUrls$3 = privacy.excludeUrls || [];
499
706
  const blockBodies = privacy.blockNetworkBodies || [];
707
+ maxBodySize$2 = limits?.maxNetworkBodySize ? limits.maxNetworkBodySize * 1024 : DEFAULT_MAX_BODY_SIZE;
500
708
  originalFetch = window.fetch;
501
709
  window.fetch = async function(...args) {
502
710
  const startTime = performance.now();
@@ -526,7 +734,7 @@ const networkCapture = {
526
734
  let requestBody;
527
735
  if (!shouldBlockBody && options.body) {
528
736
  const bodyStr = safeStringify(options.body);
529
- requestBody = bodyStr.length > MAX_BODY_SIZE ? bodyStr.substring(0, MAX_BODY_SIZE) + "..." : bodyStr;
737
+ requestBody = bodyStr.length > maxBodySize$2 ? bodyStr.substring(0, maxBodySize$2) + "..." : bodyStr;
530
738
  }
531
739
  try {
532
740
  const response = await originalFetch(...args);
@@ -537,9 +745,9 @@ const networkCapture = {
537
745
  let responseBody;
538
746
  let responseSize;
539
747
  try {
540
- if (!shouldBlockBody && (contentType.includes("application/json") || contentType.includes("text/plain") || contentType.includes("text/html"))) {
748
+ if (!shouldBlockBody && (contentType.includes("application/json") || contentType.includes("text/plain") || contentType.includes("text/html") || contentType.includes("text/x-component"))) {
541
749
  const text = await clonedResponse.text();
542
- responseBody = text.length > MAX_BODY_SIZE ? text.substring(0, MAX_BODY_SIZE) + "..." : text;
750
+ responseBody = text.length > maxBodySize$2 ? text.substring(0, maxBodySize$2) + "..." : text;
543
751
  responseSize = new Blob([responseBody]).size;
544
752
  } else if (contentLength) {
545
753
  responseSize = parseInt(contentLength, 10);
@@ -560,7 +768,7 @@ const networkCapture = {
560
768
  {
561
769
  method,
562
770
  url,
563
- status: response.status,
771
+ statusCode: response.status,
564
772
  requestHeaders,
565
773
  responseHeaders: Object.fromEntries(response.headers.entries()),
566
774
  requestBody,
@@ -606,7 +814,7 @@ const networkCapture = {
606
814
  {
607
815
  method,
608
816
  url,
609
- status: 0,
817
+ statusCode: 0,
610
818
  requestHeaders,
611
819
  responseHeaders: {},
612
820
  requestBody,
@@ -645,7 +853,7 @@ const networkCapture = {
645
853
  xhr.send = function(body) {
646
854
  if (body && !shouldExcludeUrl(url, excludeUrls$3)) {
647
855
  const bodyStr = safeStringify(body);
648
- requestBody = bodyStr.length > MAX_BODY_SIZE ? bodyStr.substring(0, MAX_BODY_SIZE) + "..." : bodyStr;
856
+ requestBody = bodyStr.length > maxBodySize$2 ? bodyStr.substring(0, maxBodySize$2) + "..." : bodyStr;
649
857
  }
650
858
  return originalSend.apply(this, [body]);
651
859
  };
@@ -678,7 +886,7 @@ const networkCapture = {
678
886
  }
679
887
  let responseBody;
680
888
  let responseSize;
681
- if (contentType.includes("application/json") || contentType.includes("text/plain") || contentType.includes("text/html")) {
889
+ if (contentType.includes("application/json") || contentType.includes("text/plain") || contentType.includes("text/html") || contentType.includes("text/x-component")) {
682
890
  try {
683
891
  let responseText;
684
892
  if (xhr.responseType === "" || xhr.responseType === "text") {
@@ -689,7 +897,7 @@ const networkCapture = {
689
897
  responseText = "";
690
898
  }
691
899
  if (responseText) {
692
- responseBody = responseText.length > MAX_BODY_SIZE ? responseText.substring(0, MAX_BODY_SIZE) + "..." : responseText;
900
+ responseBody = responseText.length > maxBodySize$2 ? responseText.substring(0, maxBodySize$2) + "..." : responseText;
693
901
  responseSize = new Blob([responseBody]).size;
694
902
  }
695
903
  } catch {
@@ -710,7 +918,7 @@ const networkCapture = {
710
918
  {
711
919
  method,
712
920
  url,
713
- status: xhr.status,
921
+ statusCode: xhr.status,
714
922
  requestHeaders,
715
923
  responseHeaders,
716
924
  requestBody,
@@ -766,10 +974,20 @@ const errorCapture = {
766
974
  };
767
975
  window.addEventListener("error", errorHandler$1, true);
768
976
  rejectionHandler = (event) => {
977
+ let reasonMessage;
978
+ if (event.reason instanceof Error) {
979
+ reasonMessage = event.reason.message || event.reason.toString();
980
+ } else if (typeof event.reason === "string") {
981
+ reasonMessage = event.reason;
982
+ } else if (event.reason && typeof event.reason === "object") {
983
+ reasonMessage = event.reason.message || safeStringify(event.reason);
984
+ } else {
985
+ reasonMessage = safeStringify(event.reason);
986
+ }
769
987
  const log = createLogEntry(
770
988
  "error",
771
989
  {
772
- message: `Unhandled Promise Rejection: ${safeStringify(event.reason)}`,
990
+ message: `Unhandled Promise Rejection: ${reasonMessage}`,
773
991
  stack: event.reason?.stack || "",
774
992
  context: {},
775
993
  isUncaught: true
@@ -794,6 +1012,7 @@ const errorCapture = {
794
1012
  };
795
1013
  let OriginalWebSocket = null;
796
1014
  let excludeUrls$2 = [];
1015
+ let maxBodySize$1 = 10 * 1024;
797
1016
  const wsThrottleMap = /* @__PURE__ */ new Map();
798
1017
  const THROTTLE_WINDOW = 1e3;
799
1018
  const MIN_THROTTLE_MS = 100;
@@ -829,12 +1048,15 @@ function shouldCaptureMessage$1(ws, direction) {
829
1048
  }
830
1049
  return shouldCapture;
831
1050
  }
1051
+ let activityCallback$1;
832
1052
  const websocketCapture = {
833
1053
  name: "websocket",
834
- install(handler2, privacy) {
1054
+ install(handler2, privacy, onActivity, limits) {
835
1055
  if (!isBrowser()) return;
836
1056
  if (OriginalWebSocket) return;
837
1057
  excludeUrls$2 = privacy.excludeUrls || [];
1058
+ activityCallback$1 = onActivity;
1059
+ maxBodySize$1 = limits?.maxNetworkBodySize ? limits.maxNetworkBodySize * 1024 : 10 * 1024;
838
1060
  OriginalWebSocket = window.WebSocket;
839
1061
  window.WebSocket = function(url, protocols) {
840
1062
  const ws = new OriginalWebSocket(url, protocols);
@@ -852,9 +1074,10 @@ const websocketCapture = {
852
1074
  handler2(log);
853
1075
  });
854
1076
  ws.addEventListener("message", (event) => {
1077
+ activityCallback$1?.();
855
1078
  if (!shouldCaptureMessage$1(ws, "received")) return;
856
1079
  const isBinary = event.data instanceof ArrayBuffer || event.data instanceof Blob;
857
- const { content, size } = truncateMessage(event.data, isBinary);
1080
+ const { content, size } = truncateMessage(event.data, isBinary, maxBodySize$1);
858
1081
  const log = createLogEntry("websocket", {
859
1082
  url: wsUrl,
860
1083
  event: "message",
@@ -869,9 +1092,10 @@ const websocketCapture = {
869
1092
  });
870
1093
  const originalSend = ws.send.bind(ws);
871
1094
  ws.send = function(data) {
1095
+ activityCallback$1?.();
872
1096
  if (shouldCaptureMessage$1(ws, "sent")) {
873
1097
  const isBinary = data instanceof ArrayBuffer || data instanceof Blob;
874
- const { content, size } = truncateMessage(data, isBinary);
1098
+ const { content, size } = truncateMessage(data, isBinary, maxBodySize$1);
875
1099
  const log = createLogEntry("websocket", {
876
1100
  url: wsUrl,
877
1101
  event: "message",
@@ -934,6 +1158,7 @@ const websocketCapture = {
934
1158
  };
935
1159
  let OriginalEventSource = null;
936
1160
  let excludeUrls$1 = [];
1161
+ let maxBodySize = 10 * 1024;
937
1162
  const sseThrottleMap = /* @__PURE__ */ new Map();
938
1163
  const SSE_THROTTLE_MS = 100;
939
1164
  function shouldCaptureMessage(eventSource) {
@@ -950,13 +1175,16 @@ function shouldCaptureMessage(eventSource) {
950
1175
  }
951
1176
  return false;
952
1177
  }
1178
+ let activityCallback;
953
1179
  const sseCapture = {
954
1180
  name: "sse",
955
- install(handler2, privacy) {
1181
+ install(handler2, privacy, onActivity, limits) {
956
1182
  if (!isBrowser()) return;
957
1183
  if (!window.EventSource) return;
958
1184
  if (OriginalEventSource) return;
959
1185
  excludeUrls$1 = privacy.excludeUrls || [];
1186
+ activityCallback = onActivity;
1187
+ maxBodySize = limits?.maxNetworkBodySize ? limits.maxNetworkBodySize * 1024 : 10 * 1024;
960
1188
  OriginalEventSource = window.EventSource;
961
1189
  window.EventSource = function(url, eventSourceInitDict) {
962
1190
  const eventSource = new OriginalEventSource(url, eventSourceInitDict);
@@ -974,8 +1202,9 @@ const sseCapture = {
974
1202
  handler2(log);
975
1203
  });
976
1204
  eventSource.addEventListener("message", (event) => {
1205
+ activityCallback?.();
977
1206
  if (!shouldCaptureMessage(eventSource)) return;
978
- const { content, size } = truncateMessage(event.data, false);
1207
+ const { content, size } = truncateMessage(event.data, false, maxBodySize);
979
1208
  const log = createLogEntry("sse", {
980
1209
  url: sseUrl,
981
1210
  event: "message",
@@ -1053,10 +1282,33 @@ const storageCapture = {
1053
1282
  if (originalStorage) return;
1054
1283
  captureHandler = handler2;
1055
1284
  originalStorage = {
1285
+ getItem: Storage.prototype.getItem,
1056
1286
  setItem: Storage.prototype.setItem,
1057
1287
  removeItem: Storage.prototype.removeItem,
1058
1288
  clear: Storage.prototype.clear
1059
1289
  };
1290
+ Storage.prototype.getItem = function(key) {
1291
+ const isSessionStorage = this === sessionStorage;
1292
+ if (isLogging || key.startsWith("__logspace")) {
1293
+ return originalStorage.getItem.call(this, key);
1294
+ }
1295
+ const value = originalStorage.getItem.call(this, key);
1296
+ try {
1297
+ isLogging = true;
1298
+ const storageType = isSessionStorage ? "sessionStorage" : "localStorage";
1299
+ const log = createLogEntry("storage", {
1300
+ storageType,
1301
+ operation: "getItem",
1302
+ key,
1303
+ value: value ? truncateValue(value).content : null,
1304
+ valueSize: value ? new Blob([value]).size : 0
1305
+ });
1306
+ handler2(log);
1307
+ } finally {
1308
+ isLogging = false;
1309
+ }
1310
+ return value;
1311
+ };
1060
1312
  Storage.prototype.setItem = function(key, value) {
1061
1313
  const isSessionStorage = this === sessionStorage;
1062
1314
  if (isLogging || key.startsWith("__logspace")) {
@@ -1145,6 +1397,7 @@ const storageCapture = {
1145
1397
  uninstall() {
1146
1398
  if (!isBrowser()) return;
1147
1399
  if (originalStorage) {
1400
+ Storage.prototype.getItem = originalStorage.getItem;
1148
1401
  Storage.prototype.setItem = originalStorage.setItem;
1149
1402
  Storage.prototype.removeItem = originalStorage.removeItem;
1150
1403
  Storage.prototype.clear = originalStorage.clear;
@@ -1282,6 +1535,10 @@ let fidObserver = null;
1282
1535
  let clsObserver = null;
1283
1536
  let fcpObserver = null;
1284
1537
  let inpObserver = null;
1538
+ let visibilityHandler = null;
1539
+ let loadHandler = null;
1540
+ let sendVitalsTimeout = null;
1541
+ let navigationTimingTimeout = null;
1285
1542
  const metrics = {
1286
1543
  lcp: null,
1287
1544
  fid: null,
@@ -1427,17 +1684,19 @@ const performanceCapture = {
1427
1684
  inpObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1428
1685
  } catch {
1429
1686
  }
1430
- window.addEventListener("visibilitychange", () => {
1687
+ visibilityHandler = () => {
1431
1688
  if (document.visibilityState === "hidden") {
1432
1689
  sendWebVitals();
1433
1690
  }
1434
- });
1435
- window.addEventListener("load", () => {
1436
- setTimeout(() => {
1691
+ };
1692
+ window.addEventListener("visibilitychange", visibilityHandler);
1693
+ loadHandler = () => {
1694
+ navigationTimingTimeout = setTimeout(() => {
1437
1695
  sendNavigationTiming();
1438
1696
  }, 0);
1439
- });
1440
- setTimeout(() => {
1697
+ };
1698
+ window.addEventListener("load", loadHandler);
1699
+ sendVitalsTimeout = setTimeout(() => {
1441
1700
  sendWebVitals();
1442
1701
  }, 1e4);
1443
1702
  } catch {
@@ -1450,6 +1709,22 @@ const performanceCapture = {
1450
1709
  observer.disconnect();
1451
1710
  }
1452
1711
  });
1712
+ if (visibilityHandler) {
1713
+ window.removeEventListener("visibilitychange", visibilityHandler);
1714
+ visibilityHandler = null;
1715
+ }
1716
+ if (loadHandler) {
1717
+ window.removeEventListener("load", loadHandler);
1718
+ loadHandler = null;
1719
+ }
1720
+ if (sendVitalsTimeout) {
1721
+ clearTimeout(sendVitalsTimeout);
1722
+ sendVitalsTimeout = null;
1723
+ }
1724
+ if (navigationTimingTimeout) {
1725
+ clearTimeout(navigationTimingTimeout);
1726
+ navigationTimingTimeout = null;
1727
+ }
1453
1728
  lcpObserver = null;
1454
1729
  fidObserver = null;
1455
1730
  clsObserver = null;
@@ -1470,6 +1745,71 @@ let resourceObserver = null;
1470
1745
  let excludeUrls = [];
1471
1746
  const loggedResources = /* @__PURE__ */ new Set();
1472
1747
  let handler$1 = null;
1748
+ const fontUrlToFamilyMap = /* @__PURE__ */ new Map();
1749
+ function buildFontFamilyMap() {
1750
+ if (!isBrowser()) return;
1751
+ try {
1752
+ for (const sheet of Array.from(document.styleSheets)) {
1753
+ try {
1754
+ if (!sheet.cssRules) continue;
1755
+ for (const rule2 of Array.from(sheet.cssRules)) {
1756
+ if (rule2 instanceof CSSFontFaceRule) {
1757
+ const fontFamily = rule2.style.getPropertyValue("font-family").replace(/["']/g, "").trim();
1758
+ const src = rule2.style.getPropertyValue("src");
1759
+ if (fontFamily && src) {
1760
+ const urlMatches = src.matchAll(/url\(["']?([^"')]+)["']?\)/g);
1761
+ for (const match of urlMatches) {
1762
+ if (match[1]) {
1763
+ try {
1764
+ const absoluteUrl = new URL(match[1], sheet.href || window.location.href).href;
1765
+ fontUrlToFamilyMap.set(absoluteUrl, fontFamily);
1766
+ fontUrlToFamilyMap.set(match[1], fontFamily);
1767
+ } catch {
1768
+ fontUrlToFamilyMap.set(match[1], fontFamily);
1769
+ }
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+ } catch {
1776
+ }
1777
+ }
1778
+ } catch {
1779
+ }
1780
+ }
1781
+ function getFontFamilyForUrl(url) {
1782
+ if (fontUrlToFamilyMap.has(url)) {
1783
+ return fontUrlToFamilyMap.get(url);
1784
+ }
1785
+ try {
1786
+ const urlObj = new URL(url);
1787
+ const filename = urlObj.pathname.split("/").pop();
1788
+ if (filename) {
1789
+ for (const [mapUrl, family] of fontUrlToFamilyMap.entries()) {
1790
+ if (mapUrl.endsWith(filename) || mapUrl.includes(filename)) {
1791
+ return family;
1792
+ }
1793
+ }
1794
+ }
1795
+ } catch {
1796
+ }
1797
+ if ("fonts" in document) {
1798
+ try {
1799
+ const urlLower = url.toLowerCase();
1800
+ for (const font of document.fonts) {
1801
+ if (font.status === "loaded") {
1802
+ const family = font.family.replace(/["']/g, "");
1803
+ if (urlLower.includes(family.toLowerCase().replace(/\s+/g, ""))) {
1804
+ return family;
1805
+ }
1806
+ }
1807
+ }
1808
+ } catch {
1809
+ }
1810
+ }
1811
+ return void 0;
1812
+ }
1473
1813
  function getResourceType(entry) {
1474
1814
  const initiatorType = entry.initiatorType || "other";
1475
1815
  const url = entry.name;
@@ -1524,13 +1864,14 @@ function processResourceEntry(entry) {
1524
1864
  } else if (duration > 1e3) {
1525
1865
  severity = "warn";
1526
1866
  }
1867
+ const fontFamily = resourceType === "font" ? getFontFamilyForUrl(entry.name) : void 0;
1527
1868
  const log = createLogEntry(
1528
1869
  "network",
1529
1870
  {
1530
1871
  method: "GET",
1531
1872
  // Resource loads are always GET
1532
1873
  url: entry.name,
1533
- status: 200,
1874
+ statusCode: 200,
1534
1875
  // Resources that successfully load return 200
1535
1876
  resourceType,
1536
1877
  initiatorType: entry.initiatorType,
@@ -1539,6 +1880,8 @@ function processResourceEntry(entry) {
1539
1880
  decodedBodySize,
1540
1881
  encodedBodySize,
1541
1882
  cached,
1883
+ // Font family name (only for font resources)
1884
+ ...fontFamily && { fontFamily },
1542
1885
  // Timing breakdown
1543
1886
  timing: {
1544
1887
  dns: Math.round(entry.domainLookupEnd - entry.domainLookupStart),
@@ -1562,11 +1905,13 @@ const resourceCapture = {
1562
1905
  handler$1 = captureHandler2;
1563
1906
  excludeUrls = privacy.excludeUrls || [];
1564
1907
  try {
1908
+ buildFontFamilyMap();
1565
1909
  const existingResources = performance.getEntriesByType("resource");
1566
1910
  existingResources.forEach((entry) => {
1567
1911
  processResourceEntry(entry);
1568
1912
  });
1569
1913
  resourceObserver = new PerformanceObserver((list2) => {
1914
+ buildFontFamilyMap();
1570
1915
  const entries = list2.getEntries();
1571
1916
  entries.forEach((entry) => {
1572
1917
  processResourceEntry(entry);
@@ -1589,6 +1934,7 @@ const resourceCapture = {
1589
1934
  */
1590
1935
  reset() {
1591
1936
  loggedResources.clear();
1937
+ fontUrlToFamilyMap.clear();
1592
1938
  }
1593
1939
  };
1594
1940
  let originalPushState = null;
@@ -13840,7 +14186,7 @@ const state = {
13840
14186
  stopFn: null,
13841
14187
  startTime: null
13842
14188
  };
13843
- let currentTabId = null;
14189
+ let currentTabId$1 = null;
13844
14190
  const DEFAULT_CONFIG$1 = {
13845
14191
  maskAllInputs: false,
13846
14192
  inlineStylesheet: true,
@@ -13868,7 +14214,7 @@ function startRRWebRecording(config2 = {}, onEvent, tabId) {
13868
14214
  return false;
13869
14215
  }
13870
14216
  const mergedConfig = { ...DEFAULT_CONFIG$1, ...config2 };
13871
- currentTabId = tabId || null;
14217
+ currentTabId$1 = tabId || null;
13872
14218
  try {
13873
14219
  state.events = [];
13874
14220
  state.startTime = Date.now();
@@ -13877,7 +14223,7 @@ function startRRWebRecording(config2 = {}, onEvent, tabId) {
13877
14223
  emit: (event, isCheckout) => {
13878
14224
  const tabAwareEvent = {
13879
14225
  ...event,
13880
- tabId: currentTabId || void 0
14226
+ tabId: currentTabId$1 || void 0
13881
14227
  };
13882
14228
  state.events.push(tabAwareEvent);
13883
14229
  onEvent?.(tabAwareEvent);
@@ -13937,486 +14283,123 @@ function addRRWebCustomEvent(tag, payload) {
13937
14283
  console.warn("[LogSpace] Failed to add custom event:", error);
13938
14284
  }
13939
14285
  }
13940
- const LEADER_KEY = "__logspace_leader";
13941
- const SESSION_KEY = "__logspace_session";
13942
- const CHANNEL_NAME = "logspace-multi-tab";
13943
- const HEARTBEAT_INTERVAL = 2e3;
13944
- const HEARTBEAT_STALE_THRESHOLD = 5e3;
13945
- const LEADER_CHECK_INTERVAL = 3e3;
13946
- class MultiTabCoordinator {
13947
- constructor() {
13948
- this.channel = null;
13949
- this.isLeader = false;
13950
- this.callbacks = null;
13951
- this.heartbeatTimer = null;
13952
- this.leaderCheckTimer = null;
13953
- this.currentSessionId = null;
13954
- this.initialized = false;
13955
- this.debug = false;
13956
- this.tabId = this.generateTabId();
13957
- }
13958
- /**
13959
- * Generate unique tab identifier
13960
- */
13961
- generateTabId() {
13962
- const timestamp = Date.now().toString(36);
13963
- const random = Math.random().toString(36).substring(2, 8);
13964
- return `tab_${timestamp}_${random}`;
13965
- }
13966
- /**
13967
- * Check if BroadcastChannel is supported
13968
- */
13969
- isSupported() {
13970
- return typeof BroadcastChannel !== "undefined" && typeof localStorage !== "undefined";
13971
- }
13972
- /**
13973
- * Initialize the multi-tab coordinator
13974
- */
13975
- init(callbacks, debug = false) {
13976
- if (this.initialized) return;
13977
- if (!this.isSupported()) {
13978
- if (debug) console.log("[LogSpace MultiTab] BroadcastChannel not supported, running single-tab mode");
13979
- return;
13980
- }
13981
- this.callbacks = callbacks;
13982
- this.debug = debug;
13983
- this.initialized = true;
13984
- this.channel = new BroadcastChannel(CHANNEL_NAME);
13985
- this.channel.onmessage = (event) => this.handleMessage(event.data);
13986
- window.addEventListener("beforeunload", () => this.handleUnload());
13987
- this.electLeader();
13988
- if (this.debug) {
13989
- console.log("[LogSpace MultiTab] Initialized", { tabId: this.tabId, isLeader: this.isLeader });
13990
- }
14286
+ const DB_NAME = "logspace-sdk-db";
14287
+ const DB_VERSION = 1;
14288
+ const STORES = {
14289
+ PENDING_SESSIONS: "pending_sessions",
14290
+ // Sessions waiting to be sent
14291
+ CURRENT_SESSION: "current_session"
14292
+ // Current active session backup
14293
+ };
14294
+ let db = null;
14295
+ let dbInitPromise = null;
14296
+ async function initDB() {
14297
+ if (dbInitPromise) {
14298
+ return dbInitPromise;
13991
14299
  }
13992
- /**
13993
- * Attempt to become leader or join as follower
13994
- */
13995
- electLeader() {
13996
- const existingLeader = this.getLeaderState();
13997
- if (existingLeader && !this.isLeaderStale(existingLeader)) {
13998
- this.becomeFollower(existingLeader.sessionId);
13999
- } else {
14000
- this.tryBecomeLeader();
14001
- }
14300
+ if (db) {
14301
+ return db;
14002
14302
  }
14003
- /**
14004
- * Try to claim leadership
14005
- */
14006
- tryBecomeLeader() {
14007
- const existingSession = this.getSessionState();
14008
- const sessionId = existingSession?.id || this.generateSessionId();
14009
- const isNewSession = !existingSession;
14010
- const leaderState = {
14011
- tabId: this.tabId,
14012
- sessionId,
14013
- heartbeat: Date.now()
14303
+ dbInitPromise = new Promise((resolve2, reject) => {
14304
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
14305
+ request.onerror = () => {
14306
+ dbInitPromise = null;
14307
+ reject(new Error("Failed to open IndexedDB"));
14014
14308
  };
14015
- try {
14016
- localStorage.setItem(LEADER_KEY, JSON.stringify(leaderState));
14017
- const verified = this.getLeaderState();
14018
- if (verified?.tabId === this.tabId) {
14019
- this.becomeLeader(sessionId, isNewSession);
14020
- } else {
14021
- this.becomeFollower(verified.sessionId);
14309
+ request.onsuccess = () => {
14310
+ db = request.result;
14311
+ resolve2(db);
14312
+ };
14313
+ request.onupgradeneeded = (event) => {
14314
+ const database = event.target.result;
14315
+ if (!database.objectStoreNames.contains(STORES.PENDING_SESSIONS)) {
14316
+ const pendingStore = database.createObjectStore(STORES.PENDING_SESSIONS, { keyPath: "id" });
14317
+ pendingStore.createIndex("by-created", "createdAt");
14022
14318
  }
14023
- } catch (error) {
14024
- if (this.debug) console.warn("[LogSpace MultiTab] Failed to claim leadership:", error);
14025
- }
14026
- }
14027
- /**
14028
- * Become the leader tab
14029
- */
14030
- becomeLeader(sessionId, isNewSession) {
14031
- this.isLeader = true;
14032
- this.currentSessionId = sessionId;
14033
- if (isNewSession) {
14034
- const sessionState = {
14035
- id: sessionId,
14036
- startTime: Date.now()
14037
- };
14038
- localStorage.setItem(SESSION_KEY, JSON.stringify(sessionState));
14039
- }
14040
- this.startHeartbeat();
14041
- this.broadcast({ type: "leader-announce", tabId: this.tabId, sessionId });
14042
- this.callbacks?.onBecomeLeader(sessionId, isNewSession);
14043
- if (this.debug) {
14044
- console.log("[LogSpace MultiTab] Became leader", { sessionId, isNewSession });
14045
- }
14319
+ if (!database.objectStoreNames.contains(STORES.CURRENT_SESSION)) {
14320
+ database.createObjectStore(STORES.CURRENT_SESSION, { keyPath: "id" });
14321
+ }
14322
+ };
14323
+ });
14324
+ return dbInitPromise;
14325
+ }
14326
+ function isIndexedDBAvailable() {
14327
+ return typeof indexedDB !== "undefined";
14328
+ }
14329
+ async function saveSessionCheckpoint(sessionId, logs2, rrwebEvents2, sessionData) {
14330
+ if (!isIndexedDBAvailable()) return;
14331
+ try {
14332
+ const database = await initDB();
14333
+ const transaction = database.transaction(STORES.CURRENT_SESSION, "readwrite");
14334
+ const store = transaction.objectStore(STORES.CURRENT_SESSION);
14335
+ const backup = {
14336
+ id: "current",
14337
+ // Single record, always overwrite
14338
+ sessionId,
14339
+ logs: logs2,
14340
+ rrwebEvents: rrwebEvents2,
14341
+ startTime: sessionData.startTime,
14342
+ url: sessionData.url,
14343
+ title: sessionData.title,
14344
+ userId: sessionData.userId,
14345
+ userTraits: sessionData.userTraits,
14346
+ metadata: sessionData.metadata,
14347
+ lastCheckpoint: Date.now()
14348
+ };
14349
+ await new Promise((resolve2, reject) => {
14350
+ const request = store.put(backup);
14351
+ request.onsuccess = () => resolve2();
14352
+ request.onerror = () => reject(request.error);
14353
+ });
14354
+ } catch (error) {
14355
+ console.warn("[LogSpace] Failed to save session checkpoint:", error);
14046
14356
  }
14047
- /**
14048
- * Become a follower tab
14049
- */
14050
- becomeFollower(sessionId) {
14051
- this.isLeader = false;
14052
- this.currentSessionId = sessionId;
14053
- this.stopHeartbeat();
14054
- this.startLeaderCheck();
14055
- this.callbacks?.onBecomeFollower(sessionId);
14056
- if (this.debug) {
14057
- console.log("[LogSpace MultiTab] Became follower", { sessionId });
14058
- }
14059
- }
14060
- /**
14061
- * Handle incoming broadcast messages
14062
- */
14063
- handleMessage(message) {
14064
- if (this.debug) {
14065
- console.log("[LogSpace MultiTab] Received message:", message.type);
14066
- }
14067
- switch (message.type) {
14068
- case "leader-announce":
14069
- if (message.tabId !== this.tabId) {
14070
- if (this.isLeader) {
14071
- this.becomeFollower(message.sessionId);
14072
- }
14073
- this.callbacks?.onLeaderChanged(message.tabId);
14074
- }
14075
- break;
14076
- case "leader-leaving":
14077
- if (!this.isLeader) {
14078
- if (this.debug) {
14079
- console.log("[LogSpace MultiTab] Leader leaving, attempting to claim leadership");
14080
- }
14081
- setTimeout(() => this.tryBecomeLeader(), Math.random() * 100);
14082
- }
14083
- break;
14084
- case "log":
14085
- if (this.isLeader && message.tabId !== this.tabId) {
14086
- this.callbacks?.onReceiveLog(message.log);
14087
- }
14088
- break;
14089
- case "rrweb-event":
14090
- if (this.isLeader && message.tabId !== this.tabId) {
14091
- this.callbacks?.onReceiveRRWebEvent(message.event);
14092
- }
14093
- break;
14094
- case "request-session":
14095
- if (this.isLeader && message.tabId !== this.tabId) {
14096
- const session2 = this.getSessionState();
14097
- if (session2) {
14098
- this.broadcast({
14099
- type: "session-info",
14100
- sessionId: session2.id,
14101
- startTime: session2.startTime
14102
- });
14103
- }
14104
- }
14105
- break;
14106
- case "session-info":
14107
- if (!this.isLeader) {
14108
- this.currentSessionId = message.sessionId;
14109
- }
14110
- break;
14111
- }
14112
- }
14113
- /**
14114
- * Start heartbeat for leader
14115
- */
14116
- startHeartbeat() {
14117
- this.stopHeartbeat();
14118
- this.stopLeaderCheck();
14119
- this.heartbeatTimer = setInterval(() => {
14120
- if (this.isLeader) {
14121
- this.updateHeartbeat();
14122
- }
14123
- }, HEARTBEAT_INTERVAL);
14124
- this.updateHeartbeat();
14125
- }
14126
- /**
14127
- * Stop heartbeat timer
14128
- */
14129
- stopHeartbeat() {
14130
- if (this.heartbeatTimer) {
14131
- clearInterval(this.heartbeatTimer);
14132
- this.heartbeatTimer = null;
14133
- }
14134
- }
14135
- /**
14136
- * Update heartbeat in localStorage
14137
- */
14138
- updateHeartbeat() {
14139
- const state2 = this.getLeaderState();
14140
- if (state2 && state2.tabId === this.tabId) {
14141
- state2.heartbeat = Date.now();
14142
- try {
14143
- localStorage.setItem(LEADER_KEY, JSON.stringify(state2));
14144
- } catch (error) {
14145
- }
14146
- }
14147
- }
14148
- /**
14149
- * Start checking if leader is alive (for followers)
14150
- */
14151
- startLeaderCheck() {
14152
- this.stopLeaderCheck();
14153
- this.leaderCheckTimer = setInterval(() => {
14154
- if (!this.isLeader) {
14155
- const leader = this.getLeaderState();
14156
- if (!leader || this.isLeaderStale(leader)) {
14157
- if (this.debug) {
14158
- console.log("[LogSpace MultiTab] Leader appears dead, attempting takeover");
14159
- }
14160
- this.tryBecomeLeader();
14161
- }
14162
- }
14163
- }, LEADER_CHECK_INTERVAL);
14164
- }
14165
- /**
14166
- * Stop leader check timer
14167
- */
14168
- stopLeaderCheck() {
14169
- if (this.leaderCheckTimer) {
14170
- clearInterval(this.leaderCheckTimer);
14171
- this.leaderCheckTimer = null;
14172
- }
14173
- }
14174
- /**
14175
- * Check if leader heartbeat is stale
14176
- */
14177
- isLeaderStale(leader) {
14178
- return Date.now() - leader.heartbeat > HEARTBEAT_STALE_THRESHOLD;
14179
- }
14180
- /**
14181
- * Get leader state from localStorage
14182
- */
14183
- getLeaderState() {
14184
- try {
14185
- const data = localStorage.getItem(LEADER_KEY);
14186
- return data ? JSON.parse(data) : null;
14187
- } catch {
14188
- return null;
14189
- }
14190
- }
14191
- /**
14192
- * Get session state from localStorage
14193
- */
14194
- getSessionState() {
14195
- try {
14196
- const data = localStorage.getItem(SESSION_KEY);
14197
- return data ? JSON.parse(data) : null;
14198
- } catch {
14199
- return null;
14200
- }
14201
- }
14202
- /**
14203
- * Generate session ID
14204
- */
14205
- generateSessionId() {
14206
- const timestamp = Date.now().toString(36);
14207
- const random = Math.random().toString(36).substring(2, 10);
14208
- return `sdk_${timestamp}_${random}`;
14209
- }
14210
- /**
14211
- * Broadcast message to other tabs
14212
- */
14213
- broadcast(message) {
14214
- if (this.channel) {
14215
- this.channel.postMessage(message);
14216
- }
14217
- }
14218
- /**
14219
- * Handle page unload - clean leader handoff
14220
- */
14221
- handleUnload() {
14222
- if (this.isLeader) {
14223
- this.broadcast({ type: "leader-leaving", tabId: this.tabId });
14224
- try {
14225
- localStorage.removeItem(LEADER_KEY);
14226
- } catch {
14227
- }
14228
- }
14229
- this.cleanup();
14230
- }
14231
- /**
14232
- * Send log to leader (for follower tabs)
14233
- */
14234
- sendLog(log) {
14235
- if (!this.initialized || !this.channel) return;
14236
- if (this.isLeader) {
14237
- this.callbacks?.onReceiveLog(log);
14238
- } else {
14239
- this.broadcast({ type: "log", tabId: this.tabId, log });
14240
- }
14241
- }
14242
- /**
14243
- * Send rrweb event to leader (for follower tabs)
14244
- */
14245
- sendRRWebEvent(event) {
14246
- if (!this.initialized || !this.channel) return;
14247
- if (this.isLeader) {
14248
- this.callbacks?.onReceiveRRWebEvent(event);
14249
- } else {
14250
- this.broadcast({ type: "rrweb-event", tabId: this.tabId, event });
14251
- }
14252
- }
14253
- /**
14254
- * Update session user info (stored in localStorage for all tabs)
14255
- */
14256
- updateSessionUser(userId, traits) {
14257
- const session2 = this.getSessionState();
14258
- if (session2) {
14259
- session2.userId = userId;
14260
- session2.userTraits = traits;
14261
- try {
14262
- localStorage.setItem(SESSION_KEY, JSON.stringify(session2));
14263
- } catch {
14264
- }
14265
- }
14266
- }
14267
- /**
14268
- * End session and clear shared state
14269
- */
14270
- endSession() {
14271
- try {
14272
- localStorage.removeItem(SESSION_KEY);
14273
- if (this.isLeader) {
14274
- localStorage.removeItem(LEADER_KEY);
14275
- }
14276
- } catch {
14277
- }
14278
- }
14279
- /**
14280
- * Get current tab ID
14281
- */
14282
- getTabId() {
14283
- return this.tabId;
14284
- }
14285
- /**
14286
- * Get current session ID
14287
- */
14288
- getSessionId() {
14289
- return this.currentSessionId;
14290
- }
14291
- /**
14292
- * Check if this tab is the leader
14293
- */
14294
- isLeaderTab() {
14295
- return this.isLeader;
14296
- }
14297
- /**
14298
- * Check if multi-tab mode is active
14299
- */
14300
- isActive() {
14301
- return this.initialized && this.channel !== null;
14302
- }
14303
- /**
14304
- * Clean up resources
14305
- */
14306
- cleanup() {
14307
- this.stopHeartbeat();
14308
- this.stopLeaderCheck();
14309
- if (this.channel) {
14310
- this.channel.close();
14311
- this.channel = null;
14312
- }
14313
- this.initialized = false;
14314
- }
14315
- /**
14316
- * Force become leader (for testing or recovery)
14317
- */
14318
- forceLeadership() {
14319
- if (!this.initialized) return;
14320
- const session2 = this.getSessionState();
14321
- const sessionId = session2?.id || this.generateSessionId();
14322
- this.becomeLeader(sessionId, !session2);
14357
+ }
14358
+ async function getCurrentSessionBackup() {
14359
+ if (!isIndexedDBAvailable()) return null;
14360
+ try {
14361
+ const database = await initDB();
14362
+ const transaction = database.transaction(STORES.CURRENT_SESSION, "readonly");
14363
+ const store = transaction.objectStore(STORES.CURRENT_SESSION);
14364
+ return new Promise((resolve2, reject) => {
14365
+ const request = store.get("current");
14366
+ request.onsuccess = () => resolve2(request.result || null);
14367
+ request.onerror = () => reject(request.error);
14368
+ });
14369
+ } catch (error) {
14370
+ console.warn("[LogSpace] Failed to get session backup:", error);
14371
+ return null;
14323
14372
  }
14324
14373
  }
14325
- const multiTabCoordinator = new MultiTabCoordinator();
14326
- const DB_NAME = "logspace-sdk-db";
14327
- const DB_VERSION = 1;
14328
- const STORES = {
14329
- PENDING_SESSIONS: "pending_sessions",
14330
- // Sessions waiting to be sent
14331
- CURRENT_SESSION: "current_session"
14332
- // Current active session backup
14333
- };
14334
- let db = null;
14335
- let dbInitPromise = null;
14336
- async function initDB() {
14337
- if (dbInitPromise) {
14338
- return dbInitPromise;
14339
- }
14340
- if (db) {
14341
- return db;
14342
- }
14343
- dbInitPromise = new Promise((resolve2, reject) => {
14344
- const request = indexedDB.open(DB_NAME, DB_VERSION);
14345
- request.onerror = () => {
14346
- dbInitPromise = null;
14347
- reject(new Error("Failed to open IndexedDB"));
14348
- };
14349
- request.onsuccess = () => {
14350
- db = request.result;
14351
- resolve2(db);
14352
- };
14353
- request.onupgradeneeded = (event) => {
14354
- const database = event.target.result;
14355
- if (!database.objectStoreNames.contains(STORES.PENDING_SESSIONS)) {
14356
- const pendingStore = database.createObjectStore(STORES.PENDING_SESSIONS, { keyPath: "id" });
14357
- pendingStore.createIndex("by-created", "createdAt");
14358
- }
14359
- if (!database.objectStoreNames.contains(STORES.CURRENT_SESSION)) {
14360
- database.createObjectStore(STORES.CURRENT_SESSION, { keyPath: "id" });
14361
- }
14362
- };
14363
- });
14364
- return dbInitPromise;
14365
- }
14366
- function isIndexedDBAvailable() {
14367
- return typeof indexedDB !== "undefined";
14368
- }
14369
- async function saveSessionCheckpoint(sessionId, logs2, rrwebEvents2, sessionData) {
14374
+ async function clearCurrentSessionBackup() {
14370
14375
  if (!isIndexedDBAvailable()) return;
14371
14376
  try {
14372
14377
  const database = await initDB();
14373
14378
  const transaction = database.transaction(STORES.CURRENT_SESSION, "readwrite");
14374
14379
  const store = transaction.objectStore(STORES.CURRENT_SESSION);
14375
- const backup = {
14376
- id: "current",
14377
- // Single record, always overwrite
14378
- sessionId,
14379
- logs: logs2,
14380
- rrwebEvents: rrwebEvents2,
14381
- startTime: sessionData.startTime,
14382
- url: sessionData.url,
14383
- title: sessionData.title,
14384
- userId: sessionData.userId,
14385
- userTraits: sessionData.userTraits,
14386
- metadata: sessionData.metadata,
14387
- lastCheckpoint: Date.now()
14388
- };
14389
14380
  await new Promise((resolve2, reject) => {
14390
- const request = store.put(backup);
14381
+ const request = store.delete("current");
14391
14382
  request.onsuccess = () => resolve2();
14392
14383
  request.onerror = () => reject(request.error);
14393
14384
  });
14394
14385
  } catch (error) {
14395
- console.warn("[LogSpace] Failed to save session checkpoint:", error);
14386
+ console.warn("[LogSpace] Failed to clear session backup:", error);
14396
14387
  }
14397
14388
  }
14398
- async function getCurrentSessionBackup() {
14399
- if (!isIndexedDBAvailable()) return null;
14389
+ async function clearCurrentSessionBackupFor(sessionId) {
14390
+ if (!isIndexedDBAvailable()) return;
14400
14391
  try {
14401
14392
  const database = await initDB();
14402
- const transaction = database.transaction(STORES.CURRENT_SESSION, "readonly");
14393
+ const transaction = database.transaction(STORES.CURRENT_SESSION, "readwrite");
14403
14394
  const store = transaction.objectStore(STORES.CURRENT_SESSION);
14404
- return new Promise((resolve2, reject) => {
14395
+ const current = await new Promise((resolve2, reject) => {
14405
14396
  const request = store.get("current");
14406
14397
  request.onsuccess = () => resolve2(request.result || null);
14407
14398
  request.onerror = () => reject(request.error);
14408
14399
  });
14409
- } catch (error) {
14410
- console.warn("[LogSpace] Failed to get session backup:", error);
14411
- return null;
14412
- }
14413
- }
14414
- async function clearCurrentSessionBackup() {
14415
- if (!isIndexedDBAvailable()) return;
14416
- try {
14417
- const database = await initDB();
14418
- const transaction = database.transaction(STORES.CURRENT_SESSION, "readwrite");
14419
- const store = transaction.objectStore(STORES.CURRENT_SESSION);
14400
+ if (!current || current.sessionId !== sessionId) {
14401
+ return;
14402
+ }
14420
14403
  await new Promise((resolve2, reject) => {
14421
14404
  const request = store.delete("current");
14422
14405
  request.onsuccess = () => resolve2();
@@ -14529,14 +14512,13 @@ function closeDB() {
14529
14512
  dbInitPromise = null;
14530
14513
  }
14531
14514
  }
14532
- const SDK_VERSION = "0.1.0";
14515
+ const SDK_VERSION = "1.1.0";
14533
14516
  let config = null;
14534
14517
  let session = null;
14535
14518
  let logs = [];
14536
14519
  let rrwebEvents = [];
14537
14520
  let initialized = false;
14538
14521
  let endReason = "manual";
14539
- let sessionEndedByHardLimit = false;
14540
14522
  let sessionEndedByIdle = false;
14541
14523
  let rateLimiter = { count: 0, windowStart: 0 };
14542
14524
  let deduplication = { lastLogHash: null, lastLogCount: 0 };
@@ -14549,11 +14531,16 @@ let visibilityTimer = null;
14549
14531
  let lastMouseMoveTime = 0;
14550
14532
  let samplingTriggered = false;
14551
14533
  let samplingTriggerType = null;
14534
+ let samplingFirstTriggerTime = null;
14552
14535
  let samplingTriggerTime = null;
14553
14536
  let samplingEndTimer = null;
14537
+ let samplingTrimTimer = null;
14554
14538
  let continuingSessionId = null;
14555
14539
  let continuingSessionStartTime = null;
14540
+ let pendingIdentity = null;
14541
+ let currentIdentity = null;
14556
14542
  let recoveryTargetSessionId = null;
14543
+ const processedRecoverySessionIds = /* @__PURE__ */ new Set();
14557
14544
  const DEFAULT_CONFIG = {
14558
14545
  capture: {
14559
14546
  rrweb: true,
@@ -14570,7 +14557,7 @@ const DEFAULT_CONFIG = {
14570
14557
  },
14571
14558
  rrweb: {
14572
14559
  maskAllInputs: false,
14573
- checkoutEveryNth: 200,
14560
+ checkoutEveryNth: 150,
14574
14561
  recordCanvas: false
14575
14562
  },
14576
14563
  privacy: {
@@ -14585,15 +14572,17 @@ const DEFAULT_CONFIG = {
14585
14572
  maxLogs: 1e4,
14586
14573
  maxSize: 10 * 1024 * 1024,
14587
14574
  // 10MB
14588
- maxDuration: 300,
14589
- // 5 minutes
14590
- idleTimeout: 30,
14591
- // 30 seconds
14575
+ maxDuration: 1800,
14576
+ // 30 minutes
14577
+ idleTimeout: 120,
14578
+ // 2 minutes
14592
14579
  navigateAwayTimeout: 120,
14593
14580
  // 2 minutes grace period
14594
14581
  rateLimit: 100,
14595
14582
  // 100 logs/second
14596
- deduplicate: true
14583
+ deduplicate: true,
14584
+ maxNetworkBodySize: 10
14585
+ // 10KB (in KB, will be multiplied by 1024)
14597
14586
  },
14598
14587
  autoEnd: {
14599
14588
  continueOnRefresh: true,
@@ -14602,10 +14591,10 @@ const DEFAULT_CONFIG = {
14602
14591
  },
14603
14592
  sampling: {
14604
14593
  enabled: false,
14605
- bufferBefore: 15,
14606
- // 15 seconds before trigger
14607
- recordAfter: 15,
14608
- // 15 seconds after trigger
14594
+ bufferBefore: 30,
14595
+ // 30 seconds before trigger
14596
+ recordAfter: 30,
14597
+ // 30 seconds after trigger
14609
14598
  triggers: {
14610
14599
  onError: true,
14611
14600
  onConsoleError: true,
@@ -14641,6 +14630,87 @@ function generateSessionId() {
14641
14630
  const random = Math.random().toString(36).substring(2, 10);
14642
14631
  return `sdk_${timestamp}_${random}`;
14643
14632
  }
14633
+ const TAB_ID_KEY = "__logspace_tab_id";
14634
+ const SESSION_ID_KEY = "__logspace_session_id";
14635
+ let currentTabId = null;
14636
+ function generateTabId() {
14637
+ const timestamp = Date.now().toString(36);
14638
+ const random = Math.random().toString(36).substring(2, 8);
14639
+ return `tab_${timestamp}_${random}`;
14640
+ }
14641
+ function getOrCreateTabId() {
14642
+ if (currentTabId) return currentTabId;
14643
+ if (typeof sessionStorage !== "undefined") {
14644
+ const stored = sessionStorage.getItem(TAB_ID_KEY);
14645
+ if (stored) {
14646
+ currentTabId = stored;
14647
+ return stored;
14648
+ }
14649
+ }
14650
+ const created = generateTabId();
14651
+ currentTabId = created;
14652
+ try {
14653
+ sessionStorage.setItem(TAB_ID_KEY, created);
14654
+ } catch {
14655
+ }
14656
+ return created;
14657
+ }
14658
+ function setSessionContext(sessionId, tabId) {
14659
+ try {
14660
+ sessionStorage.setItem(SESSION_ID_KEY, sessionId);
14661
+ sessionStorage.setItem(TAB_ID_KEY, tabId);
14662
+ } catch {
14663
+ }
14664
+ try {
14665
+ window.__logspace_session_id = sessionId;
14666
+ window.__logspace_tab_id = tabId;
14667
+ } catch {
14668
+ }
14669
+ }
14670
+ function getSessionOrigin() {
14671
+ if (typeof document === "undefined") return null;
14672
+ const referrer = document.referrer || void 0;
14673
+ let openerSessionId;
14674
+ let openerTabId;
14675
+ let openerUrl;
14676
+ if (typeof window !== "undefined" && window.opener) {
14677
+ try {
14678
+ const opener = window.opener;
14679
+ openerSessionId = opener.sessionStorage?.getItem(SESSION_ID_KEY) ?? opener.__logspace_session_id;
14680
+ openerTabId = opener.sessionStorage?.getItem(TAB_ID_KEY) ?? opener.__logspace_tab_id;
14681
+ openerUrl = opener.location?.href;
14682
+ } catch {
14683
+ }
14684
+ }
14685
+ if (!referrer && !openerSessionId && !openerTabId && !openerUrl) {
14686
+ return null;
14687
+ }
14688
+ return {
14689
+ referrer,
14690
+ openerSessionId,
14691
+ openerTabId,
14692
+ openerUrl
14693
+ };
14694
+ }
14695
+ function buildSessionMetadata(metadata) {
14696
+ const origin = getSessionOrigin();
14697
+ if (!origin && !metadata) return void 0;
14698
+ if (!origin) return metadata;
14699
+ const existingOrigin = metadata?.logspaceOrigin;
14700
+ if (existingOrigin && typeof existingOrigin === "object" && !Array.isArray(existingOrigin)) {
14701
+ return {
14702
+ ...metadata,
14703
+ logspaceOrigin: {
14704
+ ...origin,
14705
+ ...existingOrigin
14706
+ }
14707
+ };
14708
+ }
14709
+ return {
14710
+ ...metadata || {},
14711
+ logspaceOrigin: origin
14712
+ };
14713
+ }
14644
14714
  function hashLog(log) {
14645
14715
  return `${log.type}:${JSON.stringify(log.data)}`;
14646
14716
  }
@@ -14653,13 +14723,13 @@ function checkRateLimit() {
14653
14723
  rateLimiter.count = 1;
14654
14724
  return true;
14655
14725
  }
14656
- if (rateLimiter.count >= config.limits.rateLimit) {
14726
+ rateLimiter.count++;
14727
+ if (rateLimiter.count > config.limits.rateLimit) {
14657
14728
  if (config.debug) {
14658
14729
  console.warn("[LogSpace] Rate limit exceeded, dropping log");
14659
14730
  }
14660
14731
  return false;
14661
14732
  }
14662
- rateLimiter.count++;
14663
14733
  return true;
14664
14734
  }
14665
14735
  function checkDeduplication(log) {
@@ -14692,9 +14762,6 @@ function checkLimits() {
14692
14762
  }
14693
14763
  function resetIdleTimer() {
14694
14764
  if (!config?.autoEnd.onIdle) return;
14695
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
14696
- return;
14697
- }
14698
14765
  if (idleTimer) {
14699
14766
  clearTimeout(idleTimer);
14700
14767
  }
@@ -14712,9 +14779,6 @@ function resetIdleTimer() {
14712
14779
  }
14713
14780
  function handleUserActivity() {
14714
14781
  if (!config || !initialized) return;
14715
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
14716
- return;
14717
- }
14718
14782
  if (sessionEndedByIdle && (!session || session.status === "stopped")) {
14719
14783
  if (config.debug) {
14720
14784
  console.log("[LogSpace] User activity detected - starting new session");
@@ -14747,33 +14811,54 @@ function removeActivityListeners() {
14747
14811
  window.removeEventListener("mousemove", handleMouseMove, { capture: true });
14748
14812
  }
14749
14813
  function triggerSampling(triggerType, triggerLog) {
14750
- if (!config?.sampling.enabled || samplingTriggered) return;
14814
+ if (!config?.sampling.enabled) return;
14815
+ if (unloadHandled || isNavigatingAway) {
14816
+ if (config.debug) {
14817
+ console.log(`[LogSpace] Ignoring sampling trigger during unload: ${triggerType}`);
14818
+ }
14819
+ return;
14820
+ }
14821
+ const isFirstTrigger = !samplingTriggered;
14751
14822
  samplingTriggered = true;
14752
14823
  samplingTriggerType = triggerType;
14824
+ if (isFirstTrigger) {
14825
+ samplingFirstTriggerTime = Date.now();
14826
+ }
14753
14827
  samplingTriggerTime = Date.now();
14828
+ stopSamplingTrimTimer();
14754
14829
  if (config.debug) {
14755
- console.log(`[LogSpace] Sampling triggered by: ${triggerType}`);
14830
+ if (isFirstTrigger) {
14831
+ console.log(`[LogSpace] Sampling triggered by: ${triggerType}`);
14832
+ } else {
14833
+ console.log(`[LogSpace] Sampling window extended by: ${triggerType}`);
14834
+ }
14835
+ }
14836
+ if (isFirstTrigger) {
14837
+ config.hooks.onSamplingTrigger?.(triggerType, triggerLog);
14838
+ }
14839
+ if (samplingEndTimer) {
14840
+ clearTimeout(samplingEndTimer);
14841
+ samplingEndTimer = null;
14756
14842
  }
14757
- config.hooks.onSamplingTrigger?.(triggerType, triggerLog);
14758
14843
  samplingEndTimer = setTimeout(() => {
14759
14844
  if (session?.status === "recording") {
14760
14845
  if (config?.debug) {
14761
14846
  console.log("[LogSpace] Sampling recordAfter period complete, ending session");
14762
14847
  }
14763
14848
  endReason = "samplingTriggered";
14764
- LogSpace.stopSession();
14849
+ stopSessionInternal({ restartImmediately: true });
14765
14850
  }
14766
14851
  }, config.sampling.recordAfter * 1e3);
14767
14852
  }
14768
14853
  function checkSamplingTrigger(log) {
14769
- if (!config?.sampling.enabled || samplingTriggered) return;
14854
+ if (!config?.sampling.enabled) return;
14770
14855
  if (config.sampling.triggers.onError && log.type === "error") {
14771
14856
  triggerSampling("error", log);
14772
14857
  return;
14773
14858
  }
14774
14859
  if (config.sampling.triggers.onConsoleError && log.type === "console") {
14775
14860
  const consoleData = log.data;
14776
- if (consoleData.level === "error") {
14861
+ if (consoleData.level === "error" || consoleData.level === "assert") {
14777
14862
  triggerSampling("consoleError", log);
14778
14863
  return;
14779
14864
  }
@@ -14787,19 +14872,114 @@ function checkSamplingTrigger(log) {
14787
14872
  }
14788
14873
  }
14789
14874
  function trimLogsToSamplingWindow() {
14790
- if (!config?.sampling.enabled || !samplingTriggerTime || !session) return;
14791
- const bufferStartTime = samplingTriggerTime - config.sampling.bufferBefore * 1e3;
14875
+ if (!config?.sampling.enabled || !samplingFirstTriggerTime || !samplingTriggerTime || !session) return;
14876
+ const bufferStartTime = samplingFirstTriggerTime - config.sampling.bufferBefore * 1e3;
14792
14877
  const bufferEndTime = samplingTriggerTime + config.sampling.recordAfter * 1e3;
14793
14878
  logs = logs.filter((log) => {
14794
14879
  return log.timestamp >= bufferStartTime && log.timestamp <= bufferEndTime;
14795
14880
  });
14796
- rrwebEvents = rrwebEvents.filter((event) => {
14881
+ const recentEvents = rrwebEvents.filter((event) => {
14797
14882
  return event.timestamp >= bufferStartTime && event.timestamp <= bufferEndTime;
14798
14883
  });
14884
+ const hasFullSnapshot = recentEvents.some((event) => event.type === 2);
14885
+ if (config.debug) {
14886
+ console.log(
14887
+ `[LogSpace] trimLogsToSamplingWindow - before: ${rrwebEvents.length} events, recentEvents: ${recentEvents.length}, hasFullSnapshot in recent: ${hasFullSnapshot}`
14888
+ );
14889
+ console.log(
14890
+ `[LogSpace] trimLogsToSamplingWindow - rrweb types before: ${rrwebEvents.map((e) => e.type).join(",")}`
14891
+ );
14892
+ }
14893
+ if (hasFullSnapshot) {
14894
+ rrwebEvents = recentEvents;
14895
+ } else {
14896
+ const fullSnapshotsBeforeWindow = [];
14897
+ for (let i = 0; i < rrwebEvents.length; i++) {
14898
+ const event = rrwebEvents[i];
14899
+ if (event && event.type === 2 && event.timestamp < bufferStartTime) {
14900
+ fullSnapshotsBeforeWindow.push(event);
14901
+ }
14902
+ }
14903
+ if (config.debug) {
14904
+ console.log(
14905
+ `[LogSpace] trimLogsToSamplingWindow - found ${fullSnapshotsBeforeWindow.length} snapshots before window`
14906
+ );
14907
+ }
14908
+ if (fullSnapshotsBeforeWindow.length > 0) {
14909
+ rrwebEvents = [...fullSnapshotsBeforeWindow, ...recentEvents];
14910
+ } else {
14911
+ rrwebEvents = recentEvents;
14912
+ }
14913
+ }
14914
+ if (config.debug) {
14915
+ console.log(
14916
+ `[LogSpace] trimLogsToSamplingWindow - after: ${rrwebEvents.length} events, types: ${rrwebEvents.map((e) => e.type).join(",")}`
14917
+ );
14918
+ }
14919
+ rebuildSessionStats();
14799
14920
  if (config.debug) {
14800
14921
  console.log(`[LogSpace] Trimmed to sampling window: ${logs.length} logs, ${rrwebEvents.length} rrweb events`);
14801
14922
  }
14802
14923
  }
14924
+ const SAMPLING_TRIM_INTERVAL = 5 * 1e3;
14925
+ function trimRollingBuffer() {
14926
+ if (!config?.sampling.enabled || samplingTriggered || !session) return;
14927
+ const now = Date.now();
14928
+ const cutoffTime = now - config.sampling.bufferBefore * 1e3;
14929
+ const prevLogCount = logs.length;
14930
+ const prevRrwebCount = rrwebEvents.length;
14931
+ logs = logs.filter((log) => log.timestamp >= cutoffTime);
14932
+ const recentEvents = rrwebEvents.filter((event) => event.timestamp >= cutoffTime);
14933
+ const hasFullSnapshot = recentEvents.some((event) => event.type === 2);
14934
+ if (hasFullSnapshot) {
14935
+ rrwebEvents = recentEvents;
14936
+ } else {
14937
+ let firstFullSnapshotIndex = -1;
14938
+ const fullSnapshotIndices = [];
14939
+ for (let i = 0; i < rrwebEvents.length; i++) {
14940
+ const event = rrwebEvents[i];
14941
+ if (event && event.type === 2 && event.timestamp < cutoffTime) {
14942
+ if (firstFullSnapshotIndex === -1) {
14943
+ firstFullSnapshotIndex = i;
14944
+ }
14945
+ fullSnapshotIndices.push(i);
14946
+ }
14947
+ }
14948
+ if (firstFullSnapshotIndex >= 0) {
14949
+ const snapshotsToKeep = fullSnapshotIndices.map((i) => rrwebEvents[i]).filter((e) => e !== void 0);
14950
+ rrwebEvents = [...snapshotsToKeep, ...recentEvents];
14951
+ } else {
14952
+ rrwebEvents = recentEvents;
14953
+ }
14954
+ }
14955
+ currentSize = logs.reduce((sum, log) => sum + JSON.stringify(log).length, 0);
14956
+ rrwebSize = rrwebEvents.reduce((sum, event) => sum + JSON.stringify(event).length, 0);
14957
+ rebuildSessionStats();
14958
+ const trimmedLogs = prevLogCount - logs.length;
14959
+ const trimmedRrweb = prevRrwebCount - rrwebEvents.length;
14960
+ if (config.debug && (trimmedLogs > 0 || trimmedRrweb > 0)) {
14961
+ console.log(
14962
+ `[LogSpace] Rolling buffer trimmed: ${trimmedLogs} logs, ${trimmedRrweb} rrweb events (keeping last ${config.sampling.bufferBefore}s: ${logs.length} logs, ${rrwebEvents.length} rrweb events)`
14963
+ );
14964
+ }
14965
+ }
14966
+ function startSamplingTrimTimer() {
14967
+ if (samplingTrimTimer || !config?.sampling.enabled) return;
14968
+ samplingTrimTimer = setInterval(() => {
14969
+ trimRollingBuffer();
14970
+ }, SAMPLING_TRIM_INTERVAL);
14971
+ if (config.debug) {
14972
+ console.log(
14973
+ `[LogSpace] Sampling rolling buffer started (trimming every ${SAMPLING_TRIM_INTERVAL / 1e3}s, keeping ${config.sampling.bufferBefore}s)`
14974
+ );
14975
+ }
14976
+ }
14977
+ function stopSamplingTrimTimer() {
14978
+ if (samplingTrimTimer) {
14979
+ clearInterval(samplingTrimTimer);
14980
+ samplingTrimTimer = null;
14981
+ }
14982
+ }
14803
14983
  const handleLog = (logData) => {
14804
14984
  if (!config || !session || session.status !== "recording") return;
14805
14985
  if (!checkRateLimit()) return;
@@ -14811,40 +14991,31 @@ const handleLog = (logData) => {
14811
14991
  console.log(`[LogSpace] Session auto-ended: ${limitReached}`);
14812
14992
  }
14813
14993
  endReason = limitReached;
14814
- sessionEndedByHardLimit = true;
14815
14994
  config.hooks.onLimitReached?.(limitReached);
14816
- LogSpace.stopSession();
14995
+ stopSessionInternal({ restartImmediately: true });
14817
14996
  }
14818
14997
  return;
14819
14998
  }
14820
- const tabId = multiTabCoordinator.isActive() ? multiTabCoordinator.getTabId() : "single";
14999
+ const tabId = getOrCreateTabId();
14821
15000
  const log = {
14822
15001
  id: `${logs.length + 1}`,
14823
15002
  timestamp: Date.now(),
14824
15003
  // Use absolute timestamp (consistent with rrweb events)
14825
15004
  tabId,
14826
- // Tab ID from multi-tab coordinator
14827
15005
  tabUrl: typeof window !== "undefined" ? window.location.href : "",
14828
15006
  type: logData.type,
14829
15007
  data: logData.data,
14830
15008
  severity: logData.severity
14831
15009
  };
14832
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
14833
- multiTabCoordinator.sendLog(log);
14834
- resetIdleTimer();
14835
- if (config.debug) {
14836
- console.log("[LogSpace] Captured (sent to leader):", log.type, log.data);
14837
- }
14838
- return;
14839
- }
14840
- logs.push(log);
14841
- currentSize += JSON.stringify(log).length;
14842
- updateStats(log);
15010
+ const maskedLog = applyPrivacy(log, config.privacy);
15011
+ logs.push(maskedLog);
15012
+ currentSize += JSON.stringify(maskedLog).length;
15013
+ updateStats(maskedLog);
14843
15014
  resetIdleTimer();
14844
- checkSamplingTrigger(log);
14845
- config.hooks.onAction?.(log, session);
15015
+ checkSamplingTrigger(maskedLog);
15016
+ config.hooks.onAction?.(maskedLog, session);
14846
15017
  if (config.debug) {
14847
- console.log("[LogSpace] Captured:", log.type, log.data);
15018
+ console.log("[LogSpace] Captured:", maskedLog.type, maskedLog.data);
14848
15019
  }
14849
15020
  };
14850
15021
  function updateStats(log) {
@@ -14877,6 +15048,24 @@ function updateStats(log) {
14877
15048
  break;
14878
15049
  }
14879
15050
  }
15051
+ function rebuildSessionStats() {
15052
+ if (!session) return;
15053
+ const tabCount = session.stats.tabCount || 1;
15054
+ session.stats = {
15055
+ logCount: 0,
15056
+ networkLogCount: 0,
15057
+ consoleLogCount: 0,
15058
+ errorCount: 0,
15059
+ storageLogCount: 0,
15060
+ interactionLogCount: 0,
15061
+ performanceLogCount: 0,
15062
+ annotationCount: 0,
15063
+ tabCount
15064
+ };
15065
+ for (const log of logs) {
15066
+ updateStats(log);
15067
+ }
15068
+ }
14880
15069
  function installCaptures() {
14881
15070
  if (!config) return;
14882
15071
  const privacyWithSdkExclusions = { ...config.privacy };
@@ -14888,16 +15077,16 @@ function installCaptures() {
14888
15077
  consoleCapture.install(handleLog, config.privacy);
14889
15078
  }
14890
15079
  if (config.capture.network) {
14891
- networkCapture.install(handleLog, privacyWithSdkExclusions);
15080
+ networkCapture.install(handleLog, privacyWithSdkExclusions, resetIdleTimer, config.limits);
14892
15081
  }
14893
15082
  if (config.capture.errors) {
14894
15083
  errorCapture.install(handleLog, config.privacy);
14895
15084
  }
14896
15085
  if (config.capture.websocket) {
14897
- websocketCapture.install(handleLog, config.privacy);
15086
+ websocketCapture.install(handleLog, config.privacy, resetIdleTimer, config.limits);
14898
15087
  }
14899
15088
  if (config.capture.sse) {
14900
- sseCapture.install(handleLog, config.privacy);
15089
+ sseCapture.install(handleLog, config.privacy, resetIdleTimer, config.limits);
14901
15090
  }
14902
15091
  if (config.capture.storage) {
14903
15092
  storageCapture.install(handleLog, config.privacy);
@@ -14928,12 +15117,10 @@ function uninstallCaptures() {
14928
15117
  spaNavigationCapture.uninstall();
14929
15118
  }
14930
15119
  let unloadHandled = false;
15120
+ let isNavigatingAway = false;
14931
15121
  const CHECKPOINT_INTERVAL = 15 * 1e3;
14932
15122
  async function saveCheckpoint() {
14933
15123
  if (!session || session.status !== "recording" || !isIndexedDBAvailable()) return;
14934
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
14935
- return;
14936
- }
14937
15124
  try {
14938
15125
  await saveSessionCheckpoint(session.id, logs, rrwebEvents, {
14939
15126
  startTime: session.startTime,
@@ -14968,6 +15155,37 @@ function stopCheckpointTimer() {
14968
15155
  }
14969
15156
  }
14970
15157
  const EMERGENCY_BACKUP_KEY = "__logspace_emergency_backup";
15158
+ const SAMPLING_STATE_KEY = "__logspace_sampling_state";
15159
+ function saveSamplingState(sessionId) {
15160
+ if (!samplingTriggered) return;
15161
+ try {
15162
+ const state2 = {
15163
+ triggered: samplingTriggered,
15164
+ triggerType: samplingTriggerType,
15165
+ firstTriggerTime: samplingFirstTriggerTime,
15166
+ triggerTime: samplingTriggerTime,
15167
+ sessionId
15168
+ };
15169
+ localStorage.setItem(SAMPLING_STATE_KEY, JSON.stringify(state2));
15170
+ } catch (error) {
15171
+ }
15172
+ }
15173
+ function getSamplingState() {
15174
+ try {
15175
+ const state2 = localStorage.getItem(SAMPLING_STATE_KEY);
15176
+ if (state2) {
15177
+ return JSON.parse(state2);
15178
+ }
15179
+ } catch (error) {
15180
+ }
15181
+ return null;
15182
+ }
15183
+ function clearSamplingState() {
15184
+ try {
15185
+ localStorage.removeItem(SAMPLING_STATE_KEY);
15186
+ } catch (error) {
15187
+ }
15188
+ }
14971
15189
  function saveEmergencyBackup(payload) {
14972
15190
  try {
14973
15191
  if (payload.logs.length === 0 && (!payload.rrwebEvents || payload.rrwebEvents.length < 3)) {
@@ -14997,16 +15215,9 @@ function clearEmergencyBackup() {
14997
15215
  function handleUnload() {
14998
15216
  if (unloadHandled) return;
14999
15217
  if (!config || !session || session.status !== "recording") return;
15000
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
15001
- if (config.debug) {
15002
- console.log("[LogSpace] Follower tab unloading - no session to save (data is with leader)");
15003
- }
15004
- return;
15005
- }
15218
+ isNavigatingAway = true;
15006
15219
  unloadHandled = true;
15007
15220
  endReason = "unload";
15008
- const payload = buildPayload();
15009
- if (!payload) return;
15010
15221
  if (config.autoEnd.continueOnRefresh) {
15011
15222
  try {
15012
15223
  sessionStorage.setItem("logspace_continuing_session_id", session.id);
@@ -15017,6 +15228,13 @@ function handleUnload() {
15017
15228
  }
15018
15229
  } catch {
15019
15230
  }
15231
+ if (samplingTriggered) {
15232
+ saveSamplingState(session.id);
15233
+ }
15234
+ }
15235
+ const payload = buildPayload();
15236
+ if (!payload) {
15237
+ return;
15020
15238
  }
15021
15239
  saveEmergencyBackup(payload);
15022
15240
  if (isIndexedDBAvailable()) {
@@ -15024,7 +15242,7 @@ function handleUnload() {
15024
15242
  });
15025
15243
  }
15026
15244
  if (config.debug) {
15027
- console.log("[LogSpace] Session saved for recovery on next visit");
15245
+ console.log("[LogSpace] Session paused and saved for continuation");
15028
15246
  }
15029
15247
  }
15030
15248
  function handleVisibilityChange() {
@@ -15036,7 +15254,7 @@ function handleVisibilityChange() {
15036
15254
  }
15037
15255
  if (config.capture.rrweb) {
15038
15256
  addRRWebCustomEvent("tab-hidden", {
15039
- tabId: multiTabCoordinator.isActive() ? multiTabCoordinator.getTabId() : "main",
15257
+ tabId: getOrCreateTabId(),
15040
15258
  timestamp: Date.now()
15041
15259
  });
15042
15260
  }
@@ -15045,14 +15263,8 @@ function handleVisibilityChange() {
15045
15263
  }
15046
15264
  visibilityTimer = setTimeout(() => {
15047
15265
  if (session?.status === "recording" && document.visibilityState === "hidden") {
15048
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
15049
- if (config?.debug) {
15050
- console.log("[LogSpace] Grace period expired, but follower tab - not ending session");
15051
- }
15052
- return;
15053
- }
15054
15266
  if (config?.debug) {
15055
- console.log("[LogSpace] Grace period expired, ending session");
15267
+ console.log("[LogSpace] Grace period expired, saving session for continuation");
15056
15268
  }
15057
15269
  endReason = "navigateAway";
15058
15270
  handleUnload();
@@ -15066,8 +15278,20 @@ function handleVisibilityChange() {
15066
15278
  clearTimeout(visibilityTimer);
15067
15279
  visibilityTimer = null;
15068
15280
  }
15281
+ if (isNavigatingAway || unloadHandled) {
15282
+ const wasNavigating = isNavigatingAway;
15283
+ const wasUnloadHandled = unloadHandled;
15284
+ isNavigatingAway = false;
15285
+ unloadHandled = false;
15286
+ if (config?.debug) {
15287
+ console.log("[LogSpace] Navigation flags reset - page is visible", {
15288
+ wasNavigating,
15289
+ wasUnloadHandled
15290
+ });
15291
+ }
15292
+ }
15069
15293
  if (config?.capture.rrweb && session?.status === "recording") {
15070
- const tabId = multiTabCoordinator.isActive() ? multiTabCoordinator.getTabId() : "main";
15294
+ const tabId = getOrCreateTabId();
15071
15295
  if (config.debug) {
15072
15296
  console.log("[LogSpace] Tab visible, forcing full rrweb snapshot for:", tabId);
15073
15297
  }
@@ -15087,8 +15311,13 @@ function buildPayload() {
15087
15311
  if (!session) return null;
15088
15312
  if (config?.sampling.enabled && !samplingTriggered) {
15089
15313
  if (config.debug) {
15090
- console.log("[LogSpace] Sampling enabled but not triggered - session not sent");
15314
+ console.log("[LogSpace] Sampling enabled but not triggered - session discarded");
15091
15315
  }
15316
+ let discardReason = "noTrigger";
15317
+ if (endReason === "idle") discardReason = "idle";
15318
+ else if (endReason === "maxDuration") discardReason = "maxDuration";
15319
+ else if (endReason === "manual") discardReason = "manual";
15320
+ config.hooks.onSessionDiscarded?.(session, discardReason);
15092
15321
  return null;
15093
15322
  }
15094
15323
  if (config?.sampling.enabled && samplingTriggered) {
@@ -15120,6 +15349,65 @@ function buildPayload() {
15120
15349
  }
15121
15350
  return payload;
15122
15351
  }
15352
+ async function stopSessionInternal(options) {
15353
+ if (!session || session.status === "stopped") {
15354
+ return;
15355
+ }
15356
+ const shouldRestart = options?.restartImmediately === true;
15357
+ const endedSessionId = session.id;
15358
+ session.status = "stopped";
15359
+ session.endTime = Date.now();
15360
+ continuingSessionId = null;
15361
+ if (idleTimer) {
15362
+ clearTimeout(idleTimer);
15363
+ idleTimer = null;
15364
+ }
15365
+ if (durationTimer) {
15366
+ clearTimeout(durationTimer);
15367
+ durationTimer = null;
15368
+ }
15369
+ if (visibilityTimer) {
15370
+ clearTimeout(visibilityTimer);
15371
+ visibilityTimer = null;
15372
+ }
15373
+ stopCheckpointTimer();
15374
+ stopSamplingTrimTimer();
15375
+ if (config?.capture.rrweb) {
15376
+ const events = stopRRWebRecording();
15377
+ if (config.debug) {
15378
+ console.log("[LogSpace] rrweb recording stopped, events:", events.length);
15379
+ }
15380
+ }
15381
+ uninstallCaptures();
15382
+ config?.hooks.onSessionEnd?.(session, logs);
15383
+ if (config?.debug) {
15384
+ console.log("[LogSpace] Session stopped:", session.id, {
15385
+ duration: session.endTime - session.startTime,
15386
+ logs: logs.length,
15387
+ rrwebEvents: rrwebEvents.length,
15388
+ reason: endReason
15389
+ });
15390
+ }
15391
+ const payload = buildPayload();
15392
+ if (shouldRestart) {
15393
+ LogSpace.startSession();
15394
+ }
15395
+ if (!payload) {
15396
+ await clearCurrentSessionBackupFor(endedSessionId);
15397
+ clearEmergencyBackup();
15398
+ clearSamplingState();
15399
+ return;
15400
+ }
15401
+ try {
15402
+ await sendPayload(payload);
15403
+ await clearCurrentSessionBackupFor(endedSessionId);
15404
+ clearEmergencyBackup();
15405
+ clearSamplingState();
15406
+ } catch (error) {
15407
+ await addPendingSession(payload);
15408
+ throw error;
15409
+ }
15410
+ }
15123
15411
  async function compressPayload(data) {
15124
15412
  const encoder = new TextEncoder();
15125
15413
  const inputData = encoder.encode(data);
@@ -15146,9 +15434,7 @@ async function compressPayload(data) {
15146
15434
  }
15147
15435
  return new Blob([inputData], { type: "application/json" });
15148
15436
  }
15149
- async function sendSession() {
15150
- const payload = buildPayload();
15151
- if (!payload) return;
15437
+ async function sendPayload(payload) {
15152
15438
  if (config?.dryRun) {
15153
15439
  console.log("[LogSpace] 🔶 DRY RUN - Session payload:", {
15154
15440
  id: payload.id,
@@ -15198,8 +15484,8 @@ async function sendSession() {
15198
15484
  // Use actual session start time for correct video seek calculation
15199
15485
  // SDK-specific fields for identify() and session info
15200
15486
  url: payload.url,
15201
- userId: session?.userId,
15202
- userTraits: session?.userTraits,
15487
+ userId: payload.userId,
15488
+ userTraits: payload.userTraits,
15203
15489
  duration: payload.duration,
15204
15490
  stats: payload.stats
15205
15491
  };
@@ -15289,6 +15575,27 @@ async function sendPayloadToServer(payload) {
15289
15575
  throw new Error(`HTTP ${response.status}: ${errorText}`);
15290
15576
  }
15291
15577
  }
15578
+ function serializeDedupValue(value) {
15579
+ try {
15580
+ return JSON.stringify(value);
15581
+ } catch {
15582
+ return String(value);
15583
+ }
15584
+ }
15585
+ function getLogDedupKey(log) {
15586
+ if (log.id) {
15587
+ return `id:${log.id}|ts:${log.timestamp}|type:${log.type}`;
15588
+ }
15589
+ return `ts:${log.timestamp}|type:${log.type}|data:${serializeDedupValue(log.data)}`;
15590
+ }
15591
+ function getEventDedupKey(event) {
15592
+ const eventId = event.id ?? event.data?.id;
15593
+ if (eventId !== void 0 && eventId !== null) {
15594
+ return `id:${String(eventId)}|ts:${event.timestamp}|type:${event.type}`;
15595
+ }
15596
+ const data = event.data;
15597
+ return `ts:${event.timestamp}|type:${event.type}|data:${serializeDedupValue(data)}`;
15598
+ }
15292
15599
  function mergeRecoveredData(recoveredLogs, recoveredEvents) {
15293
15600
  if (!session || session.status !== "recording") {
15294
15601
  if (config?.debug) {
@@ -15297,8 +15604,16 @@ function mergeRecoveredData(recoveredLogs, recoveredEvents) {
15297
15604
  return;
15298
15605
  }
15299
15606
  if (recoveredLogs.length > 0) {
15300
- const existingLogTimestamps = new Set(logs.map((l) => l.timestamp));
15301
- const newLogs = recoveredLogs.filter((l) => !existingLogTimestamps.has(l.timestamp));
15607
+ const existingLogKeys = new Set(logs.map((l) => getLogDedupKey(l)));
15608
+ const newLogs = [];
15609
+ for (const log of recoveredLogs) {
15610
+ const key = getLogDedupKey(log);
15611
+ if (existingLogKeys.has(key)) {
15612
+ continue;
15613
+ }
15614
+ existingLogKeys.add(key);
15615
+ newLogs.push(log);
15616
+ }
15302
15617
  if (newLogs.length > 0) {
15303
15618
  logs = [...newLogs, ...logs];
15304
15619
  for (const log of newLogs) {
@@ -15315,8 +15630,16 @@ function mergeRecoveredData(recoveredLogs, recoveredEvents) {
15315
15630
  }
15316
15631
  }
15317
15632
  if (recoveredEvents.length > 0) {
15318
- const existingEventTimestamps = new Set(rrwebEvents.map((e) => e.timestamp));
15319
- const newEvents = recoveredEvents.filter((e) => !existingEventTimestamps.has(e.timestamp));
15633
+ const existingEventKeys = new Set(rrwebEvents.map((e) => getEventDedupKey(e)));
15634
+ const newEvents = [];
15635
+ for (const event of recoveredEvents) {
15636
+ const key = getEventDedupKey(event);
15637
+ if (existingEventKeys.has(key)) {
15638
+ continue;
15639
+ }
15640
+ existingEventKeys.add(key);
15641
+ newEvents.push(event);
15642
+ }
15320
15643
  if (newEvents.length > 0) {
15321
15644
  rrwebEvents = [...newEvents, ...rrwebEvents];
15322
15645
  for (const event of newEvents) {
@@ -15337,11 +15660,17 @@ async function recoverPendingSessions() {
15337
15660
  try {
15338
15661
  const emergencyBackup = getEmergencyBackup();
15339
15662
  if (emergencyBackup) {
15340
- if (recoveryTargetSessionId && emergencyBackup.id === recoveryTargetSessionId) {
15663
+ if (processedRecoverySessionIds.has(emergencyBackup.id)) {
15664
+ if (config.debug) {
15665
+ console.log("[LogSpace] Skipping already processed emergency backup:", emergencyBackup.id);
15666
+ }
15667
+ clearEmergencyBackup();
15668
+ } else if (recoveryTargetSessionId && emergencyBackup.id === recoveryTargetSessionId) {
15341
15669
  if (config.debug) {
15342
15670
  console.log("[LogSpace] Merging emergency backup into continuing session:", emergencyBackup.id);
15343
15671
  }
15344
15672
  mergeRecoveredData(emergencyBackup.logs, emergencyBackup.rrwebEvents || []);
15673
+ processedRecoverySessionIds.add(emergencyBackup.id);
15345
15674
  clearEmergencyBackup();
15346
15675
  await clearCurrentSessionBackup();
15347
15676
  } else {
@@ -15375,44 +15704,59 @@ async function recoverPendingSessions() {
15375
15704
  if (pendingSessions.length === 0) {
15376
15705
  const backup = await getCurrentSessionBackup();
15377
15706
  if (backup && backup.logs.length > 0) {
15378
- if (config.debug) {
15379
- console.log("[LogSpace] Recovering crashed session:", backup.sessionId);
15380
- }
15381
- const recoveredPayload = {
15382
- id: backup.sessionId,
15383
- startTime: backup.startTime,
15384
- endTime: backup.lastCheckpoint,
15385
- duration: backup.lastCheckpoint - backup.startTime,
15386
- url: backup.url,
15387
- title: backup.title,
15388
- userId: backup.userId,
15389
- userTraits: backup.userTraits,
15390
- metadata: backup.metadata,
15391
- logs: backup.logs,
15392
- stats: {
15393
- logCount: backup.logs.length,
15394
- networkLogCount: backup.logs.filter((l) => l.type === "network").length,
15395
- consoleLogCount: backup.logs.filter((l) => l.type === "console").length,
15396
- errorCount: backup.logs.filter((l) => l.type === "error").length,
15397
- storageLogCount: backup.logs.filter((l) => l.type === "storage").length,
15398
- interactionLogCount: backup.logs.filter((l) => l.type === "interaction").length,
15399
- performanceLogCount: backup.logs.filter((l) => l.type === "performance").length,
15400
- annotationCount: backup.logs.filter((l) => l.type === "annotation").length,
15401
- tabCount: 1
15402
- // SDK always has single tab
15403
- },
15404
- endReason: "crash",
15405
- recordingType: "rrweb",
15406
- rrwebEvents: backup.rrwebEvents,
15407
- sdkVersion: SDK_VERSION
15408
- };
15409
- pendingSessions.push({
15410
- id: backup.sessionId,
15411
- data: recoveredPayload,
15412
- createdAt: backup.lastCheckpoint,
15413
- retryCount: 0
15414
- });
15415
- await clearCurrentSessionBackup();
15707
+ if (recoveryTargetSessionId && backup.sessionId === recoveryTargetSessionId) {
15708
+ if (config.debug) {
15709
+ console.log("[LogSpace] Merging checkpoint backup into continuing session:", backup.sessionId);
15710
+ }
15711
+ mergeRecoveredData(backup.logs, backup.rrwebEvents || []);
15712
+ processedRecoverySessionIds.add(backup.sessionId);
15713
+ await clearCurrentSessionBackup();
15714
+ } else if (processedRecoverySessionIds.has(backup.sessionId)) {
15715
+ if (config.debug) {
15716
+ console.log("[LogSpace] Skipping already processed backup:", backup.sessionId);
15717
+ }
15718
+ await clearCurrentSessionBackup();
15719
+ } else {
15720
+ if (config.debug) {
15721
+ console.log("[LogSpace] Recovering crashed session:", backup.sessionId);
15722
+ }
15723
+ const recoveredPayload = {
15724
+ id: backup.sessionId,
15725
+ startTime: backup.startTime,
15726
+ endTime: backup.lastCheckpoint,
15727
+ duration: backup.lastCheckpoint - backup.startTime,
15728
+ url: backup.url,
15729
+ title: backup.title,
15730
+ userId: backup.userId,
15731
+ userTraits: backup.userTraits,
15732
+ metadata: backup.metadata,
15733
+ logs: backup.logs,
15734
+ stats: {
15735
+ logCount: backup.logs.length,
15736
+ networkLogCount: backup.logs.filter((l) => l.type === "network").length,
15737
+ consoleLogCount: backup.logs.filter((l) => l.type === "console").length,
15738
+ errorCount: backup.logs.filter((l) => l.type === "error").length,
15739
+ storageLogCount: backup.logs.filter((l) => l.type === "storage").length,
15740
+ interactionLogCount: backup.logs.filter((l) => l.type === "interaction").length,
15741
+ performanceLogCount: backup.logs.filter((l) => l.type === "performance").length,
15742
+ annotationCount: backup.logs.filter((l) => l.type === "annotation").length,
15743
+ tabCount: 1
15744
+ // SDK always has single tab
15745
+ },
15746
+ endReason: "crash",
15747
+ recordingType: "rrweb",
15748
+ rrwebEvents: backup.rrwebEvents,
15749
+ sdkVersion: SDK_VERSION
15750
+ };
15751
+ pendingSessions.push({
15752
+ id: backup.sessionId,
15753
+ data: recoveredPayload,
15754
+ createdAt: backup.lastCheckpoint,
15755
+ retryCount: 0
15756
+ });
15757
+ await addPendingSession(recoveredPayload);
15758
+ await clearCurrentSessionBackup();
15759
+ }
15416
15760
  }
15417
15761
  }
15418
15762
  if (pendingSessions.length === 0) return;
@@ -15420,16 +15764,25 @@ async function recoverPendingSessions() {
15420
15764
  console.log(`[LogSpace] Recovering ${pendingSessions.length} pending session(s)`);
15421
15765
  }
15422
15766
  for (const stored of pendingSessions) {
15767
+ if (processedRecoverySessionIds.has(stored.id)) {
15768
+ if (config.debug) {
15769
+ console.log("[LogSpace] Skipping already processed pending session:", stored.id);
15770
+ }
15771
+ await removePendingSession(stored.id);
15772
+ continue;
15773
+ }
15423
15774
  if (recoveryTargetSessionId && stored.id === recoveryTargetSessionId) {
15424
15775
  if (config.debug) {
15425
15776
  console.log("[LogSpace] Merging pending session into continuing session:", stored.id);
15426
15777
  }
15427
15778
  mergeRecoveredData(stored.data.logs, stored.data.rrwebEvents || []);
15779
+ processedRecoverySessionIds.add(stored.id);
15428
15780
  await removePendingSession(stored.id);
15429
15781
  continue;
15430
15782
  }
15431
15783
  try {
15432
15784
  await sendPayloadToServer(stored.data);
15785
+ processedRecoverySessionIds.add(stored.id);
15433
15786
  await removePendingSession(stored.id);
15434
15787
  if (config.debug) {
15435
15788
  console.log("[LogSpace] Recovered session sent:", stored.id);
@@ -15452,11 +15805,11 @@ async function recoverPendingSessions() {
15452
15805
  const LogSpace = {
15453
15806
  /**
15454
15807
  * Initialize the SDK
15808
+ * @throws Error if SDK is already initialized
15455
15809
  */
15456
15810
  init(userConfig) {
15457
15811
  if (initialized) {
15458
- console.warn("[LogSpace] SDK already initialized");
15459
- return;
15812
+ throw new Error("[LogSpace] SDK already initialized. Call destroy() first if you need to reinitialize.");
15460
15813
  }
15461
15814
  if (typeof window === "undefined") {
15462
15815
  console.warn("[LogSpace] SDK requires browser environment");
@@ -15469,54 +15822,9 @@ const LogSpace = {
15469
15822
  version: SDK_VERSION,
15470
15823
  serverUrl: config.serverUrl,
15471
15824
  limits: config.limits,
15472
- persistence: isIndexedDBAvailable() ? "enabled" : "disabled",
15473
- multiTab: multiTabCoordinator.isSupported() ? "enabled" : "disabled"
15825
+ persistence: isIndexedDBAvailable() ? "enabled" : "disabled"
15474
15826
  });
15475
15827
  }
15476
- if (multiTabCoordinator.isSupported()) {
15477
- multiTabCoordinator.init(
15478
- {
15479
- onBecomeLeader: (sessionId, isNewSession) => {
15480
- if (config?.debug) {
15481
- console.log("[LogSpace] This tab is now the leader", { sessionId, isNewSession });
15482
- }
15483
- if (!isNewSession) {
15484
- continuingSessionId = sessionId;
15485
- }
15486
- if (!session) {
15487
- LogSpace.startSession();
15488
- }
15489
- },
15490
- onBecomeFollower: (sessionId) => {
15491
- if (config?.debug) {
15492
- console.log("[LogSpace] This tab is now a follower", { sessionId });
15493
- }
15494
- if (!session) {
15495
- LogSpace.startSession();
15496
- }
15497
- },
15498
- onReceiveLog: (log) => {
15499
- if (session?.status === "recording") {
15500
- logs.push(log);
15501
- currentSize += JSON.stringify(log).length;
15502
- updateStats(log);
15503
- }
15504
- },
15505
- onReceiveRRWebEvent: (event) => {
15506
- if (session?.status === "recording") {
15507
- rrwebEvents.push(event);
15508
- rrwebSize += JSON.stringify(event).length;
15509
- }
15510
- },
15511
- onLeaderChanged: (newLeaderTabId) => {
15512
- if (config?.debug) {
15513
- console.log("[LogSpace] Leader changed to:", newLeaderTabId);
15514
- }
15515
- }
15516
- },
15517
- config.debug
15518
- );
15519
- }
15520
15828
  if (config.autoEnd.continueOnRefresh) {
15521
15829
  try {
15522
15830
  const savedSessionId = sessionStorage.getItem("logspace_continuing_session_id");
@@ -15563,9 +15871,8 @@ const LogSpace = {
15563
15871
  installActivityListeners();
15564
15872
  }
15565
15873
  unloadHandled = false;
15566
- if (!multiTabCoordinator.isActive()) {
15567
- this.startSession();
15568
- }
15874
+ isNavigatingAway = false;
15875
+ this.startSession();
15569
15876
  recoverPendingSessions().catch(() => {
15570
15877
  });
15571
15878
  },
@@ -15581,20 +15888,16 @@ const LogSpace = {
15581
15888
  console.warn("[LogSpace] Session already recording");
15582
15889
  return;
15583
15890
  }
15584
- if (sessionEndedByHardLimit) {
15585
- if (config.debug) {
15586
- console.log("[LogSpace] Session not restarted - previous session ended by hard limit");
15587
- }
15588
- return;
15589
- }
15590
15891
  sessionEndedByIdle = false;
15591
15892
  samplingTriggered = false;
15592
15893
  samplingTriggerType = null;
15894
+ samplingFirstTriggerTime = null;
15593
15895
  samplingTriggerTime = null;
15594
15896
  if (samplingEndTimer) {
15595
15897
  clearTimeout(samplingEndTimer);
15596
15898
  samplingEndTimer = null;
15597
15899
  }
15900
+ stopSamplingTrimTimer();
15598
15901
  logs = [];
15599
15902
  rrwebEvents = [];
15600
15903
  currentSize = 0;
@@ -15604,28 +15907,44 @@ const LogSpace = {
15604
15907
  endReason = "manual";
15605
15908
  let sessionId;
15606
15909
  let sessionStartTime;
15607
- if (multiTabCoordinator.isActive() && multiTabCoordinator.getSessionId()) {
15608
- sessionId = multiTabCoordinator.getSessionId();
15609
- sessionStartTime = Date.now();
15610
- } else if (continuingSessionId) {
15910
+ let restoredSamplingState = false;
15911
+ if (continuingSessionId) {
15611
15912
  sessionId = continuingSessionId;
15612
15913
  sessionStartTime = continuingSessionStartTime || Date.now();
15613
15914
  if (config.debug) {
15614
15915
  console.log("[LogSpace] Using continuing session ID:", sessionId, "with original startTime:", sessionStartTime);
15615
15916
  }
15917
+ const savedSamplingState = getSamplingState();
15918
+ if (savedSamplingState && savedSamplingState.sessionId === sessionId && savedSamplingState.triggered) {
15919
+ samplingTriggered = savedSamplingState.triggered;
15920
+ samplingTriggerType = savedSamplingState.triggerType;
15921
+ samplingFirstTriggerTime = savedSamplingState.firstTriggerTime;
15922
+ samplingTriggerTime = savedSamplingState.triggerTime;
15923
+ restoredSamplingState = true;
15924
+ if (config.debug) {
15925
+ console.log("[LogSpace] Restored sampling state from before refresh:", savedSamplingState.triggerType);
15926
+ }
15927
+ }
15928
+ clearSamplingState();
15616
15929
  continuingSessionId = null;
15617
15930
  continuingSessionStartTime = null;
15618
15931
  } else {
15619
15932
  sessionId = generateSessionId();
15620
15933
  sessionStartTime = Date.now();
15621
15934
  }
15935
+ const identityToApply = pendingIdentity || currentIdentity;
15936
+ const tabId = getOrCreateTabId();
15937
+ const sessionMetadata = buildSessionMetadata(metadata);
15622
15938
  session = {
15623
15939
  id: sessionId,
15940
+ tabId,
15624
15941
  startTime: sessionStartTime,
15625
15942
  status: "recording",
15626
15943
  url: window.location.href,
15627
15944
  title: document.title,
15628
- metadata,
15945
+ metadata: sessionMetadata,
15946
+ userId: identityToApply?.userId,
15947
+ userTraits: identityToApply?.traits,
15629
15948
  stats: {
15630
15949
  logCount: 0,
15631
15950
  networkLogCount: 0,
@@ -15635,11 +15954,10 @@ const LogSpace = {
15635
15954
  interactionLogCount: 0,
15636
15955
  performanceLogCount: 0,
15637
15956
  annotationCount: 0,
15638
- tabCount: multiTabCoordinator.isActive() ? 0 : 1
15639
- // Will be updated by multi-tab coordinator
15957
+ tabCount: 1
15640
15958
  }
15641
15959
  };
15642
- const tabId = multiTabCoordinator.isActive() ? multiTabCoordinator.getTabId() : void 0;
15960
+ setSessionContext(sessionId, tabId);
15643
15961
  if (config.capture.rrweb) {
15644
15962
  const rrwebStarted = startRRWebRecording(
15645
15963
  {
@@ -15652,23 +15970,17 @@ const LogSpace = {
15652
15970
  },
15653
15971
  (event) => {
15654
15972
  if (!session || session.status !== "recording") return;
15655
- if (multiTabCoordinator.isActive() && !multiTabCoordinator.isLeaderTab()) {
15656
- multiTabCoordinator.sendRRWebEvent(event);
15657
- resetIdleTimer();
15658
- return;
15659
- }
15660
15973
  const eventSize = JSON.stringify(event).length;
15661
15974
  rrwebSize += eventSize;
15662
15975
  const totalSize = currentSize + rrwebSize;
15663
15976
  if (config && totalSize >= config.limits.maxSize) {
15664
- if (config.autoEnd.onLimitReached && !sessionEndedByHardLimit) {
15665
- sessionEndedByHardLimit = true;
15977
+ if (config.autoEnd.onLimitReached) {
15666
15978
  if (config.debug) {
15667
15979
  console.log("[LogSpace] Session auto-ended: maxSize (rrweb)");
15668
15980
  }
15669
15981
  endReason = "maxSize";
15670
15982
  config.hooks.onLimitReached?.("maxSize");
15671
- LogSpace.stopSession();
15983
+ stopSessionInternal({ restartImmediately: true });
15672
15984
  }
15673
15985
  return;
15674
15986
  }
@@ -15689,13 +16001,47 @@ const LogSpace = {
15689
16001
  }
15690
16002
  endReason = "maxDuration";
15691
16003
  config?.hooks.onLimitReached?.("maxDuration");
15692
- this.stopSession();
16004
+ stopSessionInternal({ restartImmediately: true });
15693
16005
  }
15694
16006
  }, config.limits.maxDuration * 1e3);
15695
16007
  }
15696
16008
  resetIdleTimer();
15697
16009
  installCaptures();
15698
16010
  startCheckpointTimer();
16011
+ if (config.sampling.enabled && !restoredSamplingState) {
16012
+ startSamplingTrimTimer();
16013
+ } else if (config.sampling.enabled && restoredSamplingState && samplingTriggerTime) {
16014
+ const elapsed = Date.now() - samplingTriggerTime;
16015
+ const remainingTime = Math.max(0, config.sampling.recordAfter * 1e3 - elapsed);
16016
+ if (config.debug) {
16017
+ console.log(`[LogSpace] Sampling was triggered before refresh, ${remainingTime}ms remaining in recordAfter`);
16018
+ }
16019
+ if (remainingTime > 0) {
16020
+ samplingEndTimer = setTimeout(() => {
16021
+ if (session?.status === "recording") {
16022
+ if (config?.debug) {
16023
+ console.log("[LogSpace] Sampling recordAfter period complete, ending session");
16024
+ }
16025
+ endReason = "samplingTriggered";
16026
+ stopSessionInternal({ restartImmediately: true });
16027
+ }
16028
+ }, remainingTime);
16029
+ } else {
16030
+ if (config.debug) {
16031
+ console.log("[LogSpace] Sampling recordAfter period already elapsed, ending session");
16032
+ }
16033
+ endReason = "samplingTriggered";
16034
+ stopSessionInternal({ restartImmediately: true });
16035
+ }
16036
+ }
16037
+ if (session && identityToApply) {
16038
+ if (pendingIdentity && config.debug) {
16039
+ console.log("[LogSpace] Applied pending user identity:", identityToApply.userId);
16040
+ }
16041
+ }
16042
+ if (pendingIdentity) {
16043
+ pendingIdentity = null;
16044
+ }
15699
16045
  if (session) {
15700
16046
  config.hooks.onSessionStart?.(session);
15701
16047
  if (config.debug) {
@@ -15707,68 +16053,23 @@ const LogSpace = {
15707
16053
  * Stop recording and send session to server
15708
16054
  */
15709
16055
  async stopSession() {
15710
- if (!session || session.status === "stopped") {
15711
- return;
15712
- }
15713
- session.status = "stopped";
15714
- session.endTime = Date.now();
15715
- continuingSessionId = null;
15716
- if (idleTimer) {
15717
- clearTimeout(idleTimer);
15718
- idleTimer = null;
15719
- }
15720
- if (durationTimer) {
15721
- clearTimeout(durationTimer);
15722
- durationTimer = null;
15723
- }
15724
- if (visibilityTimer) {
15725
- clearTimeout(visibilityTimer);
15726
- visibilityTimer = null;
15727
- }
15728
- stopCheckpointTimer();
15729
- if (config?.capture.rrweb) {
15730
- const events = stopRRWebRecording();
15731
- if (config.debug) {
15732
- console.log("[LogSpace] rrweb recording stopped, events:", events.length);
15733
- }
15734
- }
15735
- uninstallCaptures();
15736
- config?.hooks.onSessionEnd?.(session, logs);
15737
- if (config?.debug) {
15738
- console.log("[LogSpace] Session stopped:", session.id, {
15739
- duration: session.endTime - session.startTime,
15740
- logs: logs.length,
15741
- rrwebEvents: rrwebEvents.length,
15742
- reason: endReason
15743
- });
15744
- }
15745
- if (!multiTabCoordinator.isActive() || multiTabCoordinator.isLeaderTab()) {
15746
- try {
15747
- await sendSession();
15748
- await clearCurrentSessionBackup();
15749
- clearEmergencyBackup();
15750
- if (multiTabCoordinator.isActive()) {
15751
- multiTabCoordinator.endSession();
15752
- }
15753
- } catch (error) {
15754
- const payload = buildPayload();
15755
- if (payload) {
15756
- await addPendingSession(payload);
15757
- }
15758
- throw error;
15759
- }
15760
- }
16056
+ await stopSessionInternal();
15761
16057
  },
15762
16058
  /**
15763
16059
  * Identify user
16060
+ * If called before session starts, the identity will be queued and applied when session starts
15764
16061
  */
15765
16062
  identify(userId, traits) {
15766
- if (!session) return;
16063
+ currentIdentity = { userId, traits };
16064
+ if (!session) {
16065
+ pendingIdentity = { userId, traits };
16066
+ if (config?.debug) {
16067
+ console.log("[LogSpace] User identity queued (session not started yet):", userId);
16068
+ }
16069
+ return;
16070
+ }
15767
16071
  session.userId = userId;
15768
16072
  session.userTraits = traits;
15769
- if (multiTabCoordinator.isActive()) {
15770
- multiTabCoordinator.updateSessionUser(userId, traits);
15771
- }
15772
16073
  if (config?.debug) {
15773
16074
  console.log("[LogSpace] User identified:", userId);
15774
16075
  }
@@ -15781,7 +16082,7 @@ const LogSpace = {
15781
16082
  type: "annotation",
15782
16083
  data: {
15783
16084
  annotationType: "event",
15784
- event,
16085
+ eventName: event,
15785
16086
  properties
15786
16087
  }
15787
16088
  });
@@ -15862,6 +16163,51 @@ const LogSpace = {
15862
16163
  getRRWebEvents() {
15863
16164
  return [...rrwebEvents];
15864
16165
  },
16166
+ /**
16167
+ * Get current configuration (read-only)
16168
+ * Returns the full NormalizedConfig plus convenience top-level fields
16169
+ * that match the setConfig() interface for easy verification
16170
+ */
16171
+ getConfig() {
16172
+ if (!config) return null;
16173
+ return {
16174
+ ...config,
16175
+ // Expose convenience top-level fields that match setConfig() interface
16176
+ rateLimit: config.limits.rateLimit,
16177
+ maxLogs: config.limits.maxLogs,
16178
+ maxSize: config.limits.maxSize,
16179
+ idleTimeout: config.limits.idleTimeout
16180
+ };
16181
+ },
16182
+ /**
16183
+ * Update configuration at runtime
16184
+ * Only certain settings can be changed after initialization
16185
+ */
16186
+ setConfig(updates) {
16187
+ if (!config) {
16188
+ console.warn("[LogSpace] SDK not initialized, cannot update config");
16189
+ return;
16190
+ }
16191
+ if (updates.rateLimit !== void 0) {
16192
+ config.limits.rateLimit = updates.rateLimit;
16193
+ }
16194
+ if (updates.maxLogs !== void 0) {
16195
+ config.limits.maxLogs = updates.maxLogs;
16196
+ }
16197
+ if (updates.maxSize !== void 0) {
16198
+ config.limits.maxSize = updates.maxSize;
16199
+ }
16200
+ if (updates.idleTimeout !== void 0) {
16201
+ config.limits.idleTimeout = updates.idleTimeout;
16202
+ resetIdleTimer();
16203
+ }
16204
+ if (updates.debug !== void 0) {
16205
+ config.debug = updates.debug;
16206
+ }
16207
+ if (config.debug) {
16208
+ console.log("[LogSpace] Config updated:", updates);
16209
+ }
16210
+ },
15865
16211
  /**
15866
16212
  * Destroy SDK
15867
16213
  */
@@ -15884,9 +16230,7 @@ const LogSpace = {
15884
16230
  visibilityTimer = null;
15885
16231
  }
15886
16232
  stopCheckpointTimer();
15887
- if (multiTabCoordinator.isActive()) {
15888
- multiTabCoordinator.cleanup();
15889
- }
16233
+ stopSamplingTrimTimer();
15890
16234
  if (config?.capture.rrweb) {
15891
16235
  stopRRWebRecording();
15892
16236
  }
@@ -15905,14 +16249,16 @@ const LogSpace = {
15905
16249
  rrwebEvents = [];
15906
16250
  currentSize = 0;
15907
16251
  rrwebSize = 0;
15908
- sessionEndedByHardLimit = false;
15909
16252
  sessionEndedByIdle = false;
15910
16253
  samplingTriggered = false;
15911
16254
  samplingTriggerType = null;
16255
+ samplingFirstTriggerTime = null;
15912
16256
  samplingTriggerTime = null;
15913
16257
  recoveryTargetSessionId = null;
15914
16258
  unloadHandled = false;
16259
+ isNavigatingAway = false;
15915
16260
  lastMouseMoveTime = 0;
16261
+ currentTabId = null;
15916
16262
  initialized = false;
15917
16263
  if (wasDebug) {
15918
16264
  console.log("[LogSpace] SDK destroyed");