@lunardinos/browser 0.2.3 → 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 +408 -382
- package/dist/src/recorder-core.d.ts +9 -0
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +176 -19
- 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 +45 -0
- package/dist/src/session-lifecycle.d.ts.map +1 -0
- package/dist/src/session-lifecycle.js +70 -0
- package/dist/src/session-lifecycle.test.d.ts +2 -0
- package/dist/src/session-lifecycle.test.d.ts.map +1 -0
- package/dist/src/session-lifecycle.test.js +93 -0
- 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
|
@@ -11,6 +11,13 @@ interface RecorderConfig {
|
|
|
11
11
|
batchInterval?: number;
|
|
12
12
|
/** Initial persistence mode: 'cookie' (default) or 'cookieless'. Can be changed at runtime via setPersistence(). */
|
|
13
13
|
defaultPersistence?: PersistenceMode;
|
|
14
|
+
/**
|
|
15
|
+
* BCP-47 language the app renders in (e.g. "de"). Static fallback used when a page
|
|
16
|
+
* event doesn't carry its own locale and `<html lang>` isn't set. Prefer passing the
|
|
17
|
+
* current locale per navigation via the framework SDK's page metadata, since the app
|
|
18
|
+
* knows the shown language and it can change at runtime.
|
|
19
|
+
*/
|
|
20
|
+
locale?: string;
|
|
14
21
|
}
|
|
15
22
|
interface PageEventPayload {
|
|
16
23
|
href: string;
|
|
@@ -18,6 +25,8 @@ interface PageEventPayload {
|
|
|
18
25
|
routePattern: string | null;
|
|
19
26
|
params: Record<string, string> | null;
|
|
20
27
|
referrer?: string | null;
|
|
28
|
+
/** BCP-47 language this page was rendered in (e.g. "de"). The app knows this. */
|
|
29
|
+
locale?: string | null;
|
|
21
30
|
}
|
|
22
31
|
export interface IdentifyPayload {
|
|
23
32
|
user: {
|
|
@@ -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,24 @@
|
|
|
1
|
-
import record from "@lunardinos/rrweb-record";
|
|
1
|
+
import record, {} from "@lunardinos/rrweb-record";
|
|
2
2
|
import { createPersistence } from "./persistence";
|
|
3
|
+
import { isNavigation, isUserActivity, SESSION_TIMEOUT_MS, sessionExpired, storedSessionUsable, } from "./session-lifecycle";
|
|
3
4
|
const DEFAULT_ENDPOINT = "https://ingest.lunardinos.com";
|
|
5
|
+
/** Timestamp of the last user activity, alongside the session id in sessionStorage. */
|
|
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;
|
|
4
22
|
/** Compress a string via the CompressionStream API (gzip). Returns null if unavailable or on error. */
|
|
5
23
|
async function gzipCompress(input) {
|
|
6
24
|
try {
|
|
@@ -37,6 +55,38 @@ function truncateText(text) {
|
|
|
37
55
|
return null;
|
|
38
56
|
return text.length > 40 ? `${text.slice(0, 37)}...` : text;
|
|
39
57
|
}
|
|
58
|
+
// Hard filter for auth tokens / secrets apps leak into URLs — notably Supabase,
|
|
59
|
+
// which puts `#access_token=…&refresh_token=…` in the location hash after an OAuth
|
|
60
|
+
// callback. Strip them here so live tokens never leave the browser. The server
|
|
61
|
+
// re-applies the same filter on ingest (defense-in-depth). Kept in sync with
|
|
62
|
+
// packages/shared/src/redact-url.ts (the SDK bundle is standalone, so it can't
|
|
63
|
+
// import the shared server module).
|
|
64
|
+
const AUTH_URL_KEYS = "access[_-]?token|refresh[_-]?token|provider[_-]?token|provider[_-]?refresh[_-]?token|id[_-]?token|token|token[_-]?type|expires[_-]?at|expires[_-]?in|code|client[_-]?secret|session[_-]?token|jwt|api[_-]?key|password|secret";
|
|
65
|
+
const AUTH_PAIR = new RegExp(`\\b(${AUTH_URL_KEYS})=[^&#\\s"']*`, "gi");
|
|
66
|
+
const AUTH_FRAG = new RegExp(`#[^#\\s]*\\b(?:${AUTH_URL_KEYS})=`, "i");
|
|
67
|
+
function redactAuthFromUrl(value) {
|
|
68
|
+
if (value == null)
|
|
69
|
+
return value;
|
|
70
|
+
if (value.indexOf("=") === -1)
|
|
71
|
+
return value;
|
|
72
|
+
let v = value;
|
|
73
|
+
if (AUTH_FRAG.test(v)) {
|
|
74
|
+
const h = v.indexOf("#");
|
|
75
|
+
if (h >= 0)
|
|
76
|
+
v = v.slice(0, h);
|
|
77
|
+
}
|
|
78
|
+
return v.replace(AUTH_PAIR, (m) => `${m.slice(0, m.indexOf("=") + 1)}REDACTED`);
|
|
79
|
+
}
|
|
80
|
+
/** Read the page's rendered language from `<html lang>`, lowercased, or null. */
|
|
81
|
+
function readHtmlLang() {
|
|
82
|
+
try {
|
|
83
|
+
const lang = document.documentElement.getAttribute("lang");
|
|
84
|
+
return lang?.trim() ? lang.trim().toLowerCase() : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
40
90
|
/** Extract UTM parameters from the current URL. */
|
|
41
91
|
function extractUtmParams() {
|
|
42
92
|
const params = new URLSearchParams(window.location.search);
|
|
@@ -57,6 +107,13 @@ function extractUtmParams() {
|
|
|
57
107
|
*/
|
|
58
108
|
export function createLunarDinosRecorder(config) {
|
|
59
109
|
const { apiKey, endpoint = DEFAULT_ENDPOINT, rrwebOptions = {}, batchInterval = 2000 } = config;
|
|
110
|
+
const configLocale = config.locale ?? null;
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the locale to stamp on a page event: an explicit per-page value wins
|
|
113
|
+
* (the app told us), then the live `<html lang>` (authoritative and updates on
|
|
114
|
+
* navigation), then the static config default.
|
|
115
|
+
*/
|
|
116
|
+
const resolveLocale = (explicit) => explicit ?? readHtmlLang() ?? configLocale;
|
|
60
117
|
// Always inline images so they display correctly in replay (the replay
|
|
61
118
|
// iframe is on a different origin and can't fetch original URLs).
|
|
62
119
|
const defaultRrwebOptions = {
|
|
@@ -85,7 +142,18 @@ export function createLunarDinosRecorder(config) {
|
|
|
85
142
|
method: "POST",
|
|
86
143
|
headers: reqHeaders,
|
|
87
144
|
body,
|
|
88
|
-
})
|
|
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
|
+
});
|
|
89
157
|
}
|
|
90
158
|
/** Send via navigator.sendBeacon — reliable during page unload, no custom headers. */
|
|
91
159
|
function sendViaBeacon(json) {
|
|
@@ -120,7 +188,17 @@ export function createLunarDinosRecorder(config) {
|
|
|
120
188
|
let visitorId = null;
|
|
121
189
|
let stopFn = null;
|
|
122
190
|
let intervalId = null;
|
|
191
|
+
// Periodic full-snapshot ("checkout") timer. Bounds segment size for long
|
|
192
|
+
// single-page sessions: each snapshot becomes an independently-replayable
|
|
193
|
+
// segment boundary, so the player never has to load one giant blob.
|
|
194
|
+
let checkoutIntervalId = null;
|
|
123
195
|
let recording = false;
|
|
196
|
+
/** Last user activity, for {@link SESSION_TIMEOUT_MS}. */
|
|
197
|
+
let lastActivityAt = 0;
|
|
198
|
+
/** Set while rotating, since taking the new session's snapshot re-enters emit. */
|
|
199
|
+
let rotating = false;
|
|
200
|
+
/** The page the app last reported, so a rotated session opens on it. */
|
|
201
|
+
let currentPage = {};
|
|
124
202
|
// Click enrichment state
|
|
125
203
|
let pendingClickContext = null;
|
|
126
204
|
// Input enrichment state
|
|
@@ -185,11 +263,55 @@ export function createLunarDinosRecorder(config) {
|
|
|
185
263
|
truncateText(getDirectText(clicked)) ??
|
|
186
264
|
clicked.querySelector("[aria-label]")?.getAttribute("aria-label") ??
|
|
187
265
|
null;
|
|
188
|
-
const href = tag === "a"
|
|
266
|
+
const href = redactAuthFromUrl(tag === "a"
|
|
189
267
|
? clicked.getAttribute("href")
|
|
190
|
-
: (clicked.closest("a")?.getAttribute("href") ?? null);
|
|
268
|
+
: (clicked.closest("a")?.getAttribute("href") ?? null));
|
|
191
269
|
pendingClickContext = { clickTag: tag, clickRole: role, clickName: name, clickHref: href };
|
|
192
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* End the current session and open a new one.
|
|
273
|
+
*
|
|
274
|
+
* Order matters: pending events belong to the session that produced them, so
|
|
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.
|
|
281
|
+
*/
|
|
282
|
+
function rotateSession(snapshot) {
|
|
283
|
+
if (rotating)
|
|
284
|
+
return;
|
|
285
|
+
rotating = true;
|
|
286
|
+
try {
|
|
287
|
+
flush();
|
|
288
|
+
sessionId = crypto.randomUUID();
|
|
289
|
+
persistence.setSessionId(sessionId);
|
|
290
|
+
if (typeof sessionStorage !== "undefined") {
|
|
291
|
+
sessionStorage.setItem("__ld_sid", sessionId);
|
|
292
|
+
}
|
|
293
|
+
lastActivityAt = Date.now();
|
|
294
|
+
markActivity();
|
|
295
|
+
if (snapshot) {
|
|
296
|
+
try {
|
|
297
|
+
record.takeNavigationSnapshot(currentPage);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Recording already stopped; the next start() will snapshot anyway.
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
rotating = false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** Persist the activity clock so a reload in the same tab can honour it. */
|
|
309
|
+
function markActivity() {
|
|
310
|
+
lastActivityAt = Date.now();
|
|
311
|
+
if (typeof sessionStorage !== "undefined") {
|
|
312
|
+
sessionStorage.setItem(SESSION_ACTIVITY_KEY, String(lastActivityAt));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
193
315
|
function flush(options) {
|
|
194
316
|
if (events.length === 0 || !sessionId)
|
|
195
317
|
return;
|
|
@@ -232,16 +354,24 @@ export function createLunarDinosRecorder(config) {
|
|
|
232
354
|
}
|
|
233
355
|
flush();
|
|
234
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
|
+
}
|
|
235
367
|
function emitPageEvent(payload) {
|
|
236
368
|
if (!recording)
|
|
237
369
|
return;
|
|
370
|
+
currentPage = pageMetadata(payload);
|
|
238
371
|
try {
|
|
239
|
-
//
|
|
240
|
-
record.
|
|
241
|
-
|
|
242
|
-
title: payload.title,
|
|
243
|
-
routePattern: payload.routePattern ?? undefined,
|
|
244
|
-
params: payload.params,
|
|
372
|
+
// The navigation event, then Meta + FullSnapshot of the new page
|
|
373
|
+
record.takeNavigationSnapshot({
|
|
374
|
+
...currentPage,
|
|
245
375
|
referrer: payload.referrer ?? externalReferrer(),
|
|
246
376
|
});
|
|
247
377
|
}
|
|
@@ -324,7 +454,15 @@ export function createLunarDinosRecorder(config) {
|
|
|
324
454
|
let isNewSession = false;
|
|
325
455
|
if (persistence.mode === "cookie") {
|
|
326
456
|
const persistedSession = persistence.getSessionId();
|
|
327
|
-
|
|
457
|
+
// sessionStorage outlives the session cookie — it's scoped to the tab, not
|
|
458
|
+
// to 30 minutes — so reusing it unconditionally resurrected sessions the
|
|
459
|
+
// cookie had already expired. Only honour it while it's still fresh.
|
|
460
|
+
const storedAt = typeof sessionStorage !== "undefined"
|
|
461
|
+
? Number(sessionStorage.getItem(SESSION_ACTIVITY_KEY))
|
|
462
|
+
: Number.NaN;
|
|
463
|
+
const storedSession = storedSessionUsable(storedAt, Date.now()) && typeof sessionStorage !== "undefined"
|
|
464
|
+
? sessionStorage.getItem(SESSION_STORAGE_KEY)
|
|
465
|
+
: null;
|
|
328
466
|
isNewSession = !persistedSession && !storedSession;
|
|
329
467
|
sessionId = persistedSession || storedSession || existingSessionId || crypto.randomUUID();
|
|
330
468
|
persistence.setSessionId(sessionId);
|
|
@@ -345,6 +483,7 @@ export function createLunarDinosRecorder(config) {
|
|
|
345
483
|
}
|
|
346
484
|
events = [];
|
|
347
485
|
recording = true;
|
|
486
|
+
markActivity();
|
|
348
487
|
// Listen for clicks/inputs in capture phase to grab context before rrweb sees it
|
|
349
488
|
document.addEventListener("click", captureClickContext, true);
|
|
350
489
|
document.addEventListener("input", captureInputContext, true);
|
|
@@ -352,20 +491,24 @@ export function createLunarDinosRecorder(config) {
|
|
|
352
491
|
document.addEventListener("submit", captureSubmit, true);
|
|
353
492
|
window.addEventListener("error", captureError);
|
|
354
493
|
window.addEventListener("unhandledrejection", captureRejection);
|
|
494
|
+
currentPage = startOpts.pageMetadata ? pageMetadata(startOpts.pageMetadata) : {};
|
|
355
495
|
stopFn = record({
|
|
356
496
|
...defaultRrwebOptions,
|
|
357
497
|
...rrwebOptions,
|
|
358
498
|
initialPageMetadata: startOpts.pageMetadata
|
|
359
|
-
? {
|
|
360
|
-
href: startOpts.pageMetadata.href,
|
|
361
|
-
title: startOpts.pageMetadata.title,
|
|
362
|
-
routePattern: startOpts.pageMetadata.routePattern ?? undefined,
|
|
363
|
-
params: startOpts.pageMetadata.params,
|
|
364
|
-
referrer: startOpts.pageMetadata.referrer ?? undefined,
|
|
365
|
-
}
|
|
499
|
+
? { ...currentPage, referrer: startOpts.pageMetadata.referrer ?? undefined }
|
|
366
500
|
: undefined,
|
|
367
501
|
emit(event) {
|
|
368
502
|
const ev = event;
|
|
503
|
+
// A navigation, or anything a human did, keeps the session alive; if the
|
|
504
|
+
// gap since the last one is too long this is a new sitting, so close the
|
|
505
|
+
// old session before the event is recorded against it.
|
|
506
|
+
if (isUserActivity(ev) && !rotating) {
|
|
507
|
+
if (sessionExpired(lastActivityAt, Date.now()))
|
|
508
|
+
rotateSession(!isNavigation(ev));
|
|
509
|
+
else
|
|
510
|
+
markActivity();
|
|
511
|
+
}
|
|
369
512
|
// Attach click context to rrweb click events
|
|
370
513
|
if (ev.type === 3 && ev.data?.source === 2 && ev.data?.type === 2 && pendingClickContext) {
|
|
371
514
|
const d = ev.data;
|
|
@@ -391,10 +534,20 @@ export function createLunarDinosRecorder(config) {
|
|
|
391
534
|
if (persistence.mode === "cookieless" || isNewSession) {
|
|
392
535
|
emitSessionMeta();
|
|
393
536
|
}
|
|
394
|
-
// Flush immediately so the
|
|
537
|
+
// Flush immediately so the first snapshot (which can exceed sendBeacon's
|
|
395
538
|
// ~64 KB limit) is delivered via regular fetch before the user navigates away.
|
|
396
539
|
flush();
|
|
397
540
|
intervalId = setInterval(flush, batchInterval);
|
|
541
|
+
// Take a periodic full snapshot so long single-page sessions are split into
|
|
542
|
+
// bounded, independently-replayable segments (matches PostHog's 5-min cadence).
|
|
543
|
+
checkoutIntervalId = setInterval(() => {
|
|
544
|
+
try {
|
|
545
|
+
record.takeFullSnapshot(true);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
/* not recording / mid-teardown */
|
|
549
|
+
}
|
|
550
|
+
}, FULL_SNAPSHOT_INTERVAL_MS);
|
|
398
551
|
return sessionId;
|
|
399
552
|
}
|
|
400
553
|
function stop() {
|
|
@@ -417,6 +570,10 @@ export function createLunarDinosRecorder(config) {
|
|
|
417
570
|
clearInterval(intervalId);
|
|
418
571
|
intervalId = null;
|
|
419
572
|
}
|
|
573
|
+
if (checkoutIntervalId) {
|
|
574
|
+
clearInterval(checkoutIntervalId);
|
|
575
|
+
checkoutIntervalId = null;
|
|
576
|
+
}
|
|
420
577
|
stopFn?.();
|
|
421
578
|
stopFn = null;
|
|
422
579
|
recording = false;
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a recorded session ends.
|
|
3
|
+
*
|
|
4
|
+
* A session is one sitting. The recorder used to resolve its id once at start
|
|
5
|
+
* and never revisit it, so a tab left open stayed a single session for as long
|
|
6
|
+
* as the tab lived — production accumulated recordings of 20 days and 14k
|
|
7
|
+
* segments, which are neither watchable nor meaningful as analytics.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* User inactivity that ends a session. Matched to the session cookie's max-age
|
|
11
|
+
* so the in-tab and cross-navigation rules agree on when a session ends.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SESSION_TIMEOUT_MS: number;
|
|
14
|
+
/** rrweb event types, narrowed to what this module needs. */
|
|
15
|
+
interface ActivityEvent {
|
|
16
|
+
type: number;
|
|
17
|
+
data?: {
|
|
18
|
+
source?: number;
|
|
19
|
+
tag?: string;
|
|
20
|
+
};
|
|
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
|
+
*/
|
|
29
|
+
export declare function isUserActivity(event: ActivityEvent): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Whether the gap since `lastActivityAt` has ended the session.
|
|
32
|
+
*
|
|
33
|
+
* `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
|
|
34
|
+
* session rather than the end of one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sessionExpired(lastActivityAt: number, now: number): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a session id cached in sessionStorage may still be used.
|
|
39
|
+
*
|
|
40
|
+
* sessionStorage is scoped to the tab, not to 30 minutes, so without this check
|
|
41
|
+
* a reload resurrects a session the cookie had already expired.
|
|
42
|
+
*/
|
|
43
|
+
export declare function storedSessionUsable(storedAt: number, now: number): boolean;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=session-lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-lifecycle.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,QAAiB,CAAA;AAoBhD,6DAA6D;AAC7D,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,6EAA6E;AAC7E,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAE1D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAM5D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAG1E"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a recorded session ends.
|
|
3
|
+
*
|
|
4
|
+
* A session is one sitting. The recorder used to resolve its id once at start
|
|
5
|
+
* and never revisit it, so a tab left open stayed a single session for as long
|
|
6
|
+
* as the tab lived — production accumulated recordings of 20 days and 14k
|
|
7
|
+
* segments, which are neither watchable nor meaningful as analytics.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* User inactivity that ends a session. Matched to the session cookie's max-age
|
|
11
|
+
* so the in-tab and cross-navigation rules agree on when a session ends.
|
|
12
|
+
*/
|
|
13
|
+
export const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* rrweb incremental sources that mean a human did something.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately excludes mutations (source 0): a page that polls, animates or
|
|
18
|
+
* re-renders on a timer emits those forever with nobody watching, and counting
|
|
19
|
+
* them as activity is exactly what keeps an abandoned tab alive.
|
|
20
|
+
*/
|
|
21
|
+
const USER_ACTIVITY_SOURCES = new Set([
|
|
22
|
+
1, // MouseMove
|
|
23
|
+
2, // MouseInteraction
|
|
24
|
+
3, // Scroll
|
|
25
|
+
5, // Input
|
|
26
|
+
6, // TouchMove
|
|
27
|
+
7, // MediaInteraction
|
|
28
|
+
12, // Drag
|
|
29
|
+
14, // Selection
|
|
30
|
+
]);
|
|
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
|
+
*/
|
|
40
|
+
export function isUserActivity(event) {
|
|
41
|
+
// A navigation is always a person doing something.
|
|
42
|
+
if (isNavigation(event))
|
|
43
|
+
return true;
|
|
44
|
+
// IncrementalSnapshot (3) — only the interaction sources count.
|
|
45
|
+
if (event.type === 3)
|
|
46
|
+
return USER_ACTIVITY_SOURCES.has(event.data?.source ?? -1);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether the gap since `lastActivityAt` has ended the session.
|
|
51
|
+
*
|
|
52
|
+
* `lastActivityAt` of 0 means nothing has happened yet, which is the start of a
|
|
53
|
+
* session rather than the end of one.
|
|
54
|
+
*/
|
|
55
|
+
export function sessionExpired(lastActivityAt, now) {
|
|
56
|
+
if (lastActivityAt <= 0)
|
|
57
|
+
return false;
|
|
58
|
+
return now - lastActivityAt > SESSION_TIMEOUT_MS;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether a session id cached in sessionStorage may still be used.
|
|
62
|
+
*
|
|
63
|
+
* sessionStorage is scoped to the tab, not to 30 minutes, so without this check
|
|
64
|
+
* a reload resurrects a session the cookie had already expired.
|
|
65
|
+
*/
|
|
66
|
+
export function storedSessionUsable(storedAt, now) {
|
|
67
|
+
if (!Number.isFinite(storedAt) || storedAt <= 0)
|
|
68
|
+
return false;
|
|
69
|
+
return now - storedAt < SESSION_TIMEOUT_MS;
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-lifecycle.test.d.ts","sourceRoot":"","sources":["../../src/session-lifecycle.test.ts"],"names":[],"mappings":""}
|