@lunardinos/browser 0.2.5 → 0.2.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.
@@ -4467,11 +4467,14 @@ function storedSessionUsable(storedAt, now) {
4467
4467
  return false;
4468
4468
  return now - storedAt < SESSION_TIMEOUT_MS;
4469
4469
  }
4470
+ var KEYFRAME_INTERVAL_MS = 60 * 1000;
4471
+ function keyframeDue(lastActivityAt, lastTickAt, visible) {
4472
+ return visible && lastActivityAt > lastTickAt;
4473
+ }
4470
4474
 
4471
4475
  // src/recorder-core.ts
4472
4476
  var DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
4473
4477
  var SESSION_ACTIVITY_KEY = "__ld_sid_at";
4474
- var FULL_SNAPSHOT_INTERVAL_MS = 60 * 1000;
4475
4478
  async function gzipCompress(input) {
4476
4479
  try {
4477
4480
  if (typeof CompressionStream === "undefined")
@@ -4594,9 +4597,10 @@ function createLunarDinosRecorder(config) {
4594
4597
  let visitorId = null;
4595
4598
  let stopFn = null;
4596
4599
  let intervalId = null;
4597
- let checkoutIntervalId = null;
4600
+ let keyframeIntervalId = null;
4598
4601
  let recording2 = false;
4599
4602
  let lastActivityAt = 0;
4603
+ let activitySinceTouch = false;
4600
4604
  let rotating = false;
4601
4605
  let currentPage = {};
4602
4606
  let pendingClickContext = null;
@@ -4669,6 +4673,7 @@ function createLunarDinosRecorder(config) {
4669
4673
  }
4670
4674
  function markActivity() {
4671
4675
  lastActivityAt = Date.now();
4676
+ activitySinceTouch = true;
4672
4677
  if (typeof sessionStorage !== "undefined") {
4673
4678
  sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
4674
4679
  }
@@ -4683,7 +4688,10 @@ function createLunarDinosRecorder(config) {
4683
4688
  if (vid)
4684
4689
  payload.visitorId = vid;
4685
4690
  const json = JSON.stringify(payload);
4686
- persistence.touchSession();
4691
+ if (activitySinceTouch) {
4692
+ persistence.touchSession();
4693
+ activitySinceTouch = false;
4694
+ }
4687
4695
  if (options?.beacon) {
4688
4696
  sendViaBeacon(json);
4689
4697
  return;
@@ -4843,11 +4851,16 @@ function createLunarDinosRecorder(config) {
4843
4851
  }
4844
4852
  flush();
4845
4853
  intervalId = setInterval(flush, batchInterval);
4846
- checkoutIntervalId = setInterval(() => {
4854
+ let lastTickAt = Date.now();
4855
+ keyframeIntervalId = setInterval(() => {
4856
+ const due = keyframeDue(lastActivityAt, lastTickAt, document.visibilityState !== "hidden");
4857
+ lastTickAt = Date.now();
4858
+ if (!due)
4859
+ return;
4847
4860
  try {
4848
4861
  record_default.takeFullSnapshot(true);
4849
4862
  } catch {}
4850
- }, FULL_SNAPSHOT_INTERVAL_MS);
4863
+ }, KEYFRAME_INTERVAL_MS);
4851
4864
  return sessionId;
4852
4865
  }
4853
4866
  function stop() {
@@ -4867,9 +4880,9 @@ function createLunarDinosRecorder(config) {
4867
4880
  clearInterval(intervalId);
4868
4881
  intervalId = null;
4869
4882
  }
4870
- if (checkoutIntervalId) {
4871
- clearInterval(checkoutIntervalId);
4872
- checkoutIntervalId = null;
4883
+ if (keyframeIntervalId) {
4884
+ clearInterval(keyframeIntervalId);
4885
+ keyframeIntervalId = null;
4873
4886
  }
4874
4887
  stopFn?.();
4875
4888
  stopFn = null;
@@ -1 +1 @@
1
- {"version":3,"file":"recorder-core.d.ts","sourceRoot":"","sources":["../../src/recorder-core.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAShG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAuBpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuED,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAmjBzE"}
1
+ {"version":3,"file":"recorder-core.d.ts","sourceRoot":"","sources":["../../src/recorder-core.ts"],"names":[],"mappings":"AACA,OAAO,EAA8C,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAWhG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAOpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuED,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CA8jBzE"}
@@ -1,24 +1,9 @@
1
1
  import record, {} from "@lunardinos/rrweb-record";
2
2
  import { createPersistence } from "./persistence";
3
- import { isNavigation, isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
3
+ import { isNavigation, isUserActivity, KEYFRAME_INTERVAL_MS, keyframeDue, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
4
4
  const DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
5
5
  /** Timestamp of the last user activity, alongside the session id in sessionStorage. */
6
6
  const SESSION_ACTIVITY_KEY = "__ld_sid_at";
7
- /**
8
- * How often to force a full snapshot.
9
- *
10
- * These are the replay's keyframes: the player can only start rendering from a
11
- * snapshot, so seeking to an arbitrary point costs replaying every mutation
12
- * since the preceding one. That makes this interval the ceiling on seek latency
13
- * — at five minutes, jumping around a long recording meant fast-forwarding
14
- * through up to five minutes of DOM mutations per jump.
15
- *
16
- * The tradeoff is upload size: each snapshot is the whole DOM. Stylesheets and
17
- * images are content-addressed and deduplicated across the session, so the
18
- * marginal cost of an extra snapshot is mostly the node tree, which compresses
19
- * well — worth paying for a 5x cut in seek latency.
20
- */
21
- const FULL_SNAPSHOT_INTERVAL_MS = 60 * 1000;
22
7
  /** Compress a string via the CompressionStream API (gzip). Returns null if unavailable or on error. */
23
8
  async function gzipCompress(input) {
24
9
  try {
@@ -188,13 +173,15 @@ export function createLunarDinosRecorder(config) {
188
173
  let visitorId = null;
189
174
  let stopFn = null;
190
175
  let intervalId = null;
191
- // Periodic full-snapshot ("checkout") timer. Bounds segment size for long
192
- // single-page sessions: each snapshot becomes an independently-replayable
193
- // segment boundary, so the player never has to load one giant blob.
194
- let checkoutIntervalId = null;
176
+ // Periodic keyframe timer. Bounds segment size for long single-page
177
+ // sessions: each snapshot becomes an independently-replayable segment
178
+ // boundary, so the player never has to load one giant blob.
179
+ let keyframeIntervalId = null;
195
180
  let recording = false;
196
- /** Last user activity, for {@link SESSION_TIMEOUT_MS}. */
181
+ /** Last user activity, for {@link SESSION_TIMEOUT_MS} and {@link keyframeDue}. */
197
182
  let lastActivityAt = 0;
183
+ /** User activity since the session cookie was last refreshed. */
184
+ let activitySinceTouch = false;
198
185
  /** Set while rotating, since taking the new session's snapshot re-enters emit. */
199
186
  let rotating = false;
200
187
  /** The page the app last reported, so a rotated session opens on it. */
@@ -308,6 +295,7 @@ export function createLunarDinosRecorder(config) {
308
295
  /** Persist the activity clock so a reload in the same tab can honour it. */
309
296
  function markActivity() {
310
297
  lastActivityAt = Date.now();
298
+ activitySinceTouch = true;
311
299
  if (typeof sessionStorage !== "undefined") {
312
300
  sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
313
301
  }
@@ -322,8 +310,12 @@ export function createLunarDinosRecorder(config) {
322
310
  if (vid)
323
311
  payload.visitorId = vid;
324
312
  const json = JSON.stringify(payload);
325
- // Extend rolling session cookie on each flush
326
- persistence.touchSession();
313
+ // Extend the rolling session cookie, but only for something a person did:
314
+ // keyframes and DOM churn flush too, and would keep an idle session alive.
315
+ if (activitySinceTouch) {
316
+ persistence.touchSession();
317
+ activitySinceTouch = false;
318
+ }
327
319
  // During page unload (beforeunload / MPA navigation), use sendBeacon
328
320
  // for guaranteed delivery. No async compression — page may tear down.
329
321
  if (options?.beacon) {
@@ -538,16 +530,21 @@ export function createLunarDinosRecorder(config) {
538
530
  // ~64 KB limit) is delivered via regular fetch before the user navigates away.
539
531
  flush();
540
532
  intervalId = setInterval(flush, batchInterval);
541
- // Take a periodic full snapshot so long single-page sessions are split into
542
- // bounded, independently-replayable segments (matches PostHog's 5-min cadence).
543
- checkoutIntervalId = setInterval(() => {
533
+ // Take a periodic keyframe so long single-page sessions are split into
534
+ // bounded, independently-replayable segments — only while someone is there.
535
+ let lastTickAt = Date.now();
536
+ keyframeIntervalId = setInterval(() => {
537
+ const due = keyframeDue(lastActivityAt, lastTickAt, document.visibilityState !== "hidden");
538
+ lastTickAt = Date.now();
539
+ if (!due)
540
+ return;
544
541
  try {
545
542
  record.takeFullSnapshot(true);
546
543
  }
547
544
  catch {
548
545
  /* not recording / mid-teardown */
549
546
  }
550
- }, FULL_SNAPSHOT_INTERVAL_MS);
547
+ }, KEYFRAME_INTERVAL_MS);
551
548
  return sessionId;
552
549
  }
553
550
  function stop() {
@@ -570,9 +567,9 @@ export function createLunarDinosRecorder(config) {
570
567
  clearInterval(intervalId);
571
568
  intervalId = null;
572
569
  }
573
- if (checkoutIntervalId) {
574
- clearInterval(checkoutIntervalId);
575
- checkoutIntervalId = null;
570
+ if (keyframeIntervalId) {
571
+ clearInterval(keyframeIntervalId);
572
+ keyframeIntervalId = null;
576
573
  }
577
574
  stopFn?.();
578
575
  stopFn = null;
@@ -9,7 +9,8 @@ import { buildRecorderBundle } from "./testing/recorder-bundle";
9
9
  * A navigation is a Custom "navigation" event followed by Meta + FullSnapshot;
10
10
  * a keyframe is Meta + FullSnapshot alone. Keyframes are the recorder's own
11
11
  * doing, so they must not keep an idle session alive, and a session rotated
12
- * after inactivity must open on a navigation so it has a landing page.
12
+ * after inactivity must open on a navigation so it has a landing page. They are
13
+ * only taken while someone is there: the tab is visible and the user active.
13
14
  */
14
15
  const PAGE = `<!DOCTYPE html><html lang="de"><head><title>App</title></head><body>
15
16
  <div id="app"><h1>Shifts</h1><button id="b">Apply</button></div>
@@ -24,6 +25,20 @@ afterAll(() => browser.close());
24
25
  async function openPage() {
25
26
  const uploads = [];
26
27
  const page = await browser.newPage();
28
+ // Count writes of the session cookie, which carries the rolling 30-minute expiry.
29
+ await page.addInitScript(() => {
30
+ const desc = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");
31
+ const g = globalThis;
32
+ g.__sessionCookieWrites = 0;
33
+ Object.defineProperty(document, "cookie", {
34
+ get: () => desc.get.call(document),
35
+ set: (v) => {
36
+ if (v.startsWith("__ld_sid="))
37
+ g.__sessionCookieWrites++;
38
+ desc.set.call(document, v);
39
+ },
40
+ });
41
+ });
27
42
  await page.route("https://app.test/**", (route) => {
28
43
  const { pathname } = new URL(route.request().url());
29
44
  if (pathname === "/recorder.js")
@@ -62,7 +77,27 @@ async function settle(page, ms = 2_100) {
62
77
  await page.clock.runFor(ms);
63
78
  await new Promise((r) => setTimeout(r, 150));
64
79
  }
80
+ /** Make the tab hidden or visible, as switching tabs does. */
81
+ async function setVisible(page, visible) {
82
+ await page.evaluate((v) => {
83
+ Object.defineProperty(document, "visibilityState", {
84
+ configurable: true,
85
+ get: () => (v ? "visible" : "hidden"),
86
+ });
87
+ Object.defineProperty(document, "hidden", { configurable: true, get: () => !v });
88
+ document.dispatchEvent(new Event("visibilitychange"));
89
+ }, visible);
90
+ }
91
+ /** Move the mouse a little, every 10 s for `minutes`: a user who is there. */
92
+ async function stayActive(page, minutes) {
93
+ for (let i = 0; i < minutes * 6; i++) {
94
+ await page.mouse.move(100 + (i % 2) * 50, 100);
95
+ await settle(page, 10_000);
96
+ }
97
+ }
98
+ const cookieWrites = (page) => page.evaluate(() => globalThis.__sessionCookieWrites);
65
99
  const all = (uploads) => uploads.flatMap((u) => u.events);
100
+ const fullSnapshots = (events) => events.filter((e) => e.type === 2);
66
101
  const kinds = (events) => events
67
102
  .filter((e) => e.type !== 3 && e.type !== 0 && e.type !== 1)
68
103
  .map((e) => (e.type === 5 ? `5:${e.data.tag}` : String(e.type)));
@@ -105,14 +140,74 @@ describe("recorder uploads", () => {
105
140
  test("a periodic keyframe is Meta + FullSnapshot without a navigation", async () => {
106
141
  const { page, uploads } = await openPage();
107
142
  const before = all(uploads).length;
143
+ await page.mouse.move(100, 100);
108
144
  await settle(page, 60_000);
109
145
  expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
110
146
  await page.close();
111
147
  });
148
+ test("an active user in a visible tab gets a keyframe every interval", async () => {
149
+ const { page, uploads } = await openPage();
150
+ const before = all(uploads).length;
151
+ await stayActive(page, 5);
152
+ expect(kinds(all(uploads).slice(before))).toEqual([
153
+ "4",
154
+ "2",
155
+ "4",
156
+ "2",
157
+ "4",
158
+ "2",
159
+ "4",
160
+ "2",
161
+ "4",
162
+ "2",
163
+ ]);
164
+ await page.close();
165
+ });
166
+ test("no keyframes once the user is idle", async () => {
167
+ const { page, uploads } = await openPage();
168
+ // Nobody touches the page after it opens.
169
+ const before = all(uploads).length;
170
+ await settle(page, 10 * 60_000);
171
+ expect(fullSnapshots(all(uploads).slice(before))).toEqual([]);
172
+ await page.close();
173
+ });
174
+ test("no keyframes while the tab is hidden", async () => {
175
+ const { page, uploads } = await openPage();
176
+ await setVisible(page, false);
177
+ const before = all(uploads).length;
178
+ await stayActive(page, 3);
179
+ expect(fullSnapshots(all(uploads).slice(before))).toEqual([]);
180
+ await page.close();
181
+ });
182
+ test("after the user comes back, the next keyframe comes within the interval", async () => {
183
+ const { page, uploads } = await openPage();
184
+ await setVisible(page, false);
185
+ await settle(page, 10 * 60_000);
186
+ await setVisible(page, true);
187
+ const before = all(uploads).length;
188
+ await page.mouse.move(100, 100);
189
+ await settle(page, 60_000);
190
+ expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
191
+ await page.close();
192
+ });
193
+ test("keyframes and DOM churn don't extend the session cookie; user activity does", async () => {
194
+ const { page } = await openPage();
195
+ const opened = await cookieWrites(page);
196
+ // The first tick's keyframe, and a page re-rendering on its own.
197
+ await page.evaluate(() => {
198
+ document.getElementById("app").append("tick");
199
+ });
200
+ await settle(page, 60_000);
201
+ expect(await cookieWrites(page)).toBe(opened);
202
+ await page.mouse.move(100, 100);
203
+ await settle(page);
204
+ expect(await cookieWrites(page)).toBe(opened + 1);
205
+ await page.close();
206
+ });
112
207
  test("keyframes don't keep an idle session alive; it rotates onto a navigation", async () => {
113
208
  const { page, uploads } = await openPage();
114
209
  const first = uploads[0].sessionId;
115
- // 31 idle minutes: only keyframes are recorded meanwhile.
210
+ // 31 idle minutes: at most the first keyframe is recorded meanwhile.
116
211
  await settle(page, 31 * 60_000);
117
212
  expect(new Set(uploads.map((u) => u.sessionId))).toEqual(new Set([first]));
118
213
  await page.click("#b");
@@ -41,5 +41,26 @@ export declare function sessionExpired(lastActivityAt: number, now: number): boo
41
41
  * a reload resurrects a session the cookie had already expired.
42
42
  */
43
43
  export declare function storedSessionUsable(storedAt: number, now: number): boolean;
44
+ /**
45
+ * How often to take a keyframe (Meta + FullSnapshot) while the user is active.
46
+ *
47
+ * The player can only start rendering from a snapshot, so seeking to an
48
+ * arbitrary point costs replaying every mutation since the preceding one. That
49
+ * makes this interval the ceiling on seek latency. Each snapshot is the whole
50
+ * DOM, but stylesheets and images are deduplicated, so the marginal cost is
51
+ * mostly the node tree, which compresses well.
52
+ */
53
+ export declare const KEYFRAME_INTERVAL_MS: number;
54
+ /**
55
+ * Whether the periodic keyframe should be taken now.
56
+ *
57
+ * Only while a person is there: the tab is visible and they did something
58
+ * since the previous tick. Idle users and background tabs are the bulk of
59
+ * recorded time, and a keyframe of a page nobody is touching is bytes for
60
+ * nothing. Comparing against the previous tick rather than a fixed window
61
+ * keeps this right when timers fire late, so keyframes always resume at the
62
+ * first tick after the user returns, within an interval.
63
+ */
64
+ export declare function keyframeDue(lastActivityAt: number, lastTickAt: number, visible: boolean): boolean;
44
65
  export {};
45
66
  //# sourceMappingURL=session-lifecycle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAiB,CAAA;AAoBhD,6DAA6D;AAC7D,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG1E"}
1
+ {"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAiB,CAAA;AAoBhD,6DAA6D;AAC7D,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG1E;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,QAAY,CAAA;AAE7C;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,cAAc,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAEjG"}
@@ -68,3 +68,26 @@ export function storedSessionUsable(storedAt, now) {
68
68
  return false;
69
69
  return now - storedAt < SESSION_TIMEOUT_MS;
70
70
  }
71
+ /**
72
+ * How often to take a keyframe (Meta + FullSnapshot) while the user is active.
73
+ *
74
+ * The player can only start rendering from a snapshot, so seeking to an
75
+ * arbitrary point costs replaying every mutation since the preceding one. That
76
+ * makes this interval the ceiling on seek latency. Each snapshot is the whole
77
+ * DOM, but stylesheets and images are deduplicated, so the marginal cost is
78
+ * mostly the node tree, which compresses well.
79
+ */
80
+ export const KEYFRAME_INTERVAL_MS = 60 * 1000;
81
+ /**
82
+ * Whether the periodic keyframe should be taken now.
83
+ *
84
+ * Only while a person is there: the tab is visible and they did something
85
+ * since the previous tick. Idle users and background tabs are the bulk of
86
+ * recorded time, and a keyframe of a page nobody is touching is bytes for
87
+ * nothing. Comparing against the previous tick rather than a fixed window
88
+ * keeps this right when timers fire late, so keyframes always resume at the
89
+ * first tick after the user returns, within an interval.
90
+ */
91
+ export function keyframeDue(lastActivityAt, lastTickAt, visible) {
92
+ return visible && lastActivityAt > lastTickAt;
93
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
2
+ import { isUserActivity, KEYFRAME_INTERVAL_MS, keyframeDue, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
3
3
  /**
4
4
  * These rules decide how a visitor's time is cut into sessions, and both failure
5
5
  * directions are expensive: too permissive and a tab left open becomes a 20-day
@@ -91,3 +91,23 @@ describe("storedSessionUsable", () => {
91
91
  expect(storedSessionUsable(0, NOW)).toBe(false);
92
92
  });
93
93
  });
94
+ describe("keyframeDue", () => {
95
+ const TICK = NOW + KEYFRAME_INTERVAL_MS;
96
+ test("takes a keyframe while the user is active and the tab is visible", () => {
97
+ expect(keyframeDue(TICK - 1_000, NOW, true)).toBe(true);
98
+ });
99
+ test("not while the tab is hidden, however recent the activity", () => {
100
+ expect(keyframeDue(TICK - 1_000, NOW, false)).toBe(false);
101
+ });
102
+ test("not when nothing happened since the previous tick", () => {
103
+ // The snapshot would only repeat what the player can already rebuild, and
104
+ // idle tabs are most tabs.
105
+ expect(keyframeDue(NOW, NOW, true)).toBe(false);
106
+ expect(keyframeDue(NOW - 20 * 24 * 60 * 60_000, NOW, true)).toBe(false);
107
+ });
108
+ test("activity right after a tick still counts when the next one fires late", () => {
109
+ // Timers drift: a busy or throttled page fires the next tick well past the
110
+ // interval, and the activity must not have aged out of a fixed window.
111
+ expect(keyframeDue(NOW + 1, NOW, true)).toBe(true);
112
+ });
113
+ });
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Measure what the recorder uploads per minute of an active user.
2
+ * Measure what the recorder uploads per minute of an active and an idle user.
3
3
  *
4
- * bun packages/browser/src/testing/measure-bytes.ts [--minutes 5] [--seed 1]
4
+ * bun packages/browser/src/testing/measure-bytes.ts [--minutes 5] [--idle 10] [--seed 1]
5
5
  *
6
6
  * Runs the production recorder (`createLunarDinosRecorder`) in real Chromium on
7
7
  * a fake clock, against an app-like page: a shift list with cards and avatars,
8
8
  * a detail view and a form, switched by SPA navigations the way the framework
9
9
  * SDKs report them. The user is active the whole time — mouse, scroll, clicks,
10
- * typing, a navigation every 20 s. Every upload is intercepted and counted as it
10
+ * typing, a navigation every 20 s. Then the user leaves the tab open and idle
11
+ * for `--idle` minutes while the page keeps re-rendering its clock and badges,
12
+ * as real apps poll and animate. Every upload is intercepted and counted as it
11
13
  * goes over the wire (gzip where the recorder compresses).
12
14
  *
13
15
  * The page is synthetic, so the absolute number is a baseline, not a forecast:
@@ -21,6 +23,7 @@ const opt = (n, d) => {
21
23
  return i === -1 ? d : args[i + 1];
22
24
  };
23
25
  const minutes = Number(opt("minutes", "5"));
26
+ const idleMinutes = Number(opt("idle", "10"));
24
27
  const seed = Number(opt("seed", "1"));
25
28
  const CSS = `
26
29
  body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fafafa; }
@@ -77,10 +80,10 @@ setInterval(() => {
77
80
  window.__flush = () => recorder.flush();
78
81
  }</script></body></html>`;
79
82
  const recorderJs = await buildRecorderBundle();
80
- let wireBytes = 0;
81
- let jsonBytes = 0;
82
- let requests = 0;
83
- const eventTypes = {};
83
+ const newTally = () => ({ wire: 0, json: 0, requests: 0, events: {} });
84
+ const active = newTally();
85
+ const idle = newTally();
86
+ let tally = active;
84
87
  const browser = await chromium.launch();
85
88
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
86
89
  await page.route("https://app.test/**", (route) => {
@@ -98,15 +101,15 @@ await page.route("https://ingest.test/**", async (route) => {
98
101
  if (req.method() !== "POST")
99
102
  return route.fulfill({ status: 204, body: "" });
100
103
  const body = req.postDataBuffer() ?? Buffer.alloc(0);
101
- wireBytes += body.length;
102
- requests++;
104
+ tally.wire += body.length;
105
+ tally.requests++;
103
106
  const gzipped = req.headers()["content-encoding"] === "gzip";
104
107
  const json = gzipped ? Bun.gunzipSync(new Uint8Array(body)) : body;
105
- jsonBytes += json.length;
108
+ tally.json += json.length;
106
109
  const { events } = JSON.parse(new TextDecoder().decode(json));
107
110
  for (const e of events) {
108
111
  const k = e.type === 3 ? `3.${e.data?.source}` : e.type === 5 ? `5.${e.data?.tag}` : String(e.type);
109
- eventTypes[k] = (eventTypes[k] ?? 0) + 1;
112
+ tally.events[k] = (tally.events[k] ?? 0) + 1;
110
113
  }
111
114
  return route.fulfill({ status: 200, body: "{}" });
112
115
  });
@@ -140,12 +143,29 @@ for (let sec = 0; sec < minutes * 60; sec++) {
140
143
  await page.clock.runFor(1000);
141
144
  await settle();
142
145
  }
143
- await page.evaluate(() => window.__flush());
144
- await page.clock.runFor(100);
145
- await new Promise((r) => setTimeout(r, 300));
146
+ /** Upload what's buffered and let it land, so it counts toward the phase that made it. */
147
+ async function drain() {
148
+ await page.evaluate(() => window.__flush());
149
+ await page.clock.runFor(100);
150
+ await new Promise((r) => setTimeout(r, 300));
151
+ }
152
+ await drain();
153
+ tally = idle;
154
+ for (let sec = 0; sec < idleMinutes * 60; sec += 10) {
155
+ await page.clock.runFor(10_000);
156
+ await settle();
157
+ }
158
+ await drain();
146
159
  await browser.close();
147
- const perMin = (n) => `${(n / minutes / 1024).toFixed(1)} KB`;
148
- console.log(`${minutes} active minutes, ${navs} navigations, ${requests} uploads`);
149
- console.log(`wire: ${perMin(wireBytes)} per active minute (${(wireBytes / 1024).toFixed(0)} KB total)`);
150
- console.log(`json: ${perMin(jsonBytes)} per active minute (${(jsonBytes / 1024).toFixed(0)} KB total)`);
151
- console.log("events:", eventTypes);
160
+ function report(label, t, mins) {
161
+ if (mins === 0)
162
+ return;
163
+ const perMin = (n) => `${(n / mins / 1024).toFixed(1)} KB`;
164
+ console.log(`${mins} ${label} minutes, ${t.requests} uploads`);
165
+ console.log(` wire: ${perMin(t.wire)} per ${label} minute (${(t.wire / 1024).toFixed(0)} KB total)`);
166
+ console.log(` json: ${perMin(t.json)} per ${label} minute (${(t.json / 1024).toFixed(0)} KB total)`);
167
+ console.log(" events:", t.events);
168
+ }
169
+ console.log(`${navs} navigations`);
170
+ report("active", active, minutes);
171
+ report("idle", idle, idleMinutes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunardinos/browser",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {