@scoperat/tracker 0.2.0 → 0.3.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.
@@ -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,88 @@ 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
+ if (typeof navigator === "undefined" || !navigator.sendBeacon) {
1378
+ this.flush().catch(() => {
1379
+ });
1380
+ } else {
1381
+ this.flushSync();
1382
+ }
1383
+ }
1384
+ }
1385
+ readStoredUserId() {
1386
+ if (typeof localStorage === "undefined") return void 0;
1387
+ try {
1388
+ return localStorage.getItem(USER_ID_KEY) ?? void 0;
1389
+ } catch {
1390
+ return void 0;
1391
+ }
1392
+ }
1393
+ writeStoredUserId(userId) {
1394
+ if (typeof localStorage === "undefined") return;
1395
+ try {
1396
+ localStorage.setItem(USER_ID_KEY, userId);
1397
+ } catch {
1398
+ }
1399
+ }
1400
+ clearStoredUserId() {
1401
+ if (typeof localStorage === "undefined") return;
1402
+ try {
1403
+ localStorage.removeItem(USER_ID_KEY);
1404
+ } catch {
1405
+ }
1406
+ }
1331
1407
  clearTicketSession() {
1332
1408
  this.ticketSessionFetch = null;
1333
1409
  if (typeof localStorage === "undefined") return;
@@ -1392,25 +1468,96 @@ var TrackerImpl = class {
1392
1468
  })();
1393
1469
  return this.ticketSessionFetch;
1394
1470
  }
1395
- getOrCreateSessionId() {
1396
- if (typeof window === "undefined") return void 0;
1471
+ /**
1472
+ * Return the id of the current session, rotating it when the previous
1473
+ * session has expired through inactivity. On rotation it emits a
1474
+ * `$session_end` for the timed-out session (backdated to the last
1475
+ * recorded activity) and a `$session_start` for the new one. Called on
1476
+ * every tracked event so sessions advance with real user activity.
1477
+ */
1478
+ ensureSession(now) {
1479
+ if (typeof window === "undefined") return this.sessionId;
1397
1480
  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;
1481
+ const lastActivity = this.readLastActivity();
1482
+ const existingId = this.readStoredSession();
1483
+ const expired = lastActivity === void 0 || now - lastActivity > this.sessionTimeout;
1402
1484
  if (existingId && !expired) {
1403
- localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1485
+ this.sessionId = existingId;
1486
+ try {
1487
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1488
+ } catch {
1489
+ }
1404
1490
  return existingId;
1405
1491
  }
1492
+ if (existingId) {
1493
+ this.emitSessionEvent(
1494
+ SESSION_END_EVENT,
1495
+ existingId,
1496
+ "timeout",
1497
+ lastActivity !== void 0 ? new Date(lastActivity).toISOString() : void 0
1498
+ );
1499
+ }
1406
1500
  const newId = generateAnonId();
1407
- sessionStorage.setItem(SESSION_ID_KEY, newId);
1408
- localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1501
+ this.persistSession(newId, now);
1502
+ this.emitSessionEvent(
1503
+ SESSION_START_EVENT,
1504
+ newId,
1505
+ existingId ? "timeout" : "initial"
1506
+ );
1409
1507
  return newId;
1410
1508
  } catch {
1411
- return generateAnonId();
1509
+ return this.sessionId ?? generateAnonId();
1510
+ }
1511
+ }
1512
+ readStoredSession() {
1513
+ if (this.sessionId) return this.sessionId;
1514
+ if (typeof sessionStorage === "undefined") return void 0;
1515
+ try {
1516
+ return sessionStorage.getItem(SESSION_ID_KEY) ?? void 0;
1517
+ } catch {
1518
+ return void 0;
1519
+ }
1520
+ }
1521
+ readLastActivity() {
1522
+ if (typeof localStorage === "undefined") return void 0;
1523
+ try {
1524
+ const raw = localStorage.getItem(LAST_ACTIVITY_KEY);
1525
+ return raw ? Number(raw) : void 0;
1526
+ } catch {
1527
+ return void 0;
1528
+ }
1529
+ }
1530
+ persistSession(id, now) {
1531
+ this.sessionId = id;
1532
+ if (typeof window === "undefined") return;
1533
+ try {
1534
+ sessionStorage.setItem(SESSION_ID_KEY, id);
1535
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(now));
1536
+ } catch {
1412
1537
  }
1413
1538
  }
1539
+ /**
1540
+ * Enqueue a session lifecycle event already stamped with its session id
1541
+ * (so it never re-enters {@link ensureSession} and risks recursion).
1542
+ */
1543
+ emitSessionEvent(event, sessionId, reason, occurredAt) {
1544
+ if (!this.writeKey) return;
1545
+ const traitsContext = Object.keys(this.traits).length > 0 ? { traits: this.traits } : {};
1546
+ this.buffer.push({
1547
+ event,
1548
+ properties: { reason },
1549
+ occurred_at: occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1550
+ source: "browser",
1551
+ org_id: this.orgId,
1552
+ user_id: this.userId,
1553
+ anonymous_id: this.anonymousId,
1554
+ session_id: sessionId,
1555
+ context: {
1556
+ ...getBrowserContext(),
1557
+ ...traitsContext
1558
+ }
1559
+ });
1560
+ }
1414
1561
  clearSession() {
1415
1562
  if (typeof window === "undefined") return;
1416
1563
  try {
@@ -1420,7 +1567,7 @@ var TrackerImpl = class {
1420
1567
  }
1421
1568
  }
1422
1569
  getSessionId() {
1423
- return this.getOrCreateSessionId();
1570
+ return this.ensureSession(Date.now());
1424
1571
  }
1425
1572
  /** @internal Used by the widget to read tracker config. */
1426
1573
  _getEndpoint() {
@@ -1474,7 +1621,7 @@ var TrackerImpl = class {
1474
1621
  org_id: evt.org_id ?? this.orgId,
1475
1622
  user_id: evt.user_id ?? this.userId,
1476
1623
  anonymous_id: evt.anonymous_id ?? this.anonymousId,
1477
- session_id: evt.session_id ?? this.getOrCreateSessionId(),
1624
+ session_id: evt.session_id ?? this.ensureSession(Date.now()),
1478
1625
  context: {
1479
1626
  ...getBrowserContext(),
1480
1627
  ...traitsContext,