@loggerjs/browser 0.5.3 → 0.5.4

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/README.md CHANGED
@@ -71,6 +71,37 @@ Logs batch over HTTP, queue while offline, replay with backoff when the network
71
71
  - `exportLogsToZip()` + `downloadBlob()` — export a persisted store as a zip containing `logs.ndjson` or `logs.json`, `manifest.json`, optional per-session files, and optional `recent.ndjson` or `recent.json`.
72
72
  - Call the `indexedDbTransport()` instance's `stats()` to read flush, prune, query, drop, and buffer-depth counters.
73
73
 
74
+ For a support-log store that survives reloads and exports per page session, the
75
+ short setup is:
76
+
77
+ ```ts
78
+ import { createLogger } from "@loggerjs/core";
79
+ import { indexedDbTransport } from "@loggerjs/browser/transport-indexeddb";
80
+ import { downloadBlob, exportLogsToZip } from "@loggerjs/browser/export-zip";
81
+ import { privacyGuardProcessor, redactProcessor } from "@loggerjs/processors";
82
+
83
+ const supportStore = indexedDbTransport({
84
+ dbName: "my-app-support-logs",
85
+ localStorageSpill: { namespace: "my-app-support-logs" },
86
+ });
87
+
88
+ export const logger = createLogger({
89
+ category: ["web"],
90
+ processors: [redactProcessor(), privacyGuardProcessor()],
91
+ transports: [supportStore],
92
+ });
93
+
94
+ export async function downloadSupportLogs() {
95
+ await logger.flush();
96
+ const zip = await exportLogsToZip(supportStore, {
97
+ groupBySession: true,
98
+ includeRecent: true,
99
+ query: { order: "asc" },
100
+ });
101
+ downloadBlob(zip, "support-logs.zip");
102
+ }
103
+ ```
104
+
74
105
  `indexedDbTransport()` assigns a page-session id by default for browser support
75
106
  stores. Use `query({ sessionId })`, `sessions()`, and
76
107
  `exportLogsToZip(..., { groupBySession: true, includeRecent: true })` to build
package/dist/index.cjs CHANGED
@@ -898,10 +898,12 @@ const DEFAULT_SESSION_CONTEXT_KEY$1 = "sessionId";
898
898
  const DEFAULT_SPILL_MAX_ENTRIES = 200;
899
899
  const DEFAULT_SPILL_MAX_BYTES = 512 * 1024;
900
900
  const DEFAULT_SPILL_MAX_ORIGIN_BYTES = 5 * 1024 * 1024;
901
- const DB_VERSION = 3;
901
+ const DB_VERSION = 4;
902
902
  const ORDER_INDEX_NAME = "createdAtSeq";
903
903
  const SESSION_INDEX_NAME = "sessionId";
904
904
  const SESSION_ORDER_INDEX_NAME = "sessionIdCreatedAtSeq";
905
+ const SESSION_STORE_SUFFIX = "__sessions";
906
+ const SESSION_LAST_SEEN_INDEX_NAME = "lastSeenSessionId";
905
907
  const SPILL_SCHEMA = "loggerjs.indexeddb-spill.v1";
