@maple-dev/browser 0.8.0 → 0.10.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 +146 -3
- package/dist/console-BrjBnOiX.mjs +73 -0
- package/dist/deferred-CdtlxG0Q.mjs +723 -0
- package/dist/index.d.mts +233 -30
- package/dist/index.mjs +1 -640
- package/dist/react.d.mts +88 -0
- package/dist/react.mjs +130 -0
- package/dist/replay-session-DdVYdHc_.mjs +527 -0
- package/dist/{sink-D9w1kg0Q.mjs → sink-BOR9_z4b.mjs} +733 -177
- package/dist/src-Bzmp8rVr.mjs +1722 -0
- package/package.json +37 -14
- package/dist/replay-session-BYZfi3iZ.mjs +0 -327
|
@@ -1,3 +1,185 @@
|
|
|
1
|
+
//#region ../browser-session/src/platform/url-privacy.ts
|
|
2
|
+
const REDACTED = "REDACTED";
|
|
3
|
+
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;
|
|
4
|
+
/** Sanitizers live on `globalThis`, like consent: one page, one policy, however many SDK copies. */
|
|
5
|
+
const SANITIZERS_KEY = "__MAPLE_URL_SANITIZERS__";
|
|
6
|
+
function sanitizers() {
|
|
7
|
+
const owner = globalThis;
|
|
8
|
+
const existing = owner[SANITIZERS_KEY];
|
|
9
|
+
if (existing instanceof Set) return existing;
|
|
10
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
11
|
+
owner[SANITIZERS_KEY] = fresh;
|
|
12
|
+
return fresh;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Register a host-app sanitizer. Additive, like the consent gates: an SDK
|
|
16
|
+
* initialized without one must not remove the one another SDK was given.
|
|
17
|
+
*/
|
|
18
|
+
function addUrlSanitizer(sanitizer) {
|
|
19
|
+
sanitizers().add(sanitizer);
|
|
20
|
+
}
|
|
21
|
+
function redactParams(params) {
|
|
22
|
+
let changed = false;
|
|
23
|
+
for (const name of new Set(params.keys())) {
|
|
24
|
+
if (!SENSITIVE_PARAM.test(name)) continue;
|
|
25
|
+
params.set(name, REDACTED);
|
|
26
|
+
changed = true;
|
|
27
|
+
}
|
|
28
|
+
return changed;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Implicit-flow tokens ride the fragment as `#access_token=…&…`; a hash-routed
|
|
32
|
+
* app carries them in the route's own query, `#/reset?token=…`. The route path
|
|
33
|
+
* is kept either way.
|
|
34
|
+
*/
|
|
35
|
+
function redactFragment(fragment) {
|
|
36
|
+
if (fragment.startsWith("/") || fragment.startsWith("!/")) {
|
|
37
|
+
const queryAt = fragment.indexOf("?");
|
|
38
|
+
if (queryAt === -1) return fragment;
|
|
39
|
+
const params = new URLSearchParams(fragment.slice(queryAt + 1));
|
|
40
|
+
return redactParams(params) ? `${fragment.slice(0, queryAt)}?${params.toString()}` : fragment;
|
|
41
|
+
}
|
|
42
|
+
if (!fragment.includes("=")) return fragment;
|
|
43
|
+
const params = new URLSearchParams(fragment);
|
|
44
|
+
return redactParams(params) ? params.toString() : fragment;
|
|
45
|
+
}
|
|
46
|
+
const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
|
|
47
|
+
const RELATIVE_BASE = "http://relative.invalid";
|
|
48
|
+
/** Redact credential-shaped query and fragment parameters, keeping the URL's form. */
|
|
49
|
+
function redactUrl(url) {
|
|
50
|
+
if (!url || !url.includes("?") && !url.includes("#")) return url;
|
|
51
|
+
const absolute = ABSOLUTE.test(url);
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(url, absolute ? void 0 : RELATIVE_BASE);
|
|
55
|
+
} catch {
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
let changed = redactParams(parsed.searchParams);
|
|
59
|
+
const fragment = parsed.hash.slice(1);
|
|
60
|
+
const redactedFragment = redactFragment(fragment);
|
|
61
|
+
if (redactedFragment !== fragment) {
|
|
62
|
+
parsed.hash = redactedFragment;
|
|
63
|
+
changed = true;
|
|
64
|
+
}
|
|
65
|
+
if (!changed) return url;
|
|
66
|
+
if (absolute) return parsed.href;
|
|
67
|
+
if (url.startsWith("//")) return parsed.href.slice(parsed.protocol.length);
|
|
68
|
+
const tail = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
69
|
+
return url.startsWith("/") ? tail : tail.replace(/^\//, "");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The URL as it may leave the page: default redaction, then every registered
|
|
73
|
+
* host sanitizer. A sanitizer that throws or returns a non-string yields the
|
|
74
|
+
* default-redacted URL rather than the raw one.
|
|
75
|
+
*/
|
|
76
|
+
function scrubUrl(url) {
|
|
77
|
+
let out = redactUrl(url);
|
|
78
|
+
for (const sanitizer of sanitizers()) try {
|
|
79
|
+
const next = sanitizer(out);
|
|
80
|
+
if (typeof next === "string") out = next;
|
|
81
|
+
} catch {}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region ../browser-session/src/platform/cookie.ts
|
|
86
|
+
/** Scope cookies to the registered domain so subdomains share them. */
|
|
87
|
+
let crossSubdomainCookie = true;
|
|
88
|
+
/** Explicit `Domain=` override; `""` forces a host-only cookie. */
|
|
89
|
+
let cookieDomainOverride;
|
|
90
|
+
/** Memoized probe result. `undefined` = not resolved yet. */
|
|
91
|
+
let probedCookieDomain;
|
|
92
|
+
/**
|
|
93
|
+
* Apply the host app's cookie configuration. Called from `configurePrivacy`, so
|
|
94
|
+
* both SDKs get it from the single call they already make.
|
|
95
|
+
*
|
|
96
|
+
* Like the consent gates, this only ever *tightens*: an app that initializes two
|
|
97
|
+
* SDKs, only one of which passes a `privacy` block, must not have the other's
|
|
98
|
+
* absent option widen the cookie back out to every subdomain.
|
|
99
|
+
*
|
|
100
|
+
* "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
|
|
101
|
+
* first-write-wins: `""` (host-only) is the tightest value there is, and a
|
|
102
|
+
* second SDK asking for it has to win over an earlier `"example.com"`. Between
|
|
103
|
+
* two non-empty domains the shorter one is the broader (`example.com` covers
|
|
104
|
+
* `app.example.com`, not the reverse), so the longer string wins.
|
|
105
|
+
*/
|
|
106
|
+
function configureCookieScope(options) {
|
|
107
|
+
if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
|
|
108
|
+
if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
|
|
109
|
+
probedCookieDomain = void 0;
|
|
110
|
+
}
|
|
111
|
+
/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
|
|
112
|
+
function tighterCookieDomain(current, next) {
|
|
113
|
+
if (current === void 0) return next;
|
|
114
|
+
if (current === "" || next === "") return "";
|
|
115
|
+
return next.length > current.length ? next : current;
|
|
116
|
+
}
|
|
117
|
+
function readRawCookie(name) {
|
|
118
|
+
if (typeof document === "undefined") return void 0;
|
|
119
|
+
try {
|
|
120
|
+
for (const part of document.cookie.split(";")) {
|
|
121
|
+
const raw = part.trim();
|
|
122
|
+
if (!raw.startsWith(`${name}=`)) continue;
|
|
123
|
+
return decodeURIComponent(raw.slice(name.length + 1));
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
128
|
+
if (typeof document === "undefined") return false;
|
|
129
|
+
const attributes = [
|
|
130
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
131
|
+
"path=/",
|
|
132
|
+
`max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
|
133
|
+
"SameSite=Lax"
|
|
134
|
+
];
|
|
135
|
+
if (domain) attributes.push(`domain=.${domain}`);
|
|
136
|
+
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
|
|
137
|
+
try {
|
|
138
|
+
document.cookie = attributes.join("; ");
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Expire `name` on the shared domain and host-only: it may predate a narrowed scope. */
|
|
145
|
+
function deleteRawCookie(name) {
|
|
146
|
+
const domain = cookieDomain();
|
|
147
|
+
setRawCookie(name, "", domain, 0);
|
|
148
|
+
if (domain) setRawCookie(name, "", "", 0);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The broadest domain this browser will actually accept a cookie for, found by
|
|
152
|
+
* probing rather than by carrying a public-suffix list, the same trick
|
|
153
|
+
* posthog-js uses. Candidates start at the broadest (the last two labels) and
|
|
154
|
+
* narrow a label at a time, with the first that sticks winning, so
|
|
155
|
+
* `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
|
|
156
|
+
*
|
|
157
|
+
* Returns `""` (host-only cookie) for single-label hosts like `localhost` and
|
|
158
|
+
* for bare IPs, neither of which can carry a `Domain=` attribute.
|
|
159
|
+
*/
|
|
160
|
+
function probeCookieDomain() {
|
|
161
|
+
if (typeof document === "undefined" || typeof location === "undefined") return "";
|
|
162
|
+
const hostname = location.hostname;
|
|
163
|
+
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
|
|
164
|
+
const parts = hostname.split(".");
|
|
165
|
+
if (parts.length < 2) return "";
|
|
166
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
167
|
+
const candidate = parts.slice(i).join(".");
|
|
168
|
+
const probe = "__maple_probe";
|
|
169
|
+
if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
|
|
170
|
+
setRawCookie(probe, "", candidate, 0);
|
|
171
|
+
return candidate;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
function cookieDomain() {
|
|
177
|
+
if (cookieDomainOverride !== void 0) return cookieDomainOverride;
|
|
178
|
+
if (!crossSubdomainCookie) return "";
|
|
179
|
+
if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
|
|
180
|
+
return probedCookieDomain;
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
1
183
|
//#region ../browser-session/src/platform/json.ts
|
|
2
184
|
/**
|
|
3
185
|
* Minimal structural guards for records read back out of browser storage.
|
|
@@ -42,6 +224,88 @@ function isStringRecord(value) {
|
|
|
42
224
|
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
43
225
|
}
|
|
44
226
|
//#endregion
|
|
227
|
+
//#region ../browser-session/src/session/idle.ts
|
|
228
|
+
/**
|
|
229
|
+
* Rotate the session after this much inactivity (PostHog's default). The visit
|
|
230
|
+
* claim uses the same window: longer and a rotated session goes unbilled,
|
|
231
|
+
* shorter and one continuous visit is billed twice.
|
|
232
|
+
*/
|
|
233
|
+
const IDLE_TIMEOUT_MS = 18e5;
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region ../browser-session/src/identity/visit.ts
|
|
236
|
+
/**
|
|
237
|
+
* The billable unit: one visit per visitor per idle window, across every tab and
|
|
238
|
+
* subdomain. `SessionId` cannot be that unit: sessionStorage scopes it to one
|
|
239
|
+
* tab and one origin, and it must stay that way because `session_replays`
|
|
240
|
+
* resolves each `(OrgId, SessionId)` by `argMax(field, Version)` over the whole
|
|
241
|
+
* row, so two tabs sharing an id would overwrite each other.
|
|
242
|
+
*
|
|
243
|
+
* The marker is the visit's latest activity, not the moment it was claimed:
|
|
244
|
+
* active tabs keep extending it, so a tab opened an hour into a busy visit is
|
|
245
|
+
* not a second charge, and 30 idle minutes (a session rotation) start a new one.
|
|
246
|
+
*/
|
|
247
|
+
const STORAGE_KEY$2 = "maple.visit";
|
|
248
|
+
/** Cookie names cannot contain `.` per RFC 6265's token grammar. */
|
|
249
|
+
const COOKIE_NAME$1 = "maple_visit";
|
|
250
|
+
/** In-memory copy: all a page load has when persistence is off or blocked. */
|
|
251
|
+
let ephemeral$1;
|
|
252
|
+
function parseMarker(raw) {
|
|
253
|
+
if (!raw) return void 0;
|
|
254
|
+
const parsed = Number.parseInt(raw, 10);
|
|
255
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
256
|
+
}
|
|
257
|
+
function readMarker(persist) {
|
|
258
|
+
if (!persist) return ephemeral$1;
|
|
259
|
+
const fromCookie = parseMarker(readRawCookie(COOKIE_NAME$1));
|
|
260
|
+
if (fromCookie !== void 0) return fromCookie;
|
|
261
|
+
try {
|
|
262
|
+
const fromStorage = parseMarker(window.localStorage.getItem(STORAGE_KEY$2));
|
|
263
|
+
if (fromStorage !== void 0) return fromStorage;
|
|
264
|
+
} catch {}
|
|
265
|
+
return ephemeral$1;
|
|
266
|
+
}
|
|
267
|
+
function writeMarker(at, persist) {
|
|
268
|
+
ephemeral$1 = at;
|
|
269
|
+
if (!persist) return;
|
|
270
|
+
const value = String(at);
|
|
271
|
+
try {
|
|
272
|
+
window.localStorage.setItem(STORAGE_KEY$2, value);
|
|
273
|
+
} catch {}
|
|
274
|
+
setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - at)) / 1e3);
|
|
275
|
+
}
|
|
276
|
+
function withinVisit(marker, at) {
|
|
277
|
+
return marker !== void 0 && Math.abs(at - marker) < 18e5;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Claim the visit `at` belongs to, returning whether this caller is the one
|
|
281
|
+
* charged for it. `persist` is false when visitor tracking is off, which keeps
|
|
282
|
+
* the claim in memory: no identifier-adjacent cookie the host app opted out of.
|
|
283
|
+
*/
|
|
284
|
+
function claimVisit(at, persist) {
|
|
285
|
+
if (typeof window === "undefined") return false;
|
|
286
|
+
const marker = readMarker(persist);
|
|
287
|
+
if (withinVisit(marker, at)) {
|
|
288
|
+
if (at > marker) writeMarker(at, persist);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
writeMarker(at, persist);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
/** Extend the current visit to `at`. Never opens one: only `claimVisit` does. */
|
|
295
|
+
function touchVisit(at, persist) {
|
|
296
|
+
if (typeof window === "undefined") return;
|
|
297
|
+
const marker = readMarker(persist);
|
|
298
|
+
if (withinVisit(marker, at) && at > marker) writeMarker(at, persist);
|
|
299
|
+
}
|
|
300
|
+
/** Drop the claim from every store, for a visitor-tracking opt-out. */
|
|
301
|
+
function clearVisitClaim() {
|
|
302
|
+
ephemeral$1 = void 0;
|
|
303
|
+
try {
|
|
304
|
+
window.localStorage.removeItem(STORAGE_KEY$2);
|
|
305
|
+
} catch {}
|
|
306
|
+
deleteRawCookie(COOKIE_NAME$1);
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
45
309
|
//#region ../browser-session/src/identity/visitor.ts
|
|
46
310
|
/**
|
|
47
311
|
* A persistent per-browser visitor id.
|
|
@@ -99,96 +363,11 @@ let enabled = true;
|
|
|
99
363
|
let cached;
|
|
100
364
|
let persisted = false;
|
|
101
365
|
let mintedThisLoad = false;
|
|
102
|
-
/** Scope the cookie to the registered domain so subdomains share the id. */
|
|
103
|
-
let crossSubdomainCookie = true;
|
|
104
|
-
/** Explicit `Domain=` override; `""` forces a host-only cookie. */
|
|
105
|
-
let cookieDomainOverride;
|
|
106
|
-
/** Memoized probe result. `undefined` = not resolved yet. */
|
|
107
|
-
let probedCookieDomain;
|
|
108
366
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* Like the consent gates, this only ever *tightens*: an app that initializes two
|
|
113
|
-
* SDKs, only one of which passes a `privacy` block, must not have the other's
|
|
114
|
-
* absent option widen the cookie back out to every subdomain.
|
|
115
|
-
*
|
|
116
|
-
* "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
|
|
117
|
-
* first-write-wins: `""` (host-only) is the tightest value there is, and a
|
|
118
|
-
* second SDK asking for it has to win over an earlier `"example.com"`. Between
|
|
119
|
-
* two non-empty domains the shorter one is the broader — `example.com` covers
|
|
120
|
-
* `app.example.com` and not the reverse — so the longer string wins.
|
|
367
|
+
* Kept under this name because `configurePrivacy` and the SDK tests call it;
|
|
368
|
+
* the domain decision lives in `platform/cookie.ts`, shared with the visit claim.
|
|
121
369
|
*/
|
|
122
|
-
|
|
123
|
-
if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
|
|
124
|
-
if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
|
|
125
|
-
probedCookieDomain = void 0;
|
|
126
|
-
}
|
|
127
|
-
/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
|
|
128
|
-
function tighterCookieDomain(current, next) {
|
|
129
|
-
if (current === void 0) return next;
|
|
130
|
-
if (current === "" || next === "") return "";
|
|
131
|
-
return next.length > current.length ? next : current;
|
|
132
|
-
}
|
|
133
|
-
function readRawCookie(name) {
|
|
134
|
-
if (typeof document === "undefined") return void 0;
|
|
135
|
-
try {
|
|
136
|
-
for (const part of document.cookie.split(";")) {
|
|
137
|
-
const raw = part.trim();
|
|
138
|
-
if (!raw.startsWith(`${name}=`)) continue;
|
|
139
|
-
return decodeURIComponent(raw.slice(name.length + 1));
|
|
140
|
-
}
|
|
141
|
-
} catch {}
|
|
142
|
-
}
|
|
143
|
-
function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
144
|
-
if (typeof document === "undefined") return false;
|
|
145
|
-
const attributes = [
|
|
146
|
-
`${name}=${encodeURIComponent(value)}`,
|
|
147
|
-
"path=/",
|
|
148
|
-
`max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
|
149
|
-
"SameSite=Lax"
|
|
150
|
-
];
|
|
151
|
-
if (domain) attributes.push(`domain=.${domain}`);
|
|
152
|
-
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
|
|
153
|
-
try {
|
|
154
|
-
document.cookie = attributes.join("; ");
|
|
155
|
-
return true;
|
|
156
|
-
} catch {
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* The broadest domain this browser will actually accept a cookie for, found by
|
|
162
|
-
* probing rather than by carrying a public-suffix list — the same trick
|
|
163
|
-
* posthog-js uses. Candidates start at the broadest (the last two labels) and
|
|
164
|
-
* narrow a label at a time, with the first that sticks winning — so
|
|
165
|
-
* `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
|
|
166
|
-
*
|
|
167
|
-
* Returns `""` (host-only cookie) for single-label hosts like `localhost` and
|
|
168
|
-
* for bare IPs, neither of which can carry a `Domain=` attribute.
|
|
169
|
-
*/
|
|
170
|
-
function probeCookieDomain() {
|
|
171
|
-
if (typeof document === "undefined" || typeof location === "undefined") return "";
|
|
172
|
-
const hostname = location.hostname;
|
|
173
|
-
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
|
|
174
|
-
const parts = hostname.split(".");
|
|
175
|
-
if (parts.length < 2) return "";
|
|
176
|
-
for (let i = parts.length - 2; i >= 0; i--) {
|
|
177
|
-
const candidate = parts.slice(i).join(".");
|
|
178
|
-
const probe = "__maple_probe";
|
|
179
|
-
if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
|
|
180
|
-
setRawCookie(probe, "", candidate, 0);
|
|
181
|
-
return candidate;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return "";
|
|
185
|
-
}
|
|
186
|
-
function cookieDomain() {
|
|
187
|
-
if (cookieDomainOverride !== void 0) return cookieDomainOverride;
|
|
188
|
-
if (!crossSubdomainCookie) return "";
|
|
189
|
-
if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
|
|
190
|
-
return probedCookieDomain;
|
|
191
|
-
}
|
|
370
|
+
const configureVisitorCookie = configureCookieScope;
|
|
192
371
|
function parseRecord(raw) {
|
|
193
372
|
if (!raw) return void 0;
|
|
194
373
|
const value = parseJsonObject(raw);
|
|
@@ -299,9 +478,8 @@ function setVisitorTracking(nextEnabled) {
|
|
|
299
478
|
try {
|
|
300
479
|
window.localStorage.removeItem(STORAGE_KEY$1);
|
|
301
480
|
} catch {}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
|
|
481
|
+
deleteRawCookie(COOKIE_NAME);
|
|
482
|
+
clearVisitClaim();
|
|
305
483
|
}
|
|
306
484
|
//#endregion
|
|
307
485
|
//#region ../browser-session/src/events/trace-id.ts
|
|
@@ -316,6 +494,26 @@ function activeTraceId() {
|
|
|
316
494
|
const id = traceIdProvider();
|
|
317
495
|
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
318
496
|
}
|
|
497
|
+
const slots = [];
|
|
498
|
+
/** Report a span that just started. Fills the innermost open slot, first span wins. */
|
|
499
|
+
function noteStartedTraceId(traceId) {
|
|
500
|
+
const slot = slots.at(-1);
|
|
501
|
+
if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
|
|
502
|
+
}
|
|
503
|
+
/** Run `fn`, returning its result and the trace id of the first span started during it. */
|
|
504
|
+
function withStartedTraceId(fn) {
|
|
505
|
+
const slot = { traceId: void 0 };
|
|
506
|
+
slots.push(slot);
|
|
507
|
+
try {
|
|
508
|
+
return {
|
|
509
|
+
result: fn(),
|
|
510
|
+
traceId: slot.traceId
|
|
511
|
+
};
|
|
512
|
+
} finally {
|
|
513
|
+
const index = slots.lastIndexOf(slot);
|
|
514
|
+
if (index !== -1) slots.splice(index, 1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
319
517
|
//#endregion
|
|
320
518
|
//#region ../browser-session/src/capture/shared.ts
|
|
321
519
|
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
@@ -359,6 +557,21 @@ function truncate(stack) {
|
|
|
359
557
|
if (!stack) return void 0;
|
|
360
558
|
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
361
559
|
}
|
|
560
|
+
const BLOCK_SELECTOR = `[data-rr-block]`;
|
|
561
|
+
/**
|
|
562
|
+
* Whether `el` sits inside a blocked subtree. Mirrors rrweb's own ancestor
|
|
563
|
+
* check so the two capture paths agree on what is hidden.
|
|
564
|
+
*
|
|
565
|
+
* Walks parents rather than using `closest()`: no string reaches a selector
|
|
566
|
+
* parser, so a malformed marker cannot throw into the host app's event handler.
|
|
567
|
+
*/
|
|
568
|
+
const isBlocked = (el) => {
|
|
569
|
+
for (let node = el; node !== null; node = node.parentElement) {
|
|
570
|
+
if (node.classList?.contains("rr-block")) return true;
|
|
571
|
+
if (node.hasAttribute?.("data-rr-block")) return true;
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
};
|
|
362
575
|
//#endregion
|
|
363
576
|
//#region ../browser-session/src/capture/interactions.ts
|
|
364
577
|
const MAX_TEXT = 120;
|
|
@@ -366,7 +579,8 @@ const MAX_TEXT = 120;
|
|
|
366
579
|
* Capture clicks and input events as session events. Listens in the capture
|
|
367
580
|
* phase so it sees interactions even when the host app calls
|
|
368
581
|
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
369
|
-
* element. Click target text is omitted when `maskAllText` is set
|
|
582
|
+
* element. Click target text is omitted when `maskAllText` is set, and when the
|
|
583
|
+
* target sits inside a blocked (`.rr-block` / `data-rr-block`) subtree.
|
|
370
584
|
*/
|
|
371
585
|
function installInteractionCapture(emit, maskAllText) {
|
|
372
586
|
const onClick = (event) => {
|
|
@@ -375,7 +589,7 @@ function installInteractionCapture(emit, maskAllText) {
|
|
|
375
589
|
safeEmit(emit, {
|
|
376
590
|
type: "click",
|
|
377
591
|
targetSelector: selectorOf(target),
|
|
378
|
-
targetText: maskAllText ? void 0 : textOf(target)
|
|
592
|
+
targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
|
|
379
593
|
});
|
|
380
594
|
};
|
|
381
595
|
const onInput = (event) => {
|
|
@@ -448,11 +662,13 @@ const browserDocument = () => globalThis.document;
|
|
|
448
662
|
const SDK_HINT_HEADER = "x-maple-sdk";
|
|
449
663
|
/** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
|
|
450
664
|
const sdkHint = (name, version) => `${name}/${version}`;
|
|
451
|
-
/** Auth + identity headers shared by every ingest request. */
|
|
665
|
+
/** Auth + identity headers shared by every ingest request. No key, no `Authorization`. */
|
|
452
666
|
function ingestHeaders(config) {
|
|
667
|
+
const identity = { [SDK_HINT_HEADER]: config.sdk };
|
|
668
|
+
if (!config.ingestKey) return identity;
|
|
453
669
|
return {
|
|
454
670
|
Authorization: `Bearer ${config.ingestKey}`,
|
|
455
|
-
|
|
671
|
+
...identity
|
|
456
672
|
};
|
|
457
673
|
}
|
|
458
674
|
const lastWarnAt = /* @__PURE__ */ new Map();
|
|
@@ -463,40 +679,107 @@ function warnDropped(what, error) {
|
|
|
463
679
|
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
464
680
|
}
|
|
465
681
|
/**
|
|
466
|
-
*
|
|
682
|
+
* Combined budget for this SDK's in-flight `keepalive` bodies.
|
|
683
|
+
*
|
|
684
|
+
* The Fetch spec caps the *combined* in-flight keepalive body at 64 KiB per
|
|
685
|
+
* page, and the browser rejects a request that would cross it. On the way out
|
|
686
|
+
* up to three of our writes go at once (metadata row, final events batch, last
|
|
687
|
+
* replay chunk), so the budget has to be shared rather than checked per
|
|
688
|
+
* request. It stops short of 64 KiB because the OTLP trace exporter spends
|
|
689
|
+
* from the same page-wide allowance under its own accounting.
|
|
467
690
|
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
|
|
472
|
-
|
|
691
|
+
* Over the budget a write goes out as a normal request, which the page may or
|
|
692
|
+
* may not survive long enough to finish: strictly better than a guaranteed
|
|
693
|
+
* rejection.
|
|
694
|
+
*/
|
|
695
|
+
const KEEPALIVE_BUDGET_BYTES = 49152;
|
|
696
|
+
/** On `globalThis`: two bundled SDK copies still share one page-wide allowance. */
|
|
697
|
+
const KEEPALIVE_KEY = "__MAPLE_KEEPALIVE_INFLIGHT__";
|
|
698
|
+
function keepaliveInflight() {
|
|
699
|
+
const owner = globalThis;
|
|
700
|
+
const existing = owner[KEEPALIVE_KEY];
|
|
701
|
+
if (existing) return existing;
|
|
702
|
+
const fresh = { bytes: 0 };
|
|
703
|
+
owner[KEEPALIVE_KEY] = fresh;
|
|
704
|
+
return fresh;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Reserve `bytes` of the shared keepalive budget. Returns the release function
|
|
708
|
+
* when the request may use `keepalive`, `undefined` when it must not.
|
|
709
|
+
*/
|
|
710
|
+
function reserveKeepalive(requested, bytes) {
|
|
711
|
+
if (!requested) return void 0;
|
|
712
|
+
const inflight = keepaliveInflight();
|
|
713
|
+
if (inflight.bytes + bytes > KEEPALIVE_BUDGET_BYTES) return void 0;
|
|
714
|
+
inflight.bytes += bytes;
|
|
715
|
+
let released = false;
|
|
716
|
+
return () => {
|
|
717
|
+
if (released) return;
|
|
718
|
+
released = true;
|
|
719
|
+
inflight.bytes = Math.max(0, inflight.bytes - bytes);
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
|
|
723
|
+
function byteLength(body) {
|
|
724
|
+
return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* POST to ingest, spending the shared keepalive budget when `keepalive` is
|
|
728
|
+
* requested. Resolves with the status only: the response body is cancelled
|
|
729
|
+
* before the reservation is released, because the browser counts a keepalive
|
|
730
|
+
* request against the page-wide limit until its response body ends, not
|
|
731
|
+
* until headers arrive. Rejects exactly as `fetch` does; callers own the
|
|
732
|
+
* error policy.
|
|
473
733
|
*/
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
734
|
+
async function postToIngest(url, headers, body, keepalive) {
|
|
735
|
+
const release = reserveKeepalive(keepalive, byteLength(body));
|
|
736
|
+
try {
|
|
737
|
+
const response = await fetch(url, {
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers,
|
|
740
|
+
body,
|
|
741
|
+
keepalive: release !== void 0
|
|
742
|
+
});
|
|
743
|
+
await response.body?.cancel().catch(() => {});
|
|
744
|
+
return {
|
|
745
|
+
ok: response.ok,
|
|
746
|
+
status: response.status
|
|
747
|
+
};
|
|
748
|
+
} finally {
|
|
749
|
+
release?.();
|
|
750
|
+
}
|
|
478
751
|
}
|
|
479
|
-
/**
|
|
752
|
+
/**
|
|
753
|
+
* Smallest well-formed gzip stream: a 10-byte header plus an 8-byte trailer.
|
|
754
|
+
* Anything shorter cannot carry a complete member, whatever it contains.
|
|
755
|
+
*/
|
|
756
|
+
const GZIP_MIN_BYTES = 18;
|
|
757
|
+
/**
|
|
758
|
+
* gzip a byte buffer using the native CompressionStream (no library).
|
|
759
|
+
*
|
|
760
|
+
* Throws rather than returning a partial stream. The write and close promises
|
|
761
|
+
* used to be discarded, so a page torn down mid-compression still resolved
|
|
762
|
+
* `arrayBuffer()` — with only the bytes already flushed — and the caller posted
|
|
763
|
+
* a 3-byte header stub as if it were a complete chunk. Ingest rejected those as
|
|
764
|
+
* corrupt gzip; they were 82% of all replay-blob failures, overwhelmingly from
|
|
765
|
+
* crawlers, which tear the page down on nearly every session.
|
|
766
|
+
*/
|
|
480
767
|
async function gzip(bytes) {
|
|
481
768
|
const stream = new CompressionStream("gzip");
|
|
482
769
|
const writer = stream.writable.getWriter();
|
|
483
|
-
writer.write(bytes);
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
770
|
+
const written = writer.write(bytes).then(() => writer.close());
|
|
771
|
+
const [buffer] = await Promise.all([new Response(stream.readable).arrayBuffer(), written]);
|
|
772
|
+
const out = new Uint8Array(buffer);
|
|
773
|
+
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`);
|
|
774
|
+
return out;
|
|
487
775
|
}
|
|
488
776
|
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
489
777
|
async function postSessionMeta(config, row, keepalive = false) {
|
|
490
778
|
const body = `${JSON.stringify(row)}\n`;
|
|
491
|
-
await
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
"content-type": "application/x-ndjson"
|
|
496
|
-
},
|
|
497
|
-
body,
|
|
498
|
-
keepalive: keepaliveFor(keepalive, body.length)
|
|
499
|
-
}).catch((error) => {
|
|
779
|
+
await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
780
|
+
...ingestHeaders(config),
|
|
781
|
+
"content-type": "application/x-ndjson"
|
|
782
|
+
}, body, keepalive).catch((error) => {
|
|
500
783
|
warnDropped("metadata POST", error);
|
|
501
784
|
});
|
|
502
785
|
}
|
|
@@ -504,15 +787,10 @@ async function postSessionMeta(config, row, keepalive = false) {
|
|
|
504
787
|
async function postSessionEvents(config, rows, keepalive = false) {
|
|
505
788
|
if (rows.length === 0) return;
|
|
506
789
|
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
507
|
-
await
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
"content-type": "application/x-ndjson"
|
|
512
|
-
},
|
|
513
|
-
body,
|
|
514
|
-
keepalive: keepaliveFor(keepalive, body.length)
|
|
515
|
-
}).catch((error) => {
|
|
790
|
+
await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
|
|
791
|
+
...ingestHeaders(config),
|
|
792
|
+
"content-type": "application/x-ndjson"
|
|
793
|
+
}, body, keepalive).catch((error) => {
|
|
516
794
|
warnDropped("events POST", error);
|
|
517
795
|
});
|
|
518
796
|
}
|
|
@@ -520,20 +798,15 @@ const SESSION_EXHAUSTED_STATUS = 413;
|
|
|
520
798
|
/** POST a gzipped rrweb event chunk. */
|
|
521
799
|
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
522
800
|
try {
|
|
523
|
-
const response = await
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
"x-maple-duration-ms": String(meta.durationMs)
|
|
533
|
-
},
|
|
534
|
-
body: gzipped,
|
|
535
|
-
keepalive: keepaliveFor(keepalive, gzipped.byteLength)
|
|
536
|
-
});
|
|
801
|
+
const response = await postToIngest(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
802
|
+
...ingestHeaders(config),
|
|
803
|
+
"content-type": "application/octet-stream",
|
|
804
|
+
"x-maple-session-id": meta.sessionId,
|
|
805
|
+
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
806
|
+
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
807
|
+
"x-maple-event-count": String(meta.eventCount),
|
|
808
|
+
"x-maple-duration-ms": String(meta.durationMs)
|
|
809
|
+
}, gzipped, keepalive);
|
|
537
810
|
if (response.ok) return "accepted";
|
|
538
811
|
return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
|
|
539
812
|
} catch (error) {
|
|
@@ -558,9 +831,51 @@ function parse(ua) {
|
|
|
558
831
|
return {
|
|
559
832
|
browserName: /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown",
|
|
560
833
|
osName: /windows/i.test(ua) ? "Windows" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /linux/i.test(ua) ? "Linux" : "Unknown",
|
|
561
|
-
deviceType: /mobile|iphone
|
|
834
|
+
deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
562
835
|
};
|
|
563
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Crawler tokens, every one carrying a delimiter or a scheme on purpose.
|
|
839
|
+
*
|
|
840
|
+
* A bare `bot` substring matches `CUBOT`, an Android phone brand that appears in
|
|
841
|
+
* ordinary mobile UAs, and would file those visitors as crawlers. Requiring
|
|
842
|
+
* `bot/`, `bot;` or `bot)` keeps the `Name/version` and `(compatible; Name;
|
|
843
|
+
* +url)` conventions real crawlers follow while missing the phone. This mirrors
|
|
844
|
+
* the generic tail of the server-side classifier in
|
|
845
|
+
* `@maple/query-engine`'s `user-agent.ts`, deliberately without the ~50 named
|
|
846
|
+
* signatures: every one of those ends in a token already listed here, and the
|
|
847
|
+
* list ships in a size-budgeted bundle to customers.
|
|
848
|
+
*/
|
|
849
|
+
const BOT_TOKENS = [
|
|
850
|
+
"bot/",
|
|
851
|
+
"bot;",
|
|
852
|
+
"bot)",
|
|
853
|
+
"crawler",
|
|
854
|
+
"spider",
|
|
855
|
+
"+http",
|
|
856
|
+
"headless"
|
|
857
|
+
];
|
|
858
|
+
/**
|
|
859
|
+
* Whether the UA is a crawler rather than a person.
|
|
860
|
+
*
|
|
861
|
+
* Used to route crawlers to a metadata-only session: they still appear in Web
|
|
862
|
+
* Analytics — the server-side classifier is what labels them there — but they do
|
|
863
|
+
* not download rrweb or upload replay chunks. Crawlers execute the beacon and
|
|
864
|
+
* then tear the page down mid-flush, which produced the large majority of all
|
|
865
|
+
* corrupt replay-chunk uploads, and every chunk they did manage to upload spent
|
|
866
|
+
* a customer's replay quota on a session nobody will ever watch.
|
|
867
|
+
*
|
|
868
|
+
* Deliberately does not consult `navigator.webdriver`: it is set by every
|
|
869
|
+
* Playwright and Puppeteer run, headed or not. The `headless` token does match
|
|
870
|
+
* the old headless shell's `HeadlessChrome` UA, which production crawlers use
|
|
871
|
+
* too (see the test cases), so an e2e suite that wants replay recorded runs
|
|
872
|
+
* headed, or sets an explicit `userAgent` without `headless` in it. Chrome's
|
|
873
|
+
* new headless mode can still report `HeadlessChrome/`, so it is not enough.
|
|
874
|
+
*/
|
|
875
|
+
function isLikelyBot(ua) {
|
|
876
|
+
const lower = ua.toLowerCase();
|
|
877
|
+
return BOT_TOKENS.some((token) => lower.includes(token));
|
|
878
|
+
}
|
|
564
879
|
//#endregion
|
|
565
880
|
//#region ../browser-session/src/events/meta-row.ts
|
|
566
881
|
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
@@ -580,8 +895,8 @@ function buildSessionMetaRow(input) {
|
|
|
580
895
|
const now = /* @__PURE__ */ new Date();
|
|
581
896
|
const location = browserLocation();
|
|
582
897
|
const identity = input.identity;
|
|
583
|
-
const entryUrl = input.entry?.entryUrl
|
|
584
|
-
const referrer = input.entry?.referrer ?? "";
|
|
898
|
+
const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
|
|
899
|
+
const referrer = scrubUrl(input.entry?.referrer ?? "");
|
|
585
900
|
const utm = input.entry?.utm ?? {};
|
|
586
901
|
const row = {
|
|
587
902
|
session_id: input.sessionId,
|
|
@@ -589,7 +904,7 @@ function buildSessionMetaRow(input) {
|
|
|
589
904
|
status: input.status,
|
|
590
905
|
version: input.version,
|
|
591
906
|
user_id: identity?.id ?? input.userId ?? "",
|
|
592
|
-
url_initial:
|
|
907
|
+
url_initial: entryUrl,
|
|
593
908
|
user_agent: userAgent,
|
|
594
909
|
browser_name: ua.browserName,
|
|
595
910
|
os_name: ua.osName,
|
|
@@ -598,15 +913,17 @@ function buildSessionMetaRow(input) {
|
|
|
598
913
|
resource_attributes: {
|
|
599
914
|
"maple.session.recorded": input.recorded ? "true" : "false",
|
|
600
915
|
"maple.session.replay_format": "rrweb",
|
|
916
|
+
...input.recorded && input.replayTrigger ? { "maple.session.replay_trigger": input.replayTrigger } : void 0,
|
|
601
917
|
...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
|
|
602
918
|
...input.environment ? {
|
|
603
919
|
"deployment.environment": input.environment,
|
|
604
920
|
"deployment.environment.name": input.environment
|
|
605
921
|
} : void 0,
|
|
606
|
-
...input.serviceVersion ? { "
|
|
922
|
+
...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
|
|
607
923
|
},
|
|
608
924
|
visitor_id: input.visitorId ?? "",
|
|
609
925
|
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
926
|
+
billable_start: input.billableStart ? 1 : 0,
|
|
610
927
|
user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
|
|
611
928
|
user_name: identity?.username ?? "",
|
|
612
929
|
group_id: identity?.groupId ?? "",
|
|
@@ -652,15 +969,10 @@ function pathOf(url) {
|
|
|
652
969
|
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
653
970
|
async function postSessionMetaRow(target, row, keepalive = false) {
|
|
654
971
|
const body = `${JSON.stringify(row)}\n`;
|
|
655
|
-
await
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
"content-type": "application/x-ndjson"
|
|
660
|
-
},
|
|
661
|
-
body,
|
|
662
|
-
keepalive: keepaliveFor(keepalive, body.length)
|
|
663
|
-
}).catch(() => {});
|
|
972
|
+
await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
973
|
+
...ingestHeaders(target),
|
|
974
|
+
"content-type": "application/x-ndjson"
|
|
975
|
+
}, body, keepalive).catch(() => {});
|
|
664
976
|
}
|
|
665
977
|
//#endregion
|
|
666
978
|
//#region ../browser-session/src/platform/approximate-size.ts
|
|
@@ -676,8 +988,6 @@ function approximateSize(value) {
|
|
|
676
988
|
//#endregion
|
|
677
989
|
//#region ../browser-session/src/session/session.ts
|
|
678
990
|
const STORAGE_KEY = "maple.session";
|
|
679
|
-
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
680
|
-
const IDLE_TIMEOUT_MS = 18e5;
|
|
681
991
|
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
682
992
|
const MAX_SESSION_MS = 864e5;
|
|
683
993
|
/**
|
|
@@ -735,6 +1045,19 @@ function parseSessionRecord(raw) {
|
|
|
735
1045
|
if (typeof value.visitorIsNew !== "boolean") return void 0;
|
|
736
1046
|
record.visitorIsNew = value.visitorIsNew;
|
|
737
1047
|
}
|
|
1048
|
+
if (value.billable !== void 0) {
|
|
1049
|
+
if (typeof value.billable !== "boolean") return void 0;
|
|
1050
|
+
record.billable = value.billable;
|
|
1051
|
+
}
|
|
1052
|
+
if (value.replaySampled !== void 0) {
|
|
1053
|
+
if (typeof value.replaySampled !== "boolean") return void 0;
|
|
1054
|
+
record.replaySampled = value.replaySampled;
|
|
1055
|
+
}
|
|
1056
|
+
if (value.replayBuffered !== void 0) {
|
|
1057
|
+
if (typeof value.replayBuffered !== "boolean") return void 0;
|
|
1058
|
+
record.replayBuffered = value.replayBuffered;
|
|
1059
|
+
}
|
|
1060
|
+
if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger;
|
|
738
1061
|
if (value.utm !== void 0) {
|
|
739
1062
|
if (!isStringRecord(value.utm)) return void 0;
|
|
740
1063
|
record.utm = value.utm;
|
|
@@ -750,6 +1073,12 @@ const UTM_KEYS = [
|
|
|
750
1073
|
];
|
|
751
1074
|
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
752
1075
|
let ephemeral;
|
|
1076
|
+
/**
|
|
1077
|
+
* Set while sessionStorage writes throw. On quota exhaustion reads still work,
|
|
1078
|
+
* so the stored copy is a frozen snapshot: `metaVersion` pinned at 1 billed
|
|
1079
|
+
* every heartbeat as a new session. Keep this separate from the read try/catch.
|
|
1080
|
+
*/
|
|
1081
|
+
let writesFailed = false;
|
|
753
1082
|
const rotationListeners = /* @__PURE__ */ new Set();
|
|
754
1083
|
/**
|
|
755
1084
|
* Read the entry URL/referrer/UTM of the current page.
|
|
@@ -768,11 +1097,12 @@ function readEntryContext() {
|
|
|
768
1097
|
if (value) utm[key] = value.slice(0, 128);
|
|
769
1098
|
}
|
|
770
1099
|
} catch {}
|
|
1100
|
+
const href = scrubUrl(location.href);
|
|
771
1101
|
return {
|
|
772
|
-
entryUrl:
|
|
773
|
-
entryReferrer: typeof document !== "undefined" ? document.referrer : "",
|
|
1102
|
+
entryUrl: href,
|
|
1103
|
+
entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
|
|
774
1104
|
utm,
|
|
775
|
-
lastUrl:
|
|
1105
|
+
lastUrl: href,
|
|
776
1106
|
pageViews: 0,
|
|
777
1107
|
clickCount: 0,
|
|
778
1108
|
errorCount: 0
|
|
@@ -790,6 +1120,7 @@ function freshRecord(now) {
|
|
|
790
1120
|
};
|
|
791
1121
|
}
|
|
792
1122
|
function readRecord() {
|
|
1123
|
+
if (writesFailed && ephemeral) return ephemeral;
|
|
793
1124
|
try {
|
|
794
1125
|
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
795
1126
|
if (!raw) return void 0;
|
|
@@ -802,10 +1133,112 @@ function writeRecord(record) {
|
|
|
802
1133
|
ephemeral = record;
|
|
803
1134
|
try {
|
|
804
1135
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
1136
|
+
writesFailed = false;
|
|
1137
|
+
} catch {
|
|
1138
|
+
writesFailed = true;
|
|
1139
|
+
}
|
|
1140
|
+
claimTab(record.id);
|
|
1141
|
+
}
|
|
1142
|
+
const TAB_LEASE_PREFIX = "maple.session.tab.";
|
|
1143
|
+
const TAB_LEASE_TTL_MS = 12e4;
|
|
1144
|
+
const TAB_LEASE_REFRESH_MS = 5e3;
|
|
1145
|
+
const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
|
|
1146
|
+
/** The session id this tab has checked (or minted) and may keep using. */
|
|
1147
|
+
let verifiedSessionId;
|
|
1148
|
+
let leaseWrittenAt = 0;
|
|
1149
|
+
let releaseInstalled = false;
|
|
1150
|
+
/**
|
|
1151
|
+
* Set from `pagehide` until the page is shown again. The lifecycle's own
|
|
1152
|
+
* `pagehide` handler posts an `ended` row after ours runs, which writes the
|
|
1153
|
+
* record; re-leasing there would make the next load of this tab look like a
|
|
1154
|
+
* duplicate and rotate on every reload.
|
|
1155
|
+
*/
|
|
1156
|
+
let leaseReleased = false;
|
|
1157
|
+
function tabNonce() {
|
|
1158
|
+
const owner = globalThis;
|
|
1159
|
+
const existing = owner[TAB_NONCE_KEY];
|
|
1160
|
+
if (typeof existing === "string") return existing;
|
|
1161
|
+
const fresh = crypto.randomUUID();
|
|
1162
|
+
owner[TAB_NONCE_KEY] = fresh;
|
|
1163
|
+
return fresh;
|
|
1164
|
+
}
|
|
1165
|
+
function leaseStorage() {
|
|
1166
|
+
try {
|
|
1167
|
+
return typeof window !== "undefined" ? window.localStorage : void 0;
|
|
1168
|
+
} catch {
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function readLease(storage, sessionId) {
|
|
1173
|
+
const raw = storage.getItem(`${TAB_LEASE_PREFIX}${sessionId}`);
|
|
1174
|
+
if (!raw) return void 0;
|
|
1175
|
+
const split = raw.lastIndexOf(":");
|
|
1176
|
+
const at = Number(raw.slice(split + 1));
|
|
1177
|
+
return split > 0 && Number.isFinite(at) ? {
|
|
1178
|
+
nonce: raw.slice(0, split),
|
|
1179
|
+
at
|
|
1180
|
+
} : void 0;
|
|
1181
|
+
}
|
|
1182
|
+
function claimTab(sessionId) {
|
|
1183
|
+
const now = Date.now();
|
|
1184
|
+
if (verifiedSessionId === sessionId && now - leaseWrittenAt < TAB_LEASE_REFRESH_MS) return;
|
|
1185
|
+
if (verifiedSessionId !== void 0 && verifiedSessionId !== sessionId) releaseTab(verifiedSessionId);
|
|
1186
|
+
verifiedSessionId = sessionId;
|
|
1187
|
+
if (leaseReleased) return;
|
|
1188
|
+
leaseWrittenAt = now;
|
|
1189
|
+
const storage = leaseStorage();
|
|
1190
|
+
if (!storage) return;
|
|
1191
|
+
try {
|
|
1192
|
+
storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
|
|
1193
|
+
} catch {}
|
|
1194
|
+
installLeaseRelease();
|
|
1195
|
+
}
|
|
1196
|
+
function releaseTab(sessionId) {
|
|
1197
|
+
const storage = leaseStorage();
|
|
1198
|
+
if (!storage) return;
|
|
1199
|
+
try {
|
|
1200
|
+
if (readLease(storage, sessionId)?.nonce === tabNonce()) storage.removeItem(`${TAB_LEASE_PREFIX}${sessionId}`);
|
|
805
1201
|
} catch {}
|
|
806
1202
|
}
|
|
1203
|
+
function installLeaseRelease() {
|
|
1204
|
+
if (releaseInstalled || typeof globalThis.addEventListener !== "function") return;
|
|
1205
|
+
releaseInstalled = true;
|
|
1206
|
+
globalThis.addEventListener("pagehide", () => {
|
|
1207
|
+
leaseReleased = true;
|
|
1208
|
+
if (verifiedSessionId !== void 0) releaseTab(verifiedSessionId);
|
|
1209
|
+
});
|
|
1210
|
+
globalThis.addEventListener("pageshow", () => {
|
|
1211
|
+
leaseReleased = false;
|
|
1212
|
+
leaseWrittenAt = 0;
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
/** Drop leases a crashed tab never released. */
|
|
1216
|
+
function pruneLeases(storage, now) {
|
|
1217
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
1218
|
+
const key = storage.key(i);
|
|
1219
|
+
if (!key?.startsWith(TAB_LEASE_PREFIX)) continue;
|
|
1220
|
+
const lease = readLease(storage, key.slice(18));
|
|
1221
|
+
if (!lease || now - lease.at > TAB_LEASE_TTL_MS) storage.removeItem(key);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Whether another live tab holds `record`'s session. Checked once per session
|
|
1226
|
+
* id per tab; after that this tab has claimed it.
|
|
1227
|
+
*/
|
|
1228
|
+
function ownedByAnotherTab(record, now) {
|
|
1229
|
+
if (verifiedSessionId === record.id) return false;
|
|
1230
|
+
const storage = leaseStorage();
|
|
1231
|
+
if (!storage) return false;
|
|
1232
|
+
try {
|
|
1233
|
+
pruneLeases(storage, now);
|
|
1234
|
+
const lease = readLease(storage, record.id);
|
|
1235
|
+
return lease !== void 0 && lease.nonce !== tabNonce() && now - lease.at <= TAB_LEASE_TTL_MS;
|
|
1236
|
+
} catch {
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
807
1240
|
function isSessionExpired(record, now = Date.now()) {
|
|
808
|
-
return now - record.lastActivityAt >
|
|
1241
|
+
return now - record.lastActivityAt > 18e5 || now - record.startedAt > MAX_SESSION_MS;
|
|
809
1242
|
}
|
|
810
1243
|
/** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
|
|
811
1244
|
function onSessionRotate(listener) {
|
|
@@ -839,7 +1272,7 @@ function installRotatedRecord(previous, next) {
|
|
|
839
1272
|
function getSession() {
|
|
840
1273
|
const now = Date.now();
|
|
841
1274
|
const existing = readRecord();
|
|
842
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1275
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
843
1276
|
const record = {
|
|
844
1277
|
...migrateRecord(existing),
|
|
845
1278
|
lastActivityAt: now
|
|
@@ -867,13 +1300,13 @@ function rotateSession() {
|
|
|
867
1300
|
*/
|
|
868
1301
|
function touchSession(now) {
|
|
869
1302
|
const existing = readRecord();
|
|
870
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1303
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
871
1304
|
const migrated = migrateRecord(existing);
|
|
872
1305
|
const touched = {
|
|
873
1306
|
...migrated,
|
|
874
1307
|
lastActivityAt: now
|
|
875
1308
|
};
|
|
876
|
-
if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
|
|
1309
|
+
if (migrated !== existing || verifiedSessionId !== existing.id || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
|
|
877
1310
|
return touched;
|
|
878
1311
|
}
|
|
879
1312
|
const record = freshRecord(now);
|
|
@@ -900,7 +1333,7 @@ function noteNavigation(url) {
|
|
|
900
1333
|
const record = touchSession(now);
|
|
901
1334
|
writeRecord({
|
|
902
1335
|
...record,
|
|
903
|
-
lastUrl: url,
|
|
1336
|
+
lastUrl: scrubUrl(url),
|
|
904
1337
|
pageViews: (record.pageViews ?? 0) + 1,
|
|
905
1338
|
lastActivityAt: now
|
|
906
1339
|
});
|
|
@@ -916,6 +1349,22 @@ function noteCounts(counts) {
|
|
|
916
1349
|
});
|
|
917
1350
|
}
|
|
918
1351
|
/**
|
|
1352
|
+
* Whether `sessionId` is the session charged for its visit. `claim` runs once,
|
|
1353
|
+
* on the first row, and the answer is persisted so heartbeats, reloads and the
|
|
1354
|
+
* `ended` row all agree with it.
|
|
1355
|
+
*/
|
|
1356
|
+
function resolveBillable(sessionId, claim) {
|
|
1357
|
+
const record = readRecord();
|
|
1358
|
+
if (!record || record.id !== sessionId) return false;
|
|
1359
|
+
if (record.billable !== void 0) return record.billable;
|
|
1360
|
+
const billable = claim();
|
|
1361
|
+
writeRecord({
|
|
1362
|
+
...record,
|
|
1363
|
+
billable
|
|
1364
|
+
});
|
|
1365
|
+
return billable;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
919
1368
|
* The persisted session record as-is — no activity touch, no rotation. Use
|
|
920
1369
|
* this to read counters when posting a metadata row; `getSession()` would
|
|
921
1370
|
* rotate an idle session out from under the row being written.
|
|
@@ -966,8 +1415,87 @@ function nextMetaVersion() {
|
|
|
966
1415
|
});
|
|
967
1416
|
return version;
|
|
968
1417
|
}
|
|
1418
|
+
/** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
|
|
1419
|
+
function uniformRandom() {
|
|
1420
|
+
const values = /* @__PURE__ */ new Uint32Array(1);
|
|
1421
|
+
crypto.getRandomValues(values);
|
|
1422
|
+
return (values[0] ?? 0) / 4294967296;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* This session's replay sampling decision, rolled against `sampleRate` the
|
|
1426
|
+
* first time it is asked and then persisted, so every page load of the session
|
|
1427
|
+
* agrees. Call after `getSession()` has resolved the session.
|
|
1428
|
+
*/
|
|
1429
|
+
function claimReplaySample(sampleRate) {
|
|
1430
|
+
const record = readRecord() ?? getSession();
|
|
1431
|
+
if (record.replaySampled !== void 0) return record.replaySampled;
|
|
1432
|
+
const sampled = uniformRandom() < sampleRate;
|
|
1433
|
+
writeRecord({
|
|
1434
|
+
...record,
|
|
1435
|
+
replaySampled: sampled
|
|
1436
|
+
});
|
|
1437
|
+
return sampled;
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* This session's replay mode: recorded at `sampleRate`, otherwise buffered for
|
|
1441
|
+
* errors at `onErrorSampleRate`. Rolled once per session and persisted, like
|
|
1442
|
+
* `claimReplaySample`.
|
|
1443
|
+
*/
|
|
1444
|
+
function claimReplayMode(sampleRate, onErrorSampleRate) {
|
|
1445
|
+
if (claimReplaySample(sampleRate)) return "record";
|
|
1446
|
+
const record = readRecord() ?? getSession();
|
|
1447
|
+
if (record.replayBuffered === void 0) {
|
|
1448
|
+
const buffered = onErrorSampleRate > 0 && uniformRandom() < onErrorSampleRate;
|
|
1449
|
+
writeRecord({
|
|
1450
|
+
...record,
|
|
1451
|
+
replayBuffered: buffered
|
|
1452
|
+
});
|
|
1453
|
+
return buffered ? "buffer" : "off";
|
|
1454
|
+
}
|
|
1455
|
+
return record.replayBuffered ? "buffer" : "off";
|
|
1456
|
+
}
|
|
1457
|
+
/** An error made this buffered session a recorded one: later loads record it from the start. */
|
|
1458
|
+
function markReplayTriggered(sessionId) {
|
|
1459
|
+
const record = readRecord();
|
|
1460
|
+
if (!record || record.id !== sessionId) return;
|
|
1461
|
+
writeRecord({
|
|
1462
|
+
...record,
|
|
1463
|
+
replaySampled: true,
|
|
1464
|
+
replayTrigger: "error"
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Pin a session to the capture mode a running page is already in. A session
|
|
1469
|
+
* minted by idle rotation mid-page inherits that page's mode rather than
|
|
1470
|
+
* rolling again, and later loads of it honour the same answer.
|
|
1471
|
+
*/
|
|
1472
|
+
function adoptReplayDecision(sessionId, recorded, buffered) {
|
|
1473
|
+
const record = readRecord();
|
|
1474
|
+
if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
|
|
1475
|
+
writeRecord({
|
|
1476
|
+
...record,
|
|
1477
|
+
replaySampled: recorded,
|
|
1478
|
+
...buffered !== void 0 && !recorded ? { replayBuffered: buffered } : void 0
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
969
1481
|
//#endregion
|
|
970
1482
|
//#region ../browser-session/src/events/events-sink.ts
|
|
1483
|
+
/** On `globalThis` like the sink: every bundled copy of this module must see the same listeners. */
|
|
1484
|
+
const LISTENERS_KEY = "__MAPLE_SESSION_EVENT_LISTENERS__";
|
|
1485
|
+
function listeners() {
|
|
1486
|
+
const global = globalThis;
|
|
1487
|
+
let set = global[LISTENERS_KEY];
|
|
1488
|
+
if (!set) {
|
|
1489
|
+
set = /* @__PURE__ */ new Set();
|
|
1490
|
+
global[LISTENERS_KEY] = set;
|
|
1491
|
+
}
|
|
1492
|
+
return set;
|
|
1493
|
+
}
|
|
1494
|
+
/** Observe every event the live sink records, e.g. for an error's breadcrumb trail. Returns an unsubscribe. */
|
|
1495
|
+
function onSessionEvent(listener) {
|
|
1496
|
+
listeners().add(listener);
|
|
1497
|
+
return () => listeners().delete(listener);
|
|
1498
|
+
}
|
|
971
1499
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
972
1500
|
const FLUSH_BYTES = 65536;
|
|
973
1501
|
/**
|
|
@@ -1014,7 +1542,7 @@ function startEventSink(config, sessionId) {
|
|
|
1014
1542
|
const batch = buffer;
|
|
1015
1543
|
buffer = [];
|
|
1016
1544
|
bufferBytes = 0;
|
|
1017
|
-
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
|
|
1545
|
+
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(config, sessionId, ev, seq)), keepalive);
|
|
1018
1546
|
};
|
|
1019
1547
|
const emit = (ev) => {
|
|
1020
1548
|
const session = markActivity();
|
|
@@ -1034,6 +1562,9 @@ function startEventSink(config, sessionId) {
|
|
|
1034
1562
|
});
|
|
1035
1563
|
bufferBytes += approximateSize(ev);
|
|
1036
1564
|
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
1565
|
+
for (const listener of listeners()) try {
|
|
1566
|
+
listener(ev);
|
|
1567
|
+
} catch {}
|
|
1037
1568
|
};
|
|
1038
1569
|
const stopNavigation = installNavigationObserver((url) => {
|
|
1039
1570
|
emit({
|
|
@@ -1081,13 +1612,14 @@ function installNavigationObserver(onNavigate) {
|
|
|
1081
1612
|
emitNav();
|
|
1082
1613
|
const origPush = history.pushState;
|
|
1083
1614
|
const origReplace = history.replaceState;
|
|
1615
|
+
const receiver = (self) => self instanceof History ? self : history;
|
|
1084
1616
|
history.pushState = function(...args) {
|
|
1085
|
-
const result = origPush.apply(this, args);
|
|
1617
|
+
const result = origPush.apply(receiver(this), args);
|
|
1086
1618
|
emitNav();
|
|
1087
1619
|
return result;
|
|
1088
1620
|
};
|
|
1089
1621
|
history.replaceState = function(...args) {
|
|
1090
|
-
const result = origReplace.apply(this, args);
|
|
1622
|
+
const result = origReplace.apply(receiver(this), args);
|
|
1091
1623
|
emitNav();
|
|
1092
1624
|
return result;
|
|
1093
1625
|
};
|
|
@@ -1150,21 +1682,33 @@ function drainPending(sink) {
|
|
|
1150
1682
|
state.bytes = 0;
|
|
1151
1683
|
for (const ev of queued) sink.emit(ev);
|
|
1152
1684
|
}
|
|
1153
|
-
/**
|
|
1154
|
-
|
|
1685
|
+
/**
|
|
1686
|
+
* Map an internal event to the snake_case ingest row (org_id is added server-side).
|
|
1687
|
+
*
|
|
1688
|
+
* Identity is resolved here, at flush time, alongside the trace id: it is the
|
|
1689
|
+
* person key funnels group on, and reading it late means an `identify()` that
|
|
1690
|
+
* lands shortly after init still stamps the initial page view. `visitor_id` is
|
|
1691
|
+
* `""` whenever the visitor cookie is off (consent, GPC, `persistVisitorId:
|
|
1692
|
+
* false`) — the same rule the metadata row applies.
|
|
1693
|
+
*/
|
|
1694
|
+
function toRow(config, sessionId, ev, seq) {
|
|
1695
|
+
const identity = config.getIdentity?.();
|
|
1155
1696
|
return {
|
|
1156
1697
|
session_id: sessionId,
|
|
1698
|
+
visitor_id: getVisitorId() ?? "",
|
|
1699
|
+
user_id: identity?.id ?? "",
|
|
1700
|
+
group_id: identity?.groupId ?? "",
|
|
1157
1701
|
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
1158
1702
|
seq,
|
|
1159
1703
|
type: ev.type,
|
|
1160
|
-
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
1704
|
+
url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
|
|
1161
1705
|
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
1162
1706
|
level: ev.level ?? "",
|
|
1163
1707
|
message: ev.message ?? "",
|
|
1164
1708
|
target_selector: ev.targetSelector ?? "",
|
|
1165
1709
|
target_text: ev.targetText ?? "",
|
|
1166
1710
|
net_method: ev.net?.method ?? "",
|
|
1167
|
-
net_url: ev.net
|
|
1711
|
+
net_url: ev.net ? scrubUrl(ev.net.url) : "",
|
|
1168
1712
|
net_status: ev.net?.status ?? 0,
|
|
1169
1713
|
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
1170
1714
|
error_stack: ev.errorStack ?? "",
|
|
@@ -1181,7 +1725,7 @@ function toRow(sessionId, ev, seq) {
|
|
|
1181
1725
|
* posted at session start, whose counters are all zero and therefore read as a
|
|
1182
1726
|
* bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
|
|
1183
1727
|
* every heartbeat is an unmerged part until the next merge. Billing meters only
|
|
1184
|
-
* `version == 1` rows, so heartbeats do not double-bill.
|
|
1728
|
+
* `billable_start == 1 && version == 1` rows, so heartbeats do not double-bill.
|
|
1185
1729
|
*/
|
|
1186
1730
|
const HEARTBEAT_INTERVAL_MS = 6e4;
|
|
1187
1731
|
/**
|
|
@@ -1199,6 +1743,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1199
1743
|
let errorCountBase = 0;
|
|
1200
1744
|
let sinkClickCountAtStart = 0;
|
|
1201
1745
|
let sinkErrorCountAtStart = 0;
|
|
1746
|
+
const isRecorded = () => typeof hooks.recorded === "function" ? hooks.recorded() : hooks.recorded;
|
|
1202
1747
|
/**
|
|
1203
1748
|
* The persisted record of the session this lifecycle owns.
|
|
1204
1749
|
*
|
|
@@ -1247,6 +1792,10 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1247
1792
|
const post = (status, keepalive) => {
|
|
1248
1793
|
const record = liveRecord();
|
|
1249
1794
|
const counts = countsFor(record);
|
|
1795
|
+
const visitorId = getVisitorId();
|
|
1796
|
+
const persistVisit = visitorId !== void 0;
|
|
1797
|
+
const billableStart = resolveBillable(record.id, () => claimVisit(Date.now(), persistVisit));
|
|
1798
|
+
touchVisit(record.lastActivityAt, persistVisit);
|
|
1250
1799
|
hooks.post(buildSessionMetaRow({
|
|
1251
1800
|
sessionId: record.id,
|
|
1252
1801
|
startedAt: new Date(record.startedAt),
|
|
@@ -1257,7 +1806,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1257
1806
|
captureUserEmail: options.captureUserEmail,
|
|
1258
1807
|
environment: options.environment,
|
|
1259
1808
|
serviceVersion: options.serviceVersion,
|
|
1260
|
-
visitorId
|
|
1809
|
+
visitorId,
|
|
1261
1810
|
visitorIsNew: record.visitorIsNew === true,
|
|
1262
1811
|
visitorIdPersisted: isVisitorIdPersisted(),
|
|
1263
1812
|
entry: entryContextOf(record),
|
|
@@ -1266,7 +1815,9 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1266
1815
|
pageViews: counts.pageViews,
|
|
1267
1816
|
errorCount: counts.errorCount,
|
|
1268
1817
|
traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
|
|
1269
|
-
recorded:
|
|
1818
|
+
recorded: isRecorded(),
|
|
1819
|
+
replayTrigger: record.replayTrigger,
|
|
1820
|
+
billableStart
|
|
1270
1821
|
}), keepalive);
|
|
1271
1822
|
};
|
|
1272
1823
|
const stopHeartbeat = () => {
|
|
@@ -1278,6 +1829,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1278
1829
|
if (stopped || running) return;
|
|
1279
1830
|
running = true;
|
|
1280
1831
|
const record = liveRecord();
|
|
1832
|
+
adoptReplayDecision(record.id, isRecorded(), hooks.buffered?.());
|
|
1281
1833
|
rebaseCounts(record);
|
|
1282
1834
|
hooks.onStart?.(record);
|
|
1283
1835
|
post("active", false);
|
|
@@ -1339,6 +1891,9 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1339
1891
|
get sessionId() {
|
|
1340
1892
|
return current.id;
|
|
1341
1893
|
},
|
|
1894
|
+
announce: () => {
|
|
1895
|
+
if (running) post("active", false);
|
|
1896
|
+
},
|
|
1342
1897
|
shutdown: async (shutdownOptions) => {
|
|
1343
1898
|
if (stopped) return;
|
|
1344
1899
|
stopped = true;
|
|
@@ -1374,6 +1929,7 @@ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
|
|
|
1374
1929
|
const MAX_TRACE_IDS_PER_SESSION = 200;
|
|
1375
1930
|
/** Record a trace id seen during the session. Idempotent per id. */
|
|
1376
1931
|
function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
1932
|
+
noteStartedTraceId(traceId);
|
|
1377
1933
|
if (!sessionId) return;
|
|
1378
1934
|
let ids = observedTraceIdsBySession.get(sessionId);
|
|
1379
1935
|
if (!ids) {
|
|
@@ -1421,4 +1977,4 @@ function readSessionSink() {
|
|
|
1421
1977
|
return globalThis[SESSION_SINK_KEY];
|
|
1422
1978
|
}
|
|
1423
1979
|
//#endregion
|
|
1424
|
-
export { setActiveTraceIdProvider as C,
|
|
1980
|
+
export { setActiveTraceIdProvider as A, postSessionMeta as C, BLOCK_SELECTOR as D, selectorOf as E, scrubUrl as F, configureVisitorCookie as M, setVisitorTracking as N, safeEmit as O, addUrlSanitizer as P, postSessionBlob as S, warnDropped as T, rotateSession as _, recordTraceId as a, gzip as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, withStartedTraceId as j, activeTraceId as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, sdkHint as w, ingestHeaders as x, isLikelyBot as y };
|