ray-logger-core 0.0.4 → 0.0.6

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/index.js CHANGED
@@ -484,6 +484,27 @@ function noop$4() {
484
484
  // noop
485
485
  }
486
486
 
487
+ const BINARY_RESPONSE_BODY_PLACEHOLDER = 'arraybuffer';
488
+ const TEXTUAL_CONTENT_TYPE_PATTERNS = [
489
+ /^text\//i,
490
+ /^application\/json(?:\s*;|$)/i,
491
+ /^application\/[\w.+-]+\+json(?:\s*;|$)/i,
492
+ /^application\/javascript(?:\s*;|$)/i,
493
+ /^application\/xml(?:\s*;|$)/i,
494
+ /^application\/[\w.+-]+\+xml(?:\s*;|$)/i,
495
+ /^application\/x-www-form-urlencoded(?:\s*;|$)/i,
496
+ ];
497
+ function isTextualContentType(contentType) {
498
+ if (!contentType) {
499
+ return undefined;
500
+ }
501
+ const normalized = contentType.trim();
502
+ if (!normalized) {
503
+ return undefined;
504
+ }
505
+ return TEXTUAL_CONTENT_TYPE_PATTERNS.some((pattern) => pattern.test(normalized));
506
+ }
507
+
487
508
  function installFetchCollector(options) {
488
509
  const originalFetch = options.fetchRef ?? globalThis.fetch;
489
510
  if (typeof originalFetch !== 'function' || typeof globalThis.fetch !== 'function') {
@@ -619,6 +640,10 @@ async function readResponseBody(response, config) {
619
640
  if (config.enableRecordResponseBody === false) {
620
641
  return { bodySkippedReason: 'response-body-disabled' };
621
642
  }
643
+ const isTextual = isTextualContentType(response.headers.get('content-type'));
644
+ if (isTextual === false) {
645
+ return { body: BINARY_RESPONSE_BODY_PLACEHOLDER };
646
+ }
622
647
  try {
623
648
  return { body: await response.clone().text() };
624
649
  }
@@ -821,7 +846,11 @@ function readXhrResponseBody(xhr, config) {
821
846
  return { bodySkippedReason: 'response-body-disabled' };
822
847
  }
823
848
  if (xhr.responseType && xhr.responseType !== 'text') {
824
- return { bodySkippedReason: `response-body-${xhr.responseType}` };
849
+ return { body: BINARY_RESPONSE_BODY_PLACEHOLDER };
850
+ }
851
+ const isTextual = isTextualContentType(xhr.getResponseHeader('content-type'));
852
+ if (isTextual === false) {
853
+ return { body: BINARY_RESPONSE_BODY_PLACEHOLDER };
825
854
  }
826
855
  try {
827
856
  return { body: xhr.responseText };
@@ -1100,6 +1129,19 @@ function hasRrwebFullSnapshot(events) {
1100
1129
  function isRrwebFullSnapshot(event) {
1101
1130
  return event.type === 'rrweb' && event.payload.rrwebEvent.type === 2;
1102
1131
  }
1132
+ function filterRepeatedRrwebFullSnapshotsSince(events, sinceMs) {
1133
+ let hasWindowFullSnapshot = false;
1134
+ return events.filter((event) => {
1135
+ if (!isRrwebFullSnapshot(event) || event.timestamp < sinceMs) {
1136
+ return true;
1137
+ }
1138
+ if (hasWindowFullSnapshot) {
1139
+ return false;
1140
+ }
1141
+ hasWindowFullSnapshot = true;
1142
+ return true;
1143
+ });
1144
+ }
1103
1145
  function isRrwebFullSnapshotBefore(event, beforeMs) {
1104
1146
  return isRrwebFullSnapshot(event) && event.timestamp < beforeMs;
1105
1147
  }
@@ -1109,6 +1151,169 @@ const SESSION_META_STORE = 'sessionMeta';
1109
1151
  const EVENT_CHUNKS_STORE = 'eventChunks';
1110
1152
  const EVENT_CHUNKS_BY_NAMESPACE_MAX_TIMESTAMP_INDEX = 'byNamespaceMaxTimestamp';
1111
1153
  const EVENT_CHUNKS_BY_NAMESPACE_MIN_TIMESTAMP_INDEX = 'byNamespaceMinTimestamp';
1154
+ function normalizeNonNegativeNumber(value, fallback) {
1155
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
1156
+ }
1157
+ function normalizeOptionalNonNegativeNumber(value) {
1158
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
1159
+ }
1160
+ async function collectNamespaceUsage(db) {
1161
+ const meta = await collectSessionMeta(db);
1162
+ const chunks = await collectEventChunks(db);
1163
+ const byNamespace = new Map();
1164
+ for (const record of meta) {
1165
+ byNamespace.set(record.namespaceKey, {
1166
+ namespaceKey: record.namespaceKey,
1167
+ updatedAt: normalizeRecordUpdatedAt(record.updatedAt),
1168
+ chunkCount: 0,
1169
+ estimatedBytes: 0,
1170
+ });
1171
+ }
1172
+ for (const chunk of chunks) {
1173
+ const current = byNamespace.get(chunk.namespaceKey) ?? {
1174
+ namespaceKey: chunk.namespaceKey,
1175
+ updatedAt: 0,
1176
+ chunkCount: 0,
1177
+ estimatedBytes: 0,
1178
+ };
1179
+ current.chunkCount += 1;
1180
+ current.estimatedBytes += estimateChunkBytes(chunk);
1181
+ byNamespace.set(chunk.namespaceKey, current);
1182
+ }
1183
+ return [...byNamespace.values()];
1184
+ }
1185
+ function collectSessionMeta(db) {
1186
+ return collectCursorValues(db.transaction(SESSION_META_STORE, 'readonly').objectStore(SESSION_META_STORE).openCursor());
1187
+ }
1188
+ function collectEventChunks(db) {
1189
+ return collectCursorValues(db.transaction(EVENT_CHUNKS_STORE, 'readonly').objectStore(EVENT_CHUNKS_STORE).openCursor());
1190
+ }
1191
+ function collectCursorValues(request) {
1192
+ return new Promise((resolve, reject) => {
1193
+ const values = [];
1194
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1195
+ request.onsuccess = () => {
1196
+ const cursor = request.result;
1197
+ if (!cursor) {
1198
+ resolve(values);
1199
+ return;
1200
+ }
1201
+ values.push(cursor.value);
1202
+ cursor.continue();
1203
+ };
1204
+ });
1205
+ }
1206
+ async function deleteNamespace(db, namespaceKey) {
1207
+ const tx = db.transaction([SESSION_META_STORE, EVENT_CHUNKS_STORE], 'readwrite');
1208
+ tx.objectStore(SESSION_META_STORE).delete(namespaceKey);
1209
+ const range = IDBKeyRange.bound([namespaceKey, 0], [namespaceKey, Number.MAX_SAFE_INTEGER]);
1210
+ const store = tx.objectStore(EVENT_CHUNKS_STORE);
1211
+ const request = store.openCursor(range);
1212
+ request.onsuccess = () => {
1213
+ const cursor = request.result;
1214
+ if (!cursor) {
1215
+ return;
1216
+ }
1217
+ cursor.delete();
1218
+ cursor.continue();
1219
+ };
1220
+ await transactionDone(tx);
1221
+ }
1222
+ function normalizeRecordUpdatedAt(updatedAt) {
1223
+ return Number.isFinite(updatedAt) ? updatedAt : 0;
1224
+ }
1225
+ function estimateChunkBytes(chunk) {
1226
+ return JSON.stringify(chunk.events).length * 2;
1227
+ }
1228
+ function openDatabase(dbName) {
1229
+ return new Promise((resolve, reject) => {
1230
+ const request = globalThis.indexedDB.open(dbName, IDB_VERSION);
1231
+ request.onupgradeneeded = () => {
1232
+ const db = request.result;
1233
+ if (!db.objectStoreNames.contains(SESSION_META_STORE)) {
1234
+ db.createObjectStore(SESSION_META_STORE, { keyPath: 'namespaceKey' });
1235
+ }
1236
+ let eventChunksStore;
1237
+ if (!db.objectStoreNames.contains(EVENT_CHUNKS_STORE)) {
1238
+ eventChunksStore = db.createObjectStore(EVENT_CHUNKS_STORE, {
1239
+ keyPath: ['namespaceKey', 'chunkSeq'],
1240
+ });
1241
+ }
1242
+ else {
1243
+ eventChunksStore = request.transaction.objectStore(EVENT_CHUNKS_STORE);
1244
+ }
1245
+ if (!eventChunksStore.indexNames.contains(EVENT_CHUNKS_BY_NAMESPACE_MAX_TIMESTAMP_INDEX)) {
1246
+ eventChunksStore.createIndex(EVENT_CHUNKS_BY_NAMESPACE_MAX_TIMESTAMP_INDEX, [
1247
+ 'namespaceKey',
1248
+ 'maxTimestamp',
1249
+ ]);
1250
+ }
1251
+ if (!eventChunksStore.indexNames.contains(EVENT_CHUNKS_BY_NAMESPACE_MIN_TIMESTAMP_INDEX)) {
1252
+ eventChunksStore.createIndex(EVENT_CHUNKS_BY_NAMESPACE_MIN_TIMESTAMP_INDEX, [
1253
+ 'namespaceKey',
1254
+ 'minTimestamp',
1255
+ ]);
1256
+ }
1257
+ };
1258
+ request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB.'));
1259
+ request.onsuccess = () => resolve(request.result);
1260
+ });
1261
+ }
1262
+ function requestToPromise(request) {
1263
+ return new Promise((resolve, reject) => {
1264
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
1265
+ request.onsuccess = () => resolve(request.result);
1266
+ });
1267
+ }
1268
+ function transactionDone(transaction) {
1269
+ return new Promise((resolve, reject) => {
1270
+ transaction.oncomplete = () => resolve();
1271
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
1272
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
1273
+ });
1274
+ }
1275
+ function collectEventsCursor(request, options) {
1276
+ return new Promise((resolve, reject) => {
1277
+ const values = [];
1278
+ const sinceMs = options.sinceMs ?? Number.NEGATIVE_INFINITY;
1279
+ const untilMs = options.untilMs ?? Number.POSITIVE_INFINITY;
1280
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1281
+ request.onsuccess = () => {
1282
+ const cursor = request.result;
1283
+ if (!cursor) {
1284
+ resolve(values);
1285
+ return;
1286
+ }
1287
+ const chunk = cursor.value;
1288
+ for (const event of chunk.events) {
1289
+ if (event.timestamp >= sinceMs && event.timestamp <= untilMs) {
1290
+ values.push(event);
1291
+ }
1292
+ }
1293
+ cursor.continue();
1294
+ };
1295
+ });
1296
+ }
1297
+ function findLatestRrwebFullSnapshotCursor(request, beforeMs) {
1298
+ return new Promise((resolve, reject) => {
1299
+ request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1300
+ request.onsuccess = () => {
1301
+ const cursor = request.result;
1302
+ if (!cursor) {
1303
+ resolve(undefined);
1304
+ return;
1305
+ }
1306
+ const chunk = cursor.value;
1307
+ const snapshot = latestRrwebFullSnapshotBefore(chunk.events, beforeMs);
1308
+ if (snapshot) {
1309
+ resolve(snapshot);
1310
+ return;
1311
+ }
1312
+ cursor.continue();
1313
+ };
1314
+ });
1315
+ }
1316
+
1112
1317
  class IndexedDbEventStore {
1113
1318
  namespaceKey;
1114
1319
  dbName;
@@ -1276,168 +1481,6 @@ function emptyCleanupResult() {
1276
1481
  chunksAfter: 0,
1277
1482
  };
1278
1483
  }
1279
- function normalizeNonNegativeNumber(value, fallback) {
1280
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
1281
- }
1282
- function normalizeOptionalNonNegativeNumber(value) {
1283
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
1284
- }
1285
- async function collectNamespaceUsage(db) {
1286
- const meta = await collectSessionMeta(db);
1287
- const chunks = await collectEventChunks(db);
1288
- const byNamespace = new Map();
1289
- for (const record of meta) {
1290
- byNamespace.set(record.namespaceKey, {
1291
- namespaceKey: record.namespaceKey,
1292
- updatedAt: normalizeRecordUpdatedAt(record.updatedAt),
1293
- chunkCount: 0,
1294
- estimatedBytes: 0,
1295
- });
1296
- }
1297
- for (const chunk of chunks) {
1298
- const current = byNamespace.get(chunk.namespaceKey) ?? {
1299
- namespaceKey: chunk.namespaceKey,
1300
- updatedAt: 0,
1301
- chunkCount: 0,
1302
- estimatedBytes: 0,
1303
- };
1304
- current.chunkCount += 1;
1305
- current.estimatedBytes += estimateChunkBytes(chunk);
1306
- byNamespace.set(chunk.namespaceKey, current);
1307
- }
1308
- return [...byNamespace.values()];
1309
- }
1310
- function collectSessionMeta(db) {
1311
- return collectCursorValues(db.transaction(SESSION_META_STORE, 'readonly').objectStore(SESSION_META_STORE).openCursor());
1312
- }
1313
- function collectEventChunks(db) {
1314
- return collectCursorValues(db.transaction(EVENT_CHUNKS_STORE, 'readonly').objectStore(EVENT_CHUNKS_STORE).openCursor());
1315
- }
1316
- function collectCursorValues(request) {
1317
- return new Promise((resolve, reject) => {
1318
- const values = [];
1319
- request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1320
- request.onsuccess = () => {
1321
- const cursor = request.result;
1322
- if (!cursor) {
1323
- resolve(values);
1324
- return;
1325
- }
1326
- values.push(cursor.value);
1327
- cursor.continue();
1328
- };
1329
- });
1330
- }
1331
- async function deleteNamespace(db, namespaceKey) {
1332
- const tx = db.transaction([SESSION_META_STORE, EVENT_CHUNKS_STORE], 'readwrite');
1333
- tx.objectStore(SESSION_META_STORE).delete(namespaceKey);
1334
- const range = IDBKeyRange.bound([namespaceKey, 0], [namespaceKey, Number.MAX_SAFE_INTEGER]);
1335
- const store = tx.objectStore(EVENT_CHUNKS_STORE);
1336
- const request = store.openCursor(range);
1337
- request.onsuccess = () => {
1338
- const cursor = request.result;
1339
- if (!cursor) {
1340
- return;
1341
- }
1342
- cursor.delete();
1343
- cursor.continue();
1344
- };
1345
- await transactionDone(tx);
1346
- }
1347
- function normalizeRecordUpdatedAt(updatedAt) {
1348
- return Number.isFinite(updatedAt) ? updatedAt : 0;
1349
- }
1350
- function estimateChunkBytes(chunk) {
1351
- return JSON.stringify(chunk.events).length * 2;
1352
- }
1353
- function openDatabase(dbName) {
1354
- return new Promise((resolve, reject) => {
1355
- const request = globalThis.indexedDB.open(dbName, IDB_VERSION);
1356
- request.onupgradeneeded = () => {
1357
- const db = request.result;
1358
- if (!db.objectStoreNames.contains(SESSION_META_STORE)) {
1359
- db.createObjectStore(SESSION_META_STORE, { keyPath: 'namespaceKey' });
1360
- }
1361
- let eventChunksStore;
1362
- if (!db.objectStoreNames.contains(EVENT_CHUNKS_STORE)) {
1363
- eventChunksStore = db.createObjectStore(EVENT_CHUNKS_STORE, {
1364
- keyPath: ['namespaceKey', 'chunkSeq'],
1365
- });
1366
- }
1367
- else {
1368
- eventChunksStore = request.transaction.objectStore(EVENT_CHUNKS_STORE);
1369
- }
1370
- if (!eventChunksStore.indexNames.contains(EVENT_CHUNKS_BY_NAMESPACE_MAX_TIMESTAMP_INDEX)) {
1371
- eventChunksStore.createIndex(EVENT_CHUNKS_BY_NAMESPACE_MAX_TIMESTAMP_INDEX, [
1372
- 'namespaceKey',
1373
- 'maxTimestamp',
1374
- ]);
1375
- }
1376
- if (!eventChunksStore.indexNames.contains(EVENT_CHUNKS_BY_NAMESPACE_MIN_TIMESTAMP_INDEX)) {
1377
- eventChunksStore.createIndex(EVENT_CHUNKS_BY_NAMESPACE_MIN_TIMESTAMP_INDEX, [
1378
- 'namespaceKey',
1379
- 'minTimestamp',
1380
- ]);
1381
- }
1382
- };
1383
- request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB.'));
1384
- request.onsuccess = () => resolve(request.result);
1385
- });
1386
- }
1387
- function requestToPromise(request) {
1388
- return new Promise((resolve, reject) => {
1389
- request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
1390
- request.onsuccess = () => resolve(request.result);
1391
- });
1392
- }
1393
- function transactionDone(transaction) {
1394
- return new Promise((resolve, reject) => {
1395
- transaction.oncomplete = () => resolve();
1396
- transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
1397
- transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
1398
- });
1399
- }
1400
- function collectEventsCursor(request, options) {
1401
- return new Promise((resolve, reject) => {
1402
- const values = [];
1403
- const sinceMs = options.sinceMs ?? Number.NEGATIVE_INFINITY;
1404
- const untilMs = options.untilMs ?? Number.POSITIVE_INFINITY;
1405
- request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1406
- request.onsuccess = () => {
1407
- const cursor = request.result;
1408
- if (!cursor) {
1409
- resolve(values);
1410
- return;
1411
- }
1412
- const chunk = cursor.value;
1413
- for (const event of chunk.events) {
1414
- if (event.timestamp >= sinceMs && event.timestamp <= untilMs) {
1415
- values.push(event);
1416
- }
1417
- }
1418
- cursor.continue();
1419
- };
1420
- });
1421
- }
1422
- function findLatestRrwebFullSnapshotCursor(request, beforeMs) {
1423
- return new Promise((resolve, reject) => {
1424
- request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed.'));
1425
- request.onsuccess = () => {
1426
- const cursor = request.result;
1427
- if (!cursor) {
1428
- resolve(undefined);
1429
- return;
1430
- }
1431
- const chunk = cursor.value;
1432
- const snapshot = latestRrwebFullSnapshotBefore(chunk.events, beforeMs);
1433
- if (snapshot) {
1434
- resolve(snapshot);
1435
- return;
1436
- }
1437
- cursor.continue();
1438
- };
1439
- });
1440
- }
1441
1484
 
1442
1485
  function resolveSession(options) {
1443
1486
  const storage = getSessionStorage();
@@ -1581,7 +1624,7 @@ class WebLoggerCoreController {
1581
1624
  this.#estimatedBufferBytes += estimateEventBytes(recorded);
1582
1625
  if (this.#estimatedBufferBytes >= this.config.memoryFlushThresholdBytes) {
1583
1626
  void this.flushToIdb().catch(() => {
1584
- // Export still includes memory events if background persistence fails.
1627
+ // Flush errors are exposed through onFlushError and must not break host app execution.
1585
1628
  });
1586
1629
  }
1587
1630
  return recorded;
@@ -1589,7 +1632,7 @@ class WebLoggerCoreController {
1589
1632
  async flushToIdb() {
1590
1633
  this.#assertActive();
1591
1634
  if (!this.#store) {
1592
- return;
1635
+ return this.#notifyFlushSuccess({ status: 'skipped', reason: 'indexeddb-unavailable' });
1593
1636
  }
1594
1637
  await this.#waitUntilReady();
1595
1638
  if (this.#flushPromise) {
@@ -1597,13 +1640,18 @@ class WebLoggerCoreController {
1597
1640
  }
1598
1641
  const pendingEvents = this.#timeline.pending();
1599
1642
  if (pendingEvents.length === 0) {
1600
- return;
1643
+ return this.#notifyFlushSuccess({ status: 'skipped', reason: 'empty' });
1601
1644
  }
1602
1645
  this.#flushPromise = this.#store
1603
1646
  .appendChunk(pendingEvents)
1604
- .then(() => {
1647
+ .then((chunk) => {
1605
1648
  this.#timeline.clear(pendingEvents);
1606
1649
  this.#estimatedBufferBytes = estimateEventsBytes(this.#timeline.pending());
1650
+ return this.#notifyFlushSuccess(chunk ? createFlushResult(chunk) : { status: 'skipped', reason: 'empty' });
1651
+ })
1652
+ .catch((error) => {
1653
+ this.#notifyFlushError(error);
1654
+ throw error;
1607
1655
  })
1608
1656
  .finally(() => {
1609
1657
  this.#flushPromise = undefined;
@@ -1628,7 +1676,8 @@ class WebLoggerCoreController {
1628
1676
  const rrwebAnchor = hasRrwebFullSnapshot(windowEvents)
1629
1677
  ? undefined
1630
1678
  : await this.#readLatestRrwebFullSnapshotBefore(sinceMs);
1631
- const events = rrwebAnchor ? mergeEvents([rrwebAnchor], windowEvents) : windowEvents;
1679
+ const mergedEvents = rrwebAnchor ? mergeEvents([rrwebAnchor], windowEvents) : windowEvents;
1680
+ const events = filterRepeatedRrwebFullSnapshotsSince(mergedEvents, sinceMs);
1632
1681
  const includesRrwebBaseline = hasRrwebFullSnapshot(events);
1633
1682
  return this.#createExport('since', events, {
1634
1683
  sinceMs,
@@ -1677,6 +1726,23 @@ class WebLoggerCoreController {
1677
1726
  await this.#flushPromise;
1678
1727
  }
1679
1728
  }
1729
+ #notifyFlushSuccess(result) {
1730
+ try {
1731
+ this.config.onFlushSuccess?.(result);
1732
+ }
1733
+ catch {
1734
+ // Flush observer errors must not affect recording or export flow.
1735
+ }
1736
+ return result;
1737
+ }
1738
+ #notifyFlushError(error) {
1739
+ try {
1740
+ this.config.onFlushError?.(error);
1741
+ }
1742
+ catch {
1743
+ // Flush observer errors must not mask the original flush failure.
1744
+ }
1745
+ }
1680
1746
  async #waitUntilReady() {
1681
1747
  await this.#readyPromise;
1682
1748
  }
@@ -1741,6 +1807,19 @@ function estimateEventBytes(event) {
1741
1807
  function estimateEventsBytes(events) {
1742
1808
  return events.reduce((total, event) => total + estimateEventBytes(event), 0);
1743
1809
  }
1810
+ function createFlushResult(chunk) {
1811
+ return {
1812
+ status: 'flushed',
1813
+ namespaceKey: chunk.namespaceKey,
1814
+ chunkSeq: chunk.chunkSeq,
1815
+ eventCount: chunk.events.length,
1816
+ minSeq: chunk.minSeq,
1817
+ maxSeq: chunk.maxSeq,
1818
+ minTimestamp: chunk.minTimestamp,
1819
+ maxTimestamp: chunk.maxTimestamp,
1820
+ estimatedBytes: estimateEventsBytes(chunk.events),
1821
+ };
1822
+ }
1744
1823
  function mergeEvents(persistedEvents, memoryEvents) {
1745
1824
  const bySeq = new Map();
1746
1825
  for (const event of persistedEvents) {