@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.
@@ -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;AAQhG,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,CA4hBzE"}
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
- }).catch(() => { });
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 full-snapshot ("checkout") timer. Bounds segment size for long
181
- // single-page sessions: each snapshot becomes an independently-replayable
182
- // segment boundary, so the player never has to load one giant blob.
183
- 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;
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 full snapshot — without
263
- * one it starts mid-stream against a DOM the player has never seen and can't
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
- try {
280
- record.takeFullSnapshot(true);
281
- }
282
- catch {
283
- // Recording already stopped; the next start() will snapshot anyway.
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 on each flush
308
- 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
+ }
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
- // Single Pageview event carries both DOM snapshot and route metadata
344
- record.takePageviewSnapshot({
345
- href: redactAuthFromUrl(payload.href) ?? payload.href,
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 pageview snapshot (which can exceed sendBeacon's
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 full snapshot so long single-page sessions are split into
523
- // bounded, independently-replayable segments (matches PostHog's 5-min cadence).
524
- 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;
525
541
  try {
526
542
  record.takeFullSnapshot(true);
527
543
  }
528
544
  catch {
529
545
  /* not recording / mid-teardown */
530
546
  }
531
- }, FULL_SNAPSHOT_INTERVAL_MS);
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 (checkoutIntervalId) {
555
- clearInterval(checkoutIntervalId);
556
- checkoutIntervalId = null;
570
+ if (keyframeIntervalId) {
571
+ clearInterval(keyframeIntervalId);
572
+ keyframeIntervalId = null;
557
573
  }
558
574
  stopFn?.();
559
575
  stopFn = null;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=recorder.browser.test.d.ts.map
@@ -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
- /** Whether this event is evidence a person is still there. */
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;CAC3B;AAED,8DAA8D;AAC9D,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"}
@@ -28,10 +28,18 @@ const USER_ACTIVITY_SOURCES = new Set([
28
28
  12, // Drag
29
29
  14, // Selection
30
30
  ]);
31
- /** Whether this event is evidence a person is still there. */
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
- // Pageview (7) — a navigation is always a person doing something.
34
- if (event.type === 7)
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: 7 })).toBe(true);
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=measure-bytes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measure-bytes.d.ts","sourceRoot":"","sources":["../../../src/testing/measure-bytes.ts"],"names":[],"mappings":""}