@loggerjs/browser 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -894,12 +894,96 @@ function indexedDbBrowserHttpOfflineQueue(options = {}) {
894
894
  //#region src/indexeddb-transport.ts
895
895
  const DEFAULT_DB_NAME = "loggerjs";
896
896
  const DEFAULT_STORE_NAME = "logs";
897
- const DB_VERSION = 2;
897
+ const DEFAULT_SESSION_CONTEXT_KEY$1 = "sessionId";
898
+ const DEFAULT_SPILL_MAX_ENTRIES = 200;
899
+ const DEFAULT_SPILL_MAX_BYTES = 512 * 1024;
900
+ const DEFAULT_SPILL_MAX_ORIGIN_BYTES = 5 * 1024 * 1024;
901
+ const DB_VERSION = 3;
898
902
  const ORDER_INDEX_NAME = "createdAtSeq";
903
+ const SESSION_INDEX_NAME = "sessionId";
904
+ const SESSION_ORDER_INDEX_NAME = "sessionIdCreatedAtSeq";
905
+ const SPILL_SCHEMA = "loggerjs.indexeddb-spill.v1";
899
906
  function normalizePositiveInteger(value, fallback) {
900
907
  if (value === void 0 || !Number.isFinite(value)) return fallback;
901
908
  return Math.max(0, Math.floor(value));
902
909
  }
910
+ let fallbackSessionSeq = 0;
911
+ function defaultPageSessionId() {
912
+ const randomUUID = globalThis.crypto?.randomUUID;
913
+ if (randomUUID) return randomUUID.call(globalThis.crypto);
914
+ return `${Date.now().toString(36)}-${(fallbackSessionSeq++).toString(36)}`;
915
+ }
916
+ function normalizeSessionId(value) {
917
+ if (typeof value !== "string") return void 0;
918
+ const normalized = value.trim();
919
+ return normalized.length > 0 ? normalized : void 0;
920
+ }
921
+ function normalizeSessionOptions(session) {
922
+ if (session === false) return void 0;
923
+ if (typeof session === "string") return {
924
+ contextKey: DEFAULT_SESSION_CONTEXT_KEY$1,
925
+ id: normalizeSessionId(session) ?? defaultPageSessionId()
926
+ };
927
+ return {
928
+ contextKey: session?.contextKey ?? DEFAULT_SESSION_CONTEXT_KEY$1,
929
+ getId: session?.getId,
930
+ id: normalizeSessionId(session?.id) ?? defaultPageSessionId()
931
+ };
932
+ }
933
+ function resolveLocalStorage(storage) {
934
+ if (storage) return storage;
935
+ try {
936
+ return globalThis.localStorage;
937
+ } catch {
938
+ return;
939
+ }
940
+ }
941
+ function normalizeLocalStorageSpillOptions(spill, dbName, storeName) {
942
+ if (!spill) return void 0;
943
+ const storage = resolveLocalStorage(spill.storage);
944
+ if (!storage) return void 0;
945
+ const namespace = spill.namespace ?? `${dbName}:${storeName}`;
946
+ return {
947
+ drainOnCreate: spill.drainOnCreate ?? true,
948
+ key: `loggerjs:spill:v1:${namespace}`,
949
+ maxBytes: normalizePositiveInteger(spill.maxBytes, DEFAULT_SPILL_MAX_BYTES),
950
+ maxEntries: normalizePositiveInteger(spill.maxEntries, DEFAULT_SPILL_MAX_ENTRIES),
951
+ maxOriginBytes: normalizePositiveInteger(spill.maxOriginBytes, DEFAULT_SPILL_MAX_ORIGIN_BYTES),
952
+ minLevelValue: spill.minLevel === void 0 ? void 0 : (0, _loggerjs_core.toLevelValue)(spill.minLevel),
953
+ onDrop: spill.onDrop,
954
+ spillOnPageHide: spill.spillOnPageHide ?? true,
955
+ storage
956
+ };
957
+ }
958
+ function storageByteLength(key, value) {
959
+ return 2 * (key.length + value.length);
960
+ }
961
+ function estimateLocalStorageBytes(storage) {
962
+ try {
963
+ let total = 0;
964
+ for (let index = 0; index < storage.length; index += 1) {
965
+ const key = storage.key(index);
966
+ if (key === null) continue;
967
+ total += storageByteLength(key, storage.getItem(key) ?? "");
968
+ }
969
+ return total;
970
+ } catch {
971
+ return;
972
+ }
973
+ }
974
+ function isQuotaExceededError(error) {
975
+ return typeof DOMException !== "undefined" && error instanceof DOMException && (error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
976
+ }
977
+ function isSpillLogEvent(value) {
978
+ if (!value || typeof value !== "object") return false;
979
+ const event = value;
980
+ return typeof event.id === "string" && typeof event.time === "number" && typeof event.seq === "number" && typeof event.level === "number" && typeof event.levelName === "string" && typeof event.logger === "string" && typeof event.message === "string";
981
+ }
982
+ function parseSpillPayload(raw) {
983
+ const parsed = JSON.parse(raw);
984
+ if (parsed.schema !== SPILL_SCHEMA || !Array.isArray(parsed.entries)) return void 0;
985
+ return parsed.entries.filter(isSpillLogEvent);
986
+ }
903
987
  function nowMs$1() {
904
988
  return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
905
989
  }
@@ -965,6 +1049,7 @@ function decodeEntry(entry, codec) {
965
1049
  function shouldKeepEntry(entry, options) {
966
1050
  if (options.from !== void 0 && entry.createdAt < options.from) return false;
967
1051
  if (options.to !== void 0 && entry.createdAt > options.to) return false;
1052
+ if (options.sessionId !== void 0 && entry.sessionId !== options.sessionId) return false;
968
1053
  if (options.minLevel !== void 0 && entry.level < (0, _loggerjs_core.toLevelValue)(options.minLevel)) return false;
969
1054
  if (options.logger !== void 0 && entry.logger !== options.logger) return false;
970
1055
  if (options.type !== void 0 && entry.type !== options.type) return false;
@@ -972,6 +1057,21 @@ function shouldKeepEntry(entry, options) {
972
1057
  }
973
1058
  function orderRangeForQuery(options) {
974
1059
  if (typeof IDBKeyRange === "undefined") return void 0;
1060
+ if (options.sessionId !== void 0) {
1061
+ const lower = [
1062
+ options.sessionId,
1063
+ options.from ?? Number.MIN_SAFE_INTEGER,
1064
+ Number.MIN_SAFE_INTEGER,
1065
+ ""
1066
+ ];
1067
+ const upper = [
1068
+ options.sessionId,
1069
+ options.to ?? Number.MAX_SAFE_INTEGER,
1070
+ Number.MAX_SAFE_INTEGER,
1071
+ "￿"
1072
+ ];
1073
+ return IDBKeyRange.bound(lower, upper);
1074
+ }
975
1075
  const lower = options.from === void 0 ? void 0 : [
976
1076
  options.from,
977
1077
  Number.MIN_SAFE_INTEGER,
@@ -1020,12 +1120,17 @@ function indexedDbTransport(options = {}) {
1020
1120
  const flushOnPageHide = options.flushOnPageHide ?? true;
1021
1121
  const durability = options.durability ?? "default";
1022
1122
  const rootIndexedDB = options.indexedDB ?? globalThis.indexedDB;
1123
+ const sessionOptions = normalizeSessionOptions(options.session);
1124
+ const spillOptions = normalizeLocalStorageSpillOptions(options.localStorageSpill, dbName, storeName);
1023
1125
  const transactionOptions = durability === "default" ? void 0 : { durability };
1024
1126
  const buffer = [];
1025
1127
  let dbPromise;
1026
1128
  let indexedDBFactoryPromise;
1027
1129
  let timer;
1028
1130
  let flushPromise;
1131
+ let pendingFlushBatch;
1132
+ let spillDrainPromise;
1133
+ let localStorageSpillClearCandidate;
1029
1134
  let lastContext;
1030
1135
  let closed = false;
1031
1136
  const statsState = {
@@ -1052,13 +1157,23 @@ function indexedDbTransport(options = {}) {
1052
1157
  storageBucketFallbacks: 0,
1053
1158
  transactionOptionFallbacks: 0,
1054
1159
  errors: 0,
1055
- errorsByOperation: {}
1160
+ errorsByOperation: {},
1161
+ localStorageSpillWrites: 0,
1162
+ localStorageSpillEntries: 0,
1163
+ localStorageSpillDrains: 0,
1164
+ localStorageSpillDrainedEntries: 0,
1165
+ localStorageSpillDropped: 0,
1166
+ localStorageSpillDroppedByReason: {},
1167
+ localStorageSpillErrors: 0,
1168
+ localStorageSpillErrorsByOperation: {}
1056
1169
  };
1057
1170
  const snapshotStats = () => ({
1058
1171
  ...statsState,
1059
1172
  bufferDepth: buffer.length,
1060
1173
  droppedByReason: { ...statsState.droppedByReason },
1061
1174
  errorsByOperation: { ...statsState.errorsByOperation },
1175
+ localStorageSpillDroppedByReason: { ...statsState.localStorageSpillDroppedByReason },
1176
+ localStorageSpillErrorsByOperation: { ...statsState.localStorageSpillErrorsByOperation },
1062
1177
  pendingFlush: flushPromise !== void 0,
1063
1178
  persistedDroppedByReason: { ...statsState.persistedDroppedByReason }
1064
1179
  });
@@ -1085,6 +1200,18 @@ function indexedDbTransport(options = {}) {
1085
1200
  (0, _loggerjs_core.incrementLoggerMetaCounter)(`transport.indexeddb.persisted.dropped.${reason}`);
1086
1201
  options.onPersistedDrop?.(entry, reason);
1087
1202
  };
1203
+ const reportSpillError = (error, operation) => {
1204
+ statsState.localStorageSpillErrors += 1;
1205
+ incrementRecord(statsState.localStorageSpillErrorsByOperation, operation);
1206
+ reportInternalError(error, `localstorage-spill-${operation}`);
1207
+ };
1208
+ const dropSpillEvent = (event, reason) => {
1209
+ statsState.localStorageSpillDropped += 1;
1210
+ incrementRecord(statsState.localStorageSpillDroppedByReason, reason);
1211
+ (0, _loggerjs_core.incrementLoggerMetaCounter)("transport.indexeddb.localstorage_spill.dropped");
1212
+ (0, _loggerjs_core.incrementLoggerMetaCounter)(`transport.indexeddb.localstorage_spill.dropped.${reason}`);
1213
+ spillOptions?.onDrop?.(event, reason);
1214
+ };
1088
1215
  const resolveIndexedDBFactory = async () => {
1089
1216
  if (options.indexedDB || !options.storageBucketName) return rootIndexedDB;
1090
1217
  indexedDBFactoryPromise ??= (async () => {
@@ -1124,6 +1251,13 @@ function indexedDbTransport(options = {}) {
1124
1251
  "seq",
1125
1252
  "id"
1126
1253
  ]);
1254
+ ensureIndex(store, SESSION_INDEX_NAME, "sessionId");
1255
+ ensureIndex(store, SESSION_ORDER_INDEX_NAME, [
1256
+ "sessionId",
1257
+ "createdAt",
1258
+ "seq",
1259
+ "id"
1260
+ ]);
1127
1261
  });
1128
1262
  request.addEventListener("success", () => {
1129
1263
  statsState.databaseOpenCount += 1;
@@ -1214,7 +1348,7 @@ function indexedDbTransport(options = {}) {
1214
1348
  return total;
1215
1349
  });
1216
1350
  const queryEntriesByCursor = async (queryOptions) => withStore("readonly", async (store) => {
1217
- const index = getStoreIndex(store, ORDER_INDEX_NAME);
1351
+ const index = getStoreIndex(store, queryOptions.sessionId === void 0 ? ORDER_INDEX_NAME : SESSION_ORDER_INDEX_NAME);
1218
1352
  if (!index) return void 0;
1219
1353
  const entries = [];
1220
1354
  const limit = queryOptions.limit;
@@ -1227,6 +1361,31 @@ function indexedDbTransport(options = {}) {
1227
1361
  });
1228
1362
  return entries;
1229
1363
  });
1364
+ const listSessions = async (sessionQueryOptions = {}) => {
1365
+ const sessions = /* @__PURE__ */ new Map();
1366
+ for (const entry of await getEntries()) {
1367
+ if (!entry.sessionId) continue;
1368
+ const current = sessions.get(entry.sessionId);
1369
+ if (current) {
1370
+ current.firstSeen = Math.min(current.firstSeen, entry.createdAt);
1371
+ current.lastSeen = Math.max(current.lastSeen, entry.createdAt);
1372
+ current.count += 1;
1373
+ current.byteLength += entry.byteLength;
1374
+ } else sessions.set(entry.sessionId, {
1375
+ byteLength: entry.byteLength,
1376
+ count: 1,
1377
+ firstSeen: entry.createdAt,
1378
+ lastSeen: entry.createdAt,
1379
+ sessionId: entry.sessionId
1380
+ });
1381
+ }
1382
+ const ordered = [...sessions.values()].sort((left, right) => {
1383
+ if (left.lastSeen !== right.lastSeen) return left.lastSeen - right.lastSeen;
1384
+ return left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0;
1385
+ });
1386
+ if (sessionQueryOptions.order !== "asc") ordered.reverse();
1387
+ return ordered.slice(0, sessionQueryOptions.limit);
1388
+ };
1230
1389
  const pruneWithEntries = async () => {
1231
1390
  const now = Date.now();
1232
1391
  let entries = await getEntries();
@@ -1282,10 +1441,34 @@ function indexedDbTransport(options = {}) {
1282
1441
  statsState.lastPruneDurationMs = nowMs$1() - startedAt;
1283
1442
  }
1284
1443
  };
1444
+ const sessionIdForEvent = (event) => {
1445
+ if (!sessionOptions) return void 0;
1446
+ return normalizeSessionId(sessionOptions.getId?.(event)) ?? normalizeSessionId(event.context?.[sessionOptions.contextKey]) ?? sessionOptions.id;
1447
+ };
1448
+ const eventWithSessionContext = (event, sessionId) => {
1449
+ if (!sessionId || !sessionOptions?.contextKey) return event;
1450
+ if (event.context?.[sessionOptions.contextKey] === sessionId) return event;
1451
+ return {
1452
+ ...event,
1453
+ context: {
1454
+ ...event.context,
1455
+ [sessionOptions.contextKey]: sessionId
1456
+ }
1457
+ };
1458
+ };
1459
+ const eventWithResolvedSession = (event) => {
1460
+ const sessionId = sessionIdForEvent(event);
1461
+ return {
1462
+ event: eventWithSessionContext(event, sessionId),
1463
+ sessionId
1464
+ };
1465
+ };
1285
1466
  const eventToEntry = (event) => {
1286
- const payload = codec.encode(event);
1467
+ const resolved = eventWithResolvedSession(event);
1468
+ const payload = codec.encode(resolved.event);
1287
1469
  return {
1288
1470
  id: event.id,
1471
+ sessionId: resolved.sessionId,
1289
1472
  seq: event.seq,
1290
1473
  createdAt: event.time,
1291
1474
  level: event.level,
@@ -1296,6 +1479,30 @@ function indexedDbTransport(options = {}) {
1296
1479
  payload
1297
1480
  };
1298
1481
  };
1482
+ const clearLocalStorageSpill = (expectedValue) => {
1483
+ if (!spillOptions) return false;
1484
+ try {
1485
+ if (expectedValue !== void 0 && spillOptions.storage.getItem(spillOptions.key) !== expectedValue) return false;
1486
+ spillOptions.storage.removeItem(spillOptions.key);
1487
+ if (expectedValue === void 0 || localStorageSpillClearCandidate?.value === expectedValue) localStorageSpillClearCandidate = void 0;
1488
+ return true;
1489
+ } catch (error) {
1490
+ reportSpillError(error, "clear");
1491
+ return false;
1492
+ }
1493
+ };
1494
+ const rememberLocalStorageSpill = (value, events) => {
1495
+ localStorageSpillClearCandidate = {
1496
+ pendingIds: new Set(events.map((event) => event.id)),
1497
+ value
1498
+ };
1499
+ };
1500
+ const markLocalStorageSpillPersisted = (events) => {
1501
+ const candidate = localStorageSpillClearCandidate;
1502
+ if (!candidate || events.length === 0) return;
1503
+ for (const event of events) candidate.pendingIds.delete(event.id);
1504
+ if (candidate.pendingIds.size === 0) clearLocalStorageSpill(candidate.value);
1505
+ };
1299
1506
  const writeBatch = async (events) => {
1300
1507
  if (events.length === 0) return;
1301
1508
  const startedAt = nowMs$1();
@@ -1307,6 +1514,7 @@ function indexedDbTransport(options = {}) {
1307
1514
  });
1308
1515
  statsState.persisted += entries.length;
1309
1516
  (0, _loggerjs_core.incrementLoggerMetaCounter)("transport.indexeddb.persisted", entries.length);
1517
+ markLocalStorageSpillPersisted(events);
1310
1518
  await prune();
1311
1519
  statsState.flushes += 1;
1312
1520
  } catch (error) {
@@ -1316,6 +1524,124 @@ function indexedDbTransport(options = {}) {
1316
1524
  statsState.lastFlushDurationMs = nowMs$1() - startedAt;
1317
1525
  }
1318
1526
  };
1527
+ const stringifySpillPayload = (events) => (0, _loggerjs_core.safeJsonStringify)({
1528
+ createdAt: Date.now(),
1529
+ entries: events,
1530
+ schema: SPILL_SCHEMA
1531
+ }, { maxArrayLength: spillOptions?.maxEntries ?? DEFAULT_SPILL_MAX_ENTRIES });
1532
+ const spillStorageBudget = (currentValue) => {
1533
+ if (!spillOptions) return 0;
1534
+ const ownBytes = currentValue ? storageByteLength(spillOptions.key, currentValue) : 0;
1535
+ const originBytes = estimateLocalStorageBytes(spillOptions.storage);
1536
+ if (originBytes === void 0) return spillOptions.maxBytes;
1537
+ return Math.max(0, Math.min(spillOptions.maxBytes, spillOptions.maxOriginBytes - originBytes + ownBytes));
1538
+ };
1539
+ const pendingEventsForSpill = () => {
1540
+ const events = [...pendingFlushBatch ?? [], ...buffer];
1541
+ const seen = /* @__PURE__ */ new Set();
1542
+ const deduped = [];
1543
+ for (const event of events) {
1544
+ if (seen.has(event.id)) continue;
1545
+ seen.add(event.id);
1546
+ if (spillOptions?.minLevelValue !== void 0 && event.level < spillOptions.minLevelValue) continue;
1547
+ deduped.push(eventWithResolvedSession(event).event);
1548
+ }
1549
+ return deduped;
1550
+ };
1551
+ const trimSpillEventsToBudget = (events, budget) => {
1552
+ if (!spillOptions) return void 0;
1553
+ while (events.length > spillOptions.maxEntries) {
1554
+ const dropped = events.shift();
1555
+ if (dropped) dropSpillEvent(dropped, "max-entries");
1556
+ }
1557
+ while (events.length > 0) {
1558
+ const value = stringifySpillPayload(events);
1559
+ if (storageByteLength(spillOptions.key, value) <= budget) return value;
1560
+ const dropped = events.shift();
1561
+ if (dropped) dropSpillEvent(dropped, "max-bytes");
1562
+ }
1563
+ };
1564
+ const writeLocalStorageSpill = () => {
1565
+ if (!spillOptions) return;
1566
+ const pendingEvents = pendingEventsForSpill();
1567
+ if (pendingEvents.length === 0) return;
1568
+ try {
1569
+ const currentValue = spillOptions.storage.getItem(spillOptions.key);
1570
+ let events = pendingEvents;
1571
+ if (currentValue) {
1572
+ const merged = /* @__PURE__ */ new Map();
1573
+ try {
1574
+ for (const event of parseSpillPayload(currentValue) ?? []) merged.set(event.id, event);
1575
+ } catch {}
1576
+ for (const event of pendingEvents) merged.set(event.id, event);
1577
+ events = [...merged.values()];
1578
+ }
1579
+ const budget = spillStorageBudget(currentValue);
1580
+ const value = trimSpillEventsToBudget(events, budget);
1581
+ if (!value) return;
1582
+ try {
1583
+ spillOptions.storage.setItem(spillOptions.key, value);
1584
+ } catch (error) {
1585
+ if (!isQuotaExceededError(error)) throw error;
1586
+ while (events.length > 0) {
1587
+ const dropped = events.shift();
1588
+ if (dropped) dropSpillEvent(dropped, "quota");
1589
+ const retryValue = trimSpillEventsToBudget(events, budget);
1590
+ if (!retryValue) return;
1591
+ try {
1592
+ spillOptions.storage.setItem(spillOptions.key, retryValue);
1593
+ rememberLocalStorageSpill(retryValue, events);
1594
+ statsState.localStorageSpillWrites += 1;
1595
+ statsState.localStorageSpillEntries += events.length;
1596
+ return;
1597
+ } catch (retryError) {
1598
+ if (!isQuotaExceededError(retryError)) throw retryError;
1599
+ }
1600
+ }
1601
+ return;
1602
+ }
1603
+ rememberLocalStorageSpill(value, events);
1604
+ statsState.localStorageSpillWrites += 1;
1605
+ statsState.localStorageSpillEntries += events.length;
1606
+ } catch (error) {
1607
+ reportSpillError(error, "write");
1608
+ }
1609
+ };
1610
+ const drainLocalStorageSpill = async () => {
1611
+ if (!spillOptions) return;
1612
+ let raw;
1613
+ try {
1614
+ raw = spillOptions.storage.getItem(spillOptions.key);
1615
+ } catch (error) {
1616
+ reportSpillError(error, "read");
1617
+ return;
1618
+ }
1619
+ if (!raw) return;
1620
+ let events;
1621
+ try {
1622
+ events = parseSpillPayload(raw);
1623
+ } catch (error) {
1624
+ reportSpillError(error, "parse");
1625
+ clearLocalStorageSpill();
1626
+ return;
1627
+ }
1628
+ if (!events || events.length === 0) {
1629
+ clearLocalStorageSpill();
1630
+ return;
1631
+ }
1632
+ await writeBatch(events);
1633
+ clearLocalStorageSpill(raw);
1634
+ statsState.localStorageSpillDrains += 1;
1635
+ statsState.localStorageSpillDrainedEntries += events.length;
1636
+ };
1637
+ const ensureSpillDrained = async () => {
1638
+ const promise = spillDrainPromise;
1639
+ if (!promise) return;
1640
+ await promise;
1641
+ };
1642
+ if (spillOptions?.drainOnCreate) spillDrainPromise = drainLocalStorageSpill().catch((error) => reportSpillError(error, "drain")).finally(() => {
1643
+ spillDrainPromise = void 0;
1644
+ });
1319
1645
  const clearTimer = () => {
1320
1646
  if (timer) clearTimeout(timer);
1321
1647
  timer = void 0;
@@ -1323,7 +1649,13 @@ function indexedDbTransport(options = {}) {
1323
1649
  const flushPending = async () => {
1324
1650
  if (flushPromise) return flushPromise;
1325
1651
  clearTimer();
1326
- flushPromise = writeBatch(buffer.splice(0, buffer.length)).finally(() => {
1652
+ const batch = buffer.splice(0, buffer.length);
1653
+ pendingFlushBatch = batch;
1654
+ flushPromise = (async () => {
1655
+ await ensureSpillDrained();
1656
+ await writeBatch(batch);
1657
+ })().finally(() => {
1658
+ if (pendingFlushBatch === batch) pendingFlushBatch = void 0;
1327
1659
  flushPromise = void 0;
1328
1660
  if (buffer.length > 0 && !closed) schedule();
1329
1661
  });
@@ -1336,14 +1668,28 @@ function indexedDbTransport(options = {}) {
1336
1668
  }, flushIntervalMs);
1337
1669
  };
1338
1670
  const onPageHide = () => {
1339
- flushPending().catch((error) => reportInternalError(error, "pagehide-flush"));
1671
+ if (spillOptions?.spillOnPageHide) writeLocalStorageSpill();
1672
+ if (flushOnPageHide) flushPending().catch((error) => reportInternalError(error, "pagehide-flush"));
1340
1673
  };
1341
1674
  const onVisibilityChange = () => {
1342
- if (typeof document !== "undefined" && document.visibilityState === "hidden") flushPending().catch((error) => reportInternalError(error, "visibility-flush"));
1675
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1676
+ if (spillOptions?.spillOnPageHide) writeLocalStorageSpill();
1677
+ if (flushOnPageHide) flushPending().catch((error) => reportInternalError(error, "visibility-flush"));
1678
+ }
1679
+ };
1680
+ const addVisibilityChangeListener = () => {
1681
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
1682
+ document.addEventListener("visibilitychange", onVisibilityChange);
1683
+ return () => document.removeEventListener("visibilitychange", onVisibilityChange);
1684
+ }
1685
+ if (typeof globalThis.addEventListener !== "function") return void 0;
1686
+ globalThis.addEventListener("visibilitychange", onVisibilityChange);
1687
+ return () => globalThis.removeEventListener?.("visibilitychange", onVisibilityChange);
1343
1688
  };
1344
- if (flushOnPageHide) {
1689
+ let removeVisibilityChangeListener;
1690
+ if (flushOnPageHide || spillOptions?.spillOnPageHide) {
1345
1691
  globalThis.addEventListener?.("pagehide", onPageHide);
1346
- globalThis.addEventListener?.("visibilitychange", onVisibilityChange);
1692
+ removeVisibilityChangeListener = addVisibilityChangeListener();
1347
1693
  }
1348
1694
  return {
1349
1695
  name: options.name ?? "indexeddb",
@@ -1376,6 +1722,7 @@ function indexedDbTransport(options = {}) {
1376
1722
  async clear() {
1377
1723
  buffer.length = 0;
1378
1724
  clearTimer();
1725
+ clearLocalStorageSpill();
1379
1726
  await withStore("readwrite", (store) => requestToPromise(store.clear()));
1380
1727
  },
1381
1728
  async remove(ids) {
@@ -1399,6 +1746,10 @@ function indexedDbTransport(options = {}) {
1399
1746
  if (event) yield event;
1400
1747
  }
1401
1748
  },
1749
+ async sessions(sessionQueryOptions = {}) {
1750
+ await flushPending();
1751
+ return listSessions(sessionQueryOptions);
1752
+ },
1402
1753
  stats() {
1403
1754
  return snapshotStats();
1404
1755
  },
@@ -1406,12 +1757,12 @@ function indexedDbTransport(options = {}) {
1406
1757
  closed = true;
1407
1758
  clearTimer();
1408
1759
  globalThis.removeEventListener?.("pagehide", onPageHide);
1409
- globalThis.removeEventListener?.("visibilitychange", onVisibilityChange);
1410
- const flushed = flushPending();
1411
- const promise = dbPromise;
1412
- dbPromise = void 0;
1413
- return flushed.finally(() => {
1414
- promise?.then((db) => db.close());
1760
+ removeVisibilityChangeListener?.();
1761
+ removeVisibilityChangeListener = void 0;
1762
+ return flushPending().finally(() => {
1763
+ const promise = dbPromise;
1764
+ dbPromise = void 0;
1765
+ return promise?.then((db) => db.close());
1415
1766
  });
1416
1767
  }
1417
1768
  };
@@ -1438,12 +1789,14 @@ function isBrowserOnline(options) {
1438
1789
  function offlineFirstTransport(remote, options = {}) {
1439
1790
  const name = options.name ?? `offline-first(${remote.name ?? "transport"})`;
1440
1791
  const replayBatchSize = normalizeBatchSize(options.replayBatchSize);
1792
+ const queueOptions = options.queueOptions;
1441
1793
  const queue = options.queue ?? indexedDbTransport({
1442
1794
  dbName: "loggerjs-offline",
1443
1795
  flushIntervalMs: 0,
1444
1796
  name: `${name}:queue`,
1445
1797
  storeName: "offline-events",
1446
- ...options.queueOptions
1798
+ ...queueOptions,
1799
+ session: queueOptions?.session ?? false
1447
1800
  });
1448
1801
  const reliableRemote = (0, _loggerjs_core.retryTransport)(remote, {
1449
1802
  maxRetries: 2,
@@ -1570,6 +1923,7 @@ const ZIP_MAX_UINT32 = 4294967295;
1570
1923
  const LOCAL_FILE_HEADER_SIGNATURE = 67324752;
1571
1924
  const CENTRAL_DIRECTORY_SIGNATURE = 33639248;
1572
1925
  const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 101010256;
1926
+ const DEFAULT_SESSION_CONTEXT_KEY = "sessionId";
1573
1927
  let crc32Table;
1574
1928
  function getCrc32Table() {
1575
1929
  if (crc32Table) return crc32Table;
@@ -1709,6 +2063,56 @@ function normalizeMaxEvents(value) {
1709
2063
  if (!Number.isFinite(value)) return void 0;
1710
2064
  return Math.max(0, Math.floor(value));
1711
2065
  }
2066
+ function normalizeSessionExportOptions(value, logFileName) {
2067
+ if (!value) return void 0;
2068
+ const options = value === true ? {} : value;
2069
+ return {
2070
+ contextKey: options.contextKey ?? DEFAULT_SESSION_CONTEXT_KEY,
2071
+ directory: options.directory ?? "sessions",
2072
+ fallbackSessionId: options.fallbackSessionId ?? "unknown",
2073
+ logFileName: options.logFileName ?? logFileName
2074
+ };
2075
+ }
2076
+ function normalizeRecentExportOptions(value, format) {
2077
+ if (!value) return void 0;
2078
+ const options = value === true ? {} : value;
2079
+ return {
2080
+ logFileName: options.logFileName ?? (format === "json" ? "recent.json" : "recent.ndjson"),
2081
+ maxEvents: normalizeMaxEvents(options.maxEvents) ?? 100
2082
+ };
2083
+ }
2084
+ function logContentForLines(lines, format) {
2085
+ if (format === "json") return `[${lines.join(",")}]`;
2086
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
2087
+ }
2088
+ function eventTimeRange(items) {
2089
+ let from;
2090
+ let to;
2091
+ for (const item of items) {
2092
+ from = from === void 0 ? item.event.time : Math.min(from, item.event.time);
2093
+ to = to === void 0 ? item.event.time : Math.max(to, item.event.time);
2094
+ }
2095
+ return from === void 0 || to === void 0 ? void 0 : {
2096
+ from,
2097
+ to
2098
+ };
2099
+ }
2100
+ function sessionIdForEvent(event, options) {
2101
+ const value = event.context?.[options.contextKey];
2102
+ return typeof value === "string" && value.length > 0 ? value : options.fallbackSessionId;
2103
+ }
2104
+ function safeZipPathSegment(value) {
2105
+ const encoded = encodeURIComponent(value).replace(/%/g, "_");
2106
+ if (!encoded || encoded === "." || encoded === "..") return "_";
2107
+ return encoded;
2108
+ }
2109
+ function sortByEventTime(items) {
2110
+ return [...items].sort((left, right) => {
2111
+ if (left.event.time !== right.event.time) return left.event.time - right.event.time;
2112
+ if (left.event.seq !== right.event.seq) return left.event.seq - right.event.seq;
2113
+ return left.event.id < right.event.id ? -1 : left.event.id > right.event.id ? 1 : 0;
2114
+ });
2115
+ }
1712
2116
  function createLogZipBlob(files, options = {}) {
1713
2117
  const normalizedFiles = createNormalizedZipFiles(files, options);
1714
2118
  const parts = [];
@@ -1731,27 +2135,72 @@ async function exportLogsToZip(source, options = {}) {
1731
2135
  const createdAt = options.createdAt ?? Date.now();
1732
2136
  const logFileName = options.logFileName ?? (format === "json" ? "logs.json" : "logs.ndjson");
1733
2137
  const maxEvents = normalizeMaxEvents(options.maxEvents);
2138
+ const sessionExportOptions = normalizeSessionExportOptions(options.groupBySession, logFileName);
2139
+ const recentExportOptions = normalizeRecentExportOptions(options.includeRecent, format);
1734
2140
  const serialize = options.serializeEvent ?? ((event) => (0, _loggerjs_core.safeJsonStringify)(event, options.stringify ?? {}));
1735
- const lines = [];
2141
+ const exported = [];
1736
2142
  let skippedCount = 0;
1737
- let from;
1738
- let to;
1739
2143
  for await (const event of resolveSource(source, options.query)) {
1740
- if (maxEvents !== void 0 && lines.length >= maxEvents) break;
2144
+ if (maxEvents !== void 0 && exported.length >= maxEvents) break;
1741
2145
  const mapped = options.mapEvent ? options.mapEvent(event) : event;
1742
2146
  if (!mapped) {
1743
2147
  skippedCount += 1;
1744
2148
  continue;
1745
2149
  }
1746
- lines.push(serialize(mapped));
1747
- from = from === void 0 ? mapped.time : Math.min(from, mapped.time);
1748
- to = to === void 0 ? mapped.time : Math.max(to, mapped.time);
2150
+ exported.push({
2151
+ event: mapped,
2152
+ line: serialize(mapped)
2153
+ });
1749
2154
  }
1750
2155
  const files = [{
1751
2156
  name: logFileName,
1752
- content: format === "json" ? `[${lines.join(",")}]` : lines.length > 0 ? `${lines.join("\n")}\n` : "",
2157
+ content: logContentForLines(exported.map((item) => item.line), format),
1753
2158
  lastModified: createdAt
1754
2159
  }];
2160
+ const sessionManifests = [];
2161
+ if (sessionExportOptions) {
2162
+ const groups = /* @__PURE__ */ new Map();
2163
+ for (const item of exported) {
2164
+ const sessionId = sessionIdForEvent(item.event, sessionExportOptions);
2165
+ const group = groups.get(sessionId) ?? [];
2166
+ group.push(item);
2167
+ groups.set(sessionId, group);
2168
+ }
2169
+ const usedFileNames = new Set(files.map((file) => normalizeZipFileName(file.name)));
2170
+ const sortedGroups = [...groups.entries()];
2171
+ sortedGroups.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
2172
+ for (const [sessionId, group] of sortedGroups) {
2173
+ let fileName = normalizeZipFileName(`${sessionExportOptions.directory}/${safeZipPathSegment(sessionId)}/${sessionExportOptions.logFileName}`);
2174
+ let suffix = 1;
2175
+ while (usedFileNames.has(fileName)) {
2176
+ fileName = normalizeZipFileName(`${sessionExportOptions.directory}/${safeZipPathSegment(sessionId)}-${suffix}/${sessionExportOptions.logFileName}`);
2177
+ suffix += 1;
2178
+ }
2179
+ usedFileNames.add(fileName);
2180
+ const orderedGroup = sortByEventTime(group);
2181
+ files.push({
2182
+ name: fileName,
2183
+ content: logContentForLines(orderedGroup.map((item) => item.line), format),
2184
+ lastModified: createdAt
2185
+ });
2186
+ sessionManifests.push({
2187
+ logCount: orderedGroup.length,
2188
+ logFileName: fileName,
2189
+ sessionId,
2190
+ timeRange: eventTimeRange(orderedGroup)
2191
+ });
2192
+ }
2193
+ }
2194
+ let recentLogFileName;
2195
+ if (recentExportOptions) {
2196
+ const recentItems = recentExportOptions.maxEvents <= 0 ? [] : sortByEventTime(exported).slice(-recentExportOptions.maxEvents);
2197
+ recentLogFileName = normalizeZipFileName(recentExportOptions.logFileName);
2198
+ files.push({
2199
+ name: recentLogFileName,
2200
+ content: logContentForLines(recentItems.map((item) => item.line), format),
2201
+ lastModified: createdAt
2202
+ });
2203
+ }
1755
2204
  if (options.includeManifest ?? true) {
1756
2205
  const manifest = {
1757
2206
  schema: "loggerjs.log-export.v1",
@@ -1759,13 +2208,13 @@ async function exportLogsToZip(source, options = {}) {
1759
2208
  source: options.source,
1760
2209
  format,
1761
2210
  logFileName: normalizeZipFileName(logFileName),
1762
- logCount: lines.length,
2211
+ logCount: exported.length,
1763
2212
  skippedCount,
1764
2213
  query: options.query,
1765
- timeRange: from === void 0 || to === void 0 ? void 0 : {
1766
- from,
1767
- to
1768
- }
2214
+ recentLogFileName,
2215
+ sessionCount: sessionExportOptions ? sessionManifests.length : void 0,
2216
+ sessions: sessionExportOptions ? sessionManifests : void 0,
2217
+ timeRange: eventTimeRange(exported)
1769
2218
  };
1770
2219
  files.push({
1771
2220
  name: options.manifestFileName ?? "manifest.json",