@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.
- package/dist/recorder-core.js +21 -8
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +27 -30
- package/dist/src/recorder.browser.test.js +97 -2
- package/dist/src/session-lifecycle.d.ts +21 -0
- package/dist/src/session-lifecycle.d.ts.map +1 -1
- package/dist/src/session-lifecycle.js +23 -0
- package/dist/src/session-lifecycle.test.js +21 -1
- package/dist/src/testing/measure-bytes.js +39 -19
- package/package.json +1 -1
package/dist/recorder-core.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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 (
|
|
4871
|
-
clearInterval(
|
|
4872
|
-
|
|
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;
|
|
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
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
let
|
|
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
|
|
326
|
-
|
|
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
|
|
542
|
-
// bounded, independently-replayable segments
|
|
543
|
-
|
|
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
|
-
},
|
|
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 (
|
|
574
|
-
clearInterval(
|
|
575
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
await
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
console.log(
|
|
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);
|