906
908
  function normalizePositiveInteger(value, fallback) {
907
909
  if (value === void 0 || !Number.isFinite(value)) return fallback;
@@ -1026,6 +1028,9 @@ function indexExists(store, name) {
1026
1028
  function ensureIndex(store, name, keyPath) {
1027
1029
  if (!indexExists(store, name)) store.createIndex(name, keyPath);
1028
1030
  }
1031
+ function sessionStoreNameFor(storeName) {
1032
+ return `${storeName}${SESSION_STORE_SUFFIX}`;
1033
+ }
1029
1034
  function getStoreIndex(store, name) {
1030
1035
  const maybeStore = store;
1031
1036
  if (typeof maybeStore.index !== "function" || !indexExists(store, name)) return void 0;
@@ -1055,6 +1060,44 @@ function shouldKeepEntry(entry, options) {
1055
1060
  if (options.type !== void 0 && entry.type !== options.type) return false;
1056
1061
  return true;
1057
1062
  }
1063
+ function addSessionSummary(sessions, entry) {
1064
+ if (!entry.sessionId) return;
1065
+ const current = sessions.get(entry.sessionId);
1066
+ if (current) {
1067
+ current.firstSeen = Math.min(current.firstSeen, entry.createdAt);
1068
+ current.lastSeen = Math.max(current.lastSeen, entry.createdAt);
1069
+ current.count += 1;
1070
+ current.byteLength += entry.byteLength;
1071
+ return;
1072
+ }
1073
+ sessions.set(entry.sessionId, {
1074
+ byteLength: entry.byteLength,
1075
+ count: 1,
1076
+ firstSeen: entry.createdAt,
1077
+ lastSeen: entry.createdAt,
1078
+ sessionId: entry.sessionId
1079
+ });
1080
+ }
1081
+ function mergeSessionSummary(current, incoming) {
1082
+ if (!current) return incoming;
1083
+ return {
1084
+ byteLength: current.byteLength + incoming.byteLength,
1085
+ count: current.count + incoming.count,
1086
+ firstSeen: Math.min(current.firstSeen, incoming.firstSeen),
1087
+ lastSeen: Math.max(current.lastSeen, incoming.lastSeen),
1088
+ sessionId: incoming.sessionId
1089
+ };
1090
+ }
1091
+ function orderSessions(sessions, order = "desc") {
1092
+ const sorted = [...sessions].sort((left, right) => {
1093
+ if (left.lastSeen !== right.lastSeen) return left.lastSeen - right.lastSeen;
1094
+ return left.sessionId < right.sessionId ? -1 : left.sessionId > right.sessionId ? 1 : 0;
1095
+ });
1096
+ if (order === "asc") return sorted;
1097
+ const reversed = [];
1098
+ for (let index = sorted.length - 1; index >= 0; index -= 1) reversed.push(sorted[index]);
1099
+ return reversed;
1100
+ }
1058
1101
  function orderRangeForQuery(options) {
1059
1102
  if (typeof IDBKeyRange === "undefined") return void 0;
1060
1103
  if (options.sessionId !== void 0) {
@@ -1086,6 +1129,33 @@ function orderRangeForQuery(options) {
1086
1129
  if (lower) return IDBKeyRange.lowerBound(lower);
1087
1130
  if (upper) return IDBKeyRange.upperBound(upper);
1088
1131
  }
1132
+ function orderRangeForSessionId(sessionId) {
1133
+ if (typeof IDBKeyRange === "undefined") return void 0;
1134
+ return IDBKeyRange.bound([
1135
+ sessionId,
1136
+ Number.MIN_SAFE_INTEGER,
1137
+ Number.MIN_SAFE_INTEGER,
1138
+ ""
1139
+ ], [
1140
+ sessionId,
1141
+ Number.MAX_SAFE_INTEGER,
1142
+ Number.MAX_SAFE_INTEGER,
1143
+ "￿"
1144
+ ]);
1145
+ }
1146
+ function migrateSessionSummaries(logStore, sessionStore) {
1147
+ const summaries = /* @__PURE__ */ new Map();
1148
+ const request = logStore.openCursor();
1149
+ request.addEventListener("success", () => {
1150
+ const cursor = request.result;
1151
+ if (!cursor) {
1152
+ for (const summary of summaries.values()) sessionStore.put(summary);
1153
+ return;
1154
+ }
1155
+ addSessionSummary(summaries, cursor.value);
1156
+ cursor.continue();
1157
+ });
1158
+ }
1089
1159
  function iterateCursor(request, visit) {
1090
1160
  return new Promise((resolve, reject) => {
1091
1161
  request.addEventListener("success", () => {
@@ -1110,6 +1180,7 @@ function iterateCursor(request, visit) {
1110
1180
  function indexedDbTransport(options = {}) {
1111
1181
  const dbName = options.dbName ?? DEFAULT_DB_NAME;
1112
1182
  const storeName = options.storeName ?? DEFAULT_STORE_NAME;
1183
+ const sessionStoreName = sessionStoreNameFor(storeName);
1113
1184
  const codec = options.codec ?? (0, _loggerjs_core.safeJsonCodec)();
1114
1185
  const batchSize = normalizePositiveInteger(options.batchSize, 100);
1115
1186
  const flushIntervalMs = normalizePositiveInteger(options.flushIntervalMs, 1e3);
@@ -1128,6 +1199,7 @@ function indexedDbTransport(options = {}) {
1128
1199
  let indexedDBFactoryPromise;
1129
1200
  let timer;
1130
1201
  let flushPromise;
1202
+ let clearPromise;
1131
1203
  let pendingFlushBatch;
1132
1204
  let spillDrainPromise;
1133
1205
  let localStorageSpillClearCandidate;
@@ -1238,7 +1310,7 @@ function indexedDbTransport(options = {}) {
1238
1310
  if (!idb) throw new Error("IndexedDB is not available for indexedDbTransport");
1239
1311
  return new Promise((resolve, reject) => {
1240
1312
  const request = idb.open(dbName, DB_VERSION);
1241
- request.addEventListener("upgradeneeded", () => {
1313
+ request.addEventListener("upgradeneeded", (event) => {
1242
1314
  const db = request.result;
1243
1315
  const store = db.objectStoreNames.contains(storeName) ? request.transaction?.objectStore(storeName) : db.createObjectStore(storeName, { keyPath: "id" });
1244
1316
  if (!store) return;
@@ -1258,6 +1330,10 @@ function indexedDbTransport(options = {}) {
1258
1330
  "seq",
1259
1331
  "id"
1260
1332
  ]);
1333
+ const sessionStore = db.objectStoreNames.contains(sessionStoreName) ? request.transaction?.objectStore(sessionStoreName) : db.createObjectStore(sessionStoreName, { keyPath: "sessionId" });
1334
+ if (!sessionStore) return;
1335
+ ensureIndex(sessionStore, SESSION_LAST_SEEN_INDEX_NAME, ["lastSeen", "sessionId"]);
1336
+ if (event.oldVersion < 4) migrateSessionSummaries(store, sessionStore);
1261
1337
  });
1262
1338
  request.addEventListener("success", () => {
1263
1339
  statsState.databaseOpenCount += 1;
@@ -1268,13 +1344,13 @@ function indexedDbTransport(options = {}) {
1268
1344
  });
1269
1345
  return dbPromise;
1270
1346
  };
1271
- const createTransaction = (db, mode) => {
1272
- if (mode !== "readwrite" || !transactionOptions) return db.transaction(storeName, mode);
1347
+ const createTransaction = (db, mode, stores = storeName) => {
1348
+ if (mode !== "readwrite" || !transactionOptions) return db.transaction(stores, mode);
1273
1349
  try {
1274
- return db.transaction(storeName, mode, transactionOptions);
1350
+ return db.transaction(stores, mode, transactionOptions);
1275
1351
  } catch {
1276
1352
  statsState.transactionOptionFallbacks += 1;
1277
- return db.transaction(storeName, mode);
1353
+ return db.transaction(stores, mode);
1278
1354
  }
1279
1355
  };
1280
1356
  const withStore = async (mode, run) => {
@@ -1291,6 +1367,57 @@ function indexedDbTransport(options = {}) {
1291
1367
  throw error;
1292
1368
  }
1293
1369
  };
1370
+ const withLogAndSessionStores = async (mode, run) => {
1371
+ const tx = createTransaction(await openDb(), mode, [storeName, sessionStoreName]);
1372
+ const settled = transactionToPromise(tx);
1373
+ try {
1374
+ const result = await run(tx.objectStore(storeName), tx.objectStore(sessionStoreName), tx);
1375
+ await settled;
1376
+ return result;
1377
+ } catch (error) {
1378
+ if (tx.error === null) try {
1379
+ tx.abort();
1380
+ } catch {}
1381
+ throw error;
1382
+ }
1383
+ };
1384
+ const withSessionStore = async (mode, run) => {
1385
+ const tx = createTransaction(await openDb(), mode, sessionStoreName);
1386
+ const settled = transactionToPromise(tx);
1387
+ try {
1388
+ const result = await run(tx.objectStore(sessionStoreName), tx);
1389
+ await settled;
1390
+ return result;
1391
+ } catch (error) {
1392
+ if (tx.error === null) try {
1393
+ tx.abort();
1394
+ } catch {}
1395
+ throw error;
1396
+ }
1397
+ };
1398
+ const mergeSessionSummaries = async (sessionStore, entries) => {
1399
+ const summaries = /* @__PURE__ */ new Map();
1400
+ for (const entry of entries) addSessionSummary(summaries, entry);
1401
+ for (const summary of summaries.values()) {
1402
+ const current = await requestToPromise(sessionStore.get(summary.sessionId));
1403
+ await requestToPromise(sessionStore.put(mergeSessionSummary(current, summary)));
1404
+ }
1405
+ };
1406
+ const rebuildSessionSummaries = async (store, sessionStore, sessionIds) => {
1407
+ if (sessionIds.size === 0) return;
1408
+ const index = getStoreIndex(store, SESSION_ORDER_INDEX_NAME);
1409
+ for (const sessionId of sessionIds) {
1410
+ const summaries = /* @__PURE__ */ new Map();
1411
+ const range = orderRangeForSessionId(sessionId);
1412
+ await iterateCursor(index && range ? index.openCursor(range) : store.openCursor(), (cursor) => {
1413
+ const entry = cursor.value;
1414
+ if (entry.sessionId === sessionId) addSessionSummary(summaries, entry);
1415
+ });
1416
+ const summary = summaries.get(sessionId);
1417
+ if (summary) await requestToPromise(sessionStore.put(summary));
1418
+ else await requestToPromise(sessionStore.delete(sessionId));
1419
+ }
1420
+ };
1294
1421
  const getEntries = async () => orderEntries(await withStore("readonly", (store) => requestToPromise(store.getAll())));
1295
1422
  const countEntries = async () => withStore("readonly", (store) => {
1296
1423
  const maybeStore = store;
@@ -1299,25 +1426,31 @@ function indexedDbTransport(options = {}) {
1299
1426
  });
1300
1427
  const deleteEntries = async (entries, reason) => {
1301
1428
  if (entries.length === 0) return;
1302
- await withStore("readwrite", async (store) => {
1429
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
1430
+ const affectedSessionIds = /* @__PURE__ */ new Set();
1303
1431
  for (const entry of entries) {
1304
1432
  await requestToPromise(store.delete(entry.id));
1433
+ if (entry.sessionId) affectedSessionIds.add(entry.sessionId);
1305
1434
  dropPersistedEntry(entry, reason);
1306
1435
  }
1436
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
1307
1437
  });
1308
1438
  };
1309
- const deleteByCursor = async (reason, openCursor, shouldDelete) => withStore("readwrite", async (store) => {
1439
+ const deleteByCursor = async (reason, openCursor, shouldDelete) => withLogAndSessionStores("readwrite", async (store, sessionStore) => {
1310
1440
  const request = openCursor(store);
1311
1441
  if (!request) return void 0;
1312
1442
  let deleted = 0;
1443
+ const affectedSessionIds = /* @__PURE__ */ new Set();
1313
1444
  await iterateCursor(request, (cursor) => {
1314
1445
  const entry = cursor.value;
1315
1446
  if (!shouldDelete(entry, deleted)) return false;
1316
1447
  cursor.delete();
1317
1448
  deleted += 1;
1449
+ if (entry.sessionId) affectedSessionIds.add(entry.sessionId);
1318
1450
  dropPersistedEntry(entry, reason);
1319
1451
  return true;
1320
1452
  });
1453
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
1321
1454
  return deleted;
1322
1455
  });
1323
1456
  const deleteExpiredByCursor = async (cutoff) => {
@@ -1362,29 +1495,18 @@ function indexedDbTransport(options = {}) {
1362
1495
  return entries;
1363
1496
  });
1364
1497
  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
1498
+ const limit = sessionQueryOptions.limit;
1499
+ if (limit !== void 0 && limit <= 0) return [];
1500
+ return withSessionStore("readonly", async (sessionStore) => {
1501
+ const sessions = [];
1502
+ const index = getStoreIndex(sessionStore, SESSION_LAST_SEEN_INDEX_NAME);
1503
+ if (!index) return orderSessions(await requestToPromise(sessionStore.getAll()), sessionQueryOptions.order).slice(0, limit);
1504
+ await iterateCursor(index.openCursor(void 0, sessionQueryOptions.order === "asc" ? "next" : "prev"), (cursor) => {
1505
+ sessions.push(cursor.value);
1506
+ return limit === void 0 || sessions.length < limit;
1380
1507
  });
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;
1508
+ return sessions;
1385
1509
  });
1386
- if (sessionQueryOptions.order !== "asc") ordered.reverse();
1387
- return ordered.slice(0, sessionQueryOptions.limit);
1388
1510
  };
1389
1511
  const pruneWithEntries = async () => {
1390
1512
  const now = Date.now();
@@ -1509,8 +1631,16 @@ function indexedDbTransport(options = {}) {
1509
1631
  statsState.lastFlushBatchSize = events.length;
1510
1632
  const entries = events.map(eventToEntry);
1511
1633
  try {
1512
- await withStore("readwrite", (store) => {
1513
- for (const entry of entries) store.put(entry);
1634
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
1635
+ const rebuildSessionIds = /* @__PURE__ */ new Set();
1636
+ for (const entry of entries) {
1637
+ const existing = await requestToPromise(store.get(entry.id));
1638
+ if (existing?.sessionId) rebuildSessionIds.add(existing.sessionId);
1639
+ if (existing && entry.sessionId) rebuildSessionIds.add(entry.sessionId);
1640
+ await requestToPromise(store.put(entry));
1641
+ }
1642
+ await rebuildSessionSummaries(store, sessionStore, rebuildSessionIds);
1643
+ await mergeSessionSummaries(sessionStore, entries.filter((entry) => !entry.sessionId || !rebuildSessionIds.has(entry.sessionId)));
1514
1644
  });
1515
1645
  statsState.persisted += entries.length;
1516
1646
  (0, _loggerjs_core.incrementLoggerMetaCounter)("transport.indexeddb.persisted", entries.length);
@@ -1651,7 +1781,9 @@ function indexedDbTransport(options = {}) {
1651
1781
  clearTimer();
1652
1782
  const batch = buffer.splice(0, buffer.length);
1653
1783
  pendingFlushBatch = batch;
1784
+ const clearBarrier = clearPromise;
1654
1785
  flushPromise = (async () => {
1786
+ await clearBarrier;
1655
1787
  await ensureSpillDrained();
1656
1788
  await writeBatch(batch);
1657
1789
  })().finally(() => {
@@ -1720,17 +1852,36 @@ function indexedDbTransport(options = {}) {
1720
1852
  return await countEntries() ?? (await getEntries()).length;
1721
1853
  },
1722
1854
  async clear() {
1855
+ if (clearPromise) return clearPromise;
1856
+ const inFlightFlush = flushPromise;
1857
+ const inFlightDrain = spillDrainPromise;
1723
1858
  buffer.length = 0;
1724
1859
  clearTimer();
1725
1860
  clearLocalStorageSpill();
1726
- await withStore("readwrite", (store) => requestToPromise(store.clear()));
1861
+ clearPromise = (async () => {
1862
+ await inFlightFlush;
1863
+ await inFlightDrain;
1864
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
1865
+ await requestToPromise(store.clear());
1866
+ await requestToPromise(sessionStore.clear());
1867
+ });
1868
+ })().finally(() => {
1869
+ clearPromise = void 0;
1870
+ });
1871
+ return clearPromise;
1727
1872
  },
1728
1873
  async remove(ids) {
1729
1874
  await flushPending();
1730
1875
  const items = typeof ids === "string" ? [ids] : ids;
1731
1876
  if (items.length === 0) return;
1732
- await withStore("readwrite", async (store) => {
1733
- for (const id of items) await requestToPromise(store.delete(id));
1877
+ await withLogAndSessionStores("readwrite", async (store, sessionStore) => {
1878
+ const affectedSessionIds = /* @__PURE__ */ new Set();
1879
+ for (const id of items) {
1880
+ const entry = await requestToPromise(store.get(id));
1881
+ if (entry?.sessionId) affectedSessionIds.add(entry.sessionId);
1882
+ await requestToPromise(store.delete(id));
1883
+ }
1884
+ await rebuildSessionSummaries(store, sessionStore, affectedSessionIds);
1734
1885
  });
1735
1886
  },
1736
1887
  async *query(queryOptions = {}) {