@scoperat/tracker 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1204,6 +1204,9 @@ var MAX_BATCH_SIZE = 100;
1204
1204
  var ANON_ID_KEY = "__scoperat_anon_id";
1205
1205
  var SESSION_ID_KEY = "__scoperat_session_id";
1206
1206
  var LAST_ACTIVITY_KEY = "__scoperat_last_activity";
1207
+ var USER_ID_KEY = "__scoperat_user_id";
1208
+ var SESSION_START_EVENT = "$session_start";
1209
+ var SESSION_END_EVENT = "$session_end";
1207
1210
  var TICKET_SESSION_KEY = "__scoperat_ticket_session";
1208
1211
  function generateAnonId() {
1209
1212
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
@@ -1245,6 +1248,7 @@ var TrackerImpl = class {
1245
1248
  traits = {};
1246
1249
  userHash;
1247
1250
  anonymousId;
1251
+ sessionId;
1248
1252
  sessionTimeout = DEFAULT_SESSION_TIMEOUT;
1249
1253
  ticketSessionFetch = null;
1250
1254
  _flagClient = null;
@@ -1307,6 +1311,7 @@ var TrackerImpl = class {
1307
1311
  return this._flagClient;
1308
1312
  }
1309
1313
  identify(userId, traits, options) {
1314
+ const previousUserId = this.userId ?? this.readStoredUserId();
1310
1315
  this.userId = userId;
1311
1316
  if (traits) {
1312
1317
  this.traits = { ...traits };
@@ -1317,17 +1322,83 @@ var TrackerImpl = class {
1317
1322
  if (!evt.user_id) evt.user_id = userId;
1318
1323
  if (!evt.org_id && this.orgId) evt.org_id = this.orgId;
1319
1324
  }
1325
+ if (userId !== previousUserId) {
1326
+ this.writeStoredUserId(userId);
1327
+ this.startSession("login");
1328
+ }
1320
1329
  this._flagClient?.refresh();
1321
1330
  }
1322
1331
  reset() {
1332
+ this.endSession("logout", true);
1323
1333
  this.userId = void 0;
1324
1334
  this.orgId = void 0;
1325
1335
  this.traits = {};
1326
1336
  this.userHash = void 0;
1327
1337
  this.anonymousId = getOrCreateAnonId();
1328
- this.clearSession();
1338
+ this.clearStoredUserId();
1329
1339
  this.clearTicketSession();
1330
1340
  }
1341
+ /**
1342
+ * Begin a new analytics session, ending the current one first if it
1343
+ * exists. Emits `$session_end` (for the old session) and
1344
+ * `$session_start` (for the new). Called automatically on login via
1345
+ * {@link identify}; also exposed for explicit control.
1346
+ */
1347
+ startSession(reason = "login") {
1348
+ if (typeof window === "undefined") return this.sessionId;
1349
+ const now = Date.now();
1350
+ const existingId = this.readStoredSession();
1351
+ if (existingId) {
1352
+ this.emitSessionEvent(SESSION_END_EVENT, existingId, reason);
1353
+ }
1354
+ const newId = generateAnonId();
1355
+ this.persistSession(newId, now);
1356
+ this.emitSessionEvent(SESSION_START_EVENT, newId, reason);
1357
+ return newId;
1358
+ }
1359
+ /**
1360
+ * End the current analytics session (if any), emitting `$session_end`,
1361
+ * and clear the stored session. The next tracked event will lazily
1362
+ * start a fresh session. Called automatically on logout via
1363
+ * {@link reset}; also exposed for explicit control.
1364
+ *
1365
+ * @param immediate - flush buffered events synchronously (via
1366
+ * `sendBeacon`) so the end event isn't lost if the page unloads.
1367
+ */
1368
+ endSession(reason = "logout", immediate = false) {
1369
+ if (typeof window === "undefined") return;
1370
+ const existingId = this.readStoredSession();
1371
+ if (existingId) {
1372
+ this.emitSessionEvent(SESSION_END_EVENT, existingId, reason);
1373
+ }
1374
+ this.sessionId = void 0;
1375
+ this.clearSession();
1376
+ if (immediate) {
1377
+ this.flushSync();
1378
+ }
1379
+ }
1380
+ readStoredUserId() {
1381
+ if (typeof localStorage === "undefined") return void 0;
1382
+ try {
1383
+ return localStorage.getItem(USER_ID_KEY) ?? void 0;
1384
+ } catch {
1385
+ return void 0;
1386
+ }
1387
+ }
1388
+ writeStoredUserId(userId) {
1389
+ if (typeof localStorage === "undefined") return;
1390
+ try {
1391
+ localStorage.setItem(USER_ID_KEY, userId);
1392
+ } catch {
1393
+ }
1394
+ }
1395
+ clearStoredUserId() {
1396
+ if (typeof localStorage === "undefined") return;
1397
+ try {
1398
+ localStorage.removeItem(USER_ID_KEY);
1399
+ } catch {
1400
+ }
1401
+ }
1331
1402
  clearTicketSession() {
1332
1403
  this.ticketSessionFetch = null;
1333
1404
  if (typeof localStorage === "undefined") return;
@@ -1392,25 +1463,96 @@ var TrackerImpl = class {
1392
1463
  })();
1393
1464
  return this.ticketSessionFetch;
1394
1465
  }
1395
- getOrCreateSessionId() {
1396
- if (typeof window === "undefined") return void 0;
1466
+ /**
1467
+ * Return the id of the current session, rotating it when the previous
1468
+ * session has expired through inactivity. On rotation it emits a
1469
+ * `$session_end` for the timed-out session (backdated to the last
1470
+ * recorded activity) and a `$session_start` for the new one. Called on
1471
+ * every tracked event so sessions advance with real user activity.
1472
+ */
1473
+ ensureSession(now) {
1474
+ if (typeof window === "undefined") return this.sessionId;
1397
1475
  try {
1398
- const now = Date.now();
1399
- const lastActivity = localStorage.getItem(LAST_ACTIVITY_KEY);
1400
- const existingId = sessionStorage.getItem(SESSION_ID_KEY);
1401
- const expired = !lastActivity || now - Number(lastActivity) > this.sessionTimeout;
1476
+ const lastActivity = this.readLastActivity();
1477
+ const existingId = this.readStoredSession();
1478
+ const expired = lastActivity === void 0 || now - lastActivity > this.sessionTimeout;
1402
1479
  if (existingId && !expired) {
1403
- localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1480
+ this.sessionId = existingId;
1481
+ try {
1482
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1483
+ } catch {
1484
+ }
1404
1485
  return existingId;
1405
1486
  }
1487
+ if (existingId) {
1488
+ this.emitSessionEvent(
1489
+ SESSION_END_EVENT,
1490
+ existingId,
1491
+ "timeout",
1492
+ lastActivity !== void 0 ? new Date(lastActivity).toISOString() : void 0
1493
+ );
1494
+ }
1406
1495
  const newId = generateAnonId();
1407
- sessionStorage.setItem(SESSION_ID_KEY, newId);
1408
- localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1496
+ this.persistSession(newId, now);
1497
+ this.emitSessionEvent(
1498
+ SESSION_START_EVENT,
1499
+ newId,
1500
+ existingId ? "timeout" : "initial"
1501
+ );
1409
1502
  return newId;
1410
1503
  } catch {
1411
- return generateAnonId();
1504
+ return this.sessionId ?? generateAnonId();
1505
+ }
1506
+ }
1507
+ readStoredSession() {
1508
+ if (this.sessionId) return this.sessionId;
1509
+ if (typeof sessionStorage === "undefined") return void 0;
1510
+ try {
1511
+ return sessionStorage.getItem(SESSION_ID_KEY) ?? void 0;
1512
+ } catch {
1513
+ return void 0;
1514
+ }
1515
+ }
1516
+ readLastActivity() {
1517
+ if (typeof localStorage === "undefined") return void 0;
1518
+ try {
1519
+ const raw = localStorage.getItem(LAST_ACTIVITY_KEY);
1520
+ return raw ? Number(raw) : void 0;
1521
+ } catch {
1522
+ return void 0;
1412
1523
  }
1413
1524
  }
1525
+ persistSession(id, now) {
1526
+ this.sessionId = id;
1527
+ if (typeof window === "undefined") return;
1528
+ try {
1529
+ sessionStorage.setItem(SESSION_ID_KEY, id);
1530
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1531
+ } catch {
1532
+ }
1533
+ }
1534
+ /**
1535
+ * Enqueue a session lifecycle event already stamped with its session id
1536
+ * (so it never re-enters {@link ensureSession} and risks recursion).
1537
+ */
1538
+ emitSessionEvent(event, sessionId, reason, occurredAt) {
1539
+ if (!this.writeKey) return;
1540
+ const traitsContext = Object.keys(this.traits).length > 0 ? { traits: this.traits } : {};
1541
+ this.buffer.push({
1542
+ event,
1543
+ properties: { reason },
1544
+ occurred_at: occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1545
+ source: "browser",
1546
+ org_id: this.orgId,
1547
+ user_id: this.userId,
1548
+ anonymous_id: this.anonymousId,
1549
+ session_id: sessionId,
1550
+ context: {
1551
+ ...getBrowserContext(),
1552
+ ...traitsContext
1553
+ }
1554
+ });
1555
+ }
1414
1556
  clearSession() {
1415
1557
  if (typeof window === "undefined") return;
1416
1558
  try {
@@ -1420,7 +1562,7 @@ var TrackerImpl = class {
1420
1562
  }
1421
1563
  }
1422
1564
  getSessionId() {
1423
- return this.getOrCreateSessionId();
1565
+ return this.ensureSession(Date.now());
1424
1566
  }
1425
1567
  /** @internal Used by the widget to read tracker config. */
1426
1568
  _getEndpoint() {
@@ -1474,7 +1616,7 @@ var TrackerImpl = class {
1474
1616
  org_id: evt.org_id ?? this.orgId,
1475
1617
  user_id: evt.user_id ?? this.userId,
1476
1618
  anonymous_id: evt.anonymous_id ?? this.anonymousId,
1477
- session_id: evt.session_id ?? this.getOrCreateSessionId(),
1619
+ session_id: evt.session_id ?? this.ensureSession(Date.now()),
1478
1620
  context: {
1479
1621
  ...getBrowserContext(),
1480
1622
  ...traitsContext,
@@ -1515,16 +1657,34 @@ var TrackerImpl = class {
1515
1657
  return { success: false, ingested: 0 };
1516
1658
  }
1517
1659
  }
1660
+ /**
1661
+ * Fire-and-forget flush that survives page unload.
1662
+ *
1663
+ * Uses `fetch` with `keepalive` rather than `navigator.sendBeacon`:
1664
+ * beacons are always dispatched with credentials, and a JSON beacon is
1665
+ * preflighted — a credentialed preflighted request fails the CORS check
1666
+ * against the wildcard (`Access-Control-Allow-Origin: *`) ingest policy,
1667
+ * so the browser silently drops the beacon before it is ever sent. A
1668
+ * keepalive fetch is non-credentialed by default and carries the normal
1669
+ * Bearer header, giving it the exact CORS profile of the regular
1670
+ * `flush()` path while still outliving the page.
1671
+ */
1518
1672
  flushSync() {
1519
1673
  if (!this.writeKey) return;
1520
- if (typeof navigator === "undefined" || !navigator.sendBeacon) return;
1674
+ if (typeof fetch === "undefined") return;
1521
1675
  if (this.buffer.length === 0) return;
1522
1676
  const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
1523
- const payload = JSON.stringify({ events: batch });
1524
- const url = `${this.endpoint}/api/ingest/track?key=${encodeURIComponent(this.writeKey)}`;
1525
1677
  try {
1526
- const blob = new Blob([payload], { type: "application/json" });
1527
- navigator.sendBeacon(url, blob);
1678
+ void fetch(`${this.endpoint}/api/ingest/track`, {
1679
+ method: "POST",
1680
+ keepalive: true,
1681
+ headers: {
1682
+ "Content-Type": "application/json",
1683
+ Authorization: `Bearer ${this.writeKey}`
1684
+ },
1685
+ body: JSON.stringify({ events: batch })
1686
+ }).catch(() => {
1687
+ });
1528
1688
  } catch {
1529
1689
  }
1530
1690
  }