@maple-dev/browser 0.12.0 → 0.14.0
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/README.md +23 -9
- package/dist/console-iNhlufZV.mjs +183 -0
- package/dist/{deferred-BDKAWiKZ.mjs → deferred-BcyLmMyV.mjs} +32 -5
- package/dist/index.d.mts +12 -5
- package/dist/index.mjs +1 -1
- package/dist/react.d.mts +7 -4
- package/dist/react.mjs +43 -5
- package/dist/replay-session-DFPRE4Ee.mjs +906 -0
- package/dist/{sink-DbEuphC-.mjs → sink-Ji0J42ul.mjs} +848 -188
- package/dist/{src-DfU4Cnb1.mjs → src-B3Giah8c.mjs} +545 -171
- package/package.json +19 -16
- package/dist/console-Bkd3hBGp.mjs +0 -73
- package/dist/replay-session-Cg3U1RS0.mjs +0 -596
|
@@ -11,6 +11,15 @@ function mayUseStorage() {
|
|
|
11
11
|
//#region ../browser-session/src/platform/url-privacy.ts
|
|
12
12
|
const REDACTED = "REDACTED";
|
|
13
13
|
const SENSITIVE_PARAM = /^(access_token|id_token|refresh_token|token|token_hash|auth|authorization|code|oobcode|state|password|passwd|pwd|pass|secret|client_secret|api_key|apikey|key|signature|sig|otp|jwt|session|sessionid|session_id|ticket|__clerk_ticket|reset_token|magic|nonce|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
|
|
14
|
+
/**
|
|
15
|
+
* Any name that, lowercased with `_`, `-` and `.` removed, ends in a credential
|
|
16
|
+
* word: `accessToken`, `auth_token`, `user-token`, `__clerk_db_jwt`, `x-api-key`.
|
|
17
|
+
* Suffix rather than substring, so `tokenizer_mode` or `secretary` stay readable.
|
|
18
|
+
*/
|
|
19
|
+
const CREDENTIAL_SUFFIX = /(token|secret|password|passwd|apikey|jwt|signature|credential|credentials|privatekey)$/;
|
|
20
|
+
function isSensitiveName(name) {
|
|
21
|
+
return SENSITIVE_PARAM.test(name) || CREDENTIAL_SUFFIX.test(name.toLowerCase().replace(/[-_.]/g, ""));
|
|
22
|
+
}
|
|
14
23
|
/** Sanitizers live on `globalThis`, like consent: one page, one policy, however many SDK copies. */
|
|
15
24
|
const SANITIZERS_KEY = "__MAPLE_URL_SANITIZERS__";
|
|
16
25
|
function sanitizers() {
|
|
@@ -27,45 +36,68 @@ function sanitizers() {
|
|
|
27
36
|
*/
|
|
28
37
|
function addUrlSanitizer(sanitizer) {
|
|
29
38
|
sanitizers().add(sanitizer);
|
|
39
|
+
return () => void sanitizers().delete(sanitizer);
|
|
30
40
|
}
|
|
31
41
|
function redactParams(params) {
|
|
32
42
|
let changed = false;
|
|
33
43
|
for (const name of new Set(params.keys())) {
|
|
34
|
-
if (!
|
|
44
|
+
if (!isSensitiveName(name)) continue;
|
|
35
45
|
params.set(name, REDACTED);
|
|
36
46
|
changed = true;
|
|
37
47
|
}
|
|
38
48
|
return changed;
|
|
39
49
|
}
|
|
50
|
+
function redactQuery(query) {
|
|
51
|
+
const params = new URLSearchParams(query);
|
|
52
|
+
return redactParams(params) ? params.toString() : query;
|
|
53
|
+
}
|
|
40
54
|
/**
|
|
41
55
|
* Implicit-flow tokens ride the fragment as `#access_token=…&…`; a hash-routed
|
|
42
|
-
* app carries them in the route's own query, `#/reset?token
|
|
43
|
-
* is kept either way.
|
|
56
|
+
* app carries them in the route's own query, `#/reset?token=…` or `#reset?token=…`.
|
|
57
|
+
* The route path is kept either way.
|
|
44
58
|
*/
|
|
45
59
|
function redactFragment(fragment) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return redactParams(params) ? `${fragment.slice(0, queryAt)}?${params.toString()}` : fragment;
|
|
51
|
-
}
|
|
52
|
-
if (!fragment.includes("=")) return fragment;
|
|
53
|
-
const params = new URLSearchParams(fragment);
|
|
54
|
-
return redactParams(params) ? params.toString() : fragment;
|
|
60
|
+
const queryAt = fragment.indexOf("?");
|
|
61
|
+
const head = queryAt === -1 ? fragment : fragment.slice(0, queryAt);
|
|
62
|
+
const redactedHead = head.startsWith("/") || head.startsWith("!/") || !head.includes("=") ? head : redactQuery(head);
|
|
63
|
+
return queryAt === -1 ? redactedHead : `${redactedHead}?${redactQuery(fragment.slice(queryAt + 1))}`;
|
|
55
64
|
}
|
|
56
65
|
const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
|
|
57
66
|
const RELATIVE_BASE = "http://relative.invalid";
|
|
58
|
-
/**
|
|
67
|
+
/** `user:password@` after a scheme's `//`, for URLs the parser rejects. */
|
|
68
|
+
const USERINFO = /^([a-z][a-z0-9+.-]*:)?\/\/[^/?#@]*@/i;
|
|
69
|
+
/** One `name=value` pair after a query, fragment or matrix separator. */
|
|
70
|
+
const PAIR = /([?&#;])([^=&#;?]*)=([^&#;?]*)/g;
|
|
71
|
+
function decodeName(raw) {
|
|
72
|
+
try {
|
|
73
|
+
return decodeURIComponent(raw.replace(/\+/g, " "));
|
|
74
|
+
} catch {
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort redaction for a URL `new URL` rejects (`https://exa mple.com/?token=1`):
|
|
80
|
+
* returning it raw would send exactly the credentials redaction exists to stop.
|
|
81
|
+
*/
|
|
82
|
+
function redactUnparsable(url) {
|
|
83
|
+
return url.replace(USERINFO, (_, scheme) => `${scheme ?? ""}//`).replace(PAIR, (pair, separator, name) => isSensitiveName(decodeName(name)) ? `${separator}${name}=${REDACTED}` : pair);
|
|
84
|
+
}
|
|
85
|
+
/** Redact credentials in userinfo and in query and fragment parameters, keeping the URL's form. */
|
|
59
86
|
function redactUrl(url) {
|
|
60
|
-
if (!url ||
|
|
87
|
+
if (!url || !/[?#@;]/.test(url)) return url;
|
|
61
88
|
const absolute = ABSOLUTE.test(url);
|
|
62
89
|
let parsed;
|
|
63
90
|
try {
|
|
64
91
|
parsed = new URL(url, absolute ? void 0 : RELATIVE_BASE);
|
|
65
92
|
} catch {
|
|
66
|
-
return url;
|
|
93
|
+
return redactUnparsable(url);
|
|
67
94
|
}
|
|
68
95
|
let changed = redactParams(parsed.searchParams);
|
|
96
|
+
if (parsed.username || parsed.password) {
|
|
97
|
+
parsed.username = "";
|
|
98
|
+
parsed.password = "";
|
|
99
|
+
changed = true;
|
|
100
|
+
}
|
|
69
101
|
const fragment = parsed.hash.slice(1);
|
|
70
102
|
const redactedFragment = redactFragment(fragment);
|
|
71
103
|
if (redactedFragment !== fragment) {
|
|
@@ -92,13 +124,42 @@ function scrubUrl(url) {
|
|
|
92
124
|
return out;
|
|
93
125
|
}
|
|
94
126
|
//#endregion
|
|
127
|
+
//#region ../browser-session/src/platform/uuid.ts
|
|
128
|
+
/**
|
|
129
|
+
* A random v4 UUID. `crypto.randomUUID` exists only in secure contexts (HTTPS
|
|
130
|
+
* and localhost) and from Safari 15.4, so a plain-http intranet or staging site
|
|
131
|
+
* would otherwise throw from `init()`. `getRandomValues` has no such limit.
|
|
132
|
+
*/
|
|
133
|
+
function randomUuid() {
|
|
134
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
135
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
|
|
136
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 64;
|
|
137
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
138
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
139
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
95
142
|
//#region ../browser-session/src/platform/cookie.ts
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
143
|
+
/**
|
|
144
|
+
* On `globalThis`, like consent: two bundled SDK copies writing the same cookies
|
|
145
|
+
* under different `Domain=` scopes would each keep a copy the other cannot delete.
|
|
146
|
+
*/
|
|
147
|
+
const SCOPE_KEY = "__MAPLE_BROWSER_COOKIE_SCOPE__";
|
|
148
|
+
function scope() {
|
|
149
|
+
const owner = globalThis;
|
|
150
|
+
const existing = owner[SCOPE_KEY];
|
|
151
|
+
if (isCookieScope(existing)) return existing;
|
|
152
|
+
const fresh = {
|
|
153
|
+
cookieDomainOverride: void 0,
|
|
154
|
+
crossSubdomainCookie: true,
|
|
155
|
+
probedCookieDomain: void 0
|
|
156
|
+
};
|
|
157
|
+
owner[SCOPE_KEY] = fresh;
|
|
158
|
+
return fresh;
|
|
159
|
+
}
|
|
160
|
+
function isCookieScope(value) {
|
|
161
|
+
return typeof value === "object" && value !== null && "crossSubdomainCookie" in value;
|
|
162
|
+
}
|
|
102
163
|
/**
|
|
103
164
|
* Apply the host app's cookie configuration. Called from `configurePrivacy`, so
|
|
104
165
|
* both SDKs get it from the single call they already make.
|
|
@@ -114,9 +175,10 @@ let probedCookieDomain;
|
|
|
114
175
|
* `app.example.com`, not the reverse), so the longer string wins.
|
|
115
176
|
*/
|
|
116
177
|
function configureCookieScope(options) {
|
|
117
|
-
|
|
118
|
-
if (options.
|
|
119
|
-
|
|
178
|
+
const state = scope();
|
|
179
|
+
if (options.crossSubdomainCookie === false) state.crossSubdomainCookie = false;
|
|
180
|
+
if (options.cookieDomain !== void 0) state.cookieDomainOverride = tighterCookieDomain(state.cookieDomainOverride, options.cookieDomain);
|
|
181
|
+
state.probedCookieDomain = void 0;
|
|
120
182
|
}
|
|
121
183
|
/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
|
|
122
184
|
function tighterCookieDomain(current, next) {
|
|
@@ -124,16 +186,29 @@ function tighterCookieDomain(current, next) {
|
|
|
124
186
|
if (current === "" || next === "") return "";
|
|
125
187
|
return next.length > current.length ? next : current;
|
|
126
188
|
}
|
|
127
|
-
|
|
128
|
-
|
|
189
|
+
/** Every value stored under `name`: a host-only and a domain cookie can coexist. */
|
|
190
|
+
function readRawCookies(name) {
|
|
191
|
+
if (typeof document === "undefined") return [];
|
|
192
|
+
const values = [];
|
|
129
193
|
try {
|
|
130
194
|
for (const part of document.cookie.split(";")) {
|
|
131
195
|
const raw = part.trim();
|
|
132
196
|
if (!raw.startsWith(`${name}=`)) continue;
|
|
133
|
-
|
|
197
|
+
try {
|
|
198
|
+
values.push(decodeURIComponent(raw.slice(name.length + 1)));
|
|
199
|
+
} catch {}
|
|
134
200
|
}
|
|
135
201
|
} catch {}
|
|
202
|
+
return values;
|
|
203
|
+
}
|
|
204
|
+
function readRawCookie(name) {
|
|
205
|
+
return readRawCookies(name)[0];
|
|
136
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Write a cookie, returning whether the browser actually kept it. Assigning to
|
|
209
|
+
* `document.cookie` succeeds silently when the cookie is refused (blocked,
|
|
210
|
+
* oversized, bad `Domain=`), so a stored cookie is confirmed by reading it back.
|
|
211
|
+
*/
|
|
137
212
|
function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
138
213
|
if (typeof document === "undefined") return false;
|
|
139
214
|
const attributes = [
|
|
@@ -146,10 +221,10 @@ function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
|
146
221
|
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
|
|
147
222
|
try {
|
|
148
223
|
document.cookie = attributes.join("; ");
|
|
149
|
-
return true;
|
|
150
224
|
} catch {
|
|
151
225
|
return false;
|
|
152
226
|
}
|
|
227
|
+
return maxAgeSeconds <= 0 || readRawCookies(name).includes(value);
|
|
153
228
|
}
|
|
154
229
|
/**
|
|
155
230
|
* Expire `name` host-only and on every parent domain it could have been scoped
|
|
@@ -158,15 +233,19 @@ function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
|
158
233
|
*/
|
|
159
234
|
function deleteRawCookie(name) {
|
|
160
235
|
setRawCookie(name, "", "", 0);
|
|
161
|
-
|
|
162
|
-
|
|
236
|
+
const hostname = typeof location === "undefined" ? "" : location.hostname;
|
|
237
|
+
for (const domain of candidateDomains(hostname)) setRawCookie(name, "", domain, 0);
|
|
238
|
+
const override = scope().cookieDomainOverride;
|
|
239
|
+
if (override) setRawCookie(name, "", override, 0);
|
|
163
240
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Parent domains of `hostname`, broadest first. Empty for single-label hosts,
|
|
243
|
+
* IPs, and hosts with an empty label (`example.com.`), whose suffixes are not domains.
|
|
244
|
+
*/
|
|
245
|
+
function candidateDomains(hostname) {
|
|
168
246
|
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return [];
|
|
169
247
|
const parts = hostname.split(".");
|
|
248
|
+
if (parts.includes("")) return [];
|
|
170
249
|
const candidates = [];
|
|
171
250
|
for (let i = parts.length - 2; i >= 0; i--) candidates.push(parts.slice(i).join("."));
|
|
172
251
|
return candidates;
|
|
@@ -182,21 +261,21 @@ function candidateDomains() {
|
|
|
182
261
|
* for bare IPs, neither of which can carry a `Domain=` attribute.
|
|
183
262
|
*/
|
|
184
263
|
function probeCookieDomain() {
|
|
185
|
-
if (typeof document === "undefined") return "";
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
264
|
+
if (typeof document === "undefined" || typeof location === "undefined") return "";
|
|
265
|
+
const probe = "__maple_probe";
|
|
266
|
+
const value = randomUuid().slice(0, 8);
|
|
267
|
+
for (const candidate of candidateDomains(location.hostname)) if (setRawCookie(probe, value, candidate, 60)) {
|
|
268
|
+
setRawCookie(probe, "", candidate, 0);
|
|
269
|
+
return candidate;
|
|
192
270
|
}
|
|
193
271
|
return "";
|
|
194
272
|
}
|
|
195
273
|
function cookieDomain() {
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
if (
|
|
199
|
-
|
|
274
|
+
const state = scope();
|
|
275
|
+
if (state.cookieDomainOverride !== void 0) return state.cookieDomainOverride;
|
|
276
|
+
if (!state.crossSubdomainCookie) return "";
|
|
277
|
+
if (state.probedCookieDomain === void 0) state.probedCookieDomain = probeCookieDomain();
|
|
278
|
+
return state.probedCookieDomain;
|
|
200
279
|
}
|
|
201
280
|
//#endregion
|
|
202
281
|
//#region ../browser-session/src/platform/json.ts
|
|
@@ -242,6 +321,18 @@ function parseJsonObject(raw) {
|
|
|
242
321
|
function isStringRecord(value) {
|
|
243
322
|
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
244
323
|
}
|
|
324
|
+
/** A `\\` pair, or the escape `JSON.stringify` writes for a lone surrogate. */
|
|
325
|
+
const LONE_SURROGATE_ESCAPE = /\\(?:\\|u[dD][89a-fA-F][0-9a-fA-F]{2})/g;
|
|
326
|
+
/**
|
|
327
|
+
* `JSON.stringify`, with every lone surrogate written as U+FFFD. JavaScript
|
|
328
|
+
* strings may hold half an emoji; strict JSON parsers (ingest's among them)
|
|
329
|
+
* reject the escape for one, and with it the whole NDJSON batch it rides in.
|
|
330
|
+
* Paired surrogates are written as raw characters, so any `\uD8xx` escape left
|
|
331
|
+
* in the output is a lone one.
|
|
332
|
+
*/
|
|
333
|
+
function stringifyWellFormed(value) {
|
|
334
|
+
return (JSON.stringify(value) ?? "null").replace(LONE_SURROGATE_ESCAPE, (escape) => escape === "\\\\" ? escape : "\\ufffd");
|
|
335
|
+
}
|
|
245
336
|
//#endregion
|
|
246
337
|
//#region ../browser-session/src/session/idle.ts
|
|
247
338
|
/**
|
|
@@ -268,53 +359,71 @@ const STORAGE_KEY$2 = "maple.visit";
|
|
|
268
359
|
const COOKIE_NAME$1 = "maple_visit";
|
|
269
360
|
/** In-memory copy: all a page load has when persistence is off or blocked. */
|
|
270
361
|
let ephemeral$1;
|
|
271
|
-
function
|
|
362
|
+
function parseVisitMarker(raw) {
|
|
272
363
|
if (!raw) return void 0;
|
|
273
|
-
const
|
|
274
|
-
|
|
364
|
+
const match = /^(\d{1,16})(?:\.([0-9a-z]{1,32}))?$/.exec(raw);
|
|
365
|
+
if (!match?.[1]) return void 0;
|
|
366
|
+
return {
|
|
367
|
+
at: Number(match[1]),
|
|
368
|
+
token: match[2] ?? ""
|
|
369
|
+
};
|
|
275
370
|
}
|
|
276
371
|
function readMarker(persist) {
|
|
277
372
|
if (!persist) return ephemeral$1;
|
|
278
|
-
const fromCookie =
|
|
373
|
+
const fromCookie = parseVisitMarker(readRawCookie(COOKIE_NAME$1));
|
|
279
374
|
if (fromCookie !== void 0) return fromCookie;
|
|
280
375
|
try {
|
|
281
|
-
const fromStorage =
|
|
376
|
+
const fromStorage = parseVisitMarker(window.localStorage.getItem(STORAGE_KEY$2));
|
|
282
377
|
if (fromStorage !== void 0) return fromStorage;
|
|
283
378
|
} catch {}
|
|
284
379
|
return ephemeral$1;
|
|
285
380
|
}
|
|
286
|
-
function writeMarker(
|
|
287
|
-
ephemeral$1 =
|
|
381
|
+
function writeMarker(marker, persist) {
|
|
382
|
+
ephemeral$1 = marker;
|
|
288
383
|
if (!persist) return;
|
|
289
|
-
const value = String(at);
|
|
384
|
+
const value = marker.token ? `${marker.at}.${marker.token}` : String(marker.at);
|
|
290
385
|
try {
|
|
291
386
|
window.localStorage.setItem(STORAGE_KEY$2, value);
|
|
292
387
|
} catch {}
|
|
293
|
-
setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - at)) / 1e3);
|
|
388
|
+
setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - marker.at)) / 1e3);
|
|
294
389
|
}
|
|
295
390
|
function withinVisit(marker, at) {
|
|
296
|
-
return marker !== void 0 && Math.abs(at - marker) < 18e5;
|
|
391
|
+
return marker !== void 0 && Math.abs(at - marker.at) < 18e5;
|
|
297
392
|
}
|
|
298
393
|
/**
|
|
299
394
|
* Claim the visit `at` belongs to, returning whether this caller is the one
|
|
300
395
|
* charged for it. `persist` is false when visitor tracking is off, which keeps
|
|
301
396
|
* the claim in memory: no identifier-adjacent cookie the host app opted out of.
|
|
397
|
+
*
|
|
398
|
+
* Restored tabs all start at once and can each read "no visit" before any of
|
|
399
|
+
* them writes one. Each writes its own token and reads the marker back, and
|
|
400
|
+
* only the tab whose token survived is charged: the last writer wins.
|
|
302
401
|
*/
|
|
303
402
|
function claimVisit(at, persist) {
|
|
304
403
|
if (typeof window === "undefined") return false;
|
|
305
404
|
const marker = readMarker(persist);
|
|
306
405
|
if (withinVisit(marker, at)) {
|
|
307
|
-
if (at > marker) writeMarker(
|
|
406
|
+
if (at > marker.at) writeMarker({
|
|
407
|
+
at,
|
|
408
|
+
token: marker.token
|
|
409
|
+
}, persist);
|
|
308
410
|
return false;
|
|
309
411
|
}
|
|
310
|
-
|
|
311
|
-
|
|
412
|
+
const token = randomUuid().slice(0, 8);
|
|
413
|
+
writeMarker({
|
|
414
|
+
at,
|
|
415
|
+
token
|
|
416
|
+
}, persist);
|
|
417
|
+
return readMarker(persist)?.token === token;
|
|
312
418
|
}
|
|
313
419
|
/** Extend the current visit to `at`. Never opens one: only `claimVisit` does. */
|
|
314
420
|
function touchVisit(at, persist) {
|
|
315
421
|
if (typeof window === "undefined") return;
|
|
316
422
|
const marker = readMarker(persist);
|
|
317
|
-
if (withinVisit(marker, at) && at > marker) writeMarker(
|
|
423
|
+
if (withinVisit(marker, at) && at > marker.at) writeMarker({
|
|
424
|
+
at,
|
|
425
|
+
token: marker.token
|
|
426
|
+
}, persist);
|
|
318
427
|
}
|
|
319
428
|
/** Drop the claim from every store, for a visitor-tracking opt-out. */
|
|
320
429
|
function clearVisitClaim() {
|
|
@@ -377,22 +486,42 @@ const COOKIE_NAME = "maple_visitor";
|
|
|
377
486
|
* expiry onto ids already in the wild is not.
|
|
378
487
|
*/
|
|
379
488
|
const MAX_AGE_MS = 3456e7;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
489
|
+
/** Only ids this SDK could have minted; anything else in the cookie is someone else's. */
|
|
490
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
491
|
+
/**
|
|
492
|
+
* On `globalThis`, like consent: with two bundled SDK copies, per-copy state let
|
|
493
|
+
* one copy's opt-out purge the id while the other minted it straight back.
|
|
494
|
+
*/
|
|
495
|
+
const VISITOR_KEY = "__MAPLE_BROWSER_VISITOR__";
|
|
496
|
+
function visitorState() {
|
|
497
|
+
const owner = globalThis;
|
|
498
|
+
const existing = owner[VISITOR_KEY];
|
|
499
|
+
if (isVisitorState(existing)) return existing;
|
|
500
|
+
const fresh = {
|
|
501
|
+
cached: void 0,
|
|
502
|
+
enabled: true,
|
|
503
|
+
mintedThisLoad: false,
|
|
504
|
+
optedOut: false,
|
|
505
|
+
persisted: false
|
|
506
|
+
};
|
|
507
|
+
owner[VISITOR_KEY] = fresh;
|
|
508
|
+
return fresh;
|
|
509
|
+
}
|
|
510
|
+
function isVisitorState(value) {
|
|
511
|
+
return typeof value === "object" && value !== null && "optedOut" in value;
|
|
512
|
+
}
|
|
385
513
|
/**
|
|
386
514
|
* Kept under this name because `configurePrivacy` and the SDK tests call it;
|
|
387
515
|
* the domain decision lives in `platform/cookie.ts`, shared with the visit claim.
|
|
388
516
|
*/
|
|
389
517
|
const configureVisitorCookie = configureCookieScope;
|
|
390
|
-
function
|
|
518
|
+
function parseVisitorRecord(raw) {
|
|
391
519
|
if (!raw) return void 0;
|
|
392
520
|
const value = parseJsonObject(raw);
|
|
393
521
|
if (!value) return void 0;
|
|
394
522
|
const { id, mintedAt } = value;
|
|
395
|
-
if (typeof id !== "string" ||
|
|
523
|
+
if (typeof id !== "string" || !UUID.test(id)) return void 0;
|
|
524
|
+
if (typeof mintedAt !== "number" || !Number.isFinite(mintedAt)) return void 0;
|
|
396
525
|
if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
|
|
397
526
|
return {
|
|
398
527
|
id,
|
|
@@ -401,13 +530,13 @@ function parseRecord(raw) {
|
|
|
401
530
|
}
|
|
402
531
|
function readFromStorage() {
|
|
403
532
|
try {
|
|
404
|
-
return
|
|
533
|
+
return parseVisitorRecord(window.localStorage.getItem(STORAGE_KEY$1));
|
|
405
534
|
} catch {
|
|
406
535
|
return;
|
|
407
536
|
}
|
|
408
537
|
}
|
|
409
538
|
function readFromCookie() {
|
|
410
|
-
return
|
|
539
|
+
return parseVisitorRecord(readRawCookie(COOKIE_NAME));
|
|
411
540
|
}
|
|
412
541
|
function writeToStorage(record) {
|
|
413
542
|
try {
|
|
@@ -430,31 +559,32 @@ function writeToCookie(record) {
|
|
|
430
559
|
function write(record) {
|
|
431
560
|
const inStorage = writeToStorage(record);
|
|
432
561
|
const inCookie = writeToCookie(record);
|
|
433
|
-
persisted = inStorage || inCookie;
|
|
562
|
+
visitorState().persisted = inStorage || inCookie;
|
|
434
563
|
}
|
|
435
564
|
/**
|
|
436
565
|
* The current visitor id, minting one on first use. `undefined` when visitor
|
|
437
566
|
* tracking is off or outside a browser.
|
|
438
567
|
*/
|
|
439
568
|
function getVisitorId() {
|
|
440
|
-
|
|
441
|
-
if (
|
|
569
|
+
const state = visitorState();
|
|
570
|
+
if (!state.enabled || state.optedOut || typeof window === "undefined") return void 0;
|
|
571
|
+
if (state.cached) return state.cached.id;
|
|
442
572
|
const fromCookie = readFromCookie();
|
|
443
573
|
const fromStorage = readFromStorage();
|
|
444
574
|
const existing = fromCookie ?? fromStorage;
|
|
445
575
|
if (existing) {
|
|
446
|
-
cached = existing;
|
|
447
|
-
persisted = true;
|
|
576
|
+
state.cached = existing;
|
|
577
|
+
state.persisted = true;
|
|
448
578
|
if (!fromCookie) writeToCookie(existing);
|
|
449
579
|
if (!fromStorage || fromStorage.id !== existing.id) writeToStorage(existing);
|
|
450
580
|
return existing.id;
|
|
451
581
|
}
|
|
452
582
|
const record = {
|
|
453
|
-
id:
|
|
583
|
+
id: randomUuid(),
|
|
454
584
|
mintedAt: Date.now()
|
|
455
585
|
};
|
|
456
|
-
cached = record;
|
|
457
|
-
mintedThisLoad = true;
|
|
586
|
+
state.cached = record;
|
|
587
|
+
state.mintedThisLoad = true;
|
|
458
588
|
write(record);
|
|
459
589
|
return record.id;
|
|
460
590
|
}
|
|
@@ -471,8 +601,9 @@ function getVisitorId() {
|
|
|
471
601
|
*/
|
|
472
602
|
function claimNewVisitor() {
|
|
473
603
|
getVisitorId();
|
|
474
|
-
|
|
475
|
-
mintedThisLoad
|
|
604
|
+
const state = visitorState();
|
|
605
|
+
if (!state.mintedThisLoad) return false;
|
|
606
|
+
state.mintedThisLoad = false;
|
|
476
607
|
return true;
|
|
477
608
|
}
|
|
478
609
|
/**
|
|
@@ -482,18 +613,31 @@ function claimNewVisitor() {
|
|
|
482
613
|
* layer can flag it rather than quietly inflate.
|
|
483
614
|
*/
|
|
484
615
|
function isVisitorIdPersisted() {
|
|
485
|
-
return persisted;
|
|
616
|
+
return visitorState().persisted;
|
|
486
617
|
}
|
|
487
618
|
/**
|
|
488
619
|
* Turn persistent visitor tracking on or off. Turning it off also purges any
|
|
489
620
|
* id already stored — an opt-out that leaves the identifier behind is not one.
|
|
490
621
|
*/
|
|
491
622
|
function setVisitorTracking(nextEnabled) {
|
|
492
|
-
enabled = nextEnabled;
|
|
493
|
-
if (nextEnabled)
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
623
|
+
visitorState().enabled = nextEnabled;
|
|
624
|
+
if (!nextEnabled) purgeVisitorId();
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* `persistVisitorId: false` from any SDK on the page. Unlike `setVisitorTracking`,
|
|
628
|
+
* which follows consent both ways, this never turns back on for the page load.
|
|
629
|
+
*/
|
|
630
|
+
function optOutOfVisitorId() {
|
|
631
|
+
visitorState().optedOut = true;
|
|
632
|
+
purgeVisitorId();
|
|
633
|
+
}
|
|
634
|
+
/** Drop the id and the visit claim from memory and every store. */
|
|
635
|
+
function purgeVisitorId() {
|
|
636
|
+
const state = visitorState();
|
|
637
|
+
state.cached = void 0;
|
|
638
|
+
state.persisted = false;
|
|
639
|
+
state.mintedThisLoad = false;
|
|
640
|
+
if (typeof window === "undefined") return;
|
|
497
641
|
try {
|
|
498
642
|
window.localStorage.removeItem(STORAGE_KEY$1);
|
|
499
643
|
} catch {}
|
|
@@ -533,10 +677,14 @@ function consentState() {
|
|
|
533
677
|
const existing = owner[CONSENT_KEY];
|
|
534
678
|
if (existing) {
|
|
535
679
|
existing.revokedAt ||= 0;
|
|
680
|
+
existing.configured ||= false;
|
|
681
|
+
existing.deniedBeforeGate ||= false;
|
|
536
682
|
return existing;
|
|
537
683
|
}
|
|
538
684
|
const fresh = {
|
|
539
685
|
allowedSince: 0,
|
|
686
|
+
configured: false,
|
|
687
|
+
deniedBeforeGate: false,
|
|
540
688
|
granted: false,
|
|
541
689
|
listeners: /* @__PURE__ */ new Set(),
|
|
542
690
|
requireConsent: false,
|
|
@@ -565,6 +713,34 @@ function consentRevokedAt() {
|
|
|
565
713
|
return inMemory;
|
|
566
714
|
}
|
|
567
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* Withdraw consent for good: data kept from before now is never sent, and the
|
|
718
|
+
* persistent visitor id and visit claim are deleted from every store.
|
|
719
|
+
*/
|
|
720
|
+
function revoke() {
|
|
721
|
+
writeRevokedAt(Date.now());
|
|
722
|
+
purgeVisitorId();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* How long a denial on a page that never granted waits before it purges.
|
|
726
|
+
* Consent tools commonly set a default "denied" and then replay the stored
|
|
727
|
+
* "granted" once loaded; purging at once would mint a new visitor every page.
|
|
728
|
+
*/
|
|
729
|
+
const DENIAL_SETTLE_MS = 1e4;
|
|
730
|
+
let pendingRevoke;
|
|
731
|
+
function cancelPendingRevoke() {
|
|
732
|
+
if (pendingRevoke !== void 0) clearTimeout(pendingRevoke);
|
|
733
|
+
pendingRevoke = void 0;
|
|
734
|
+
}
|
|
735
|
+
/** A denial with no grant before it on this page: a withdrawal unless a grant follows soon. */
|
|
736
|
+
function revokeUnlessGranted() {
|
|
737
|
+
if (pendingRevoke !== void 0) return;
|
|
738
|
+
pendingRevoke = setTimeout(() => {
|
|
739
|
+
pendingRevoke = void 0;
|
|
740
|
+
if (!consentState().granted) revoke();
|
|
741
|
+
}, DENIAL_SETTLE_MS);
|
|
742
|
+
}
|
|
743
|
+
let warnedAboutMissingGate = false;
|
|
568
744
|
function updateEffectiveConsent(previous) {
|
|
569
745
|
const state = consentState();
|
|
570
746
|
const allowed = hasConsent();
|
|
@@ -584,9 +760,11 @@ function updateEffectiveConsent(previous) {
|
|
|
584
760
|
* denied→allowed transition would start capture on a user who never consented.
|
|
585
761
|
* To genuinely relax a gate, reload without it.
|
|
586
762
|
*/
|
|
763
|
+
/** Returns the remover for its `sanitizeUrl`, so a shutdown leaves no closure behind. */
|
|
587
764
|
function configurePrivacy(options) {
|
|
588
765
|
const state = consentState();
|
|
589
766
|
const previous = hasConsent();
|
|
767
|
+
state.configured = true;
|
|
590
768
|
state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
|
|
591
769
|
state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
|
|
592
770
|
configurePersistence(options?.persistence);
|
|
@@ -594,15 +772,39 @@ function configurePrivacy(options) {
|
|
|
594
772
|
crossSubdomainCookie: options?.crossSubdomainCookie,
|
|
595
773
|
cookieDomain: options?.cookieDomain
|
|
596
774
|
});
|
|
597
|
-
if (options?.
|
|
775
|
+
if (options?.persistVisitorId === false) optOutOfVisitorId();
|
|
776
|
+
const removeSanitizer = options?.sanitizeUrl ? addUrlSanitizer(options.sanitizeUrl) : () => {};
|
|
777
|
+
if (state.requireConsent && state.deniedBeforeGate && !state.granted) revokeUnlessGranted();
|
|
778
|
+
state.deniedBeforeGate = false;
|
|
598
779
|
updateEffectiveConsent(previous);
|
|
780
|
+
return removeSanitizer;
|
|
599
781
|
}
|
|
600
|
-
/**
|
|
782
|
+
/**
|
|
783
|
+
* Record the user's consent decision. No-op unless `requireConsent` is set.
|
|
784
|
+
*
|
|
785
|
+
* Under the gate, every `false` is a withdrawal, including one replayed on a
|
|
786
|
+
* fresh page load: the visitor id and data queued under an earlier grant must
|
|
787
|
+
* not survive it. That one purges after a short settle, so a default "denied"
|
|
788
|
+
* the consent tool replaces with "granted" does not reset the visitor.
|
|
789
|
+
*/
|
|
601
790
|
function setConsent(nextGranted) {
|
|
602
791
|
const previous = hasConsent();
|
|
603
792
|
const state = consentState();
|
|
604
|
-
|
|
793
|
+
const wasGranted = state.granted;
|
|
605
794
|
state.granted = nextGranted;
|
|
795
|
+
if (nextGranted) {
|
|
796
|
+
state.deniedBeforeGate = false;
|
|
797
|
+
cancelPendingRevoke();
|
|
798
|
+
} else if (state.requireConsent) {
|
|
799
|
+
if (wasGranted) {
|
|
800
|
+
cancelPendingRevoke();
|
|
801
|
+
revoke();
|
|
802
|
+
} else revokeUnlessGranted();
|
|
803
|
+
} else if (!state.configured) state.deniedBeforeGate = true;
|
|
804
|
+
else if (!warnedAboutMissingGate) {
|
|
805
|
+
warnedAboutMissingGate = true;
|
|
806
|
+
console.warn("[maple] setConsent(false) has no effect without privacy.requireConsent; capture continues.");
|
|
807
|
+
}
|
|
606
808
|
updateEffectiveConsent(previous);
|
|
607
809
|
}
|
|
608
810
|
/** Whether capture may proceed at all. */
|
|
@@ -653,27 +855,41 @@ function activeTraceId() {
|
|
|
653
855
|
const id = traceIdProvider();
|
|
654
856
|
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
655
857
|
}
|
|
656
|
-
const slots = [];
|
|
858
|
+
const slots$1 = [];
|
|
657
859
|
/** Report a span that just started. Fills the innermost open slot, first span wins. */
|
|
658
860
|
function noteStartedTraceId(traceId) {
|
|
659
|
-
const slot = slots.at(-1);
|
|
861
|
+
const slot = slots$1.at(-1);
|
|
660
862
|
if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
|
|
661
863
|
}
|
|
662
864
|
/** Run `fn`, returning its result and the trace id of the first span started during it. */
|
|
663
865
|
function withStartedTraceId(fn) {
|
|
664
866
|
const slot = { traceId: void 0 };
|
|
665
|
-
slots.push(slot);
|
|
867
|
+
slots$1.push(slot);
|
|
666
868
|
try {
|
|
667
869
|
return {
|
|
668
870
|
result: fn(),
|
|
669
871
|
traceId: slot.traceId
|
|
670
872
|
};
|
|
671
873
|
} finally {
|
|
672
|
-
const index = slots.lastIndexOf(slot);
|
|
673
|
-
if (index !== -1) slots.splice(index, 1);
|
|
874
|
+
const index = slots$1.lastIndexOf(slot);
|
|
875
|
+
if (index !== -1) slots$1.splice(index, 1);
|
|
674
876
|
}
|
|
675
877
|
}
|
|
676
878
|
//#endregion
|
|
879
|
+
//#region ../browser-session/src/platform/text.ts
|
|
880
|
+
/**
|
|
881
|
+
* Cut `text` to at most `max` UTF-16 units without splitting a surrogate pair,
|
|
882
|
+
* appending `suffix` when anything was cut. Half an emoji is a lone surrogate,
|
|
883
|
+
* which strict JSON parsers reject along with the batch carrying it.
|
|
884
|
+
*/
|
|
885
|
+
function truncateText(text, max, suffix = "") {
|
|
886
|
+
if (text.length <= max) return text;
|
|
887
|
+
let end = Math.max(0, max);
|
|
888
|
+
const last = text.charCodeAt(end - 1);
|
|
889
|
+
if (end > 0 && last >= 55296 && last <= 56319) end--;
|
|
890
|
+
return `${text.slice(0, end)}${suffix}`;
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
677
893
|
//#region ../browser-session/src/capture/shared.ts
|
|
678
894
|
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
679
895
|
function safeEmit(emit, ev) {
|
|
@@ -684,24 +900,25 @@ function safeEmit(emit, ev) {
|
|
|
684
900
|
//#endregion
|
|
685
901
|
//#region ../browser-session/src/capture/errors.ts
|
|
686
902
|
const MAX_STACK = 4e3;
|
|
903
|
+
const MAX_MESSAGE = 1e3;
|
|
687
904
|
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
688
905
|
function installErrorCapture(emit) {
|
|
689
906
|
const onError = (event) => {
|
|
690
907
|
safeEmit(emit, {
|
|
691
908
|
type: "error",
|
|
692
909
|
level: "error",
|
|
693
|
-
message: event.message || String(event.error ?? "Error"),
|
|
694
|
-
errorStack: truncate(event.error
|
|
910
|
+
message: truncate(event.message || String(event.error ?? "Error"), MAX_MESSAGE),
|
|
911
|
+
errorStack: truncate(describeRejection(event.error).stack, MAX_STACK),
|
|
695
912
|
traceId: activeTraceId()
|
|
696
913
|
});
|
|
697
914
|
};
|
|
698
915
|
const onRejection = (event) => {
|
|
699
|
-
const
|
|
916
|
+
const { message, stack } = describeRejection(event.reason);
|
|
700
917
|
safeEmit(emit, {
|
|
701
918
|
type: "error",
|
|
702
919
|
level: "error",
|
|
703
|
-
message:
|
|
704
|
-
errorStack: truncate(
|
|
920
|
+
message: truncate(message, MAX_MESSAGE),
|
|
921
|
+
errorStack: truncate(stack, MAX_STACK),
|
|
705
922
|
traceId: activeTraceId()
|
|
706
923
|
});
|
|
707
924
|
};
|
|
@@ -712,9 +929,25 @@ function installErrorCapture(emit) {
|
|
|
712
929
|
window.removeEventListener("unhandledrejection", onRejection);
|
|
713
930
|
};
|
|
714
931
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
932
|
+
/** An error or rejection reason is anything a `throw` or `reject()` was given. */
|
|
933
|
+
function describeRejection(reason) {
|
|
934
|
+
if (typeof reason === "string") return {
|
|
935
|
+
message: reason,
|
|
936
|
+
stack: void 0
|
|
937
|
+
};
|
|
938
|
+
if (typeof reason !== "object" || reason === null) return {
|
|
939
|
+
message: "Unhandled promise rejection",
|
|
940
|
+
stack: void 0
|
|
941
|
+
};
|
|
942
|
+
return {
|
|
943
|
+
message: ("message" in reason && typeof reason.message === "string" ? reason.message : void 0) ?? "Unhandled promise rejection",
|
|
944
|
+
stack: "stack" in reason && typeof reason.stack === "string" ? reason.stack : void 0
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/** Cut on a code-point boundary: half an emoji is a lone surrogate, which ingest rejects with its batch. */
|
|
948
|
+
function truncate(text, max) {
|
|
949
|
+
if (!text) return void 0;
|
|
950
|
+
return truncateText(text, max, "…");
|
|
718
951
|
}
|
|
719
952
|
const BLOCK_SELECTOR = `[data-rr-block]`;
|
|
720
953
|
/**
|
|
@@ -734,12 +967,28 @@ const isBlocked = (el) => {
|
|
|
734
967
|
//#endregion
|
|
735
968
|
//#region ../browser-session/src/capture/interactions.ts
|
|
736
969
|
const MAX_TEXT = 120;
|
|
970
|
+
/** Bounds the walk on a click that lands on `<body>` or a huge container. */
|
|
971
|
+
const MAX_NODES = 500;
|
|
972
|
+
/** rrweb's default mask class: replay shows its text as `*`, so neither may this. */
|
|
973
|
+
const MASK_CLASS = "rr-mask";
|
|
974
|
+
/** Text that is code, markup or user input rather than a visible label. */
|
|
975
|
+
const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
976
|
+
"SCRIPT",
|
|
977
|
+
"STYLE",
|
|
978
|
+
"NOSCRIPT",
|
|
979
|
+
"TEMPLATE",
|
|
980
|
+
"INPUT",
|
|
981
|
+
"TEXTAREA",
|
|
982
|
+
"SELECT",
|
|
983
|
+
"OPTION"
|
|
984
|
+
]);
|
|
737
985
|
/**
|
|
738
986
|
* Capture clicks and input events as session events. Listens in the capture
|
|
739
987
|
* phase so it sees interactions even when the host app calls
|
|
740
988
|
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
741
989
|
* element. Click target text is omitted when `maskAllText` is set, and when the
|
|
742
|
-
* target sits inside a blocked (`.rr-block` / `data-rr-block`)
|
|
990
|
+
* target sits inside a blocked (`.rr-block` / `data-rr-block`) or masked
|
|
991
|
+
* (`.rr-mask`) subtree; blocked descendants of the target are skipped.
|
|
743
992
|
*/
|
|
744
993
|
function installInteractionCapture(emit, maskAllText) {
|
|
745
994
|
const onClick = (event) => {
|
|
@@ -748,7 +997,7 @@ function installInteractionCapture(emit, maskAllText) {
|
|
|
748
997
|
safeEmit(emit, {
|
|
749
998
|
type: "click",
|
|
750
999
|
targetSelector: selectorOf(target),
|
|
751
|
-
targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
|
|
1000
|
+
targetText: maskAllText || isBlocked(target) || isMasked(target) ? void 0 : textOf(target)
|
|
752
1001
|
});
|
|
753
1002
|
};
|
|
754
1003
|
const onInput = (event) => {
|
|
@@ -770,10 +1019,41 @@ function installInteractionCapture(emit, maskAllText) {
|
|
|
770
1019
|
function selectorOf(el) {
|
|
771
1020
|
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
772
1021
|
}
|
|
1022
|
+
/** An element whose text must not be read: blocked, masked, code, or editable. */
|
|
1023
|
+
function isHiddenText(el) {
|
|
1024
|
+
return SKIPPED_TAGS.has(el.tagName.toUpperCase()) || el.classList.contains("rr-block") || el.classList.contains(MASK_CLASS) || el.hasAttribute("data-rr-block") || el instanceof HTMLElement && el.isContentEditable;
|
|
1025
|
+
}
|
|
1026
|
+
function isMasked(el) {
|
|
1027
|
+
for (let node = el; node !== null; node = node.parentElement) if (node.classList.contains(MASK_CLASS)) return true;
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* The target's visible label: its text nodes in document order, skipping hidden
|
|
1032
|
+
* subtrees and stopping once the cap is reached. `textContent` would read the
|
|
1033
|
+
* whole subtree, blocked parts and all, on every click.
|
|
1034
|
+
*/
|
|
773
1035
|
function textOf(el) {
|
|
774
|
-
|
|
1036
|
+
if (isHiddenText(el)) return void 0;
|
|
1037
|
+
let text = "";
|
|
1038
|
+
let visited = 0;
|
|
1039
|
+
const stack = [el];
|
|
1040
|
+
while (stack.length > 0 && visited < MAX_NODES && text.length <= MAX_TEXT) {
|
|
1041
|
+
const node = stack.pop();
|
|
1042
|
+
visited++;
|
|
1043
|
+
if (node instanceof Text) {
|
|
1044
|
+
text = `${text}${node.data}`.replace(/\s+/g, " ").trimStart();
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (node !== el && (!(node instanceof Element) || isHiddenText(node))) continue;
|
|
1048
|
+
const children = node?.childNodes ?? [];
|
|
1049
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
1050
|
+
const child = children[i];
|
|
1051
|
+
if (child) stack.push(child);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
text = text.trim();
|
|
775
1055
|
if (!text) return void 0;
|
|
776
|
-
return text
|
|
1056
|
+
return truncateText(text, MAX_TEXT, "…");
|
|
777
1057
|
}
|
|
778
1058
|
//#endregion
|
|
779
1059
|
//#region ../browser-session/src/capture/baseline.ts
|
|
@@ -899,6 +1179,10 @@ function reserveKeepalive(requested, bytes, otlp = false) {
|
|
|
899
1179
|
if (otlp) inflight.otlpBytes = Math.max(0, (inflight.otlpBytes ?? 0) - bytes);
|
|
900
1180
|
};
|
|
901
1181
|
}
|
|
1182
|
+
/** Keepalive bytes a session write could reserve right now. */
|
|
1183
|
+
function keepaliveRoom() {
|
|
1184
|
+
return Math.max(0, KEEPALIVE_BUDGET_BYTES - keepaliveInflight().bytes);
|
|
1185
|
+
}
|
|
902
1186
|
/** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
|
|
903
1187
|
function byteLength(body) {
|
|
904
1188
|
return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
|
|
@@ -946,6 +1230,7 @@ const GZIP_MIN_BYTES = 18;
|
|
|
946
1230
|
* crawlers, which tear the page down on nearly every session.
|
|
947
1231
|
*/
|
|
948
1232
|
async function gzip(bytes) {
|
|
1233
|
+
if (typeof CompressionStream !== "function") return gzipStored(bytes);
|
|
949
1234
|
const stream = new CompressionStream("gzip");
|
|
950
1235
|
const writer = stream.writable.getWriter();
|
|
951
1236
|
const written = writer.write(bytes).then(() => writer.close());
|
|
@@ -954,39 +1239,158 @@ async function gzip(bytes) {
|
|
|
954
1239
|
if (out.length < GZIP_MIN_BYTES || out[0] !== 31 || out[1] !== 139 || out[2] !== 8) throw new Error(`gzip produced ${out.length} bytes, not a complete gzip stream`);
|
|
955
1240
|
return out;
|
|
956
1241
|
}
|
|
1242
|
+
let crcTable;
|
|
1243
|
+
function crc32(bytes) {
|
|
1244
|
+
if (!crcTable) {
|
|
1245
|
+
crcTable = /* @__PURE__ */ new Uint32Array(256);
|
|
1246
|
+
for (let n = 0; n < 256; n++) {
|
|
1247
|
+
let c = n;
|
|
1248
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
1249
|
+
crcTable[n] = c >>> 0;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
let crc = 4294967295;
|
|
1253
|
+
for (const byte of bytes) crc = (crcTable[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8;
|
|
1254
|
+
return (crc ^ 4294967295) >>> 0;
|
|
1255
|
+
}
|
|
1256
|
+
/** Largest payload of one stored (uncompressed) deflate block. */
|
|
1257
|
+
const STORED_BLOCK_BYTES = 65535;
|
|
1258
|
+
/**
|
|
1259
|
+
* gzip with stored deflate blocks: valid gzip any inflater reads, built
|
|
1260
|
+
* synchronously and not compressed. For a page going away, where
|
|
1261
|
+
* `CompressionStream` never resolves: a request has to be issued before the
|
|
1262
|
+
* document is torn down, and nothing after the first await runs by then.
|
|
1263
|
+
*/
|
|
1264
|
+
function gzipStored(bytes) {
|
|
1265
|
+
const blocks = Math.max(1, Math.ceil(bytes.length / STORED_BLOCK_BYTES));
|
|
1266
|
+
const out = new Uint8Array(10 + blocks * 5 + bytes.length + 8);
|
|
1267
|
+
const view = new DataView(out.buffer);
|
|
1268
|
+
out.set([
|
|
1269
|
+
31,
|
|
1270
|
+
139,
|
|
1271
|
+
8,
|
|
1272
|
+
0,
|
|
1273
|
+
0,
|
|
1274
|
+
0,
|
|
1275
|
+
0,
|
|
1276
|
+
0,
|
|
1277
|
+
0,
|
|
1278
|
+
255
|
|
1279
|
+
]);
|
|
1280
|
+
let at = 10;
|
|
1281
|
+
for (let block = 0; block < blocks; block++) {
|
|
1282
|
+
const start = block * STORED_BLOCK_BYTES;
|
|
1283
|
+
const length = Math.min(STORED_BLOCK_BYTES, bytes.length - start);
|
|
1284
|
+
out[at] = block === blocks - 1 ? 1 : 0;
|
|
1285
|
+
view.setUint16(at + 1, length, true);
|
|
1286
|
+
view.setUint16(at + 3, ~length & 65535, true);
|
|
1287
|
+
out.set(bytes.subarray(start, start + length), at + 5);
|
|
1288
|
+
at += 5 + length;
|
|
1289
|
+
}
|
|
1290
|
+
view.setUint32(at, crc32(bytes), true);
|
|
1291
|
+
view.setUint32(at + 4, bytes.length >>> 0, true);
|
|
1292
|
+
return out;
|
|
1293
|
+
}
|
|
1294
|
+
function outcomeOf(status) {
|
|
1295
|
+
if (status >= 200 && status < 300) return "accepted";
|
|
1296
|
+
if (status === 429 || status >= 500) return "retry";
|
|
1297
|
+
if (status === 401 || status === 402 || status === 403) return "refused";
|
|
1298
|
+
return "rejected";
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Ingest URL and key pairs that were refused. Every later request with the
|
|
1302
|
+
* same key would be refused the same way, so none is sent for the rest of the
|
|
1303
|
+
* page; a re-init with another key starts clean.
|
|
1304
|
+
*/
|
|
1305
|
+
const refusedUrls = /* @__PURE__ */ new Set();
|
|
1306
|
+
/** The URL plus an FNV-1a hash of the key, so the key itself is not kept. */
|
|
1307
|
+
function channelOf(config, url) {
|
|
1308
|
+
let hash = 2166136261;
|
|
1309
|
+
for (const char of config.ingestKey ?? "") hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
|
|
1310
|
+
return `${url}|${hash >>> 0}`;
|
|
1311
|
+
}
|
|
1312
|
+
const isRefused = (config, url) => refusedUrls.has(channelOf(config, url));
|
|
1313
|
+
function refuse(config, url, status) {
|
|
1314
|
+
const channel = channelOf(config, url);
|
|
1315
|
+
if (refusedUrls.has(channel)) return;
|
|
1316
|
+
refusedUrls.add(channel);
|
|
1317
|
+
console.warn(`[maple] ingest refused ${url} (HTTP ${status}); nothing more is sent there from this page`);
|
|
1318
|
+
}
|
|
1319
|
+
/** POST one body, mapping the answer to a {@link PostOutcome}. Never throws. */
|
|
1320
|
+
async function postNdjson(config, url, body, keepalive, what) {
|
|
1321
|
+
try {
|
|
1322
|
+
const response = await postToIngest(url, {
|
|
1323
|
+
...ingestHeaders(config),
|
|
1324
|
+
"content-type": "application/x-ndjson"
|
|
1325
|
+
}, body, keepalive);
|
|
1326
|
+
const outcome = outcomeOf(response.status);
|
|
1327
|
+
if (outcome === "refused") refuse(config, url, response.status);
|
|
1328
|
+
return outcome;
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
warnDropped(what, error);
|
|
1331
|
+
return "failed";
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
957
1334
|
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
958
1335
|
async function postSessionMeta(config, row, keepalive = false) {
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1336
|
+
const url = `${config.endpoint}/v1/sessionReplays/meta`;
|
|
1337
|
+
if (isRefused(config, url)) return;
|
|
1338
|
+
await postNdjson(config, url, `${stringifyWellFormed(row)}\n`, keepalive, "metadata POST");
|
|
1339
|
+
}
|
|
1340
|
+
/** Worst first: what a caller acts on when a batch went out in parts. */
|
|
1341
|
+
const OUTCOME_RANK = {
|
|
1342
|
+
refused: 0,
|
|
1343
|
+
failed: 1,
|
|
1344
|
+
retry: 2,
|
|
1345
|
+
rejected: 3,
|
|
1346
|
+
accepted: 4
|
|
1347
|
+
};
|
|
1348
|
+
/**
|
|
1349
|
+
* Split NDJSON lines for a page going away: the oldest that fit the keepalive
|
|
1350
|
+
* room left, measured on the serialized bytes, and the rest as a plain request
|
|
1351
|
+
* that only survives if the page does. One body over the room lost all of it.
|
|
1352
|
+
*/
|
|
1353
|
+
function keepaliveBodies(lines) {
|
|
1354
|
+
const encoder = new TextEncoder();
|
|
1355
|
+
let room = keepaliveRoom();
|
|
1356
|
+
let fits = 0;
|
|
1357
|
+
for (const line of lines) {
|
|
1358
|
+
const size = encoder.encode(line).byteLength;
|
|
1359
|
+
if (size > room) break;
|
|
1360
|
+
room -= size;
|
|
1361
|
+
fits++;
|
|
1362
|
+
}
|
|
1363
|
+
return [{
|
|
1364
|
+
body: lines.slice(0, fits).join(""),
|
|
1365
|
+
keepalive: true
|
|
1366
|
+
}, {
|
|
1367
|
+
body: lines.slice(fits).join(""),
|
|
1368
|
+
keepalive: false
|
|
1369
|
+
}].filter(({ body }) => body.length > 0);
|
|
966
1370
|
}
|
|
967
1371
|
/**
|
|
968
1372
|
* POST distilled session events (NDJSON, one row per event). Never throws.
|
|
969
|
-
*
|
|
970
|
-
*
|
|
1373
|
+
* A keepalive batch larger than the room left goes out in two parts, and the
|
|
1374
|
+
* worst of their outcomes is returned.
|
|
971
1375
|
*/
|
|
972
1376
|
async function postSessionEvents(config, rows, keepalive = false) {
|
|
973
1377
|
if (rows.length === 0) return "accepted";
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
return "failed";
|
|
983
|
-
}
|
|
1378
|
+
const url = `${config.endpoint}/v1/sessionEvents`;
|
|
1379
|
+
if (isRefused(config, url)) return "refused";
|
|
1380
|
+
const lines = rows.map((row) => `${stringifyWellFormed(row)}\n`);
|
|
1381
|
+
const bodies = keepalive ? keepaliveBodies(lines) : [{
|
|
1382
|
+
body: lines.join(""),
|
|
1383
|
+
keepalive: false
|
|
1384
|
+
}];
|
|
1385
|
+
return (await Promise.all(bodies.map((part) => postNdjson(config, url, part.body, part.keepalive, "events POST")))).reduce((worst, outcome) => OUTCOME_RANK[outcome] < OUTCOME_RANK[worst] ? outcome : worst);
|
|
984
1386
|
}
|
|
985
1387
|
const SESSION_EXHAUSTED_STATUS = 413;
|
|
986
1388
|
/** POST a gzipped rrweb event chunk. */
|
|
987
1389
|
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
1390
|
+
const url = `${config.endpoint}/v1/sessionReplays/blob`;
|
|
1391
|
+
if (isRefused(config, url)) return "refused";
|
|
988
1392
|
try {
|
|
989
|
-
const response = await postToIngest(
|
|
1393
|
+
const response = await postToIngest(url, {
|
|
990
1394
|
...ingestHeaders(config),
|
|
991
1395
|
"content-type": "application/octet-stream",
|
|
992
1396
|
"x-maple-session-id": meta.sessionId,
|
|
@@ -995,8 +1399,10 @@ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
|
995
1399
|
"x-maple-event-count": String(meta.eventCount),
|
|
996
1400
|
"x-maple-duration-ms": String(meta.durationMs)
|
|
997
1401
|
}, gzipped, keepalive);
|
|
998
|
-
if (response.
|
|
999
|
-
|
|
1402
|
+
if (response.status === SESSION_EXHAUSTED_STATUS) return "exhausted";
|
|
1403
|
+
const outcome = outcomeOf(response.status);
|
|
1404
|
+
if (outcome === "refused") refuse(config, url, response.status);
|
|
1405
|
+
return outcome;
|
|
1000
1406
|
} catch (error) {
|
|
1001
1407
|
warnDropped("blob PUT", error);
|
|
1002
1408
|
return "failed";
|
|
@@ -1016,10 +1422,12 @@ function parseUserAgent(ua) {
|
|
|
1016
1422
|
return parsed;
|
|
1017
1423
|
}
|
|
1018
1424
|
function parse(ua) {
|
|
1425
|
+
const browserName = /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /samsungbrowser/i.test(ua) ? "Samsung Internet" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown";
|
|
1426
|
+
const osName = /windows/i.test(ua) ? "Windows" : /iphone|ipad|ipod|\bios\b/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /\bcros\b/i.test(ua) ? "ChromeOS" : /linux/i.test(ua) ? "Linux" : "Unknown";
|
|
1019
1427
|
return {
|
|
1020
|
-
browserName
|
|
1021
|
-
osName
|
|
1022
|
-
deviceType: /
|
|
1428
|
+
browserName,
|
|
1429
|
+
osName,
|
|
1430
|
+
deviceType: /ipad|tablet/i.test(ua) ? "tablet" : /mobile|iphone|ipod/i.test(ua) ? "mobile" : osName === "Android" ? "tablet" : "desktop"
|
|
1023
1431
|
};
|
|
1024
1432
|
}
|
|
1025
1433
|
/**
|
|
@@ -1069,7 +1477,7 @@ function isLikelyBot(ua) {
|
|
|
1069
1477
|
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
1070
1478
|
function formatCHDateTime(date) {
|
|
1071
1479
|
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
1072
|
-
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` + pad(date.getUTCMilliseconds(), 3);
|
|
1480
|
+
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` + pad(date.getUTCMilliseconds(), 3);
|
|
1073
1481
|
}
|
|
1074
1482
|
/**
|
|
1075
1483
|
* Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
|
|
@@ -1156,7 +1564,7 @@ function pathOf(url) {
|
|
|
1156
1564
|
}
|
|
1157
1565
|
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
1158
1566
|
async function postSessionMetaRow(target, row, keepalive = false) {
|
|
1159
|
-
const body = `${
|
|
1567
|
+
const body = `${stringifyWellFormed(row)}\n`;
|
|
1160
1568
|
await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
1161
1569
|
...ingestHeaders(target),
|
|
1162
1570
|
"content-type": "application/x-ndjson"
|
|
@@ -1174,13 +1582,53 @@ function approximateSize(value) {
|
|
|
1174
1582
|
}
|
|
1175
1583
|
}
|
|
1176
1584
|
//#endregion
|
|
1585
|
+
//#region ../browser-session/src/capture/patch.ts
|
|
1586
|
+
const slots = /* @__PURE__ */ new WeakMap();
|
|
1587
|
+
/**
|
|
1588
|
+
* Install `wrap(original, hooks)` as `target[key]`, or reuse the wrapper an
|
|
1589
|
+
* earlier call left in place. The wrapper must read `hooks()` on every call
|
|
1590
|
+
* and only forward to `original` when it is undefined. Returns the teardown.
|
|
1591
|
+
*/
|
|
1592
|
+
function patchMethod(target, key, hooks, wrap) {
|
|
1593
|
+
let byKey = slots.get(target);
|
|
1594
|
+
if (!byKey) {
|
|
1595
|
+
byKey = /* @__PURE__ */ new Map();
|
|
1596
|
+
slots.set(target, byKey);
|
|
1597
|
+
}
|
|
1598
|
+
let slot = byKey.get(key);
|
|
1599
|
+
if (slot && target[key] !== slot.wrapper && target[key] === slot.original) slot = void 0;
|
|
1600
|
+
if (slot) slot.hooks = hooks;
|
|
1601
|
+
else {
|
|
1602
|
+
const original = target[key];
|
|
1603
|
+
const fresh = {
|
|
1604
|
+
original,
|
|
1605
|
+
wrapper: void 0,
|
|
1606
|
+
hooks
|
|
1607
|
+
};
|
|
1608
|
+
const wrapper = wrap(original, () => fresh.hooks);
|
|
1609
|
+
fresh.wrapper = wrapper;
|
|
1610
|
+
target[key] = wrapper;
|
|
1611
|
+
byKey.set(key, fresh);
|
|
1612
|
+
slot = fresh;
|
|
1613
|
+
}
|
|
1614
|
+
const owned = slot;
|
|
1615
|
+
const map = byKey;
|
|
1616
|
+
return () => {
|
|
1617
|
+
if (owned.hooks !== hooks) return;
|
|
1618
|
+
owned.hooks = void 0;
|
|
1619
|
+
if (target[key] !== owned.wrapper) return;
|
|
1620
|
+
Reflect.set(target, key, owned.original);
|
|
1621
|
+
map.delete(key);
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
//#endregion
|
|
1177
1625
|
//#region ../browser-session/src/session/session.ts
|
|
1178
1626
|
const STORAGE_KEY = "maple.session";
|
|
1179
1627
|
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
1180
1628
|
const MAX_SESSION_MS = 864e5;
|
|
1181
1629
|
/**
|
|
1182
1630
|
* The activity path runs per span creation (`getSessionId`) and per captured
|
|
1183
|
-
*
|
|
1631
|
+
* user input (`markActivity`: clicks, typing, scrolling, mouse movement);
|
|
1184
1632
|
* persisting the bump on every call would hammer sessionStorage for no benefit
|
|
1185
1633
|
* — rotation correctness only needs sub-idle-timeout granularity.
|
|
1186
1634
|
*/
|
|
@@ -1245,6 +1693,10 @@ function parseSessionRecord(raw) {
|
|
|
1245
1693
|
if (typeof value.replayBuffered !== "boolean") return void 0;
|
|
1246
1694
|
record.replayBuffered = value.replayBuffered;
|
|
1247
1695
|
}
|
|
1696
|
+
if (value.replayHalted !== void 0) {
|
|
1697
|
+
if (typeof value.replayHalted !== "boolean") return void 0;
|
|
1698
|
+
record.replayHalted = value.replayHalted;
|
|
1699
|
+
}
|
|
1248
1700
|
if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger;
|
|
1249
1701
|
if (value.utm !== void 0) {
|
|
1250
1702
|
if (!isStringRecord(value.utm)) return void 0;
|
|
@@ -1262,6 +1714,12 @@ const UTM_KEYS = [
|
|
|
1262
1714
|
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
1263
1715
|
let ephemeral;
|
|
1264
1716
|
/**
|
|
1717
|
+
* The Storage `ephemeral` was last written to. When the key vanishes from that
|
|
1718
|
+
* same Storage (a host app's `sessionStorage.clear()` on logout), the in-memory
|
|
1719
|
+
* copy is still this tab's session; a different Storage object is not ours.
|
|
1720
|
+
*/
|
|
1721
|
+
let ephemeralStorage;
|
|
1722
|
+
/**
|
|
1265
1723
|
* Set while sessionStorage writes throw. On quota exhaustion reads still work,
|
|
1266
1724
|
* so the stored copy is a frozen snapshot: `metaVersion` pinned at 1 billed
|
|
1267
1725
|
* every heartbeat as a new session. Keep this separate from the read try/catch.
|
|
@@ -1271,9 +1729,8 @@ const rotationListeners = /* @__PURE__ */ new Set();
|
|
|
1271
1729
|
/**
|
|
1272
1730
|
* Read the entry URL/referrer/UTM of the current page.
|
|
1273
1731
|
*
|
|
1274
|
-
* Guarded for non-DOM runtimes: `freshRecord` is reachable from `
|
|
1275
|
-
*
|
|
1276
|
-
* this must not assume `location`/`document` exist.
|
|
1732
|
+
* Guarded for non-DOM runtimes: `freshRecord` is reachable from `markActivity`,
|
|
1733
|
+
* which runs outside a DOM, so this must not assume `location`/`document` exist.
|
|
1277
1734
|
*/
|
|
1278
1735
|
function readEntryContext() {
|
|
1279
1736
|
if (typeof window === "undefined" || typeof location === "undefined") return {};
|
|
@@ -1298,7 +1755,7 @@ function readEntryContext() {
|
|
|
1298
1755
|
}
|
|
1299
1756
|
function freshRecord(now) {
|
|
1300
1757
|
return {
|
|
1301
|
-
id:
|
|
1758
|
+
id: randomUuid(),
|
|
1302
1759
|
startedAt: now,
|
|
1303
1760
|
lastActivityAt: now,
|
|
1304
1761
|
chunkSeq: 0,
|
|
@@ -1310,8 +1767,9 @@ function freshRecord(now) {
|
|
|
1310
1767
|
function readRecord() {
|
|
1311
1768
|
if (!mayUseStorage() || writesFailed && ephemeral) return ephemeral;
|
|
1312
1769
|
try {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1770
|
+
const storage = window.sessionStorage;
|
|
1771
|
+
const raw = storage.getItem(STORAGE_KEY);
|
|
1772
|
+
if (!raw) return storage === ephemeralStorage ? ephemeral : void 0;
|
|
1315
1773
|
return parseSessionRecord(raw) ?? ephemeral;
|
|
1316
1774
|
} catch {
|
|
1317
1775
|
return ephemeral;
|
|
@@ -1319,9 +1777,12 @@ function readRecord() {
|
|
|
1319
1777
|
}
|
|
1320
1778
|
function writeRecord(record) {
|
|
1321
1779
|
ephemeral = record;
|
|
1780
|
+
rememberCounters(record);
|
|
1322
1781
|
if (!mayUseStorage()) return;
|
|
1323
1782
|
try {
|
|
1324
|
-
window.sessionStorage
|
|
1783
|
+
const storage = window.sessionStorage;
|
|
1784
|
+
ephemeralStorage = storage;
|
|
1785
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
1325
1786
|
writesFailed = false;
|
|
1326
1787
|
} catch {
|
|
1327
1788
|
writesFailed = true;
|
|
@@ -1331,11 +1792,13 @@ function writeRecord(record) {
|
|
|
1331
1792
|
const TAB_LEASE_PREFIX = "maple.session.tab.";
|
|
1332
1793
|
const TAB_LEASE_TTL_MS = 12e4;
|
|
1333
1794
|
const TAB_LEASE_REFRESH_MS = 5e3;
|
|
1795
|
+
const TAB_LEASE_KEEPALIVE_MS = 3e4;
|
|
1334
1796
|
const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
|
|
1335
1797
|
/** The session id this tab has checked (or minted) and may keep using. */
|
|
1336
1798
|
let verifiedSessionId;
|
|
1337
1799
|
let leaseWrittenAt = 0;
|
|
1338
1800
|
let releaseInstalled = false;
|
|
1801
|
+
let leaseTimer;
|
|
1339
1802
|
/**
|
|
1340
1803
|
* Set from `pagehide` until the page is shown again. The lifecycle's own
|
|
1341
1804
|
* `pagehide` handler posts an `ended` row after ours runs, which writes the
|
|
@@ -1347,7 +1810,7 @@ function tabNonce() {
|
|
|
1347
1810
|
const owner = globalThis;
|
|
1348
1811
|
const existing = owner[TAB_NONCE_KEY];
|
|
1349
1812
|
if (typeof existing === "string") return existing;
|
|
1350
|
-
const fresh =
|
|
1813
|
+
const fresh = randomUuid();
|
|
1351
1814
|
owner[TAB_NONCE_KEY] = fresh;
|
|
1352
1815
|
return fresh;
|
|
1353
1816
|
}
|
|
@@ -1378,10 +1841,27 @@ function claimTab(sessionId) {
|
|
|
1378
1841
|
leaseWrittenAt = now;
|
|
1379
1842
|
const storage = leaseStorage();
|
|
1380
1843
|
if (!storage) return;
|
|
1844
|
+
writeLease(storage, sessionId, now);
|
|
1845
|
+
installLeaseRelease();
|
|
1846
|
+
if (leaseTimer === void 0 && typeof setInterval === "function") leaseTimer = setInterval(refreshLease, TAB_LEASE_KEEPALIVE_MS);
|
|
1847
|
+
}
|
|
1848
|
+
function writeLease(storage, sessionId, now) {
|
|
1381
1849
|
try {
|
|
1382
1850
|
storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
|
|
1383
1851
|
} catch {}
|
|
1384
|
-
|
|
1852
|
+
}
|
|
1853
|
+
function refreshLease() {
|
|
1854
|
+
if (verifiedSessionId === void 0 || leaseReleased) return;
|
|
1855
|
+
const record = readRecord();
|
|
1856
|
+
if (!hasConsent() || record === void 0 || record.id !== verifiedSessionId || isSessionExpired(record)) {
|
|
1857
|
+
clearInterval(leaseTimer);
|
|
1858
|
+
leaseTimer = void 0;
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
const storage = leaseStorage();
|
|
1862
|
+
if (!storage) return;
|
|
1863
|
+
leaseWrittenAt = Date.now();
|
|
1864
|
+
writeLease(storage, verifiedSessionId, leaseWrittenAt);
|
|
1385
1865
|
}
|
|
1386
1866
|
function releaseTab(sessionId) {
|
|
1387
1867
|
const storage = leaseStorage();
|
|
@@ -1504,13 +1984,20 @@ function touchSession(now) {
|
|
|
1504
1984
|
return record;
|
|
1505
1985
|
}
|
|
1506
1986
|
/**
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
* the
|
|
1987
|
+
* Resolve the session a captured event belongs to. For `"input"` (what a person
|
|
1988
|
+
* does: clicks, typing, scrolling, navigation) this marks activity, rotating
|
|
1989
|
+
* first when the stored session has expired, and shares `getSessionId`'s
|
|
1990
|
+
* throttled touch; callers read the returned record's `id` to detect rotation.
|
|
1991
|
+
*
|
|
1992
|
+
* `"passive"` events (DOM mutations, console, network, errors) must neither keep
|
|
1993
|
+
* an abandoned page's session alive nor start a new one: they get the stored
|
|
1994
|
+
* record untouched, or `null` when it has expired. Undefined outside a browser.
|
|
1510
1995
|
*/
|
|
1511
|
-
function markActivity() {
|
|
1996
|
+
function markActivity(kind = "input") {
|
|
1512
1997
|
if (typeof window === "undefined") return void 0;
|
|
1513
|
-
return touchSession(Date.now());
|
|
1998
|
+
if (kind === "input") return touchSession(Date.now());
|
|
1999
|
+
const record = readRecord();
|
|
2000
|
+
return record !== void 0 && !isSessionExpired(record) ? record : null;
|
|
1514
2001
|
}
|
|
1515
2002
|
/**
|
|
1516
2003
|
* Record a page view. Persisted on the session record rather than held in the
|
|
@@ -1574,19 +2061,58 @@ function entryContextOf(record) {
|
|
|
1574
2061
|
utm: record.utm ?? {}
|
|
1575
2062
|
};
|
|
1576
2063
|
}
|
|
2064
|
+
const recentCounters = /* @__PURE__ */ new Map();
|
|
2065
|
+
const MAX_RECENT_COUNTERS = 4;
|
|
2066
|
+
function rememberCounters(record) {
|
|
2067
|
+
recentCounters.delete(record.id);
|
|
2068
|
+
recentCounters.set(record.id, {
|
|
2069
|
+
chunkSeq: record.chunkSeq,
|
|
2070
|
+
metaVersion: record.metaVersion
|
|
2071
|
+
});
|
|
2072
|
+
for (const id of recentCounters.keys()) {
|
|
2073
|
+
if (recentCounters.size <= MAX_RECENT_COUNTERS) break;
|
|
2074
|
+
recentCounters.delete(id);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1577
2077
|
/**
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
2078
|
+
* Advance one counter of `sessionId`: on its persisted record when that is the
|
|
2079
|
+
* stored one, else on this document's memory of it. Undefined for a session
|
|
2080
|
+
* this document never wrote, whose counters it cannot know.
|
|
1581
2081
|
*/
|
|
1582
|
-
function
|
|
1583
|
-
const record = readRecord()
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2082
|
+
function takeCounter(sessionId, take) {
|
|
2083
|
+
const record = readRecord();
|
|
2084
|
+
if (record?.id === sessionId) {
|
|
2085
|
+
const { value, next } = take({
|
|
2086
|
+
chunkSeq: record.chunkSeq,
|
|
2087
|
+
metaVersion: record.metaVersion
|
|
2088
|
+
});
|
|
2089
|
+
writeRecord({
|
|
2090
|
+
...record,
|
|
2091
|
+
chunkSeq: next.chunkSeq,
|
|
2092
|
+
metaVersion: next.metaVersion
|
|
2093
|
+
});
|
|
2094
|
+
return value;
|
|
2095
|
+
}
|
|
2096
|
+
const remembered = recentCounters.get(sessionId);
|
|
2097
|
+
if (!remembered) return void 0;
|
|
2098
|
+
const { value, next } = take(remembered);
|
|
2099
|
+
recentCounters.set(sessionId, next);
|
|
2100
|
+
return value;
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Take the next replay chunk sequence number of `sessionId`. Monotonic across
|
|
2104
|
+
* reloads (persisted on the session record), so a refresh continues the
|
|
2105
|
+
* sequence instead of restarting at 0 and overwriting the previous load's blobs.
|
|
2106
|
+
* Undefined when the session's counter is unknown: the chunk must be dropped.
|
|
2107
|
+
*/
|
|
2108
|
+
function nextChunkSeq(sessionId) {
|
|
2109
|
+
return takeCounter(sessionId, (counters) => ({
|
|
2110
|
+
value: counters.chunkSeq,
|
|
2111
|
+
next: {
|
|
2112
|
+
...counters,
|
|
2113
|
+
chunkSeq: counters.chunkSeq + 1
|
|
2114
|
+
}
|
|
2115
|
+
}));
|
|
1590
2116
|
}
|
|
1591
2117
|
/**
|
|
1592
2118
|
* Take the next session-metadata row version for the current session.
|
|
@@ -1596,14 +2122,17 @@ function nextChunkSeq() {
|
|
|
1596
2122
|
* older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
|
|
1597
2123
|
* counter resumes at 3 for them; a fresh session starts at 1.
|
|
1598
2124
|
*/
|
|
1599
|
-
function nextMetaVersion() {
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
2125
|
+
function nextMetaVersion(sessionId) {
|
|
2126
|
+
return takeCounter(sessionId, (counters) => {
|
|
2127
|
+
const version = (counters.metaVersion ?? 2) + 1;
|
|
2128
|
+
return {
|
|
2129
|
+
value: version,
|
|
2130
|
+
next: {
|
|
2131
|
+
...counters,
|
|
2132
|
+
metaVersion: version
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
1605
2135
|
});
|
|
1606
|
-
return version;
|
|
1607
2136
|
}
|
|
1608
2137
|
/** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
|
|
1609
2138
|
function uniformRandom() {
|
|
@@ -1644,6 +2173,19 @@ function claimReplayMode(sampleRate, onErrorSampleRate) {
|
|
|
1644
2173
|
}
|
|
1645
2174
|
return record.replayBuffered ? "buffer" : "off";
|
|
1646
2175
|
}
|
|
2176
|
+
/** Recording of `sessionId` stopped for good: later recorder starts of it do nothing. */
|
|
2177
|
+
function markReplayHalted(sessionId) {
|
|
2178
|
+
const record = readRecord();
|
|
2179
|
+
if (!record || record.id !== sessionId || record.replayHalted) return;
|
|
2180
|
+
writeRecord({
|
|
2181
|
+
...record,
|
|
2182
|
+
replayHalted: true
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
function isReplayHalted(sessionId) {
|
|
2186
|
+
const record = readRecord();
|
|
2187
|
+
return record?.id === sessionId && record.replayHalted === true;
|
|
2188
|
+
}
|
|
1647
2189
|
/** An error made this buffered session a recorded one: later loads record it from the start. */
|
|
1648
2190
|
function markReplayTriggered(sessionId) {
|
|
1649
2191
|
const record = readRecord();
|
|
@@ -1687,7 +2229,23 @@ function onSessionEvent(listener) {
|
|
|
1687
2229
|
return () => listeners().delete(listener);
|
|
1688
2230
|
}
|
|
1689
2231
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
1690
|
-
|
|
2232
|
+
/** Small enough that the batch a page leaves with usually fits the shared keepalive budget. */
|
|
2233
|
+
const FLUSH_BYTES = 32768;
|
|
2234
|
+
/** Ceiling on buffered events while ingest asks to back off; the oldest go first. */
|
|
2235
|
+
const MAX_BUFFER_BYTES = 262144;
|
|
2236
|
+
/** Pause after ingest asks for a retry (429, 5xx), doubling per answer in a row up to the cap. */
|
|
2237
|
+
const RETRY_BACKOFF_MS = 5e3;
|
|
2238
|
+
const MAX_RETRY_BACKOFF_MS = 6e4;
|
|
2239
|
+
/** Identical error rows kept per window: an error thrown every frame is one row, not sixty a second. */
|
|
2240
|
+
const MAX_REPEATED_ERRORS = 5;
|
|
2241
|
+
const REPEATED_ERROR_WINDOW_MS = 6e4;
|
|
2242
|
+
/** Event types a person causes. Only these keep a session from going idle. */
|
|
2243
|
+
const USER_EVENTS = /* @__PURE__ */ new Set([
|
|
2244
|
+
"navigation",
|
|
2245
|
+
"click",
|
|
2246
|
+
"input",
|
|
2247
|
+
"custom"
|
|
2248
|
+
]);
|
|
1691
2249
|
/** Events across which an in-flight POST can fail although ingest stored it. */
|
|
1692
2250
|
const PAGE_TRANSITIONS = [
|
|
1693
2251
|
"beforeunload",
|
|
@@ -1730,6 +2288,15 @@ function startEventSink(config, sessionId) {
|
|
|
1730
2288
|
let buffer = [];
|
|
1731
2289
|
let bufferBytes = 0;
|
|
1732
2290
|
let retry = [];
|
|
2291
|
+
let resend = [];
|
|
2292
|
+
/** The one non-keepalive POST in flight, and whether a flush was asked for meanwhile. */
|
|
2293
|
+
let inFlight;
|
|
2294
|
+
let flushAgain = false;
|
|
2295
|
+
let retryAt = 0;
|
|
2296
|
+
let retriesInARow = 0;
|
|
2297
|
+
/** Ingest refused this page's key or plan: nothing more is buffered. */
|
|
2298
|
+
let refused = false;
|
|
2299
|
+
const repeatedErrors = /* @__PURE__ */ new Map();
|
|
1733
2300
|
let seq = 0;
|
|
1734
2301
|
let pageViews = 0;
|
|
1735
2302
|
let clickCount = 0;
|
|
@@ -1741,19 +2308,74 @@ function startEventSink(config, sessionId) {
|
|
|
1741
2308
|
};
|
|
1742
2309
|
const pageTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
|
|
1743
2310
|
for (const type of PAGE_TRANSITIONS) pageTarget?.addEventListener(type, onPageTransition);
|
|
1744
|
-
const
|
|
2311
|
+
const send = async (keepalive) => {
|
|
2312
|
+
if (!keepalive && Date.now() < retryAt) return;
|
|
1745
2313
|
const fresh = buffer.map(({ ev, seq }) => toRow(config, sessionId, ev, seq));
|
|
1746
|
-
const rows = keepalive ?
|
|
2314
|
+
const rows = keepalive ? [...resend, ...fresh] : [
|
|
2315
|
+
...retry,
|
|
2316
|
+
...resend,
|
|
2317
|
+
...fresh
|
|
2318
|
+
];
|
|
1747
2319
|
if (rows.length === 0) return;
|
|
1748
2320
|
if (!keepalive) retry = [];
|
|
2321
|
+
resend = [];
|
|
1749
2322
|
buffer = [];
|
|
1750
2323
|
bufferBytes = 0;
|
|
1751
2324
|
const transitionsAtSend = pageTransitions;
|
|
1752
|
-
|
|
1753
|
-
|
|
2325
|
+
const outcome = await postSessionEvents(config, rows, keepalive);
|
|
2326
|
+
if (outcome === "refused") {
|
|
2327
|
+
refused = true;
|
|
2328
|
+
buffer = [];
|
|
2329
|
+
bufferBytes = 0;
|
|
2330
|
+
retry = [];
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
if (outcome === "retry") {
|
|
2334
|
+
retriesInARow++;
|
|
2335
|
+
retryAt = Date.now() + Math.min(MAX_RETRY_BACKOFF_MS, RETRY_BACKOFF_MS * 2 ** (retriesInARow - 1));
|
|
2336
|
+
} else retriesInARow = 0;
|
|
2337
|
+
if (outcome === "retry") resend = [...fresh, ...resend];
|
|
2338
|
+
else if (!keepalive && outcome === "failed" && pageTransitions === transitionsAtSend) retry = [...fresh, ...retry];
|
|
2339
|
+
};
|
|
2340
|
+
const flush = (keepalive = false) => {
|
|
2341
|
+
if (keepalive) return send(true);
|
|
2342
|
+
if (inFlight) {
|
|
2343
|
+
flushAgain = true;
|
|
2344
|
+
return inFlight;
|
|
2345
|
+
}
|
|
2346
|
+
const run = async () => {
|
|
2347
|
+
try {
|
|
2348
|
+
do {
|
|
2349
|
+
flushAgain = false;
|
|
2350
|
+
await send(false);
|
|
2351
|
+
} while (flushAgain);
|
|
2352
|
+
} finally {
|
|
2353
|
+
inFlight = void 0;
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
inFlight = run();
|
|
2357
|
+
return inFlight;
|
|
2358
|
+
};
|
|
2359
|
+
/** Whether `ev` repeats an error already sent `MAX_REPEATED_ERRORS` times this window. */
|
|
2360
|
+
const isRepeatedError = (ev) => {
|
|
2361
|
+
if (ev.type !== "error" && !(ev.type === "console" && ev.level === "error")) return false;
|
|
2362
|
+
const key = `${ev.type}|${ev.message ?? ""}|${(ev.errorStack ?? "").slice(0, 500)}`;
|
|
2363
|
+
const now = Date.now();
|
|
2364
|
+
const seen = repeatedErrors.get(key);
|
|
2365
|
+
if (!seen || now - seen.since > REPEATED_ERROR_WINDOW_MS) {
|
|
2366
|
+
if (repeatedErrors.size >= 100) repeatedErrors.clear();
|
|
2367
|
+
repeatedErrors.set(key, {
|
|
2368
|
+
count: 1,
|
|
2369
|
+
since: now
|
|
2370
|
+
});
|
|
2371
|
+
return false;
|
|
2372
|
+
}
|
|
2373
|
+
seen.count++;
|
|
2374
|
+
return seen.count > MAX_REPEATED_ERRORS;
|
|
1754
2375
|
};
|
|
1755
2376
|
const emit = (ev) => {
|
|
1756
|
-
const session = markActivity();
|
|
2377
|
+
const session = markActivity(USER_EVENTS.has(ev.type) ? "input" : "passive");
|
|
2378
|
+
if (session === null) return;
|
|
1757
2379
|
if (session && session.id !== sessionId) {
|
|
1758
2380
|
const nextSink = startEventSink(config, session.id);
|
|
1759
2381
|
if (ev.type !== "navigation") nextSink.emit(ev);
|
|
@@ -1764,11 +2386,16 @@ function startEventSink(config, sessionId) {
|
|
|
1764
2386
|
noteNavigation(ev.url ?? (typeof location !== "undefined" ? location.href : ""));
|
|
1765
2387
|
} else if (ev.type === "click") clickCount++;
|
|
1766
2388
|
else if (ev.type === "error" || ev.type === "console" && ev.level === "error") errorCount++;
|
|
2389
|
+
if (refused || isRepeatedError(ev)) return;
|
|
1767
2390
|
buffer.push({
|
|
1768
2391
|
ev,
|
|
1769
2392
|
seq: seq++
|
|
1770
2393
|
});
|
|
1771
2394
|
bufferBytes += approximateSize(ev);
|
|
2395
|
+
while (bufferBytes > MAX_BUFFER_BYTES && buffer.length > 1) {
|
|
2396
|
+
const dropped = buffer.shift();
|
|
2397
|
+
if (dropped) bufferBytes -= approximateSize(dropped.ev);
|
|
2398
|
+
}
|
|
1772
2399
|
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
1773
2400
|
for (const listener of listeners()) try {
|
|
1774
2401
|
listener(ev);
|
|
@@ -1819,24 +2446,18 @@ function installNavigationObserver(onNavigate) {
|
|
|
1819
2446
|
onNavigate(url);
|
|
1820
2447
|
};
|
|
1821
2448
|
emitNav();
|
|
1822
|
-
const
|
|
1823
|
-
const origReplace = history.replaceState;
|
|
2449
|
+
const hooks = { emitNav };
|
|
1824
2450
|
const receiver = (self) => self instanceof History ? self : history;
|
|
1825
|
-
|
|
1826
|
-
const result =
|
|
1827
|
-
emitNav();
|
|
1828
|
-
return result;
|
|
1829
|
-
};
|
|
1830
|
-
history.replaceState = function(...args) {
|
|
1831
|
-
const result = origReplace.apply(receiver(this), args);
|
|
1832
|
-
emitNav();
|
|
2451
|
+
const wrapHistory = (original, current) => function(...args) {
|
|
2452
|
+
const result = original.apply(receiver(this), args);
|
|
2453
|
+
current()?.emitNav();
|
|
1833
2454
|
return result;
|
|
1834
2455
|
};
|
|
2456
|
+
const unpatch = [patchMethod(history, "pushState", hooks, wrapHistory), patchMethod(history, "replaceState", hooks, wrapHistory)];
|
|
1835
2457
|
window.addEventListener("popstate", emitNav);
|
|
1836
2458
|
window.addEventListener("hashchange", emitNav);
|
|
1837
2459
|
return () => {
|
|
1838
|
-
|
|
1839
|
-
history.replaceState = origReplace;
|
|
2460
|
+
for (const off of unpatch) off();
|
|
1840
2461
|
window.removeEventListener("popstate", emitNav);
|
|
1841
2462
|
window.removeEventListener("hashchange", emitNav);
|
|
1842
2463
|
};
|
|
@@ -1947,6 +2568,10 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1947
2568
|
let stopped = false;
|
|
1948
2569
|
/** Whether a run is live: capture started, `active` posted, heartbeat armed. */
|
|
1949
2570
|
let running = false;
|
|
2571
|
+
/** A live run whose tab is hidden: `ended` posted, heartbeat off, capture kept. */
|
|
2572
|
+
let paused = false;
|
|
2573
|
+
/** Ends a paused run once its session would have gone idle, so a hidden tab does not record for long. */
|
|
2574
|
+
let pauseTimer;
|
|
1950
2575
|
let heartbeat;
|
|
1951
2576
|
let clickCountBase = 0;
|
|
1952
2577
|
let errorCountBase = 0;
|
|
@@ -2000,6 +2625,8 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2000
2625
|
};
|
|
2001
2626
|
const post = (status, keepalive) => {
|
|
2002
2627
|
const record = liveRecord();
|
|
2628
|
+
const version = nextMetaVersion(record.id);
|
|
2629
|
+
if (version === void 0) return;
|
|
2003
2630
|
const counts = countsFor(record);
|
|
2004
2631
|
const visitorId = getVisitorId();
|
|
2005
2632
|
const persistVisit = visitorId !== void 0;
|
|
@@ -2008,7 +2635,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2008
2635
|
hooks.post(buildSessionMetaRow({
|
|
2009
2636
|
sessionId: record.id,
|
|
2010
2637
|
startedAt: new Date(record.startedAt),
|
|
2011
|
-
version
|
|
2638
|
+
version,
|
|
2012
2639
|
status,
|
|
2013
2640
|
serviceName: options.serviceName,
|
|
2014
2641
|
identity: options.getIdentity?.(),
|
|
@@ -2034,6 +2661,10 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2034
2661
|
clearInterval(heartbeat);
|
|
2035
2662
|
heartbeat = void 0;
|
|
2036
2663
|
};
|
|
2664
|
+
const clearPauseTimer = () => {
|
|
2665
|
+
if (pauseTimer !== void 0) clearTimeout(pauseTimer);
|
|
2666
|
+
pauseTimer = void 0;
|
|
2667
|
+
};
|
|
2037
2668
|
const startRun = () => {
|
|
2038
2669
|
if (stopped || running) return;
|
|
2039
2670
|
running = true;
|
|
@@ -2042,6 +2673,10 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2042
2673
|
rebaseCounts(record);
|
|
2043
2674
|
hooks.onStart?.(record);
|
|
2044
2675
|
post("active", false);
|
|
2676
|
+
startHeartbeat();
|
|
2677
|
+
};
|
|
2678
|
+
const startHeartbeat = () => {
|
|
2679
|
+
stopHeartbeat();
|
|
2045
2680
|
heartbeat = setInterval(() => {
|
|
2046
2681
|
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
2047
2682
|
if (isSessionExpired(liveRecord())) {
|
|
@@ -2058,9 +2693,32 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2058
2693
|
if (!running) return;
|
|
2059
2694
|
running = false;
|
|
2060
2695
|
stopHeartbeat();
|
|
2061
|
-
|
|
2696
|
+
clearPauseTimer();
|
|
2697
|
+
if (suspend.flush && !paused) post("ended", suspend.keepalive);
|
|
2698
|
+
paused = false;
|
|
2062
2699
|
return hooks.onSuspend?.(suspend);
|
|
2063
2700
|
};
|
|
2701
|
+
const pauseRun = (onPause) => {
|
|
2702
|
+
if (!running || paused) return;
|
|
2703
|
+
paused = true;
|
|
2704
|
+
stopHeartbeat();
|
|
2705
|
+
post("ended", true);
|
|
2706
|
+
onPause({ keepalive: true });
|
|
2707
|
+
const idleIn = liveRecord().lastActivityAt + IDLE_TIMEOUT_MS - Date.now();
|
|
2708
|
+
pauseTimer = setTimeout(() => {
|
|
2709
|
+
pauseTimer = void 0;
|
|
2710
|
+
endRun({
|
|
2711
|
+
flush: true,
|
|
2712
|
+
keepalive: false
|
|
2713
|
+
});
|
|
2714
|
+
}, Math.max(0, idleIn) + 1e3);
|
|
2715
|
+
};
|
|
2716
|
+
const resumeRun = () => {
|
|
2717
|
+
clearPauseTimer();
|
|
2718
|
+
paused = false;
|
|
2719
|
+
post("active", false);
|
|
2720
|
+
startHeartbeat();
|
|
2721
|
+
};
|
|
2064
2722
|
const stopRotationListener = onSessionRotate((previous, next) => {
|
|
2065
2723
|
if (stopped || previous.id !== current.id) return;
|
|
2066
2724
|
endRun({
|
|
@@ -2079,7 +2737,8 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2079
2737
|
const doc = browserDocument();
|
|
2080
2738
|
if (!doc) return;
|
|
2081
2739
|
if (doc.visibilityState === "hidden") {
|
|
2082
|
-
|
|
2740
|
+
if (hooks.onPause) pauseRun(hooks.onPause);
|
|
2741
|
+
else endRun({
|
|
2083
2742
|
flush: true,
|
|
2084
2743
|
keepalive: true
|
|
2085
2744
|
});
|
|
@@ -2089,7 +2748,8 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2089
2748
|
getSession();
|
|
2090
2749
|
return;
|
|
2091
2750
|
}
|
|
2092
|
-
|
|
2751
|
+
if (paused) resumeRun();
|
|
2752
|
+
else startRun();
|
|
2093
2753
|
};
|
|
2094
2754
|
const visibilityTarget = typeof document !== "undefined" && typeof document.addEventListener === "function" ? document : typeof globalThis.addEventListener === "function" ? globalThis : void 0;
|
|
2095
2755
|
const pageHideTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
|
|
@@ -2101,7 +2761,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
2101
2761
|
return current.id;
|
|
2102
2762
|
},
|
|
2103
2763
|
announce: () => {
|
|
2104
|
-
if (running) post("active", false);
|
|
2764
|
+
if (running) post(paused ? "ended" : "active", false);
|
|
2105
2765
|
},
|
|
2106
2766
|
shutdown: async (shutdownOptions) => {
|
|
2107
2767
|
if (stopped) return;
|
|
@@ -2187,4 +2847,4 @@ function readSessionSink() {
|
|
|
2187
2847
|
return globalThis[SESSION_SINK_KEY];
|
|
2188
2848
|
}
|
|
2189
2849
|
//#endregion
|
|
2190
|
-
export {
|
|
2850
|
+
export { postToIngest as A, withStartedTraceId as B, OTLP_UNLOAD_TAIL_BYTES as C, otlpKeepaliveRoom as D, ingestHeaders as E, BLOCK_SELECTOR as F, mayPersistIdentifier as G, consentAllowedSince as H, safeEmit as I, setVisitorTracking as J, onConsentChange as K, truncateText as L, sdkHint as M, warnDropped as N, postSessionBlob as O, selectorOf as P, activeTraceId as R, isLikelyBot as S, gzipStored as T, consentRevokedAt as U, configurePrivacy as V, hasConsent as W, scrubUrl as X, isJsonObject as Y, mayUseStorage as Z, markReplayTriggered as _, recordTraceId as a, patchMethod as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, markReplayHalted as g, markActivity as h, readSessionSink as i, reserveKeepalive as j, postSessionMeta as k, onSessionEvent as l, isReplayHalted as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, setConsent as q, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, nextChunkSeq as v, gzip as w, postSessionMetaRow as x, rotateSession as y, setActiveTraceIdProvider as z };
|