@maple-dev/browser 0.4.0 → 0.9.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/LICENSE +1 -1
- package/README.md +81 -2
- package/dist/index.d.mts +73 -20
- package/dist/index.mjs +307 -57
- package/dist/{replay-session-B_ACRogX.mjs → replay-session-gSlAYwqV.mjs} +81 -111
- package/dist/{sink-DHTfvFgl.mjs → sink-DHPiMgU0.mjs} +776 -134
- package/package.json +26 -10
|
@@ -1,4 +1,132 @@
|
|
|
1
|
-
//#region ../browser-session/src/
|
|
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/json.ts
|
|
86
|
+
/**
|
|
87
|
+
* Minimal structural guards for records read back out of browser storage.
|
|
88
|
+
*
|
|
89
|
+
* These replace what was an `effect/Schema` decoder. This package is *bundled*
|
|
90
|
+
* into `@maple-dev/browser`, whose eager chunk every visitor downloads before
|
|
91
|
+
* any sampling decision runs — and pulling Schema in to validate two flat
|
|
92
|
+
* records cost ~30 kB gzipped, most of that SDK's entire page-load budget, for
|
|
93
|
+
* shapes the callers already declare in TypeScript. Schema earns its size where
|
|
94
|
+
* the input is genuinely unknown and the errors need to be legible; here the
|
|
95
|
+
* only question is "did we write this, and is it still the shape we write",
|
|
96
|
+
* and the only answer anyone acts on is yes/no.
|
|
97
|
+
*
|
|
98
|
+
* The semantics deliberately match what the Schema decoders did, because the
|
|
99
|
+
* records in the wild were written by them:
|
|
100
|
+
*
|
|
101
|
+
* - unknown keys are dropped rather than rejected (callers rebuild an explicit
|
|
102
|
+
* object from the keys they know),
|
|
103
|
+
* - a required key that is absent or wrongly typed rejects the whole record,
|
|
104
|
+
* - an optional key must be either absent or correctly typed — `null` is not
|
|
105
|
+
* an accepted stand-in for absent, matching `Schema.optionalKey`.
|
|
106
|
+
*/
|
|
107
|
+
/** A non-null, non-array object — the only JSON shape these records take. */
|
|
108
|
+
function isJsonObject(value) {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Parse a storage string into a plain object, or `undefined` if it is not
|
|
113
|
+
* valid JSON or not an object. Never throws: every caller here treats a
|
|
114
|
+
* corrupt record as a cache miss, not as an error worth surfacing.
|
|
115
|
+
*/
|
|
116
|
+
function parseJsonObject(raw) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
return isJsonObject(parsed) ? parsed : void 0;
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** A `Record<string, string>` — every value a string, as `Schema.Record` required. */
|
|
125
|
+
function isStringRecord(value) {
|
|
126
|
+
return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region ../browser-session/src/identity/visitor.ts
|
|
2
130
|
/**
|
|
3
131
|
* A persistent per-browser visitor id.
|
|
4
132
|
*
|
|
@@ -147,14 +275,15 @@ function cookieDomain() {
|
|
|
147
275
|
}
|
|
148
276
|
function parseRecord(raw) {
|
|
149
277
|
if (!raw) return void 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
278
|
+
const value = parseJsonObject(raw);
|
|
279
|
+
if (!value) return void 0;
|
|
280
|
+
const { id, mintedAt } = value;
|
|
281
|
+
if (typeof id !== "string" || typeof mintedAt !== "number") return void 0;
|
|
282
|
+
if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
|
|
283
|
+
return {
|
|
284
|
+
id,
|
|
285
|
+
mintedAt
|
|
286
|
+
};
|
|
158
287
|
}
|
|
159
288
|
function readFromStorage() {
|
|
160
289
|
try {
|
|
@@ -259,7 +388,338 @@ function setVisitorTracking(nextEnabled) {
|
|
|
259
388
|
if (domain) setRawCookie(COOKIE_NAME, "", "", 0);
|
|
260
389
|
}
|
|
261
390
|
//#endregion
|
|
262
|
-
//#region ../browser-session/src/
|
|
391
|
+
//#region ../browser-session/src/events/trace-id.ts
|
|
392
|
+
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
393
|
+
let traceIdProvider = () => void 0;
|
|
394
|
+
/** Wire the host SDK's active-trace-id lookup into event capture. */
|
|
395
|
+
function setActiveTraceIdProvider(provider) {
|
|
396
|
+
traceIdProvider = provider;
|
|
397
|
+
}
|
|
398
|
+
/** The trace id of the active span, or undefined when none is active. */
|
|
399
|
+
function activeTraceId() {
|
|
400
|
+
const id = traceIdProvider();
|
|
401
|
+
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
402
|
+
}
|
|
403
|
+
const slots = [];
|
|
404
|
+
/** Report a span that just started. Fills the innermost open slot, first span wins. */
|
|
405
|
+
function noteStartedTraceId(traceId) {
|
|
406
|
+
const slot = slots.at(-1);
|
|
407
|
+
if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
|
|
408
|
+
}
|
|
409
|
+
/** Run `fn`, returning its result and the trace id of the first span started during it. */
|
|
410
|
+
function withStartedTraceId(fn) {
|
|
411
|
+
const slot = { traceId: void 0 };
|
|
412
|
+
slots.push(slot);
|
|
413
|
+
try {
|
|
414
|
+
return {
|
|
415
|
+
result: fn(),
|
|
416
|
+
traceId: slot.traceId
|
|
417
|
+
};
|
|
418
|
+
} finally {
|
|
419
|
+
const index = slots.lastIndexOf(slot);
|
|
420
|
+
if (index !== -1) slots.splice(index, 1);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region ../browser-session/src/capture/shared.ts
|
|
425
|
+
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
426
|
+
function safeEmit(emit, ev) {
|
|
427
|
+
try {
|
|
428
|
+
emit(ev);
|
|
429
|
+
} catch {}
|
|
430
|
+
}
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region ../browser-session/src/capture/errors.ts
|
|
433
|
+
const MAX_STACK = 4e3;
|
|
434
|
+
/** Capture uncaught errors + unhandled promise rejections as session events. */
|
|
435
|
+
function installErrorCapture(emit) {
|
|
436
|
+
const onError = (event) => {
|
|
437
|
+
safeEmit(emit, {
|
|
438
|
+
type: "error",
|
|
439
|
+
level: "error",
|
|
440
|
+
message: event.message || String(event.error ?? "Error"),
|
|
441
|
+
errorStack: truncate(event.error?.stack),
|
|
442
|
+
traceId: activeTraceId()
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
const onRejection = (event) => {
|
|
446
|
+
const reason = event.reason;
|
|
447
|
+
safeEmit(emit, {
|
|
448
|
+
type: "error",
|
|
449
|
+
level: "error",
|
|
450
|
+
message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
|
|
451
|
+
errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
|
|
452
|
+
traceId: activeTraceId()
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
window.addEventListener("error", onError);
|
|
456
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
457
|
+
return () => {
|
|
458
|
+
window.removeEventListener("error", onError);
|
|
459
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function truncate(stack) {
|
|
463
|
+
if (!stack) return void 0;
|
|
464
|
+
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
465
|
+
}
|
|
466
|
+
const BLOCK_SELECTOR = `[data-rr-block]`;
|
|
467
|
+
/**
|
|
468
|
+
* Whether `el` sits inside a blocked subtree. Mirrors rrweb's own ancestor
|
|
469
|
+
* check so the two capture paths agree on what is hidden.
|
|
470
|
+
*
|
|
471
|
+
* Walks parents rather than using `closest()`: no string reaches a selector
|
|
472
|
+
* parser, so a malformed marker cannot throw into the host app's event handler.
|
|
473
|
+
*/
|
|
474
|
+
const isBlocked = (el) => {
|
|
475
|
+
for (let node = el; node !== null; node = node.parentElement) {
|
|
476
|
+
if (node.classList?.contains("rr-block")) return true;
|
|
477
|
+
if (node.hasAttribute?.("data-rr-block")) return true;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
};
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region ../browser-session/src/capture/interactions.ts
|
|
483
|
+
const MAX_TEXT = 120;
|
|
484
|
+
/**
|
|
485
|
+
* Capture clicks and input events as session events. Listens in the capture
|
|
486
|
+
* phase so it sees interactions even when the host app calls
|
|
487
|
+
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
488
|
+
* element. Click target text is omitted when `maskAllText` is set, and when the
|
|
489
|
+
* target sits inside a blocked (`.rr-block` / `data-rr-block`) subtree.
|
|
490
|
+
*/
|
|
491
|
+
function installInteractionCapture(emit, maskAllText) {
|
|
492
|
+
const onClick = (event) => {
|
|
493
|
+
const target = event.target;
|
|
494
|
+
if (!(target instanceof Element)) return;
|
|
495
|
+
safeEmit(emit, {
|
|
496
|
+
type: "click",
|
|
497
|
+
targetSelector: selectorOf(target),
|
|
498
|
+
targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
const onInput = (event) => {
|
|
502
|
+
const target = event.target;
|
|
503
|
+
if (!(target instanceof Element)) return;
|
|
504
|
+
safeEmit(emit, {
|
|
505
|
+
type: "input",
|
|
506
|
+
targetSelector: selectorOf(target)
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
document.addEventListener("click", onClick, true);
|
|
510
|
+
document.addEventListener("input", onInput, true);
|
|
511
|
+
return () => {
|
|
512
|
+
document.removeEventListener("click", onClick, true);
|
|
513
|
+
document.removeEventListener("input", onInput, true);
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** A short, human-readable selector: tag + #id + .first-class. */
|
|
517
|
+
function selectorOf(el) {
|
|
518
|
+
return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
|
|
519
|
+
}
|
|
520
|
+
function textOf(el) {
|
|
521
|
+
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
522
|
+
if (!text) return void 0;
|
|
523
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region ../browser-session/src/capture/baseline.ts
|
|
527
|
+
/**
|
|
528
|
+
* The capture that runs on **every** page load, sampled for replay or not.
|
|
529
|
+
*
|
|
530
|
+
* Errors and clicks are the analytics substrate, for the same reason navigation
|
|
531
|
+
* is: `error_count` drives the Sessions UI "has errors" filter and `click_count`
|
|
532
|
+
* separates a real visit from a bounce, so gating them behind replay sampling
|
|
533
|
+
* makes both a sample rather than a count — and a session row that reports zero
|
|
534
|
+
* errors because nothing was listening is worse than one that reports none,
|
|
535
|
+
* because it looks complete.
|
|
536
|
+
*
|
|
537
|
+
* Console and network capture stay on the sampled replay path: they patch
|
|
538
|
+
* `console.*`, `window.fetch` and `XMLHttpRequest`, which is a cost (and a
|
|
539
|
+
* surface) worth paying only for sessions that get a recording to attach it to.
|
|
540
|
+
*
|
|
541
|
+
* Owned by the sink rather than by the replay lifecycle, which is also what
|
|
542
|
+
* keeps the listeners installed exactly once — the recorder starting and
|
|
543
|
+
* stopping across visibility changes must not re-register them.
|
|
544
|
+
*/
|
|
545
|
+
function startBaselineCapture(emit, maskAllText) {
|
|
546
|
+
if (typeof window === "undefined" || typeof document === "undefined") return () => {};
|
|
547
|
+
const uninstall = [installErrorCapture(emit), installInteractionCapture(emit, maskAllText)];
|
|
548
|
+
return () => {
|
|
549
|
+
for (const off of uninstall) off();
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region ../browser-session/src/platform/browser-globals.ts
|
|
554
|
+
const browserNavigator = () => globalThis.navigator;
|
|
555
|
+
const browserLocation = () => globalThis.window?.location;
|
|
556
|
+
const browserDocument = () => globalThis.document;
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region ../browser-session/src/platform/transport.ts
|
|
559
|
+
/**
|
|
560
|
+
* Header stamped on every request to ingest: `<sdk-name>/<version>`, e.g.
|
|
561
|
+
* `maple-browser/0.3.0`. Browsers refuse to let a page set `user-agent`, so
|
|
562
|
+
* without this a rejected request carried nothing that said which SDK build
|
|
563
|
+
* produced it. Ingest records it as `maple.sdk` on the request span. Every
|
|
564
|
+
* gateway that receives browser traffic must allow it in CORS (`apps/ingest`
|
|
565
|
+
* and the CLI's local listener do) — a header the SDK always sends and
|
|
566
|
+
* preflight refuses blocks the whole SDK, not just the header.
|
|
567
|
+
*/
|
|
568
|
+
const SDK_HINT_HEADER = "x-maple-sdk";
|
|
569
|
+
/** `<name>/<version>` — the one shape ingest expects in `x-maple-sdk`. */
|
|
570
|
+
const sdkHint = (name, version) => `${name}/${version}`;
|
|
571
|
+
/** Auth + identity headers shared by every ingest request. */
|
|
572
|
+
function ingestHeaders(config) {
|
|
573
|
+
return {
|
|
574
|
+
Authorization: `Bearer ${config.ingestKey}`,
|
|
575
|
+
[SDK_HINT_HEADER]: config.sdk
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const lastWarnAt = /* @__PURE__ */ new Map();
|
|
579
|
+
function warnDropped(what, error) {
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
if (now - (lastWarnAt.get(what) ?? 0) < 3e4) return;
|
|
582
|
+
lastWarnAt.set(what, now);
|
|
583
|
+
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Combined budget for this SDK's in-flight `keepalive` bodies.
|
|
587
|
+
*
|
|
588
|
+
* The Fetch spec caps the *combined* in-flight keepalive body at 64 KiB per
|
|
589
|
+
* page, and the browser rejects a request that would cross it. On the way out
|
|
590
|
+
* up to three of our writes go at once (metadata row, final events batch, last
|
|
591
|
+
* replay chunk), so the budget has to be shared rather than checked per
|
|
592
|
+
* request. It stops short of 64 KiB because the OTLP trace exporter spends
|
|
593
|
+
* from the same page-wide allowance under its own accounting.
|
|
594
|
+
*
|
|
595
|
+
* Over the budget a write goes out as a normal request, which the page may or
|
|
596
|
+
* may not survive long enough to finish: strictly better than a guaranteed
|
|
597
|
+
* rejection.
|
|
598
|
+
*/
|
|
599
|
+
const KEEPALIVE_BUDGET_BYTES = 49152;
|
|
600
|
+
/** On `globalThis`: two bundled SDK copies still share one page-wide allowance. */
|
|
601
|
+
const KEEPALIVE_KEY = "__MAPLE_KEEPALIVE_INFLIGHT__";
|
|
602
|
+
function keepaliveInflight() {
|
|
603
|
+
const owner = globalThis;
|
|
604
|
+
const existing = owner[KEEPALIVE_KEY];
|
|
605
|
+
if (existing) return existing;
|
|
606
|
+
const fresh = { bytes: 0 };
|
|
607
|
+
owner[KEEPALIVE_KEY] = fresh;
|
|
608
|
+
return fresh;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Reserve `bytes` of the shared keepalive budget. Returns the release function
|
|
612
|
+
* when the request may use `keepalive`, `undefined` when it must not.
|
|
613
|
+
*/
|
|
614
|
+
function reserveKeepalive(requested, bytes) {
|
|
615
|
+
if (!requested) return void 0;
|
|
616
|
+
const inflight = keepaliveInflight();
|
|
617
|
+
if (inflight.bytes + bytes > KEEPALIVE_BUDGET_BYTES) return void 0;
|
|
618
|
+
inflight.bytes += bytes;
|
|
619
|
+
let released = false;
|
|
620
|
+
return () => {
|
|
621
|
+
if (released) return;
|
|
622
|
+
released = true;
|
|
623
|
+
inflight.bytes = Math.max(0, inflight.bytes - bytes);
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
|
|
627
|
+
function byteLength(body) {
|
|
628
|
+
return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* POST to ingest, spending the shared keepalive budget when `keepalive` is
|
|
632
|
+
* requested. Resolves with the status only: the response body is cancelled
|
|
633
|
+
* before the reservation is released, because the browser counts a keepalive
|
|
634
|
+
* request against the page-wide limit until its response body ends, not
|
|
635
|
+
* until headers arrive. Rejects exactly as `fetch` does; callers own the
|
|
636
|
+
* error policy.
|
|
637
|
+
*/
|
|
638
|
+
async function postToIngest(url, headers, body, keepalive) {
|
|
639
|
+
const release = reserveKeepalive(keepalive, byteLength(body));
|
|
640
|
+
try {
|
|
641
|
+
const response = await fetch(url, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers,
|
|
644
|
+
body,
|
|
645
|
+
keepalive: release !== void 0
|
|
646
|
+
});
|
|
647
|
+
await response.body?.cancel().catch(() => {});
|
|
648
|
+
return {
|
|
649
|
+
ok: response.ok,
|
|
650
|
+
status: response.status
|
|
651
|
+
};
|
|
652
|
+
} finally {
|
|
653
|
+
release?.();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Smallest well-formed gzip stream: a 10-byte header plus an 8-byte trailer.
|
|
658
|
+
* Anything shorter cannot carry a complete member, whatever it contains.
|
|
659
|
+
*/
|
|
660
|
+
const GZIP_MIN_BYTES = 18;
|
|
661
|
+
/**
|
|
662
|
+
* gzip a byte buffer using the native CompressionStream (no library).
|
|
663
|
+
*
|
|
664
|
+
* Throws rather than returning a partial stream. The write and close promises
|
|
665
|
+
* used to be discarded, so a page torn down mid-compression still resolved
|
|
666
|
+
* `arrayBuffer()` — with only the bytes already flushed — and the caller posted
|
|
667
|
+
* a 3-byte header stub as if it were a complete chunk. Ingest rejected those as
|
|
668
|
+
* corrupt gzip; they were 82% of all replay-blob failures, overwhelmingly from
|
|
669
|
+
* crawlers, which tear the page down on nearly every session.
|
|
670
|
+
*/
|
|
671
|
+
async function gzip(bytes) {
|
|
672
|
+
const stream = new CompressionStream("gzip");
|
|
673
|
+
const writer = stream.writable.getWriter();
|
|
674
|
+
const written = writer.write(bytes).then(() => writer.close());
|
|
675
|
+
const [buffer] = await Promise.all([new Response(stream.readable).arrayBuffer(), written]);
|
|
676
|
+
const out = new Uint8Array(buffer);
|
|
677
|
+
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`);
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
681
|
+
async function postSessionMeta(config, row, keepalive = false) {
|
|
682
|
+
const body = `${JSON.stringify(row)}\n`;
|
|
683
|
+
await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
684
|
+
...ingestHeaders(config),
|
|
685
|
+
"content-type": "application/x-ndjson"
|
|
686
|
+
}, body, keepalive).catch((error) => {
|
|
687
|
+
warnDropped("metadata POST", error);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
691
|
+
async function postSessionEvents(config, rows, keepalive = false) {
|
|
692
|
+
if (rows.length === 0) return;
|
|
693
|
+
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
694
|
+
await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
|
|
695
|
+
...ingestHeaders(config),
|
|
696
|
+
"content-type": "application/x-ndjson"
|
|
697
|
+
}, body, keepalive).catch((error) => {
|
|
698
|
+
warnDropped("events POST", error);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
const SESSION_EXHAUSTED_STATUS = 413;
|
|
702
|
+
/** POST a gzipped rrweb event chunk. */
|
|
703
|
+
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
704
|
+
try {
|
|
705
|
+
const response = await postToIngest(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
706
|
+
...ingestHeaders(config),
|
|
707
|
+
"content-type": "application/octet-stream",
|
|
708
|
+
"x-maple-session-id": meta.sessionId,
|
|
709
|
+
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
710
|
+
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
711
|
+
"x-maple-event-count": String(meta.eventCount),
|
|
712
|
+
"x-maple-duration-ms": String(meta.durationMs)
|
|
713
|
+
}, gzipped, keepalive);
|
|
714
|
+
if (response.ok) return "accepted";
|
|
715
|
+
return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
|
|
716
|
+
} catch (error) {
|
|
717
|
+
warnDropped("blob PUT", error);
|
|
718
|
+
return "failed";
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region ../browser-session/src/platform/user-agent.ts
|
|
263
723
|
let memo;
|
|
264
724
|
/** Best-effort UA parse — enough to populate filterable session facets. */
|
|
265
725
|
function parseUserAgent(ua) {
|
|
@@ -275,11 +735,53 @@ function parse(ua) {
|
|
|
275
735
|
return {
|
|
276
736
|
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",
|
|
277
737
|
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",
|
|
278
|
-
deviceType: /mobile|iphone
|
|
738
|
+
deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
279
739
|
};
|
|
280
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* Crawler tokens, every one carrying a delimiter or a scheme on purpose.
|
|
743
|
+
*
|
|
744
|
+
* A bare `bot` substring matches `CUBOT`, an Android phone brand that appears in
|
|
745
|
+
* ordinary mobile UAs, and would file those visitors as crawlers. Requiring
|
|
746
|
+
* `bot/`, `bot;` or `bot)` keeps the `Name/version` and `(compatible; Name;
|
|
747
|
+
* +url)` conventions real crawlers follow while missing the phone. This mirrors
|
|
748
|
+
* the generic tail of the server-side classifier in
|
|
749
|
+
* `@maple/query-engine`'s `user-agent.ts`, deliberately without the ~50 named
|
|
750
|
+
* signatures: every one of those ends in a token already listed here, and the
|
|
751
|
+
* list ships in a size-budgeted bundle to customers.
|
|
752
|
+
*/
|
|
753
|
+
const BOT_TOKENS = [
|
|
754
|
+
"bot/",
|
|
755
|
+
"bot;",
|
|
756
|
+
"bot)",
|
|
757
|
+
"crawler",
|
|
758
|
+
"spider",
|
|
759
|
+
"+http",
|
|
760
|
+
"headless"
|
|
761
|
+
];
|
|
762
|
+
/**
|
|
763
|
+
* Whether the UA is a crawler rather than a person.
|
|
764
|
+
*
|
|
765
|
+
* Used to route crawlers to a metadata-only session: they still appear in Web
|
|
766
|
+
* Analytics — the server-side classifier is what labels them there — but they do
|
|
767
|
+
* not download rrweb or upload replay chunks. Crawlers execute the beacon and
|
|
768
|
+
* then tear the page down mid-flush, which produced the large majority of all
|
|
769
|
+
* corrupt replay-chunk uploads, and every chunk they did manage to upload spent
|
|
770
|
+
* a customer's replay quota on a session nobody will ever watch.
|
|
771
|
+
*
|
|
772
|
+
* Deliberately does not consult `navigator.webdriver`: it is set by every
|
|
773
|
+
* Playwright and Puppeteer run, headed or not. The `headless` token does match
|
|
774
|
+
* the old headless shell's `HeadlessChrome` UA, which production crawlers use
|
|
775
|
+
* too (see the test cases), so an e2e suite that wants replay recorded runs
|
|
776
|
+
* headed, or sets an explicit `userAgent` without `headless` in it. Chrome's
|
|
777
|
+
* new headless mode can still report `HeadlessChrome/`, so it is not enough.
|
|
778
|
+
*/
|
|
779
|
+
function isLikelyBot(ua) {
|
|
780
|
+
const lower = ua.toLowerCase();
|
|
781
|
+
return BOT_TOKENS.some((token) => lower.includes(token));
|
|
782
|
+
}
|
|
281
783
|
//#endregion
|
|
282
|
-
//#region ../browser-session/src/meta-row.ts
|
|
784
|
+
//#region ../browser-session/src/events/meta-row.ts
|
|
283
785
|
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
284
786
|
function formatCHDateTime(date) {
|
|
285
787
|
const pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
@@ -292,14 +794,13 @@ function formatCHDateTime(date) {
|
|
|
292
794
|
* exotic embedders) they fall back to empty strings.
|
|
293
795
|
*/
|
|
294
796
|
function buildSessionMetaRow(input) {
|
|
295
|
-
const
|
|
296
|
-
const userAgent = g["navigator"]?.userAgent ?? "";
|
|
797
|
+
const userAgent = browserNavigator()?.userAgent ?? "";
|
|
297
798
|
const ua = parseUserAgent(userAgent);
|
|
298
799
|
const now = /* @__PURE__ */ new Date();
|
|
299
|
-
const location =
|
|
800
|
+
const location = browserLocation();
|
|
300
801
|
const identity = input.identity;
|
|
301
|
-
const entryUrl = input.entry?.entryUrl
|
|
302
|
-
const referrer = input.entry?.referrer ?? "";
|
|
802
|
+
const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
|
|
803
|
+
const referrer = scrubUrl(input.entry?.referrer ?? "");
|
|
303
804
|
const utm = input.entry?.utm ?? {};
|
|
304
805
|
const row = {
|
|
305
806
|
session_id: input.sessionId,
|
|
@@ -307,7 +808,7 @@ function buildSessionMetaRow(input) {
|
|
|
307
808
|
status: input.status,
|
|
308
809
|
version: input.version,
|
|
309
810
|
user_id: identity?.id ?? input.userId ?? "",
|
|
310
|
-
url_initial:
|
|
811
|
+
url_initial: entryUrl,
|
|
311
812
|
user_agent: userAgent,
|
|
312
813
|
browser_name: ua.browserName,
|
|
313
814
|
os_name: ua.osName,
|
|
@@ -315,12 +816,13 @@ function buildSessionMetaRow(input) {
|
|
|
315
816
|
service_name: input.serviceName,
|
|
316
817
|
resource_attributes: {
|
|
317
818
|
"maple.session.recorded": input.recorded ? "true" : "false",
|
|
318
|
-
|
|
819
|
+
"maple.session.replay_format": "rrweb",
|
|
820
|
+
...input.visitorId && input.visitorIdPersisted === false ? { "maple.visitor.persisted": "false" } : void 0,
|
|
319
821
|
...input.environment ? {
|
|
320
822
|
"deployment.environment": input.environment,
|
|
321
823
|
"deployment.environment.name": input.environment
|
|
322
|
-
} :
|
|
323
|
-
...input.serviceVersion ? { "
|
|
824
|
+
} : void 0,
|
|
825
|
+
...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
|
|
324
826
|
},
|
|
325
827
|
visitor_id: input.visitorId ?? "",
|
|
326
828
|
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
@@ -338,7 +840,7 @@ function buildSessionMetaRow(input) {
|
|
|
338
840
|
host: location?.host ?? "",
|
|
339
841
|
entry_path: pathOf(entryUrl),
|
|
340
842
|
exit_path: pathOf(input.lastUrl ?? location?.href ?? ""),
|
|
341
|
-
language:
|
|
843
|
+
language: browserNavigator()?.language ?? "",
|
|
342
844
|
last_activity_at: formatCHDateTime(now),
|
|
343
845
|
click_count: input.clickCount ?? 0,
|
|
344
846
|
page_views: input.pageViews ?? 0,
|
|
@@ -367,87 +869,15 @@ function pathOf(url) {
|
|
|
367
869
|
}
|
|
368
870
|
}
|
|
369
871
|
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
370
|
-
async function postSessionMetaRow(
|
|
371
|
-
await fetch(`${endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
372
|
-
method: "POST",
|
|
373
|
-
headers: {
|
|
374
|
-
Authorization: `Bearer ${ingestKey}`,
|
|
375
|
-
"content-type": "application/x-ndjson"
|
|
376
|
-
},
|
|
377
|
-
body: `${JSON.stringify(row)}\n`,
|
|
378
|
-
keepalive
|
|
379
|
-
}).catch(() => {});
|
|
380
|
-
}
|
|
381
|
-
//#endregion
|
|
382
|
-
//#region ../browser-session/src/replay/transport.ts
|
|
383
|
-
let lastWarnAt = 0;
|
|
384
|
-
function warnDropped(what, error) {
|
|
385
|
-
const now = Date.now();
|
|
386
|
-
if (now - lastWarnAt < 3e4) return;
|
|
387
|
-
lastWarnAt = now;
|
|
388
|
-
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
389
|
-
}
|
|
390
|
-
/** gzip a byte buffer using the native CompressionStream (no library). */
|
|
391
|
-
async function gzip(bytes) {
|
|
392
|
-
const stream = new CompressionStream("gzip");
|
|
393
|
-
const writer = stream.writable.getWriter();
|
|
394
|
-
writer.write(bytes);
|
|
395
|
-
writer.close();
|
|
396
|
-
const buffer = await new Response(stream.readable).arrayBuffer();
|
|
397
|
-
return new Uint8Array(buffer);
|
|
398
|
-
}
|
|
399
|
-
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
400
|
-
async function postSessionMeta(config, row, keepalive = false) {
|
|
872
|
+
async function postSessionMetaRow(target, row, keepalive = false) {
|
|
401
873
|
const body = `${JSON.stringify(row)}\n`;
|
|
402
|
-
await
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
"content-type": "application/x-ndjson"
|
|
407
|
-
},
|
|
408
|
-
body,
|
|
409
|
-
keepalive
|
|
410
|
-
}).catch((error) => {
|
|
411
|
-
warnDropped("metadata POST", error);
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
/** POST distilled session events (NDJSON, one row per event). Best-effort. */
|
|
415
|
-
async function postSessionEvents(config, rows, keepalive = false) {
|
|
416
|
-
if (rows.length === 0) return;
|
|
417
|
-
const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
|
|
418
|
-
await fetch(`${config.endpoint}/v1/sessionEvents`, {
|
|
419
|
-
method: "POST",
|
|
420
|
-
headers: {
|
|
421
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
422
|
-
"content-type": "application/x-ndjson"
|
|
423
|
-
},
|
|
424
|
-
body,
|
|
425
|
-
keepalive
|
|
426
|
-
}).catch((error) => {
|
|
427
|
-
warnDropped("events POST", error);
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
/** PUT a gzipped rrweb event chunk. */
|
|
431
|
-
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
432
|
-
await fetch(`${config.endpoint}/v1/sessionReplays/blob`, {
|
|
433
|
-
method: "POST",
|
|
434
|
-
headers: {
|
|
435
|
-
Authorization: `Bearer ${config.ingestKey}`,
|
|
436
|
-
"content-type": "application/octet-stream",
|
|
437
|
-
"x-maple-session-id": meta.sessionId,
|
|
438
|
-
"x-maple-chunk-seq": String(meta.chunkSeq),
|
|
439
|
-
"x-maple-is-checkpoint": meta.isCheckpoint ? "1" : "0",
|
|
440
|
-
"x-maple-event-count": String(meta.eventCount),
|
|
441
|
-
"x-maple-duration-ms": String(meta.durationMs)
|
|
442
|
-
},
|
|
443
|
-
body: gzipped,
|
|
444
|
-
keepalive
|
|
445
|
-
}).catch((error) => {
|
|
446
|
-
warnDropped("blob PUT", error);
|
|
447
|
-
});
|
|
874
|
+
await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
875
|
+
...ingestHeaders(target),
|
|
876
|
+
"content-type": "application/x-ndjson"
|
|
877
|
+
}, body, keepalive).catch(() => {});
|
|
448
878
|
}
|
|
449
879
|
//#endregion
|
|
450
|
-
//#region ../browser-session/src/
|
|
880
|
+
//#region ../browser-session/src/platform/approximate-size.ts
|
|
451
881
|
/** Approximate byte size of an event for flush-threshold accounting. Falls back
|
|
452
882
|
* to a fixed estimate for values that can't be serialized (e.g. cycles). */
|
|
453
883
|
function approximateSize(value) {
|
|
@@ -458,7 +888,7 @@ function approximateSize(value) {
|
|
|
458
888
|
}
|
|
459
889
|
}
|
|
460
890
|
//#endregion
|
|
461
|
-
//#region ../browser-session/src/session.ts
|
|
891
|
+
//#region ../browser-session/src/session/session.ts
|
|
462
892
|
const STORAGE_KEY = "maple.session";
|
|
463
893
|
/** Rotate the session after this much inactivity (PostHog's default). */
|
|
464
894
|
const IDLE_TIMEOUT_MS = 18e5;
|
|
@@ -471,6 +901,64 @@ const MAX_SESSION_MS = 864e5;
|
|
|
471
901
|
* — rotation correctness only needs sub-idle-timeout granularity.
|
|
472
902
|
*/
|
|
473
903
|
const ACTIVITY_TOUCH_THROTTLE_MS = 5e3;
|
|
904
|
+
/** Optional keys carrying a plain number. Absent is fine; wrongly typed is not. */
|
|
905
|
+
const OPTIONAL_NUMBERS = [
|
|
906
|
+
"metaVersion",
|
|
907
|
+
"pageViews",
|
|
908
|
+
"clickCount",
|
|
909
|
+
"errorCount"
|
|
910
|
+
];
|
|
911
|
+
/** Optional keys carrying a plain string. */
|
|
912
|
+
const OPTIONAL_STRINGS = [
|
|
913
|
+
"entryUrl",
|
|
914
|
+
"entryReferrer",
|
|
915
|
+
"lastUrl"
|
|
916
|
+
];
|
|
917
|
+
/**
|
|
918
|
+
* Validate a persisted session record. Deliberately still accepts records
|
|
919
|
+
* written by older SDKs, which have none of the optional fields — see the
|
|
920
|
+
* `SessionRecord` field comments.
|
|
921
|
+
*
|
|
922
|
+
* Returns `undefined` rather than throwing: the sole caller treats a corrupt
|
|
923
|
+
* record exactly as it treats unreadable storage.
|
|
924
|
+
*/
|
|
925
|
+
function parseSessionRecord(raw) {
|
|
926
|
+
const value = parseJsonObject(raw);
|
|
927
|
+
if (!value) return void 0;
|
|
928
|
+
const { id, startedAt, lastActivityAt, chunkSeq } = value;
|
|
929
|
+
if (typeof id !== "string" || typeof startedAt !== "number" || typeof lastActivityAt !== "number" || typeof chunkSeq !== "number") return;
|
|
930
|
+
const record = {
|
|
931
|
+
id,
|
|
932
|
+
startedAt,
|
|
933
|
+
lastActivityAt,
|
|
934
|
+
chunkSeq
|
|
935
|
+
};
|
|
936
|
+
for (const key of OPTIONAL_NUMBERS) {
|
|
937
|
+
const entry = value[key];
|
|
938
|
+
if (entry === void 0) continue;
|
|
939
|
+
if (typeof entry !== "number") return void 0;
|
|
940
|
+
record[key] = entry;
|
|
941
|
+
}
|
|
942
|
+
for (const key of OPTIONAL_STRINGS) {
|
|
943
|
+
const entry = value[key];
|
|
944
|
+
if (entry === void 0) continue;
|
|
945
|
+
if (typeof entry !== "string") return void 0;
|
|
946
|
+
record[key] = entry;
|
|
947
|
+
}
|
|
948
|
+
if (value.visitorIsNew !== void 0) {
|
|
949
|
+
if (typeof value.visitorIsNew !== "boolean") return void 0;
|
|
950
|
+
record.visitorIsNew = value.visitorIsNew;
|
|
951
|
+
}
|
|
952
|
+
if (value.replaySampled !== void 0) {
|
|
953
|
+
if (typeof value.replaySampled !== "boolean") return void 0;
|
|
954
|
+
record.replaySampled = value.replaySampled;
|
|
955
|
+
}
|
|
956
|
+
if (value.utm !== void 0) {
|
|
957
|
+
if (!isStringRecord(value.utm)) return void 0;
|
|
958
|
+
record.utm = value.utm;
|
|
959
|
+
}
|
|
960
|
+
return record;
|
|
961
|
+
}
|
|
474
962
|
const UTM_KEYS = [
|
|
475
963
|
"utm_source",
|
|
476
964
|
"utm_medium",
|
|
@@ -498,11 +986,12 @@ function readEntryContext() {
|
|
|
498
986
|
if (value) utm[key] = value.slice(0, 128);
|
|
499
987
|
}
|
|
500
988
|
} catch {}
|
|
989
|
+
const href = scrubUrl(location.href);
|
|
501
990
|
return {
|
|
502
|
-
entryUrl:
|
|
503
|
-
entryReferrer: typeof document !== "undefined" ? document.referrer : "",
|
|
991
|
+
entryUrl: href,
|
|
992
|
+
entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
|
|
504
993
|
utm,
|
|
505
|
-
lastUrl:
|
|
994
|
+
lastUrl: href,
|
|
506
995
|
pageViews: 0,
|
|
507
996
|
clickCount: 0,
|
|
508
997
|
errorCount: 0
|
|
@@ -523,9 +1012,7 @@ function readRecord() {
|
|
|
523
1012
|
try {
|
|
524
1013
|
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
|
525
1014
|
if (!raw) return void 0;
|
|
526
|
-
|
|
527
|
-
if (typeof parsed.id === "string" && typeof parsed.startedAt === "number" && typeof parsed.lastActivityAt === "number" && typeof parsed.chunkSeq === "number") return parsed;
|
|
528
|
-
return;
|
|
1015
|
+
return parseSessionRecord(raw) ?? ephemeral;
|
|
529
1016
|
} catch {
|
|
530
1017
|
return ephemeral;
|
|
531
1018
|
}
|
|
@@ -535,6 +1022,105 @@ function writeRecord(record) {
|
|
|
535
1022
|
try {
|
|
536
1023
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
537
1024
|
} catch {}
|
|
1025
|
+
claimTab(record.id);
|
|
1026
|
+
}
|
|
1027
|
+
const TAB_LEASE_PREFIX = "maple.session.tab.";
|
|
1028
|
+
const TAB_LEASE_TTL_MS = 12e4;
|
|
1029
|
+
const TAB_LEASE_REFRESH_MS = 5e3;
|
|
1030
|
+
const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
|
|
1031
|
+
/** The session id this tab has checked (or minted) and may keep using. */
|
|
1032
|
+
let verifiedSessionId;
|
|
1033
|
+
let leaseWrittenAt = 0;
|
|
1034
|
+
let releaseInstalled = false;
|
|
1035
|
+
/**
|
|
1036
|
+
* Set from `pagehide` until the page is shown again. The lifecycle's own
|
|
1037
|
+
* `pagehide` handler posts an `ended` row after ours runs, which writes the
|
|
1038
|
+
* record; re-leasing there would make the next load of this tab look like a
|
|
1039
|
+
* duplicate and rotate on every reload.
|
|
1040
|
+
*/
|
|
1041
|
+
let leaseReleased = false;
|
|
1042
|
+
function tabNonce() {
|
|
1043
|
+
const owner = globalThis;
|
|
1044
|
+
const existing = owner[TAB_NONCE_KEY];
|
|
1045
|
+
if (typeof existing === "string") return existing;
|
|
1046
|
+
const fresh = crypto.randomUUID();
|
|
1047
|
+
owner[TAB_NONCE_KEY] = fresh;
|
|
1048
|
+
return fresh;
|
|
1049
|
+
}
|
|
1050
|
+
function leaseStorage() {
|
|
1051
|
+
try {
|
|
1052
|
+
return typeof window !== "undefined" ? window.localStorage : void 0;
|
|
1053
|
+
} catch {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function readLease(storage, sessionId) {
|
|
1058
|
+
const raw = storage.getItem(`${TAB_LEASE_PREFIX}${sessionId}`);
|
|
1059
|
+
if (!raw) return void 0;
|
|
1060
|
+
const split = raw.lastIndexOf(":");
|
|
1061
|
+
const at = Number(raw.slice(split + 1));
|
|
1062
|
+
return split > 0 && Number.isFinite(at) ? {
|
|
1063
|
+
nonce: raw.slice(0, split),
|
|
1064
|
+
at
|
|
1065
|
+
} : void 0;
|
|
1066
|
+
}
|
|
1067
|
+
function claimTab(sessionId) {
|
|
1068
|
+
const now = Date.now();
|
|
1069
|
+
if (verifiedSessionId === sessionId && now - leaseWrittenAt < TAB_LEASE_REFRESH_MS) return;
|
|
1070
|
+
if (verifiedSessionId !== void 0 && verifiedSessionId !== sessionId) releaseTab(verifiedSessionId);
|
|
1071
|
+
verifiedSessionId = sessionId;
|
|
1072
|
+
if (leaseReleased) return;
|
|
1073
|
+
leaseWrittenAt = now;
|
|
1074
|
+
const storage = leaseStorage();
|
|
1075
|
+
if (!storage) return;
|
|
1076
|
+
try {
|
|
1077
|
+
storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
|
|
1078
|
+
} catch {}
|
|
1079
|
+
installLeaseRelease();
|
|
1080
|
+
}
|
|
1081
|
+
function releaseTab(sessionId) {
|
|
1082
|
+
const storage = leaseStorage();
|
|
1083
|
+
if (!storage) return;
|
|
1084
|
+
try {
|
|
1085
|
+
if (readLease(storage, sessionId)?.nonce === tabNonce()) storage.removeItem(`${TAB_LEASE_PREFIX}${sessionId}`);
|
|
1086
|
+
} catch {}
|
|
1087
|
+
}
|
|
1088
|
+
function installLeaseRelease() {
|
|
1089
|
+
if (releaseInstalled || typeof globalThis.addEventListener !== "function") return;
|
|
1090
|
+
releaseInstalled = true;
|
|
1091
|
+
globalThis.addEventListener("pagehide", () => {
|
|
1092
|
+
leaseReleased = true;
|
|
1093
|
+
if (verifiedSessionId !== void 0) releaseTab(verifiedSessionId);
|
|
1094
|
+
});
|
|
1095
|
+
globalThis.addEventListener("pageshow", () => {
|
|
1096
|
+
leaseReleased = false;
|
|
1097
|
+
leaseWrittenAt = 0;
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
/** Drop leases a crashed tab never released. */
|
|
1101
|
+
function pruneLeases(storage, now) {
|
|
1102
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
1103
|
+
const key = storage.key(i);
|
|
1104
|
+
if (!key?.startsWith(TAB_LEASE_PREFIX)) continue;
|
|
1105
|
+
const lease = readLease(storage, key.slice(18));
|
|
1106
|
+
if (!lease || now - lease.at > TAB_LEASE_TTL_MS) storage.removeItem(key);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Whether another live tab holds `record`'s session. Checked once per session
|
|
1111
|
+
* id per tab; after that this tab has claimed it.
|
|
1112
|
+
*/
|
|
1113
|
+
function ownedByAnotherTab(record, now) {
|
|
1114
|
+
if (verifiedSessionId === record.id) return false;
|
|
1115
|
+
const storage = leaseStorage();
|
|
1116
|
+
if (!storage) return false;
|
|
1117
|
+
try {
|
|
1118
|
+
pruneLeases(storage, now);
|
|
1119
|
+
const lease = readLease(storage, record.id);
|
|
1120
|
+
return lease !== void 0 && lease.nonce !== tabNonce() && now - lease.at <= TAB_LEASE_TTL_MS;
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
538
1124
|
}
|
|
539
1125
|
function isSessionExpired(record, now = Date.now()) {
|
|
540
1126
|
return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
|
|
@@ -571,7 +1157,7 @@ function installRotatedRecord(previous, next) {
|
|
|
571
1157
|
function getSession() {
|
|
572
1158
|
const now = Date.now();
|
|
573
1159
|
const existing = readRecord();
|
|
574
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1160
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
575
1161
|
const record = {
|
|
576
1162
|
...migrateRecord(existing),
|
|
577
1163
|
lastActivityAt: now
|
|
@@ -599,13 +1185,13 @@ function rotateSession() {
|
|
|
599
1185
|
*/
|
|
600
1186
|
function touchSession(now) {
|
|
601
1187
|
const existing = readRecord();
|
|
602
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1188
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
603
1189
|
const migrated = migrateRecord(existing);
|
|
604
1190
|
const touched = {
|
|
605
1191
|
...migrated,
|
|
606
1192
|
lastActivityAt: now
|
|
607
1193
|
};
|
|
608
|
-
if (migrated !== existing || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
|
|
1194
|
+
if (migrated !== existing || verifiedSessionId !== existing.id || now - existing.lastActivityAt > ACTIVITY_TOUCH_THROTTLE_MS) writeRecord(touched);
|
|
609
1195
|
return touched;
|
|
610
1196
|
}
|
|
611
1197
|
const record = freshRecord(now);
|
|
@@ -632,7 +1218,7 @@ function noteNavigation(url) {
|
|
|
632
1218
|
const record = touchSession(now);
|
|
633
1219
|
writeRecord({
|
|
634
1220
|
...record,
|
|
635
|
-
lastUrl: url,
|
|
1221
|
+
lastUrl: scrubUrl(url),
|
|
636
1222
|
pageViews: (record.pageViews ?? 0) + 1,
|
|
637
1223
|
lastActivityAt: now
|
|
638
1224
|
});
|
|
@@ -698,21 +1284,44 @@ function nextMetaVersion() {
|
|
|
698
1284
|
});
|
|
699
1285
|
return version;
|
|
700
1286
|
}
|
|
1287
|
+
/** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
|
|
1288
|
+
function uniformRandom() {
|
|
1289
|
+
const values = /* @__PURE__ */ new Uint32Array(1);
|
|
1290
|
+
crypto.getRandomValues(values);
|
|
1291
|
+
return (values[0] ?? 0) / 4294967296;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* This session's replay sampling decision, rolled against `sampleRate` the
|
|
1295
|
+
* first time it is asked and then persisted, so every page load of the session
|
|
1296
|
+
* agrees. Call after `getSession()` has resolved the session.
|
|
1297
|
+
*/
|
|
1298
|
+
function claimReplaySample(sampleRate) {
|
|
1299
|
+
const record = readRecord() ?? getSession();
|
|
1300
|
+
if (record.replaySampled !== void 0) return record.replaySampled;
|
|
1301
|
+
const sampled = uniformRandom() < sampleRate;
|
|
1302
|
+
writeRecord({
|
|
1303
|
+
...record,
|
|
1304
|
+
replaySampled: sampled
|
|
1305
|
+
});
|
|
1306
|
+
return sampled;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Pin a session to the capture mode a running page is already in. A session
|
|
1310
|
+
* minted by idle rotation mid-page inherits that page's mode rather than
|
|
1311
|
+
* rolling again, and later loads of it honour the same answer.
|
|
1312
|
+
*/
|
|
1313
|
+
function adoptReplayDecision(sessionId, recorded) {
|
|
1314
|
+
const record = readRecord();
|
|
1315
|
+
if (!record || record.id !== sessionId || record.replaySampled !== void 0) return;
|
|
1316
|
+
writeRecord({
|
|
1317
|
+
...record,
|
|
1318
|
+
replaySampled: recorded
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
701
1321
|
//#endregion
|
|
702
|
-
//#region ../browser-session/src/events-sink.ts
|
|
1322
|
+
//#region ../browser-session/src/events/events-sink.ts
|
|
703
1323
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
704
1324
|
const FLUSH_BYTES = 65536;
|
|
705
|
-
const ZERO_TRACE_ID = "00000000000000000000000000000000";
|
|
706
|
-
let traceIdProvider = () => void 0;
|
|
707
|
-
/** Wire the host SDK's active-trace-id lookup into event capture. */
|
|
708
|
-
function setActiveTraceIdProvider(provider) {
|
|
709
|
-
traceIdProvider = provider;
|
|
710
|
-
}
|
|
711
|
-
/** The trace id of the active span, or undefined when none is active. */
|
|
712
|
-
function activeTraceId() {
|
|
713
|
-
const id = traceIdProvider();
|
|
714
|
-
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
715
|
-
}
|
|
716
1325
|
/**
|
|
717
1326
|
* The sink is a per-session singleton, published on `globalThis` rather than a
|
|
718
1327
|
* module-level variable.
|
|
@@ -757,7 +1366,7 @@ function startEventSink(config, sessionId) {
|
|
|
757
1366
|
const batch = buffer;
|
|
758
1367
|
buffer = [];
|
|
759
1368
|
bufferBytes = 0;
|
|
760
|
-
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(sessionId, ev, seq)), keepalive);
|
|
1369
|
+
await postSessionEvents(config, batch.map(({ ev, seq }) => toRow(config, sessionId, ev, seq)), keepalive);
|
|
761
1370
|
};
|
|
762
1371
|
const emit = (ev) => {
|
|
763
1372
|
const session = markActivity();
|
|
@@ -784,6 +1393,7 @@ function startEventSink(config, sessionId) {
|
|
|
784
1393
|
url
|
|
785
1394
|
});
|
|
786
1395
|
});
|
|
1396
|
+
const stopBaselineCapture = startBaselineCapture(emit, config.maskAllText);
|
|
787
1397
|
const flushTimer = setInterval(() => void flush(), FLUSH_INTERVAL_MS);
|
|
788
1398
|
const sink = {
|
|
789
1399
|
sessionId,
|
|
@@ -792,6 +1402,7 @@ function startEventSink(config, sessionId) {
|
|
|
792
1402
|
stop: () => {
|
|
793
1403
|
clearInterval(flushTimer);
|
|
794
1404
|
stopNavigation();
|
|
1405
|
+
stopBaselineCapture();
|
|
795
1406
|
if (holder()[SINK_KEY]?.sink === sink) holder()[SINK_KEY] = void 0;
|
|
796
1407
|
},
|
|
797
1408
|
getPageViews: () => pageViews,
|
|
@@ -822,13 +1433,14 @@ function installNavigationObserver(onNavigate) {
|
|
|
822
1433
|
emitNav();
|
|
823
1434
|
const origPush = history.pushState;
|
|
824
1435
|
const origReplace = history.replaceState;
|
|
1436
|
+
const receiver = (self) => self instanceof History ? self : history;
|
|
825
1437
|
history.pushState = function(...args) {
|
|
826
|
-
const result = origPush.apply(this, args);
|
|
1438
|
+
const result = origPush.apply(receiver(this), args);
|
|
827
1439
|
emitNav();
|
|
828
1440
|
return result;
|
|
829
1441
|
};
|
|
830
1442
|
history.replaceState = function(...args) {
|
|
831
|
-
const result = origReplace.apply(this, args);
|
|
1443
|
+
const result = origReplace.apply(receiver(this), args);
|
|
832
1444
|
emitNav();
|
|
833
1445
|
return result;
|
|
834
1446
|
};
|
|
@@ -891,21 +1503,33 @@ function drainPending(sink) {
|
|
|
891
1503
|
state.bytes = 0;
|
|
892
1504
|
for (const ev of queued) sink.emit(ev);
|
|
893
1505
|
}
|
|
894
|
-
/**
|
|
895
|
-
|
|
1506
|
+
/**
|
|
1507
|
+
* Map an internal event to the snake_case ingest row (org_id is added server-side).
|
|
1508
|
+
*
|
|
1509
|
+
* Identity is resolved here, at flush time, alongside the trace id: it is the
|
|
1510
|
+
* person key funnels group on, and reading it late means an `identify()` that
|
|
1511
|
+
* lands shortly after init still stamps the initial page view. `visitor_id` is
|
|
1512
|
+
* `""` whenever the visitor cookie is off (consent, GPC, `persistVisitorId:
|
|
1513
|
+
* false`) — the same rule the metadata row applies.
|
|
1514
|
+
*/
|
|
1515
|
+
function toRow(config, sessionId, ev, seq) {
|
|
1516
|
+
const identity = config.getIdentity?.();
|
|
896
1517
|
return {
|
|
897
1518
|
session_id: sessionId,
|
|
1519
|
+
visitor_id: getVisitorId() ?? "",
|
|
1520
|
+
user_id: identity?.id ?? "",
|
|
1521
|
+
group_id: identity?.groupId ?? "",
|
|
898
1522
|
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
899
1523
|
seq,
|
|
900
1524
|
type: ev.type,
|
|
901
|
-
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
1525
|
+
url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
|
|
902
1526
|
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
903
1527
|
level: ev.level ?? "",
|
|
904
1528
|
message: ev.message ?? "",
|
|
905
1529
|
target_selector: ev.targetSelector ?? "",
|
|
906
1530
|
target_text: ev.targetText ?? "",
|
|
907
1531
|
net_method: ev.net?.method ?? "",
|
|
908
|
-
net_url: ev.net
|
|
1532
|
+
net_url: ev.net ? scrubUrl(ev.net.url) : "",
|
|
909
1533
|
net_status: ev.net?.status ?? 0,
|
|
910
1534
|
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
911
1535
|
error_stack: ev.errorStack ?? "",
|
|
@@ -913,7 +1537,7 @@ function toRow(sessionId, ev, seq) {
|
|
|
913
1537
|
};
|
|
914
1538
|
}
|
|
915
1539
|
//#endregion
|
|
916
|
-
//#region ../browser-session/src/session
|
|
1540
|
+
//#region ../browser-session/src/session/lifecycle.ts
|
|
917
1541
|
/**
|
|
918
1542
|
* How often a visible tab re-posts its `active` row.
|
|
919
1543
|
*
|
|
@@ -1019,6 +1643,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1019
1643
|
if (stopped || running) return;
|
|
1020
1644
|
running = true;
|
|
1021
1645
|
const record = liveRecord();
|
|
1646
|
+
adoptReplayDecision(record.id, hooks.recorded);
|
|
1022
1647
|
rebaseCounts(record);
|
|
1023
1648
|
hooks.onStart?.(record);
|
|
1024
1649
|
post("active", false);
|
|
@@ -1056,7 +1681,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1056
1681
|
keepalive: true
|
|
1057
1682
|
});
|
|
1058
1683
|
const onVisibilityChange = () => {
|
|
1059
|
-
const doc =
|
|
1684
|
+
const doc = browserDocument();
|
|
1060
1685
|
if (!doc) return;
|
|
1061
1686
|
if (doc.visibilityState === "hidden") {
|
|
1062
1687
|
endRun({
|
|
@@ -1095,17 +1720,34 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1095
1720
|
};
|
|
1096
1721
|
}
|
|
1097
1722
|
//#endregion
|
|
1098
|
-
//#region ../browser-session/src/sink.ts
|
|
1723
|
+
//#region ../browser-session/src/session/sink.ts
|
|
1099
1724
|
const SESSION_SINK_KEY = "__MAPLE_BROWSER_SESSION__";
|
|
1100
1725
|
const observedTraceIdsBySession = /* @__PURE__ */ new Map();
|
|
1726
|
+
/**
|
|
1727
|
+
* Ceiling on trace ids retained per session.
|
|
1728
|
+
*
|
|
1729
|
+
* A session lives up to 24h and every span feeds this, so it is otherwise
|
|
1730
|
+
* unbounded — and the whole set is serialized into the `ended` metadata row,
|
|
1731
|
+
* which is written with `keepalive` on the way out. The Fetch spec caps the
|
|
1732
|
+
* *combined* keepalive body across in-flight requests at 64 KiB, shared here
|
|
1733
|
+
* with the final events flush and the last replay chunk, so an app emitting a
|
|
1734
|
+
* span a second would silently lose its entire session row after ~30 minutes.
|
|
1735
|
+
*
|
|
1736
|
+
* Keep-first rather than keep-last: the ids are a join key for "show me this
|
|
1737
|
+
* session's traces", the UI paginates them anyway, and dropping the tail of a
|
|
1738
|
+
* long session is a smaller loss than dropping the row.
|
|
1739
|
+
*/
|
|
1740
|
+
const MAX_TRACE_IDS_PER_SESSION = 200;
|
|
1101
1741
|
/** Record a trace id seen during the session. Idempotent per id. */
|
|
1102
1742
|
function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
1743
|
+
noteStartedTraceId(traceId);
|
|
1103
1744
|
if (!sessionId) return;
|
|
1104
1745
|
let ids = observedTraceIdsBySession.get(sessionId);
|
|
1105
1746
|
if (!ids) {
|
|
1106
1747
|
ids = /* @__PURE__ */ new Set();
|
|
1107
1748
|
observedTraceIdsBySession.set(sessionId, ids);
|
|
1108
1749
|
}
|
|
1750
|
+
if (ids.size >= MAX_TRACE_IDS_PER_SESSION && !ids.has(traceId)) return;
|
|
1109
1751
|
ids.add(traceId);
|
|
1110
1752
|
}
|
|
1111
1753
|
function getObservedTraceIds(sessionId = readSessionSink()?.sessionId) {
|
|
@@ -1146,4 +1788,4 @@ function readSessionSink() {
|
|
|
1146
1788
|
return globalThis[SESSION_SINK_KEY];
|
|
1147
1789
|
}
|
|
1148
1790
|
//#endregion
|
|
1149
|
-
export { setVisitorTracking as S,
|
|
1791
|
+
export { setVisitorTracking as A, warnDropped as C, setActiveTraceIdProvider as D, activeTraceId as E, scrubUrl as M, withStartedTraceId as O, sdkHint as S, safeEmit as T, isLikelyBot as _, recordTraceId as a, postSessionBlob as b, getActiveSink as c, claimReplaySample as d, getSession as f, postSessionMetaRow as g, rotateSession as h, readSessionSink as i, addUrlSanitizer as j, configureVisitorCookie as k, queuePending as l, nextChunkSeq as m, getObservedTraceIds as n, startSessionLifecycle as o, markActivity as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, startEventSink as u, SDK_HINT_HEADER as v, BLOCK_SELECTOR as w, postSessionMeta as x, gzip as y };
|