@lunardinos/browser 0.2.4 → 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 +330 -397
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +74 -58
- package/dist/src/recorder.browser.test.d.ts +2 -0
- package/dist/src/recorder.browser.test.d.ts.map +1 -0
- package/dist/src/recorder.browser.test.js +239 -0
- package/dist/src/session-lifecycle.d.ts +29 -1
- package/dist/src/session-lifecycle.d.ts.map +1 -1
- package/dist/src/session-lifecycle.js +34 -3
- package/dist/src/session-lifecycle.test.js +30 -2
- package/dist/src/testing/measure-bytes.d.ts +2 -0
- package/dist/src/testing/measure-bytes.d.ts.map +1 -0
- package/dist/src/testing/measure-bytes.js +171 -0
- package/dist/src/testing/recorder-bundle.d.ts +6 -0
- package/dist/src/testing/recorder-bundle.d.ts.map +1 -0
- package/dist/src/testing/recorder-bundle.js +19 -0
- package/package.json +3 -2
|
@@ -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
|
-
import record from "@lunardinos/rrweb-record";
|
|
1
|
+
import record, {} from "@lunardinos/rrweb-record";
|
|
2
2
|
import { createPersistence } from "./persistence";
|
|
3
|
-
import { 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 {
|
|
@@ -142,7 +127,18 @@ export function createLunarDinosRecorder(config) {
|
|
|
142
127
|
method: "POST",
|
|
143
128
|
headers: reqHeaders,
|
|
144
129
|
body,
|
|
145
|
-
})
|
|
130
|
+
})
|
|
131
|
+
.then((res) => {
|
|
132
|
+
// Fire-and-forget by design (recording must never block the page), but a
|
|
133
|
+
// failed delivery should at least be visible in devtools/error monitoring
|
|
134
|
+
// instead of vanishing — this batch's events are gone either way.
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
console.error(`lunardinos: ingest request failed with status ${res.status}`);
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.catch(() => {
|
|
140
|
+
console.error("lunardinos: ingest request failed (network error)");
|
|
141
|
+
});
|
|
146
142
|
}
|
|
147
143
|
/** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
|
|
148
144
|
function sendViaBeacon(json) {
|
|
@@ -177,15 +173,19 @@ export function createLunarDinosRecorder(config) {
|
|
|
177
173
|
let visitorId = null;
|
|
178
174
|
let stopFn = null;
|
|
179
175
|
let intervalId = null;
|
|
180
|
-
// Periodic
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
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;
|
|
184
180
|
let recording = false;
|
|
185
|
-
/** Last user activity, for {@link SESSION_TIMEOUT_MS}. */
|
|
181
|
+
/** Last user activity, for {@link SESSION_TIMEOUT_MS} and {@link keyframeDue}. */
|
|
186
182
|
let lastActivityAt = 0;
|
|
183
|
+
/** User activity since the session cookie was last refreshed. */
|
|
184
|
+
let activitySinceTouch = false;
|
|
187
185
|
/** Set while rotating, since taking the new session's snapshot re-enters emit. */
|
|
188
186
|
let rotating = false;
|
|
187
|
+
/** The page the app last reported, so a rotated session opens on it. */
|
|
188
|
+
let currentPage = {};
|
|
189
189
|
// Click enrichment state
|
|
190
190
|
let pendingClickContext = null;
|
|
191
191
|
// Input enrichment state
|
|
@@ -259,11 +259,14 @@ export function createLunarDinosRecorder(config) {
|
|
|
259
259
|
* End the current session and open a new one.
|
|
260
260
|
*
|
|
261
261
|
* Order matters: pending events belong to the session that produced them, so
|
|
262
|
-
* they go out first, and the new session opens on a
|
|
263
|
-
* one it starts mid-stream against a DOM the player has
|
|
264
|
-
* be replayed at all.
|
|
262
|
+
* they go out first, and the new session opens on a navigation and a full
|
|
263
|
+
* snapshot — without one it starts mid-stream against a DOM the player has
|
|
264
|
+
* never seen and can't be replayed at all, and has no landing page.
|
|
265
|
+
*
|
|
266
|
+
* `snapshot: false` when a navigation triggered the rotation: its own
|
|
267
|
+
* snapshot follows and opens the new session.
|
|
265
268
|
*/
|
|
266
|
-
function rotateSession() {
|
|
269
|
+
function rotateSession(snapshot) {
|
|
267
270
|
if (rotating)
|
|
268
271
|
return;
|
|
269
272
|
rotating = true;
|
|
@@ -276,11 +279,13 @@ export function createLunarDinosRecorder(config) {
|
|
|
276
279
|
}
|
|
277
280
|
lastActivityAt = Date.now();
|
|
278
281
|
markActivity();
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
if (snapshot) {
|
|
283
|
+
try {
|
|
284
|
+
record.takeNavigationSnapshot(currentPage);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Recording already stopped; the next start() will snapshot anyway.
|
|
288
|
+
}
|
|
284
289
|
}
|
|
285
290
|
}
|
|
286
291
|
finally {
|
|
@@ -290,6 +295,7 @@ export function createLunarDinosRecorder(config) {
|
|
|
290
295
|
/** Persist the activity clock so a reload in the same tab can honour it. */
|
|
291
296
|
function markActivity() {
|
|
292
297
|
lastActivityAt = Date.now();
|
|
298
|
+
activitySinceTouch = true;
|
|
293
299
|
if (typeof sessionStorage !== "undefined") {
|
|
294
300
|
sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
|
|
295
301
|
}
|
|
@@ -304,8 +310,12 @@ export function createLunarDinosRecorder(config) {
|
|
|
304
310
|
if (vid)
|
|
305
311
|
payload.visitorId = vid;
|
|
306
312
|
const json = JSON.stringify(payload);
|
|
307
|
-
// Extend rolling session cookie
|
|
308
|
-
|
|
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
|
+
}
|
|
309
319
|
// During page unload (beforeunload / MPA navigation), use sendBeacon
|
|
310
320
|
// for guaranteed delivery. No async compression — page may tear down.
|
|
311
321
|
if (options?.beacon) {
|
|
@@ -336,18 +346,25 @@ export function createLunarDinosRecorder(config) {
|
|
|
336
346
|
}
|
|
337
347
|
flush();
|
|
338
348
|
}
|
|
349
|
+
/** What a navigation reports about a page, with auth tokens stripped from its URL. */
|
|
350
|
+
function pageMetadata(payload) {
|
|
351
|
+
return {
|
|
352
|
+
href: redactAuthFromUrl(payload.href) ?? payload.href,
|
|
353
|
+
title: payload.title,
|
|
354
|
+
routePattern: payload.routePattern ?? undefined,
|
|
355
|
+
params: payload.params,
|
|
356
|
+
locale: resolveLocale(payload.locale),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
339
359
|
function emitPageEvent(payload) {
|
|
340
360
|
if (!recording)
|
|
341
361
|
return;
|
|
362
|
+
currentPage = pageMetadata(payload);
|
|
342
363
|
try {
|
|
343
|
-
//
|
|
344
|
-
record.
|
|
345
|
-
|
|
346
|
-
title: payload.title,
|
|
347
|
-
routePattern: payload.routePattern ?? undefined,
|
|
348
|
-
params: payload.params,
|
|
364
|
+
// The navigation event, then Meta + FullSnapshot of the new page
|
|
365
|
+
record.takeNavigationSnapshot({
|
|
366
|
+
...currentPage,
|
|
349
367
|
referrer: payload.referrer ?? externalReferrer(),
|
|
350
|
-
locale: resolveLocale(payload.locale),
|
|
351
368
|
});
|
|
352
369
|
}
|
|
353
370
|
catch {
|
|
@@ -466,18 +483,12 @@ export function createLunarDinosRecorder(config) {
|
|
|
466
483
|
document.addEventListener("submit", captureSubmit, true);
|
|
467
484
|
window.addEventListener("error", captureError);
|
|
468
485
|
window.addEventListener("unhandledrejection", captureRejection);
|
|
486
|
+
currentPage = startOpts.pageMetadata ? pageMetadata(startOpts.pageMetadata) : {};
|
|
469
487
|
stopFn = record({
|
|
470
488
|
...defaultRrwebOptions,
|
|
471
489
|
...rrwebOptions,
|
|
472
490
|
initialPageMetadata: startOpts.pageMetadata
|
|
473
|
-
? {
|
|
474
|
-
href: redactAuthFromUrl(startOpts.pageMetadata.href) ?? startOpts.pageMetadata.href,
|
|
475
|
-
title: startOpts.pageMetadata.title,
|
|
476
|
-
routePattern: startOpts.pageMetadata.routePattern ?? undefined,
|
|
477
|
-
params: startOpts.pageMetadata.params,
|
|
478
|
-
referrer: startOpts.pageMetadata.referrer ?? undefined,
|
|
479
|
-
locale: resolveLocale(startOpts.pageMetadata.locale),
|
|
480
|
-
}
|
|
491
|
+
? { ...currentPage, referrer: startOpts.pageMetadata.referrer ?? undefined }
|
|
481
492
|
: undefined,
|
|
482
493
|
emit(event) {
|
|
483
494
|
const ev = event;
|
|
@@ -486,7 +497,7 @@ export function createLunarDinosRecorder(config) {
|
|
|
486
497
|
// old session before the event is recorded against it.
|
|
487
498
|
if (isUserActivity(ev) && !rotating) {
|
|
488
499
|
if (sessionExpired(lastActivityAt, Date.now()))
|
|
489
|
-
rotateSession();
|
|
500
|
+
rotateSession(!isNavigation(ev));
|
|
490
501
|
else
|
|
491
502
|
markActivity();
|
|
492
503
|
}
|
|
@@ -515,20 +526,25 @@ export function createLunarDinosRecorder(config) {
|
|
|
515
526
|
if (persistence.mode === "cookieless" || isNewSession) {
|
|
516
527
|
emitSessionMeta();
|
|
517
528
|
}
|
|
518
|
-
// Flush immediately so the
|
|
529
|
+
// Flush immediately so the first snapshot (which can exceed sendBeacon's
|
|
519
530
|
// ~64 KB limit) is delivered via regular fetch before the user navigates away.
|
|
520
531
|
flush();
|
|
521
532
|
intervalId = setInterval(flush, batchInterval);
|
|
522
|
-
// Take a periodic
|
|
523
|
-
// bounded, independently-replayable segments
|
|
524
|
-
|
|
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;
|
|
525
541
|
try {
|
|
526
542
|
record.takeFullSnapshot(true);
|
|
527
543
|
}
|
|
528
544
|
catch {
|
|
529
545
|
/* not recording / mid-teardown */
|
|
530
546
|
}
|
|
531
|
-
},
|
|
547
|
+
}, KEYFRAME_INTERVAL_MS);
|
|
532
548
|
return sessionId;
|
|
533
549
|
}
|
|
534
550
|
function stop() {
|
|
@@ -551,9 +567,9 @@ export function createLunarDinosRecorder(config) {
|
|
|
551
567
|
clearInterval(intervalId);
|
|
552
568
|
intervalId = null;
|
|
553
569
|
}
|
|
554
|
-
if (
|
|
555
|
-
clearInterval(
|
|
556
|
-
|
|
570
|
+
if (keyframeIntervalId) {
|
|
571
|
+
clearInterval(keyframeIntervalId);
|
|
572
|
+
keyframeIntervalId = null;
|
|
557
573
|
}
|
|
558
574
|
stopFn?.();
|
|
559
575
|
stopFn = null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.browser.test.d.ts","sourceRoot":"","sources":["../../src/recorder.browser.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
import { buildRecorderBundle } from "./testing/recorder-bundle";
|
|
4
|
+
/**
|
|
5
|
+
* What the recorder uploads around navigations, keyframes and session
|
|
6
|
+
* rotation, captured off the wire from the production recorder in Chromium on
|
|
7
|
+
* a fake clock.
|
|
8
|
+
*
|
|
9
|
+
* A navigation is a Custom "navigation" event followed by Meta + FullSnapshot;
|
|
10
|
+
* a keyframe is Meta + FullSnapshot alone. Keyframes are the recorder's own
|
|
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. They are
|
|
13
|
+
* only taken while someone is there: the tab is visible and the user active.
|
|
14
|
+
*/
|
|
15
|
+
const PAGE = `<!DOCTYPE html><html lang="de"><head><title>App</title></head><body>
|
|
16
|
+
<div id="app"><h1>Shifts</h1><button id="b">Apply</button></div>
|
|
17
|
+
<script src="/recorder.js"></script></body></html>`;
|
|
18
|
+
let browser;
|
|
19
|
+
let bundle;
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
bundle = await buildRecorderBundle();
|
|
22
|
+
browser = await chromium.launch();
|
|
23
|
+
});
|
|
24
|
+
afterAll(() => browser.close());
|
|
25
|
+
async function openPage() {
|
|
26
|
+
const uploads = [];
|
|
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
|
+
});
|
|
42
|
+
await page.route("https://app.test/**", (route) => {
|
|
43
|
+
const { pathname } = new URL(route.request().url());
|
|
44
|
+
if (pathname === "/recorder.js")
|
|
45
|
+
return route.fulfill({ contentType: "application/javascript", body: bundle });
|
|
46
|
+
return route.fulfill({ contentType: "text/html", body: PAGE });
|
|
47
|
+
});
|
|
48
|
+
await page.route("https://ingest.test/**", (route) => {
|
|
49
|
+
const req = route.request();
|
|
50
|
+
const body = new Uint8Array(req.postDataBuffer() ?? []);
|
|
51
|
+
const json = req.headers()["content-encoding"] === "gzip" ? Bun.gunzipSync(body) : body;
|
|
52
|
+
uploads.push(JSON.parse(new TextDecoder().decode(json)));
|
|
53
|
+
return route.fulfill({ status: 200, body: "{}" });
|
|
54
|
+
});
|
|
55
|
+
await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
|
|
56
|
+
await page.goto("https://app.test/shifts?access_token=secret");
|
|
57
|
+
await page.evaluate(() => {
|
|
58
|
+
const g = globalThis;
|
|
59
|
+
g.__recorder = g.LunarDinos.createLunarDinosRecorder({
|
|
60
|
+
apiKey: "k",
|
|
61
|
+
endpoint: "https://ingest.test",
|
|
62
|
+
});
|
|
63
|
+
g.__recorder.start({
|
|
64
|
+
pageMetadata: {
|
|
65
|
+
href: location.href,
|
|
66
|
+
title: "App",
|
|
67
|
+
routePattern: "/shifts",
|
|
68
|
+
params: null,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
await settle(page);
|
|
73
|
+
return { page, uploads };
|
|
74
|
+
}
|
|
75
|
+
/** Run the fake clock forward, then let the async gzip + fetch land on the real one. */
|
|
76
|
+
async function settle(page, ms = 2_100) {
|
|
77
|
+
await page.clock.runFor(ms);
|
|
78
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
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);
|
|
99
|
+
const all = (uploads) => uploads.flatMap((u) => u.events);
|
|
100
|
+
const fullSnapshots = (events) => events.filter((e) => e.type === 2);
|
|
101
|
+
const kinds = (events) => events
|
|
102
|
+
.filter((e) => e.type !== 3 && e.type !== 0 && e.type !== 1)
|
|
103
|
+
.map((e) => (e.type === 5 ? `5:${e.data.tag}` : String(e.type)));
|
|
104
|
+
describe("recorder uploads", () => {
|
|
105
|
+
test("a recording opens on a navigation with the page metadata, then Meta + FullSnapshot", async () => {
|
|
106
|
+
const { page, uploads } = await openPage();
|
|
107
|
+
const events = all(uploads);
|
|
108
|
+
expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
|
|
109
|
+
const [nav, meta] = events.filter((e) => e.type === 5 || e.type === 4);
|
|
110
|
+
expect(nav.data.payload).toMatchObject({
|
|
111
|
+
href: "https://app.test/shifts?access_token=REDACTED",
|
|
112
|
+
title: "App",
|
|
113
|
+
routePattern: "/shifts",
|
|
114
|
+
params: null,
|
|
115
|
+
locale: "de",
|
|
116
|
+
});
|
|
117
|
+
// Tokens are stripped from the snapshot's href too, not only the page view's.
|
|
118
|
+
expect(meta.data.href).toBe("https://app.test/shifts?access_token=REDACTED");
|
|
119
|
+
expect(events.map((e) => e.type)).not.toContain(7);
|
|
120
|
+
await page.close();
|
|
121
|
+
});
|
|
122
|
+
test("an SPA navigation is a navigation, then Meta + FullSnapshot", async () => {
|
|
123
|
+
const { page, uploads } = await openPage();
|
|
124
|
+
const before = all(uploads).length;
|
|
125
|
+
await page.evaluate(() => {
|
|
126
|
+
document.getElementById("app").innerHTML = "<h1>Profile</h1>";
|
|
127
|
+
globalThis.__recorder.emitPageEvent({
|
|
128
|
+
href: "https://app.test/profile",
|
|
129
|
+
title: "Profile",
|
|
130
|
+
routePattern: "/profile",
|
|
131
|
+
params: null,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
await settle(page);
|
|
135
|
+
const events = all(uploads).slice(before);
|
|
136
|
+
expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
|
|
137
|
+
expect(events[0].data.payload.routePattern).toBe("/profile");
|
|
138
|
+
await page.close();
|
|
139
|
+
});
|
|
140
|
+
test("a periodic keyframe is Meta + FullSnapshot without a navigation", async () => {
|
|
141
|
+
const { page, uploads } = await openPage();
|
|
142
|
+
const before = all(uploads).length;
|
|
143
|
+
await page.mouse.move(100, 100);
|
|
144
|
+
await settle(page, 60_000);
|
|
145
|
+
expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
|
|
146
|
+
await page.close();
|
|
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
|
+
});
|
|
207
|
+
test("keyframes don't keep an idle session alive; it rotates onto a navigation", async () => {
|
|
208
|
+
const { page, uploads } = await openPage();
|
|
209
|
+
const first = uploads[0].sessionId;
|
|
210
|
+
// 31 idle minutes: at most the first keyframe is recorded meanwhile.
|
|
211
|
+
await settle(page, 31 * 60_000);
|
|
212
|
+
expect(new Set(uploads.map((u) => u.sessionId))).toEqual(new Set([first]));
|
|
213
|
+
await page.click("#b");
|
|
214
|
+
await settle(page);
|
|
215
|
+
const next = uploads.filter((u) => u.sessionId !== first);
|
|
216
|
+
expect(next.length).toBeGreaterThan(0);
|
|
217
|
+
const events = all(next);
|
|
218
|
+
// The new session has a landing page: the page the app last reported.
|
|
219
|
+
expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
|
|
220
|
+
expect(events.find((e) => e.type === 5).data.payload.routePattern).toBe("/shifts");
|
|
221
|
+
await page.close();
|
|
222
|
+
});
|
|
223
|
+
test("a navigation after the timeout opens the new session with its own snapshot only", async () => {
|
|
224
|
+
const { page, uploads } = await openPage();
|
|
225
|
+
const first = uploads[0].sessionId;
|
|
226
|
+
await settle(page, 31 * 60_000);
|
|
227
|
+
await page.evaluate(() => globalThis.__recorder.emitPageEvent({
|
|
228
|
+
href: "https://app.test/profile",
|
|
229
|
+
title: "Profile",
|
|
230
|
+
routePattern: "/profile",
|
|
231
|
+
params: null,
|
|
232
|
+
}));
|
|
233
|
+
await settle(page);
|
|
234
|
+
const events = all(uploads.filter((u) => u.sessionId !== first));
|
|
235
|
+
expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
|
|
236
|
+
expect(events[0].data.payload.routePattern).toBe("/profile");
|
|
237
|
+
await page.close();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -16,9 +16,16 @@ interface ActivityEvent {
|
|
|
16
16
|
type: number;
|
|
17
17
|
data?: {
|
|
18
18
|
source?: number;
|
|
19
|
+
tag?: string;
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
|
-
/**
|
|
22
|
+
/** A Custom (5) event tagged "navigation": the app moved to another page. */
|
|
23
|
+
export declare function isNavigation(event: ActivityEvent): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether this event is evidence a person is still there.
|
|
26
|
+
*
|
|
27
|
+
* Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
|
|
28
|
+
*/
|
|
22
29
|
export declare function isUserActivity(event: ActivityEvent): boolean;
|
|
23
30
|
/**
|
|
24
31
|
* Whether the gap since `lastActivityAt` has ended the session.
|
|
@@ -34,5 +41,26 @@ export declare function sessionExpired(lastActivityAt: number, now: number): boo
|
|
|
34
41
|
* a reload resurrects a session the cookie had already expired.
|
|
35
42
|
*/
|
|
36
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;
|
|
37
65
|
export {};
|
|
38
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,CAAA;KAAE,CAAA;
|
|
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"}
|
|
@@ -28,10 +28,18 @@ const USER_ACTIVITY_SOURCES = new Set([
|
|
|
28
28
|
12, // Drag
|
|
29
29
|
14, // Selection
|
|
30
30
|
]);
|
|
31
|
-
/**
|
|
31
|
+
/** A Custom (5) event tagged "navigation": the app moved to another page. */
|
|
32
|
+
export function isNavigation(event) {
|
|
33
|
+
return event.type === 5 && event.data?.tag === "navigation";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether this event is evidence a person is still there.
|
|
37
|
+
*
|
|
38
|
+
* Keyframes (Meta + FullSnapshot) are not: the recorder takes them on a timer.
|
|
39
|
+
*/
|
|
32
40
|
export function isUserActivity(event) {
|
|
33
|
-
//
|
|
34
|
-
if (event
|
|
41
|
+
// A navigation is always a person doing something.
|
|
42
|
+
if (isNavigation(event))
|
|
35
43
|
return true;
|
|
36
44
|
// IncrementalSnapshot (3) — only the interaction sources count.
|
|
37
45
|
if (event.type === 3)
|
|
@@ -60,3 +68,26 @@ export function storedSessionUsable(storedAt, now) {
|
|
|
60
68
|
return false;
|
|
61
69
|
return now - storedAt < SESSION_TIMEOUT_MS;
|
|
62
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
|
|
@@ -19,7 +19,15 @@ describe("isUserActivity", () => {
|
|
|
19
19
|
expect(isUserActivity(inc(14))).toBe(true); // selection
|
|
20
20
|
});
|
|
21
21
|
test("a navigation is always activity", () => {
|
|
22
|
-
expect(isUserActivity({ type:
|
|
22
|
+
expect(isUserActivity({ type: 5, data: { tag: "navigation" } })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
test("a keyframe is not activity", () => {
|
|
25
|
+
// The periodic Meta + FullSnapshot is the recorder's doing, not a person's.
|
|
26
|
+
expect(isUserActivity({ type: 4 })).toBe(false);
|
|
27
|
+
expect(isUserActivity({ type: 2 })).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test("other custom events are not activity", () => {
|
|
30
|
+
expect(isUserActivity({ type: 5, data: { tag: "session:meta" } })).toBe(false);
|
|
23
31
|
});
|
|
24
32
|
test("does NOT count DOM mutations", () => {
|
|
25
33
|
// The whole point: a polling or animating page emits these forever with
|
|
@@ -83,3 +91,23 @@ describe("storedSessionUsable", () => {
|
|
|
83
91
|
expect(storedSessionUsable(0, NOW)).toBe(false);
|
|
84
92
|
});
|
|
85
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measure-bytes.d.ts","sourceRoot":"","sources":["../../../src/testing/measure-bytes.ts"],"names":[],"mappings":""}
|