@lunardinos/browser 0.2.4 → 0.2.5
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 +309 -389
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +48 -29
- 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 +144 -0
- package/dist/src/session-lifecycle.d.ts +8 -1
- package/dist/src/session-lifecycle.d.ts.map +1 -1
- package/dist/src/session-lifecycle.js +11 -3
- package/dist/src/session-lifecycle.test.js +9 -1
- 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 +151 -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;AAShG,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAuBpD,UAAU,cAAc;IACtB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACtC,6CAA6C;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,oHAAoH;IACpH,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA4BD,UAAU,gBAAgB;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,EAAE,EAAE,MAAM,CAAA;QACV,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,OAAO,CAAC,EAAE;QACR,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,SAAS,CAAC,EAAE,OAAO,CAAA;QACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;IACD,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAA;QACV,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KACvB,CAAA;CACF;AAuED,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,gBAAgB,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,YAAY,KAAK,MAAM,CAAA;IAClD,IAAI,EAAE,MAAM,IAAI,CAAA;IAChB,+FAA+F;IAC/F,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/C,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAA;IACjC,WAAW,EAAE,MAAM,OAAO,CAAA;IAC1B,iFAAiF;IACjF,aAAa,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,IAAI,CAAA;IAClD,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC5C,2EAA2E;IAC3E,cAAc,EAAE,CAAC,IAAI,EAAE,eAAe,KAAK,IAAI,CAAA;CAChD;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAmjBzE"}
|
|
@@ -1,6 +1,6 @@
|
|
|
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, 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";
|
|
@@ -142,7 +142,18 @@ export function createLunarDinosRecorder(config) {
|
|
|
142
142
|
method: "POST",
|
|
143
143
|
headers: reqHeaders,
|
|
144
144
|
body,
|
|
145
|
-
})
|
|
145
|
+
})
|
|
146
|
+
.then((res) => {
|
|
147
|
+
// Fire-and-forget by design (recording must never block the page), but a
|
|
148
|
+
// failed delivery should at least be visible in devtools/error monitoring
|
|
149
|
+
// instead of vanishing — this batch's events are gone either way.
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
console.error(`lunardinos: ingest request failed with status ${res.status}`);
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
.catch(() => {
|
|
155
|
+
console.error("lunardinos: ingest request failed (network error)");
|
|
156
|
+
});
|
|
146
157
|
}
|
|
147
158
|
/** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
|
|
148
159
|
function sendViaBeacon(json) {
|
|
@@ -186,6 +197,8 @@ export function createLunarDinosRecorder(config) {
|
|
|
186
197
|
let lastActivityAt = 0;
|
|
187
198
|
/** Set while rotating, since taking the new session's snapshot re-enters emit. */
|
|
188
199
|
let rotating = false;
|
|
200
|
+
/** The page the app last reported, so a rotated session opens on it. */
|
|
201
|
+
let currentPage = {};
|
|
189
202
|
// Click enrichment state
|
|
190
203
|
let pendingClickContext = null;
|
|
191
204
|
// Input enrichment state
|
|
@@ -259,11 +272,14 @@ export function createLunarDinosRecorder(config) {
|
|
|
259
272
|
* End the current session and open a new one.
|
|
260
273
|
*
|
|
261
274
|
* 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.
|
|
275
|
+
* they go out first, and the new session opens on a navigation and a full
|
|
276
|
+
* snapshot — without one it starts mid-stream against a DOM the player has
|
|
277
|
+
* never seen and can't be replayed at all, and has no landing page.
|
|
278
|
+
*
|
|
279
|
+
* `snapshot: false` when a navigation triggered the rotation: its own
|
|
280
|
+
* snapshot follows and opens the new session.
|
|
265
281
|
*/
|
|
266
|
-
function rotateSession() {
|
|
282
|
+
function rotateSession(snapshot) {
|
|
267
283
|
if (rotating)
|
|
268
284
|
return;
|
|
269
285
|
rotating = true;
|
|
@@ -276,11 +292,13 @@ export function createLunarDinosRecorder(config) {
|
|
|
276
292
|
}
|
|
277
293
|
lastActivityAt = Date.now();
|
|
278
294
|
markActivity();
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
295
|
+
if (snapshot) {
|
|
296
|
+
try {
|
|
297
|
+
record.takeNavigationSnapshot(currentPage);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Recording already stopped; the next start() will snapshot anyway.
|
|
301
|
+
}
|
|
284
302
|
}
|
|
285
303
|
}
|
|
286
304
|
finally {
|
|
@@ -336,18 +354,25 @@ export function createLunarDinosRecorder(config) {
|
|
|
336
354
|
}
|
|
337
355
|
flush();
|
|
338
356
|
}
|
|
357
|
+
/** What a navigation reports about a page, with auth tokens stripped from its URL. */
|
|
358
|
+
function pageMetadata(payload) {
|
|
359
|
+
return {
|
|
360
|
+
href: redactAuthFromUrl(payload.href) ?? payload.href,
|
|
361
|
+
title: payload.title,
|
|
362
|
+
routePattern: payload.routePattern ?? undefined,
|
|
363
|
+
params: payload.params,
|
|
364
|
+
locale: resolveLocale(payload.locale),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
339
367
|
function emitPageEvent(payload) {
|
|
340
368
|
if (!recording)
|
|
341
369
|
return;
|
|
370
|
+
currentPage = pageMetadata(payload);
|
|
342
371
|
try {
|
|
343
|
-
//
|
|
344
|
-
record.
|
|
345
|
-
|
|
346
|
-
title: payload.title,
|
|
347
|
-
routePattern: payload.routePattern ?? undefined,
|
|
348
|
-
params: payload.params,
|
|
372
|
+
// The navigation event, then Meta + FullSnapshot of the new page
|
|
373
|
+
record.takeNavigationSnapshot({
|
|
374
|
+
...currentPage,
|
|
349
375
|
referrer: payload.referrer ?? externalReferrer(),
|
|
350
|
-
locale: resolveLocale(payload.locale),
|
|
351
376
|
});
|
|
352
377
|
}
|
|
353
378
|
catch {
|
|
@@ -466,18 +491,12 @@ export function createLunarDinosRecorder(config) {
|
|
|
466
491
|
document.addEventListener("submit", captureSubmit, true);
|
|
467
492
|
window.addEventListener("error", captureError);
|
|
468
493
|
window.addEventListener("unhandledrejection", captureRejection);
|
|
494
|
+
currentPage = startOpts.pageMetadata ? pageMetadata(startOpts.pageMetadata) : {};
|
|
469
495
|
stopFn = record({
|
|
470
496
|
...defaultRrwebOptions,
|
|
471
497
|
...rrwebOptions,
|
|
472
498
|
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
|
-
}
|
|
499
|
+
? { ...currentPage, referrer: startOpts.pageMetadata.referrer ?? undefined }
|
|
481
500
|
: undefined,
|
|
482
501
|
emit(event) {
|
|
483
502
|
const ev = event;
|
|
@@ -486,7 +505,7 @@ export function createLunarDinosRecorder(config) {
|
|
|
486
505
|
// old session before the event is recorded against it.
|
|
487
506
|
if (isUserActivity(ev) && !rotating) {
|
|
488
507
|
if (sessionExpired(lastActivityAt, Date.now()))
|
|
489
|
-
rotateSession();
|
|
508
|
+
rotateSession(!isNavigation(ev));
|
|
490
509
|
else
|
|
491
510
|
markActivity();
|
|
492
511
|
}
|
|
@@ -515,7 +534,7 @@ export function createLunarDinosRecorder(config) {
|
|
|
515
534
|
if (persistence.mode === "cookieless" || isNewSession) {
|
|
516
535
|
emitSessionMeta();
|
|
517
536
|
}
|
|
518
|
-
// Flush immediately so the
|
|
537
|
+
// Flush immediately so the first snapshot (which can exceed sendBeacon's
|
|
519
538
|
// ~64 KB limit) is delivered via regular fetch before the user navigates away.
|
|
520
539
|
flush();
|
|
521
540
|
intervalId = setInterval(flush, batchInterval);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.browser.test.d.ts","sourceRoot":"","sources":["../../src/recorder.browser.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,144 @@
|
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
const PAGE = `<!DOCTYPE html><html lang="de"><head><title>App</title></head><body>
|
|
15
|
+
<div id="app"><h1>Shifts</h1><button id="b">Apply</button></div>
|
|
16
|
+
<script src="/recorder.js"></script></body></html>`;
|
|
17
|
+
let browser;
|
|
18
|
+
let bundle;
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
bundle = await buildRecorderBundle();
|
|
21
|
+
browser = await chromium.launch();
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => browser.close());
|
|
24
|
+
async function openPage() {
|
|
25
|
+
const uploads = [];
|
|
26
|
+
const page = await browser.newPage();
|
|
27
|
+
await page.route("https://app.test/**", (route) => {
|
|
28
|
+
const { pathname } = new URL(route.request().url());
|
|
29
|
+
if (pathname === "/recorder.js")
|
|
30
|
+
return route.fulfill({ contentType: "application/javascript", body: bundle });
|
|
31
|
+
return route.fulfill({ contentType: "text/html", body: PAGE });
|
|
32
|
+
});
|
|
33
|
+
await page.route("https://ingest.test/**", (route) => {
|
|
34
|
+
const req = route.request();
|
|
35
|
+
const body = new Uint8Array(req.postDataBuffer() ?? []);
|
|
36
|
+
const json = req.headers()["content-encoding"] === "gzip" ? Bun.gunzipSync(body) : body;
|
|
37
|
+
uploads.push(JSON.parse(new TextDecoder().decode(json)));
|
|
38
|
+
return route.fulfill({ status: 200, body: "{}" });
|
|
39
|
+
});
|
|
40
|
+
await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
|
|
41
|
+
await page.goto("https://app.test/shifts?access_token=secret");
|
|
42
|
+
await page.evaluate(() => {
|
|
43
|
+
const g = globalThis;
|
|
44
|
+
g.__recorder = g.LunarDinos.createLunarDinosRecorder({
|
|
45
|
+
apiKey: "k",
|
|
46
|
+
endpoint: "https://ingest.test",
|
|
47
|
+
});
|
|
48
|
+
g.__recorder.start({
|
|
49
|
+
pageMetadata: {
|
|
50
|
+
href: location.href,
|
|
51
|
+
title: "App",
|
|
52
|
+
routePattern: "/shifts",
|
|
53
|
+
params: null,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
await settle(page);
|
|
58
|
+
return { page, uploads };
|
|
59
|
+
}
|
|
60
|
+
/** Run the fake clock forward, then let the async gzip + fetch land on the real one. */
|
|
61
|
+
async function settle(page, ms = 2_100) {
|
|
62
|
+
await page.clock.runFor(ms);
|
|
63
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
64
|
+
}
|
|
65
|
+
const all = (uploads) => uploads.flatMap((u) => u.events);
|
|
66
|
+
const kinds = (events) => events
|
|
67
|
+
.filter((e) => e.type !== 3 && e.type !== 0 && e.type !== 1)
|
|
68
|
+
.map((e) => (e.type === 5 ? `5:${e.data.tag}` : String(e.type)));
|
|
69
|
+
describe("recorder uploads", () => {
|
|
70
|
+
test("a recording opens on a navigation with the page metadata, then Meta + FullSnapshot", async () => {
|
|
71
|
+
const { page, uploads } = await openPage();
|
|
72
|
+
const events = all(uploads);
|
|
73
|
+
expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
|
|
74
|
+
const [nav, meta] = events.filter((e) => e.type === 5 || e.type === 4);
|
|
75
|
+
expect(nav.data.payload).toMatchObject({
|
|
76
|
+
href: "https://app.test/shifts?access_token=REDACTED",
|
|
77
|
+
title: "App",
|
|
78
|
+
routePattern: "/shifts",
|
|
79
|
+
params: null,
|
|
80
|
+
locale: "de",
|
|
81
|
+
});
|
|
82
|
+
// Tokens are stripped from the snapshot's href too, not only the page view's.
|
|
83
|
+
expect(meta.data.href).toBe("https://app.test/shifts?access_token=REDACTED");
|
|
84
|
+
expect(events.map((e) => e.type)).not.toContain(7);
|
|
85
|
+
await page.close();
|
|
86
|
+
});
|
|
87
|
+
test("an SPA navigation is a navigation, then Meta + FullSnapshot", async () => {
|
|
88
|
+
const { page, uploads } = await openPage();
|
|
89
|
+
const before = all(uploads).length;
|
|
90
|
+
await page.evaluate(() => {
|
|
91
|
+
document.getElementById("app").innerHTML = "<h1>Profile</h1>";
|
|
92
|
+
globalThis.__recorder.emitPageEvent({
|
|
93
|
+
href: "https://app.test/profile",
|
|
94
|
+
title: "Profile",
|
|
95
|
+
routePattern: "/profile",
|
|
96
|
+
params: null,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
await settle(page);
|
|
100
|
+
const events = all(uploads).slice(before);
|
|
101
|
+
expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
|
|
102
|
+
expect(events[0].data.payload.routePattern).toBe("/profile");
|
|
103
|
+
await page.close();
|
|
104
|
+
});
|
|
105
|
+
test("a periodic keyframe is Meta + FullSnapshot without a navigation", async () => {
|
|
106
|
+
const { page, uploads } = await openPage();
|
|
107
|
+
const before = all(uploads).length;
|
|
108
|
+
await settle(page, 60_000);
|
|
109
|
+
expect(kinds(all(uploads).slice(before))).toEqual(["4", "2"]);
|
|
110
|
+
await page.close();
|
|
111
|
+
});
|
|
112
|
+
test("keyframes don't keep an idle session alive; it rotates onto a navigation", async () => {
|
|
113
|
+
const { page, uploads } = await openPage();
|
|
114
|
+
const first = uploads[0].sessionId;
|
|
115
|
+
// 31 idle minutes: only keyframes are recorded meanwhile.
|
|
116
|
+
await settle(page, 31 * 60_000);
|
|
117
|
+
expect(new Set(uploads.map((u) => u.sessionId))).toEqual(new Set([first]));
|
|
118
|
+
await page.click("#b");
|
|
119
|
+
await settle(page);
|
|
120
|
+
const next = uploads.filter((u) => u.sessionId !== first);
|
|
121
|
+
expect(next.length).toBeGreaterThan(0);
|
|
122
|
+
const events = all(next);
|
|
123
|
+
// The new session has a landing page: the page the app last reported.
|
|
124
|
+
expect(kinds(events).slice(0, 3)).toEqual(["5:navigation", "4", "2"]);
|
|
125
|
+
expect(events.find((e) => e.type === 5).data.payload.routePattern).toBe("/shifts");
|
|
126
|
+
await page.close();
|
|
127
|
+
});
|
|
128
|
+
test("a navigation after the timeout opens the new session with its own snapshot only", async () => {
|
|
129
|
+
const { page, uploads } = await openPage();
|
|
130
|
+
const first = uploads[0].sessionId;
|
|
131
|
+
await settle(page, 31 * 60_000);
|
|
132
|
+
await page.evaluate(() => globalThis.__recorder.emitPageEvent({
|
|
133
|
+
href: "https://app.test/profile",
|
|
134
|
+
title: "Profile",
|
|
135
|
+
routePattern: "/profile",
|
|
136
|
+
params: null,
|
|
137
|
+
}));
|
|
138
|
+
await settle(page);
|
|
139
|
+
const events = all(uploads.filter((u) => u.sessionId !== first));
|
|
140
|
+
expect(kinds(events)).toEqual(["5:navigation", "4", "2"]);
|
|
141
|
+
expect(events[0].data.payload.routePattern).toBe("/profile");
|
|
142
|
+
await page.close();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -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.
|
|
@@ -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"}
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measure-bytes.d.ts","sourceRoot":"","sources":["../../../src/testing/measure-bytes.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure what the recorder uploads per minute of an active user.
|
|
3
|
+
*
|
|
4
|
+
* bun packages/browser/src/testing/measure-bytes.ts [--minutes 5] [--seed 1]
|
|
5
|
+
*
|
|
6
|
+
* Runs the production recorder (`createLunarDinosRecorder`) in real Chromium on
|
|
7
|
+
* a fake clock, against an app-like page: a shift list with cards and avatars,
|
|
8
|
+
* a detail view and a form, switched by SPA navigations the way the framework
|
|
9
|
+
* SDKs report them. The user is active the whole time — mouse, scroll, clicks,
|
|
10
|
+
* typing, a navigation every 20 s. Every upload is intercepted and counted as it
|
|
11
|
+
* goes over the wire (gzip where the recorder compresses).
|
|
12
|
+
*
|
|
13
|
+
* The page is synthetic, so the absolute number is a baseline, not a forecast:
|
|
14
|
+
* compare runs of this script before and after a recorder change.
|
|
15
|
+
*/
|
|
16
|
+
import { chromium } from "playwright";
|
|
17
|
+
import { buildRecorderBundle } from "./recorder-bundle";
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const opt = (n, d) => {
|
|
20
|
+
const i = args.indexOf(`--${n}`);
|
|
21
|
+
return i === -1 ? d : args[i + 1];
|
|
22
|
+
};
|
|
23
|
+
const minutes = Number(opt("minutes", "5"));
|
|
24
|
+
const seed = Number(opt("seed", "1"));
|
|
25
|
+
const CSS = `
|
|
26
|
+
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fafafa; }
|
|
27
|
+
header { display: flex; gap: 16px; padding: 12px 24px; background: #1e1e2e; color: #fff; }
|
|
28
|
+
header a { color: inherit; text-decoration: none; }
|
|
29
|
+
main { padding: 24px; max-width: 960px; margin: 0 auto; }
|
|
30
|
+
.card { display: flex; gap: 12px; align-items: center; padding: 12px; margin: 8px 0; background: #fff; border: 1px solid #ddd; border-radius: 8px; }
|
|
31
|
+
.card img { width: 32px; height: 32px; border-radius: 50%; }
|
|
32
|
+
.card .title { font-weight: 600; }
|
|
33
|
+
.card .meta { color: #666; font-size: 13px; }
|
|
34
|
+
.badge { padding: 2px 8px; border-radius: 999px; background: #e0f2e9; color: #14532d; font-size: 12px; }
|
|
35
|
+
form label { display: block; margin: 12px 0 4px; }
|
|
36
|
+
form input, form textarea, form select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
|
|
37
|
+
button { padding: 8px 16px; border-radius: 6px; border: 0; background: #4f46e5; color: #fff; }
|
|
38
|
+
`;
|
|
39
|
+
// An avatar of the kind an app serves: same-origin, so the recorder inlines it.
|
|
40
|
+
const AVATAR = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#89b4fa"/><circle cx="128" cy="100" r="48" fill="#fff"/><rect x="48" y="170" width="160" height="80" rx="40" fill="#fff"/></svg>`;
|
|
41
|
+
const HTML = `<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Schichten</title><style>${CSS}</style></head>
|
|
42
|
+
<body><header><a href="#" data-route="/shifts">Schichten</a><a href="#" data-route="/profile">Profil</a><span id="clock"></span></header>
|
|
43
|
+
<main id="app"></main>
|
|
44
|
+
<script src="/recorder.js"></script>
|
|
45
|
+
<script>{
|
|
46
|
+
let s = ${seed};
|
|
47
|
+
const rnd = () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
|
|
48
|
+
const app = document.getElementById('app');
|
|
49
|
+
const views = {
|
|
50
|
+
'/shifts': () => {
|
|
51
|
+
let h = '<h1>Offene Schichten</h1><input id="search" placeholder="Suchen">';
|
|
52
|
+
for (let i = 0; i < 40; i++) h += '<div class="card" data-id="' + i + '"><img src="/avatar.svg?u=' + (i % 8) + '"><div><div class="title">Frühdienst Station ' + (i % 7 + 1) + '</div><div class="meta">Klinikum Nord · 06:00–14:00 · ' + (20 + i) + ' €/h</div></div><span class="badge">offen</span></div>';
|
|
53
|
+
return h;
|
|
54
|
+
},
|
|
55
|
+
'/shifts/[id]': (id) => '<h1>Schicht ' + id + '</h1><div class="card"><img src="/avatar.svg?u=1"><div><div class="title">Frühdienst</div><div class="meta">Klinikum Nord</div></div></div><p>' + 'Beschreibung der Schicht. '.repeat(30) + '</p><button id="apply">Bewerben</button>',
|
|
56
|
+
'/profile': () => '<h1>Profil</h1><form><label>Name</label><input id="name"><label>Qualifikation</label><select id="qual"><option>Pflegefachkraft</option><option>Pflegehelfer</option></select><label>Notizen</label><textarea id="notes"></textarea><button type="button">Speichern</button></form>',
|
|
57
|
+
};
|
|
58
|
+
const recorder = LunarDinos.createLunarDinosRecorder({ apiKey: 'measure', endpoint: 'https://ingest.test' });
|
|
59
|
+
function render(pattern, params) {
|
|
60
|
+
app.innerHTML = views[pattern](params && params.id);
|
|
61
|
+
}
|
|
62
|
+
function meta(pattern, params) {
|
|
63
|
+
const href = pattern === '/shifts/[id]' ? '/shifts/' + params.id : pattern;
|
|
64
|
+
return { href: location.origin + href, title: document.title, routePattern: pattern, params };
|
|
65
|
+
}
|
|
66
|
+
window.__navigate = (pattern, params = null) => {
|
|
67
|
+
render(pattern, params);
|
|
68
|
+
recorder.emitPageEvent(meta(pattern, params));
|
|
69
|
+
};
|
|
70
|
+
render('/shifts');
|
|
71
|
+
recorder.start({ pageMetadata: meta('/shifts', null) });
|
|
72
|
+
setInterval(() => {
|
|
73
|
+
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
|
74
|
+
const card = app.querySelector('.card .badge');
|
|
75
|
+
if (card && rnd() < 0.3) card.textContent = rnd() < 0.5 ? 'offen' : 'vergeben';
|
|
76
|
+
}, 1000);
|
|
77
|
+
window.__flush = () => recorder.flush();
|
|
78
|
+
}</script></body></html>`;
|
|
79
|
+
const recorderJs = await buildRecorderBundle();
|
|
80
|
+
let wireBytes = 0;
|
|
81
|
+
let jsonBytes = 0;
|
|
82
|
+
let requests = 0;
|
|
83
|
+
const eventTypes = {};
|
|
84
|
+
const browser = await chromium.launch();
|
|
85
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
86
|
+
await page.route("https://app.test/**", (route) => {
|
|
87
|
+
const u = new URL(route.request().url());
|
|
88
|
+
if (u.pathname === "/")
|
|
89
|
+
return route.fulfill({ contentType: "text/html", body: HTML });
|
|
90
|
+
if (u.pathname === "/recorder.js")
|
|
91
|
+
return route.fulfill({ contentType: "application/javascript", body: recorderJs });
|
|
92
|
+
if (u.pathname === "/avatar.svg")
|
|
93
|
+
return route.fulfill({ contentType: "image/svg+xml", body: AVATAR });
|
|
94
|
+
return route.fulfill({ status: 404, body: "" });
|
|
95
|
+
});
|
|
96
|
+
await page.route("https://ingest.test/**", async (route) => {
|
|
97
|
+
const req = route.request();
|
|
98
|
+
if (req.method() !== "POST")
|
|
99
|
+
return route.fulfill({ status: 204, body: "" });
|
|
100
|
+
const body = req.postDataBuffer() ?? Buffer.alloc(0);
|
|
101
|
+
wireBytes += body.length;
|
|
102
|
+
requests++;
|
|
103
|
+
const gzipped = req.headers()["content-encoding"] === "gzip";
|
|
104
|
+
const json = gzipped ? Bun.gunzipSync(new Uint8Array(body)) : body;
|
|
105
|
+
jsonBytes += json.length;
|
|
106
|
+
const { events } = JSON.parse(new TextDecoder().decode(json));
|
|
107
|
+
for (const e of events) {
|
|
108
|
+
const k = e.type === 3 ? `3.${e.data?.source}` : e.type === 5 ? `5.${e.data?.tag}` : String(e.type);
|
|
109
|
+
eventTypes[k] = (eventTypes[k] ?? 0) + 1;
|
|
110
|
+
}
|
|
111
|
+
return route.fulfill({ status: 200, body: "{}" });
|
|
112
|
+
});
|
|
113
|
+
await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
|
|
114
|
+
page.on("pageerror", (e) => console.error("pageerror", e.message));
|
|
115
|
+
await page.goto("https://app.test/");
|
|
116
|
+
/** Let the recorder's async gzip and fetch settle on the real clock. */
|
|
117
|
+
const settle = () => new Promise((r) => setTimeout(r, 30));
|
|
118
|
+
const routes = [
|
|
119
|
+
["/shifts", null],
|
|
120
|
+
["/shifts/[id]", { id: "7" }],
|
|
121
|
+
["/profile", null],
|
|
122
|
+
["/shifts/[id]", { id: "12" }],
|
|
123
|
+
];
|
|
124
|
+
let navs = 0;
|
|
125
|
+
for (let sec = 0; sec < minutes * 60; sec++) {
|
|
126
|
+
await page.mouse.move(200 + ((sec * 37) % 800), 150 + ((sec * 53) % 500), { steps: 4 });
|
|
127
|
+
if (sec % 3 === 0)
|
|
128
|
+
await page.mouse.wheel(0, sec % 6 === 0 ? 300 : -200);
|
|
129
|
+
if (sec % 20 === 19) {
|
|
130
|
+
const [pattern, params] = routes[++navs % routes.length];
|
|
131
|
+
await page.evaluate(([p, q]) => window.__navigate(p, q), [pattern, params]);
|
|
132
|
+
}
|
|
133
|
+
if (sec % 7 === 0) {
|
|
134
|
+
const input = page.locator("#search, #name").first();
|
|
135
|
+
if (await input.count())
|
|
136
|
+
await input.fill(`eingabe ${sec}`);
|
|
137
|
+
}
|
|
138
|
+
if (sec % 9 === 0)
|
|
139
|
+
await page.mouse.click(300, 300);
|
|
140
|
+
await page.clock.runFor(1000);
|
|
141
|
+
await settle();
|
|
142
|
+
}
|
|
143
|
+
await page.evaluate(() => window.__flush());
|
|
144
|
+
await page.clock.runFor(100);
|
|
145
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
146
|
+
await browser.close();
|
|
147
|
+
const perMin = (n) => `${(n / minutes / 1024).toFixed(1)} KB`;
|
|
148
|
+
console.log(`${minutes} active minutes, ${navs} navigations, ${requests} uploads`);
|
|
149
|
+
console.log(`wire: ${perMin(wireBytes)} per active minute (${(wireBytes / 1024).toFixed(0)} KB total)`);
|
|
150
|
+
console.log(`json: ${perMin(jsonBytes)} per active minute (${(jsonBytes / 1024).toFixed(0)} KB total)`);
|
|
151
|
+
console.log("events:", eventTypes);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-bundle.d.ts","sourceRoot":"","sources":["../../../src/testing/recorder-bundle.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAa3D"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* The production recorder built for the browser, exposing
|
|
6
|
+
* `LunarDinos.createLunarDinosRecorder` on the page.
|
|
7
|
+
*/
|
|
8
|
+
export async function buildRecorderBundle() {
|
|
9
|
+
const outdir = mkdtempSync(join(tmpdir(), "recorder-bundle-"));
|
|
10
|
+
const entry = join(outdir, "entry.ts");
|
|
11
|
+
await Bun.write(entry, `import { createLunarDinosRecorder } from "${import.meta.dir}/../recorder-core";\n(globalThis as any).LunarDinos = { createLunarDinosRecorder };\n`);
|
|
12
|
+
// Via the CLI: Bun.build inside `bun test` can't resolve workspace packages.
|
|
13
|
+
const build = Bun.spawnSync(["bun", "build", entry, "--target=browser", `--outdir=${outdir}`], {
|
|
14
|
+
cwd: import.meta.dir,
|
|
15
|
+
});
|
|
16
|
+
if (build.exitCode !== 0)
|
|
17
|
+
throw new Error(`bundle failed: ${build.stderr}`);
|
|
18
|
+
return Bun.file(join(outdir, "entry.js")).text();
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunardinos/browser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@lunardinos/rrweb-record": "0.1.0"
|
|
22
|
+
"@lunardinos/rrweb-record": "0.1.0",
|
|
23
|
+
"playwright": "^1.50.0"
|
|
23
24
|
}
|
|
24
25
|
}
|