@maple-dev/browser 0.8.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/README.md +64 -3
- package/dist/index.d.mts +72 -19
- package/dist/index.mjs +265 -51
- package/dist/{replay-session-BYZfi3iZ.mjs → replay-session-gSlAYwqV.mjs} +41 -8
- package/dist/{sink-D9w1kg0Q.mjs → sink-DHPiMgU0.mjs} +445 -78
- package/package.json +11 -10
|
@@ -1,3 +1,87 @@
|
|
|
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
|
|
1
85
|
//#region ../browser-session/src/platform/json.ts
|
|
2
86
|
/**
|
|
3
87
|
* Minimal structural guards for records read back out of browser storage.
|
|
@@ -316,6 +400,26 @@ function activeTraceId() {
|
|
|
316
400
|
const id = traceIdProvider();
|
|
317
401
|
return id && id !== ZERO_TRACE_ID ? id : void 0;
|
|
318
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
|
+
}
|
|
319
423
|
//#endregion
|
|
320
424
|
//#region ../browser-session/src/capture/shared.ts
|
|
321
425
|
/** Emit best-effort: capture must never throw into the host app's call site. */
|
|
@@ -359,6 +463,21 @@ function truncate(stack) {
|
|
|
359
463
|
if (!stack) return void 0;
|
|
360
464
|
return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
|
|
361
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
|
+
};
|
|
362
481
|
//#endregion
|
|
363
482
|
//#region ../browser-session/src/capture/interactions.ts
|
|
364
483
|
const MAX_TEXT = 120;
|
|
@@ -366,7 +485,8 @@ const MAX_TEXT = 120;
|
|
|
366
485
|
* Capture clicks and input events as session events. Listens in the capture
|
|
367
486
|
* phase so it sees interactions even when the host app calls
|
|
368
487
|
* `stopPropagation()`. Input *values* are never recorded; only the target
|
|
369
|
-
* element. Click target text is omitted when `maskAllText` is set
|
|
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.
|
|
370
490
|
*/
|
|
371
491
|
function installInteractionCapture(emit, maskAllText) {
|
|
372
492
|
const onClick = (event) => {
|
|
@@ -375,7 +495,7 @@ function installInteractionCapture(emit, maskAllText) {
|
|
|
375
495
|
safeEmit(emit, {
|
|
376
496
|
type: "click",
|
|
377
497
|
targetSelector: selectorOf(target),
|
|
378
|
-
targetText: maskAllText ? void 0 : textOf(target)
|
|
498
|
+
targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
|
|
379
499
|
});
|
|
380
500
|
};
|
|
381
501
|
const onInput = (event) => {
|
|
@@ -463,40 +583,107 @@ function warnDropped(what, error) {
|
|
|
463
583
|
console.warn(`[maple] session replay ${what} failed (dropping; will retry on next chunk):`, error);
|
|
464
584
|
}
|
|
465
585
|
/**
|
|
466
|
-
*
|
|
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.
|
|
467
594
|
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* outright, so a normal request — which the page may or may not survive long
|
|
472
|
-
* enough to finish — is strictly the better bet than a guaranteed rejection.
|
|
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.
|
|
473
598
|
*/
|
|
474
|
-
const
|
|
475
|
-
/**
|
|
476
|
-
|
|
477
|
-
|
|
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
|
+
}
|
|
478
655
|
}
|
|
479
|
-
/**
|
|
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
|
+
*/
|
|
480
671
|
async function gzip(bytes) {
|
|
481
672
|
const stream = new CompressionStream("gzip");
|
|
482
673
|
const writer = stream.writable.getWriter();
|
|
483
|
-
writer.write(bytes);
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
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;
|
|
487
679
|
}
|
|
488
680
|
/** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
|
|
489
681
|
async function postSessionMeta(config, row, keepalive = false) {
|
|
490
682
|
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) => {
|
|
683
|
+
await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
|
|
684
|
+
...ingestHeaders(config),
|
|
685
|
+
"content-type": "application/x-ndjson"
|
|
686
|
+
}, body, keepalive).catch((error) => {
|
|
500
687
|
warnDropped("metadata POST", error);
|
|
501
688
|
});
|
|
502
689
|
}
|
|
@@ -504,15 +691,10 @@ async function postSessionMeta(config, row, keepalive = false) {
|
|
|
504
691
|
async function postSessionEvents(config, rows, keepalive = false) {
|
|
505
692
|
if (rows.length === 0) return;
|
|
506
693
|
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) => {
|
|
694
|
+
await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
|
|
695
|
+
...ingestHeaders(config),
|
|
696
|
+
"content-type": "application/x-ndjson"
|
|
697
|
+
}, body, keepalive).catch((error) => {
|
|
516
698
|
warnDropped("events POST", error);
|
|
517
699
|
});
|
|
518
700
|
}
|
|
@@ -520,20 +702,15 @@ const SESSION_EXHAUSTED_STATUS = 413;
|
|
|
520
702
|
/** POST a gzipped rrweb event chunk. */
|
|
521
703
|
async function postSessionBlob(config, meta, gzipped, keepalive = false) {
|
|
522
704
|
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
|
-
});
|
|
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);
|
|
537
714
|
if (response.ok) return "accepted";
|
|
538
715
|
return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
|
|
539
716
|
} catch (error) {
|
|
@@ -558,9 +735,51 @@ function parse(ua) {
|
|
|
558
735
|
return {
|
|
559
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",
|
|
560
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",
|
|
561
|
-
deviceType: /mobile|iphone
|
|
738
|
+
deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
|
|
562
739
|
};
|
|
563
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
|
+
}
|
|
564
783
|
//#endregion
|
|
565
784
|
//#region ../browser-session/src/events/meta-row.ts
|
|
566
785
|
/** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
|
|
@@ -580,8 +799,8 @@ function buildSessionMetaRow(input) {
|
|
|
580
799
|
const now = /* @__PURE__ */ new Date();
|
|
581
800
|
const location = browserLocation();
|
|
582
801
|
const identity = input.identity;
|
|
583
|
-
const entryUrl = input.entry?.entryUrl
|
|
584
|
-
const referrer = input.entry?.referrer ?? "";
|
|
802
|
+
const entryUrl = scrubUrl(input.entry?.entryUrl || location?.href || "");
|
|
803
|
+
const referrer = scrubUrl(input.entry?.referrer ?? "");
|
|
585
804
|
const utm = input.entry?.utm ?? {};
|
|
586
805
|
const row = {
|
|
587
806
|
session_id: input.sessionId,
|
|
@@ -589,7 +808,7 @@ function buildSessionMetaRow(input) {
|
|
|
589
808
|
status: input.status,
|
|
590
809
|
version: input.version,
|
|
591
810
|
user_id: identity?.id ?? input.userId ?? "",
|
|
592
|
-
url_initial:
|
|
811
|
+
url_initial: entryUrl,
|
|
593
812
|
user_agent: userAgent,
|
|
594
813
|
browser_name: ua.browserName,
|
|
595
814
|
os_name: ua.osName,
|
|
@@ -603,7 +822,7 @@ function buildSessionMetaRow(input) {
|
|
|
603
822
|
"deployment.environment": input.environment,
|
|
604
823
|
"deployment.environment.name": input.environment
|
|
605
824
|
} : void 0,
|
|
606
|
-
...input.serviceVersion ? { "
|
|
825
|
+
...input.serviceVersion && /^[0-9a-f]{7,40}$/i.test(input.serviceVersion) ? { "vcs.ref.head.revision": input.serviceVersion } : void 0
|
|
607
826
|
},
|
|
608
827
|
visitor_id: input.visitorId ?? "",
|
|
609
828
|
visitor_is_new: input.visitorIsNew ? 1 : 0,
|
|
@@ -652,15 +871,10 @@ function pathOf(url) {
|
|
|
652
871
|
/** POST one session metadata row (NDJSON). Best-effort — never throws. */
|
|
653
872
|
async function postSessionMetaRow(target, row, keepalive = false) {
|
|
654
873
|
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(() => {});
|
|
874
|
+
await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
|
|
875
|
+
...ingestHeaders(target),
|
|
876
|
+
"content-type": "application/x-ndjson"
|
|
877
|
+
}, body, keepalive).catch(() => {});
|
|
664
878
|
}
|
|
665
879
|
//#endregion
|
|
666
880
|
//#region ../browser-session/src/platform/approximate-size.ts
|
|
@@ -735,6 +949,10 @@ function parseSessionRecord(raw) {
|
|
|
735
949
|
if (typeof value.visitorIsNew !== "boolean") return void 0;
|
|
736
950
|
record.visitorIsNew = value.visitorIsNew;
|
|
737
951
|
}
|
|
952
|
+
if (value.replaySampled !== void 0) {
|
|
953
|
+
if (typeof value.replaySampled !== "boolean") return void 0;
|
|
954
|
+
record.replaySampled = value.replaySampled;
|
|
955
|
+
}
|
|
738
956
|
if (value.utm !== void 0) {
|
|
739
957
|
if (!isStringRecord(value.utm)) return void 0;
|
|
740
958
|
record.utm = value.utm;
|
|
@@ -768,11 +986,12 @@ function readEntryContext() {
|
|
|
768
986
|
if (value) utm[key] = value.slice(0, 128);
|
|
769
987
|
}
|
|
770
988
|
} catch {}
|
|
989
|
+
const href = scrubUrl(location.href);
|
|
771
990
|
return {
|
|
772
|
-
entryUrl:
|
|
773
|
-
entryReferrer: typeof document !== "undefined" ? document.referrer : "",
|
|
991
|
+
entryUrl: href,
|
|
992
|
+
entryReferrer: typeof document !== "undefined" ? scrubUrl(document.referrer) : "",
|
|
774
993
|
utm,
|
|
775
|
-
lastUrl:
|
|
994
|
+
lastUrl: href,
|
|
776
995
|
pageViews: 0,
|
|
777
996
|
clickCount: 0,
|
|
778
997
|
errorCount: 0
|
|
@@ -803,6 +1022,105 @@ function writeRecord(record) {
|
|
|
803
1022
|
try {
|
|
804
1023
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
|
805
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
|
+
}
|
|
806
1124
|
}
|
|
807
1125
|
function isSessionExpired(record, now = Date.now()) {
|
|
808
1126
|
return now - record.lastActivityAt > IDLE_TIMEOUT_MS || now - record.startedAt > MAX_SESSION_MS;
|
|
@@ -839,7 +1157,7 @@ function installRotatedRecord(previous, next) {
|
|
|
839
1157
|
function getSession() {
|
|
840
1158
|
const now = Date.now();
|
|
841
1159
|
const existing = readRecord();
|
|
842
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1160
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
843
1161
|
const record = {
|
|
844
1162
|
...migrateRecord(existing),
|
|
845
1163
|
lastActivityAt: now
|
|
@@ -867,13 +1185,13 @@ function rotateSession() {
|
|
|
867
1185
|
*/
|
|
868
1186
|
function touchSession(now) {
|
|
869
1187
|
const existing = readRecord();
|
|
870
|
-
if (existing && !isSessionExpired(existing, now)) {
|
|
1188
|
+
if (existing && !isSessionExpired(existing, now) && !ownedByAnotherTab(existing, now)) {
|
|
871
1189
|
const migrated = migrateRecord(existing);
|
|
872
1190
|
const touched = {
|
|
873
1191
|
...migrated,
|
|
874
1192
|
lastActivityAt: now
|
|
875
1193
|
};
|
|
876
|
-
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);
|
|
877
1195
|
return touched;
|
|
878
1196
|
}
|
|
879
1197
|
const record = freshRecord(now);
|
|
@@ -900,7 +1218,7 @@ function noteNavigation(url) {
|
|
|
900
1218
|
const record = touchSession(now);
|
|
901
1219
|
writeRecord({
|
|
902
1220
|
...record,
|
|
903
|
-
lastUrl: url,
|
|
1221
|
+
lastUrl: scrubUrl(url),
|
|
904
1222
|
pageViews: (record.pageViews ?? 0) + 1,
|
|
905
1223
|
lastActivityAt: now
|
|
906
1224
|
});
|
|
@@ -966,6 +1284,40 @@ function nextMetaVersion() {
|
|
|
966
1284
|
});
|
|
967
1285
|
return version;
|
|
968
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
|
+
}
|
|
969
1321
|
//#endregion
|
|
970
1322
|
//#region ../browser-session/src/events/events-sink.ts
|
|
971
1323
|
const FLUSH_INTERVAL_MS = 5e3;
|
|
@@ -1014,7 +1366,7 @@ function startEventSink(config, sessionId) {
|
|
|
1014
1366
|
const batch = buffer;
|
|
1015
1367
|
buffer = [];
|
|
1016
1368
|
bufferBytes = 0;
|
|
1017
|
-
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);
|
|
1018
1370
|
};
|
|
1019
1371
|
const emit = (ev) => {
|
|
1020
1372
|
const session = markActivity();
|
|
@@ -1081,13 +1433,14 @@ function installNavigationObserver(onNavigate) {
|
|
|
1081
1433
|
emitNav();
|
|
1082
1434
|
const origPush = history.pushState;
|
|
1083
1435
|
const origReplace = history.replaceState;
|
|
1436
|
+
const receiver = (self) => self instanceof History ? self : history;
|
|
1084
1437
|
history.pushState = function(...args) {
|
|
1085
|
-
const result = origPush.apply(this, args);
|
|
1438
|
+
const result = origPush.apply(receiver(this), args);
|
|
1086
1439
|
emitNav();
|
|
1087
1440
|
return result;
|
|
1088
1441
|
};
|
|
1089
1442
|
history.replaceState = function(...args) {
|
|
1090
|
-
const result = origReplace.apply(this, args);
|
|
1443
|
+
const result = origReplace.apply(receiver(this), args);
|
|
1091
1444
|
emitNav();
|
|
1092
1445
|
return result;
|
|
1093
1446
|
};
|
|
@@ -1150,21 +1503,33 @@ function drainPending(sink) {
|
|
|
1150
1503
|
state.bytes = 0;
|
|
1151
1504
|
for (const ev of queued) sink.emit(ev);
|
|
1152
1505
|
}
|
|
1153
|
-
/**
|
|
1154
|
-
|
|
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?.();
|
|
1155
1517
|
return {
|
|
1156
1518
|
session_id: sessionId,
|
|
1519
|
+
visitor_id: getVisitorId() ?? "",
|
|
1520
|
+
user_id: identity?.id ?? "",
|
|
1521
|
+
group_id: identity?.groupId ?? "",
|
|
1157
1522
|
timestamp: formatCHDateTime(new Date(ev.timestamp ?? Date.now())),
|
|
1158
1523
|
seq,
|
|
1159
1524
|
type: ev.type,
|
|
1160
|
-
url: ev.url ?? (typeof location !== "undefined" ? location.href : ""),
|
|
1525
|
+
url: scrubUrl(ev.url ?? (typeof location !== "undefined" ? location.href : "")),
|
|
1161
1526
|
trace_id: ev.traceId ?? activeTraceId() ?? "",
|
|
1162
1527
|
level: ev.level ?? "",
|
|
1163
1528
|
message: ev.message ?? "",
|
|
1164
1529
|
target_selector: ev.targetSelector ?? "",
|
|
1165
1530
|
target_text: ev.targetText ?? "",
|
|
1166
1531
|
net_method: ev.net?.method ?? "",
|
|
1167
|
-
net_url: ev.net
|
|
1532
|
+
net_url: ev.net ? scrubUrl(ev.net.url) : "",
|
|
1168
1533
|
net_status: ev.net?.status ?? 0,
|
|
1169
1534
|
net_duration_ms: ev.net?.durationMs ?? 0,
|
|
1170
1535
|
error_stack: ev.errorStack ?? "",
|
|
@@ -1278,6 +1643,7 @@ function startSessionLifecycle(options, hooks) {
|
|
|
1278
1643
|
if (stopped || running) return;
|
|
1279
1644
|
running = true;
|
|
1280
1645
|
const record = liveRecord();
|
|
1646
|
+
adoptReplayDecision(record.id, hooks.recorded);
|
|
1281
1647
|
rebaseCounts(record);
|
|
1282
1648
|
hooks.onStart?.(record);
|
|
1283
1649
|
post("active", false);
|
|
@@ -1374,6 +1740,7 @@ const observedTraceIdsBySession = /* @__PURE__ */ new Map();
|
|
|
1374
1740
|
const MAX_TRACE_IDS_PER_SESSION = 200;
|
|
1375
1741
|
/** Record a trace id seen during the session. Idempotent per id. */
|
|
1376
1742
|
function recordTraceId(traceId, sessionId = readSessionSink()?.sessionId) {
|
|
1743
|
+
noteStartedTraceId(traceId);
|
|
1377
1744
|
if (!sessionId) return;
|
|
1378
1745
|
let ids = observedTraceIdsBySession.get(sessionId);
|
|
1379
1746
|
if (!ids) {
|
|
@@ -1421,4 +1788,4 @@ function readSessionSink() {
|
|
|
1421
1788
|
return globalThis[SESSION_SINK_KEY];
|
|
1422
1789
|
}
|
|
1423
1790
|
//#endregion
|
|
1424
|
-
export {
|
|
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 };
|