@maple-dev/browser 0.9.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 +85 -3
- package/dist/console-BrjBnOiX.mjs +73 -0
- package/dist/deferred-CdtlxG0Q.mjs +723 -0
- package/dist/index.d.mts +164 -14
- package/dist/index.mjs +1 -854
- package/dist/react.d.mts +88 -0
- package/dist/react.mjs +130 -0
- package/dist/replay-session-DdVYdHc_.mjs +527 -0
- package/dist/{sink-DHPiMgU0.mjs → sink-BOR9_z4b.mjs} +293 -104
- package/dist/src-Bzmp8rVr.mjs +1722 -0
- package/package.json +33 -11
- package/dist/replay-session-gSlAYwqV.mjs +0 -360
|
@@ -82,6 +82,104 @@ function scrubUrl(url) {
|
|
|
82
82
|
return out;
|
|
83
83
|
}
|
|
84
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
|
|
85
183
|
//#region ../browser-session/src/platform/json.ts
|
|
86
184
|
/**
|
|
87
185
|
* Minimal structural guards for records read back out of browser storage.
|
|
@@ -126,6 +224,88 @@ function isStringRecord(value) {
|
|
|
126
224
|
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
127
225
|
}
|
|
128
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
|
|
129
309
|
//#region ../browser-session/src/identity/visitor.ts
|
|
130
310
|
/**
|
|
131
311
|
* A persistent per-browser visitor id.
|
|
@@ -183,96 +363,11 @@ let enabled = true;
|
|
|
183
363
|
let cached;
|
|
184
364
|
let persisted = false;
|
|
185
365
|
let mintedThisLoad = false;
|
|
186
|
-
/** Scope the cookie to the registered domain so subdomains share the id. */
|
|
187
|
-
let crossSubdomainCookie = true;
|
|
188
|
-
/** Explicit `Domain=` override; `""` forces a host-only cookie. */
|
|
189
|
-
let cookieDomainOverride;
|
|
190
|
-
/** Memoized probe result. `undefined` = not resolved yet. */
|
|
191
|
-
let probedCookieDomain;
|
|
192
366
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* Like the consent gates, this only ever *tightens*: an app that initializes two
|
|
197
|
-
* SDKs, only one of which passes a `privacy` block, must not have the other's
|
|
198
|
-
* absent option widen the cookie back out to every subdomain.
|
|
199
|
-
*
|
|
200
|
-
* "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
|
|
201
|
-
* first-write-wins: `""` (host-only) is the tightest value there is, and a
|
|
202
|
-
* second SDK asking for it has to win over an earlier `"example.com"`. Between
|
|
203
|
-
* two non-empty domains the shorter one is the broader — `example.com` covers
|
|
204
|
-
* `app.example.com` and not the reverse — so the longer string wins.
|
|
205
|
-
*/
|
|
206
|
-
function configureVisitorCookie(options) {
|
|
207
|
-
if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
|
|
208
|
-
if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
|
|
209
|
-
probedCookieDomain = void 0;
|
|
210
|
-
}
|
|
211
|
-
/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
|
|
212
|
-
function tighterCookieDomain(current, next) {
|
|
213
|
-
if (current === void 0) return next;
|
|
214
|
-
if (current === "" || next === "") return "";
|
|
215
|
-
return next.length > current.length ? next : current;
|
|
216
|
-
}
|
|
217
|
-
function readRawCookie(name) {
|
|
218
|
-
if (typeof document === "undefined") return void 0;
|
|
219
|
-
try {
|
|
220
|
-
for (const part of document.cookie.split(";")) {
|
|
221
|
-
const raw = part.trim();
|
|
222
|
-
if (!raw.startsWith(`${name}=`)) continue;
|
|
223
|
-
return decodeURIComponent(raw.slice(name.length + 1));
|
|
224
|
-
}
|
|
225
|
-
} catch {}
|
|
226
|
-
}
|
|
227
|
-
function setRawCookie(name, value, domain, maxAgeSeconds) {
|
|
228
|
-
if (typeof document === "undefined") return false;
|
|
229
|
-
const attributes = [
|
|
230
|
-
`${name}=${encodeURIComponent(value)}`,
|
|
231
|
-
"path=/",
|
|
232
|
-
`max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
|
233
|
-
"SameSite=Lax"
|
|
234
|
-
];
|
|
235
|
-
if (domain) attributes.push(`domain=.${domain}`);
|
|
236
|
-
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
|
|
237
|
-
try {
|
|
238
|
-
document.cookie = attributes.join("; ");
|
|
239
|
-
return true;
|
|
240
|
-
} catch {
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* The broadest domain this browser will actually accept a cookie for, found by
|
|
246
|
-
* probing rather than by carrying a public-suffix list — the same trick
|
|
247
|
-
* posthog-js uses. Candidates start at the broadest (the last two labels) and
|
|
248
|
-
* narrow a label at a time, with the first that sticks winning — so
|
|
249
|
-
* `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
|
|
250
|
-
*
|
|
251
|
-
* Returns `""` (host-only cookie) for single-label hosts like `localhost` and
|
|
252
|
-
* for bare IPs, neither of which can carry a `Domain=` attribute.
|
|
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.
|
|
253
369
|
*/
|
|
254
|
-
|
|
255
|
-
if (typeof document === "undefined" || typeof location === "undefined") return "";
|
|
256
|
-
const hostname = location.hostname;
|
|
257
|
-
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return "";
|
|
258
|
-
const parts = hostname.split(".");
|
|
259
|
-
if (parts.length < 2) return "";
|
|
260
|
-
for (let i = parts.length - 2; i >= 0; i--) {
|
|
261
|
-
const candidate = parts.slice(i).join(".");
|
|
262
|
-
const probe = "__maple_probe";
|
|
263
|
-
if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
|
|
264
|
-
setRawCookie(probe, "", candidate, 0);
|
|
265
|
-
return candidate;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return "";
|
|
269
|
-
}
|
|
270
|
-
function cookieDomain() {
|
|
271
|
-
if (cookieDomainOverride !== void 0) return cookieDomainOverride;
|
|
272
|
-
if (!crossSubdomainCookie) return "";
|
|
273
|
-
if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
|
|
274
|
-
return probedCookieDomain;
|
|
275
|
-
}
|
|
370
|
+
const configureVisitorCookie = configureCookieScope;
|
|
276
371
|
function parseRecord(raw) {
|
|
277
372
|
if (!raw) return void 0;
|
|
278
373
|
const value = parseJsonObject(raw);
|
|
@@ -383,9 +478,8 @@ function setVisitorTracking(nextEnabled) {
|
|
|
383
478
|
try {
|
|
384
479
|
window.localStorage.removeItem(STORAGE_KEY$1);
|
|
385
480
|
} catch {}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
|
|
481
|
+
deleteRawCookie(COOKIE_NAME);
|
|
482
|
+
clearVisitClaim();
|
|
389
483
|
}
|
|
390
484
|
//#endregion
|
|
391
485
|
//#region ../browser-session/src/events/trace-id.ts
|
|
@@ -568,11 +662,13 @@ const browserDocument = () => globalThis.document;
|
|
|
568
662
|
const SDK_HINT_HEADER = "x-maple-sdk";
|
|
569
663
|
/** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
|
|
570
664
|
const sdkHint = (name, version) => `${name}/${version}`;
|
|
571
|
-
/** Auth + identity headers shared by every ingest request. */
|
|
665
|
+
/** Auth + identity headers shared by every ingest request. No key, no `Authorization`. */
|
|
572
666
|
function ingestHeaders(config) {
|
|
667
|
+
const identity = { [SDK_HINT_HEADER]: config.sdk };
|
|
668
|
+
if (!config.ingestKey) return identity;
|
|
573
669
|
return {
|
|
574
670
|
Authorization: `Bearer ${config.ingestKey}`,
|
|
575
|
-
|
|
671
|
+
...identity
|
|
576
672
|
};
|
|
577
673
|
}
|
|
578
674
|
const lastWarnAt = /* @__PURE__ */ new Map();
|
|
@@ -817,6 +913,7 @@ function buildSessionMetaRow(input) {
|
|
|
817
913
|
resource_attributes: {
|
|
818
914
|
"maple.session.recorded": input.recorded ? "true" : "false",
|
|
819
915
|
"maple.session.replay_format": "rrweb",
|
|
916
|
+
...input.recorded && input.replayTrigger ? { "maple.session.replay_trigger": input.replayTrigger } : void 0,
|
|
820
917
|
...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
|
|
821
918
|
...input.environment ? {
|
|
822
919
|
"deployment.environment": input.environment,
|
|
@@ -826,6 +923,7 @@ function buildSessionMetaRow(input) {
|
|
|
826
923
|
},
|
|
827
924
|
visitor_id: input.visitorId ?? "",
|
|
828
925
|
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
926
|
+
billable_start: input.billableStart ? 1 : 0,
|
|
829
927
|
user_email: (input.captureUserEmail === false ? void 0 : identity?.email) ?? "",
|
|
830
928
|
user_name: identity?.username ?? "",
|
|
831
929
|
group_id: identity?.groupId ?? "",
|
|
@@ -890,8 +988,6 @@ function approximateSize(value) {
|
|
|
890
988
|
//#endregion
|
|
891
989
|
//#region ../browser-session/src/session/session.ts
|
|
892
990
|
const STORAGE_KEY = "maple.session";
|
|
893
|
-
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
894
|
-
const IDLE_TIMEOUT_MS = 18e5;
|
|
895
991
|
/** Hard cap on a single session's lifetime regardless of activity. */
|
|
896
992
|
const MAX_SESSION_MS = 864e5;
|
|
897
993
|
/**
|
|
@@ -949,10 +1045,19 @@ function parseSessionRecord(raw) {
|
|
|
949
1045
|
if (typeof value.visitorIsNew !== "boolean") return void 0;
|
|
950
1046
|
record.visitorIsNew = value.visitorIsNew;
|
|
951
1047
|
}
|
|
1048
|
+
if (value.billable !== void 0) {
|
|
1049
|
+
if (typeof value.billable !== "boolean") return void 0;
|
|
1050
|
+
record.billable = value.billable;
|
|
1051
|
+
}
|
|
952
1052
|
if (value.replaySampled !== void 0) {
|
|
953
1053
|
if (typeof value.replaySampled !== "boolean") return void 0;
|
|
954
1054
|
record.replaySampled = value.replaySampled;
|
|
955
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;
|
|
956
1061
|
if (value.utm !== void 0) {
|
|
957
1062
|
if (!isStringRecord(value.utm)) return void 0;
|
|
958
1063
|
record.utm = value.utm;
|
|
@@ -968,6 +1073,12 @@ const UTM_KEYS = [
|
|
|
968
1073
|
];
|
|
969
1074
|
/** In-memory fallback when sessionStorage is unavailable (private mode). */
|
|
970
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;
|
|
971
1082
|
const rotationListeners = /* @__PURE__ */ new Set();
|
|
972
1083
|
/**
|
|
973
1084
|
* Read the entry URL/referrer/UTM of the current page.
|
|
@@ -1009,6 +1120,7 @@ function freshRecord(now) {
|
|
|
1009
1120
|
};
|
|
1010
1121
|
}
|
|
1011
1122
|
function readRecord() {
|
|
1123
|
+
if (writesFailed && ephemeral) return ephemeral;
|
|
1012
1124
|
try {
|
|
1013
1125
|
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
1014
1126
|
if (!raw) return void 0;
|
|
@@ -1021,7 +1133,10 @@ function writeRecord(record) {
|
|
|
1021
1133
|
ephemeral = record;
|
|
1022
1134
|
try {
|
|
1023
1135
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
1024
|
-
|
|
1136
|
+
writesFailed = false;
|
|
1137
|
+
} catch {
|
|
1138
|
+
writesFailed = true;
|
|
1139
|
+
}
|
|
1025
1140
|
claimTab(record.id);
|
|
1026
1141
|
}
|
|
1027
1142
|
const TAB_LEASE_PREFIX = "maple.session.tab.";
|
|
@@ -1123,7 +1238,7 @@ function ownedByAnotherTab(record, now) {
|
|
|
1123
1238
|
}
|
|
1124
1239
|
}
|
|
1125
1240
|
function isSessionExpired(record, now = Date.now()) {
|
|
1126
|
-
return now - record.lastActivityAt >
|
|
1241
|
+
return now - record.lastActivityAt > 18e5 || now - record.startedAt > MAX_SESSION_MS;
|
|
1127
1242
|
}
|
|
1128
1243
|
/** Observe genuine idle/lifetime rotation. Invoked before the new record is installed. */
|
|
1129
1244
|
function onSessionRotate(listener) {
|
|
@@ -1234,6 +1349,22 @@ function noteCounts(counts) {
|
|
|
1234
1349
|
});
|
|
1235
1350
|
}
|
|
1236
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
|
+
/**
|
|
1237
1368
|
* The persisted session record as-is — no activity touch, no rotation. Use
|
|
1238
1369
|
* this to read counters when posting a metadata row; `getSession()` would
|
|
1239
1370
|
* rotate an idle session out from under the row being written.
|
|
@@ -1306,20 +1437,65 @@ function claimReplaySample(sampleRate) {
|
|
|
1306
1437
|
return sampled;
|
|
1307
1438
|
}
|
|
1308
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
|
+
/**
|
|
1309
1468
|
* Pin a session to the capture mode a running page is already in. A session
|
|
1310
1469
|
* minted by idle rotation mid-page inherits that page's mode rather than
|
|
1311
1470
|
* rolling again, and later loads of it honour the same answer.
|
|
1312
1471
|
*/
|
|
1313
|
-
function adoptReplayDecision(sessionId, recorded) {
|
|
1472
|
+
function adoptReplayDecision(sessionId, recorded, buffered) {
|
|
1314
1473
|
const record = readRecord();
|
|
1315
1474
|
if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
|
|
1316
1475
|
writeRecord({
|
|
1317
1476
|
...record,
|
|
1318
|
-
replaySampled: recorded
|
|
1477
|
+
replaySampled: recorded,
|
|
1478
|
+
...buffered !== void 0 && !recorded ? { replayBuffered: buffered } : void 0
|
|
1319
1479
|
});
|
|
1320
1480
|
}
|
|
1321
1481
|
//#endregion
|
|
1322
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
|
+
}
|
|
1323
1499
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
1324
1500
|
const FLUSH_BYTES = 65536;
|
|
1325
1501
|
/**
|
|
@@ -1386,6 +1562,9 @@ function startEventSink(config, sessionId) {
|
|
|
1386
1562
|
});
|
|
1387
1563
|
bufferBytes += approximateSize(ev);
|
|
1388
1564
|
if (bufferBytes >= FLUSH_BYTES) flush();
|
|
1565
|
+
for (const listener of listeners()) try {
|
|
1566
|
+
listener(ev);
|
|
1567
|
+
} catch {}
|
|
1389
1568
|
};
|
|
1390
1569
|
const stopNavigation = installNavigationObserver((url) => {
|
|
1391
1570
|
emit({
|
|
@@ -1546,7 +1725,7 @@ function toRow(config, sessionId, ev, seq) {
|
|
|
1546
1725
|
* posted at session start, whose counters are all zero and therefore read as a
|
|
1547
1726
|
* bounce. 60s is the floor worth using: the table is a ReplacingMergeTree, so
|
|
1548
1727
|
* every heartbeat is an unmerged part until the next merge. Billing meters only
|
|
1549
|
-
* `version == 1` rows, so heartbeats do not double-bill.
|
|
1728
|
+
* `billable_start == 1 && version == 1` rows, so heartbeats do not double-bill.
|
|
1550
1729
|
*/
|
|
1551
1730
|
const HEARTBEAT_INTERVAL_MS = 6e4;
|
|
1552
1731
|
/**
|
|
@@ -1564,6 +1743,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1564
1743
|
let errorCountBase = 0;
|
|
1565
1744
|
let sinkClickCountAtStart = 0;
|
|
1566
1745
|
let sinkErrorCountAtStart = 0;
|
|
1746
|
+
const isRecorded = () => typeof hooks.recorded === "function" ? hooks.recorded() : hooks.recorded;
|
|
1567
1747
|
/**
|
|
1568
1748
|
* The persisted record of the session this lifecycle owns.
|
|
1569
1749
|
*
|
|
@@ -1612,6 +1792,10 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1612
1792
|
const post = (status, keepalive) => {
|
|
1613
1793
|
const record = liveRecord();
|
|
1614
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);
|
|
1615
1799
|
hooks.post(buildSessionMetaRow({
|
|
1616
1800
|
sessionId: record.id,
|
|
1617
1801
|
startedAt: new Date(record.startedAt),
|
|
@@ -1622,7 +1806,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1622
1806
|
captureUserEmail: options.captureUserEmail,
|
|
1623
1807
|
environment: options.environment,
|
|
1624
1808
|
serviceVersion: options.serviceVersion,
|
|
1625
|
-
visitorId
|
|
1809
|
+
visitorId,
|
|
1626
1810
|
visitorIsNew: record.visitorIsNew === true,
|
|
1627
1811
|
visitorIdPersisted: isVisitorIdPersisted(),
|
|
1628
1812
|
entry: entryContextOf(record),
|
|
@@ -1631,7 +1815,9 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1631
1815
|
pageViews: counts.pageViews,
|
|
1632
1816
|
errorCount: counts.errorCount,
|
|
1633
1817
|
traceIds: status === "ended" ? options.getTraceIds?.(record.id) : void 0,
|
|
1634
|
-
recorded:
|
|
1818
|
+
recorded: isRecorded(),
|
|
1819
|
+
replayTrigger: record.replayTrigger,
|
|
1820
|
+
billableStart
|
|
1635
1821
|
}), keepalive);
|
|
1636
1822
|
};
|
|
1637
1823
|
const stopHeartbeat = () => {
|
|
@@ -1643,7 +1829,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1643
1829
|
if (stopped || running) return;
|
|
1644
1830
|
running = true;
|
|
1645
1831
|
const record = liveRecord();
|
|
1646
|
-
adoptReplayDecision(record.id, hooks.
|
|
1832
|
+
adoptReplayDecision(record.id, isRecorded(), hooks.buffered?.());
|
|
1647
1833
|
rebaseCounts(record);
|
|
1648
1834
|
hooks.onStart?.(record);
|
|
1649
1835
|
post("active", false);
|
|
@@ -1705,6 +1891,9 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1705
1891
|
get sessionId() {
|
|
1706
1892
|
return current.id;
|
|
1707
1893
|
},
|
|
1894
|
+
announce: () => {
|
|
1895
|
+
if (running) post("active", false);
|
|
1896
|
+
},
|
|
1708
1897
|
shutdown: async (shutdownOptions) => {
|
|
1709
1898
|
if (stopped) return;
|
|
1710
1899
|
stopped = true;
|
|
@@ -1788,4 +1977,4 @@ function readSessionSink() {
|
|
|
1788
1977
|
return globalThis[SESSION_SINK_KEY];
|
|
1789
1978
|
}
|
|
1790
1979
|
//#endregion
|
|
1791
|
-
export {
|
|
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 };
|