@anyreplay/browser 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/dist/index.cjs +301 -93
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -6
- package/dist/index.d.ts +53 -6
- package/dist/index.js +300 -93
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Session replay recorder for the browser, made by [AnyReplay](https://anyreplay.com):
|
|
4
4
|
watch any user's session, translated into your own language.
|
|
5
5
|
|
|
6
|
-
- DOM-based recording (built on rrweb), not video — about
|
|
7
|
-
-
|
|
6
|
+
- DOM-based recording (built on rrweb), not video — about 28 KB gzipped
|
|
7
|
+
- Records what people type; one line masks it, and passwords and payment fields never are
|
|
8
8
|
- Tag moments with `track()` and see them on the player's timeline
|
|
9
9
|
- Sessions survive reloads and route changes; chunked, retried, and flushed with `sendBeacon` on page close
|
|
10
10
|
- Deterministic sampling per visitor, a circuit breaker, and an optional consent gate
|
|
@@ -22,7 +22,8 @@ const recorder = createRecorder({
|
|
|
22
22
|
projectKey: 'ar_pk_live_…', // from your AnyReplay project
|
|
23
23
|
// ingestUrl: 'https://in.anyreplay.com', // default; set for self-hosted
|
|
24
24
|
// sampleRate: 1, // 0..1, decided per visitor
|
|
25
|
-
// maskAllInputs: true, // default
|
|
25
|
+
// maskAllInputs: true, // off by default: field values are recorded
|
|
26
|
+
// maskAllTyping: true, // the stricter switch: fields and rich-text editors
|
|
26
27
|
// maxSessionsPerMonth: 5000, // optional cap for this project
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -48,9 +49,25 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
|
|
|
48
49
|
|
|
49
50
|
## Privacy
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
A recording contains what visitors type into forms, because a replay of a form
|
|
53
|
+
nobody could finish is worth watching only if you can see what they were trying
|
|
54
|
+
to enter. Masking is a switch you turn on:
|
|
55
|
+
|
|
56
|
+
- `maskAllInputs: true` — every form field value becomes asterisks of the same length.
|
|
57
|
+
- `maskAllTyping: true` — the same, plus the text of every `contenteditable` editor.
|
|
58
|
+
- `class="ar-mask"` — one element, or one field, masked while the rest records.
|
|
59
|
+
- `class="ar-block"` — the element's contents are not recorded at all.
|
|
60
|
+
|
|
61
|
+
Some fields are masked in every mode and no option records them: passwords and
|
|
62
|
+
one-time codes; card numbers, security codes, expiry dates and cardholder names
|
|
63
|
+
(from `autocomplete="cc-…"` or from a name, label or placeholder that reads like
|
|
64
|
+
one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
|
|
65
|
+
passes the card-number check. Masking always happens in the page, before
|
|
66
|
+
anything is sent.
|
|
67
|
+
|
|
68
|
+
`type="email"` and `type="tel"` are not on that list. An address and a phone
|
|
69
|
+
number are ordinary contact details, so they are recorded like any other field
|
|
70
|
+
and masked by `maskAllInputs`, `maskAllTyping` or `class="ar-mask"`.
|
|
54
71
|
|
|
55
72
|
## License
|
|
56
73
|
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,7 @@ __export(src_exports, {
|
|
|
30
30
|
TRACK_TAG: () => TRACK_TAG,
|
|
31
31
|
Transport: () => Transport,
|
|
32
32
|
createRecorder: () => createRecorder,
|
|
33
|
+
forgetVisitor: () => forgetVisitor,
|
|
33
34
|
hashToUnitInterval: () => hashToUnitInterval,
|
|
34
35
|
isSampledIn: () => isSampledIn,
|
|
35
36
|
resolveIdentity: () => resolveIdentity,
|
|
@@ -3673,10 +3674,12 @@ record.mirror = mirror;
|
|
|
3673
3674
|
var DEFAULTS = {
|
|
3674
3675
|
ingestUrl: "https://in.anyreplay.com",
|
|
3675
3676
|
sampleRate: 1,
|
|
3676
|
-
//
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
|
|
3677
|
+
// Both masking switches are opt-in: a recording shows what people typed,
|
|
3678
|
+
// which is the point of watching one. What that never includes is the floor
|
|
3679
|
+
// in masking.ts — passwords, payment fields and the rest — which no option
|
|
3680
|
+
// can switch off.
|
|
3681
|
+
maskAllInputs: false,
|
|
3682
|
+
maskAllTyping: false,
|
|
3680
3683
|
maskClass: "ar-mask",
|
|
3681
3684
|
blockClass: "ar-block",
|
|
3682
3685
|
requireConsent: false,
|
|
@@ -3709,11 +3712,18 @@ function resolveOptions(input) {
|
|
|
3709
3712
|
projectKey: input.projectKey,
|
|
3710
3713
|
sampleRate,
|
|
3711
3714
|
ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
maskAllInputs: input.maskAllInputs === false ? false : true
|
|
3715
|
+
maskAllInputs: readSwitch(input, "maskAllInputs"),
|
|
3716
|
+
maskAllTyping: readSwitch(input, "maskAllTyping")
|
|
3715
3717
|
};
|
|
3716
3718
|
}
|
|
3719
|
+
function readSwitch(input, name) {
|
|
3720
|
+
const value = input[name];
|
|
3721
|
+
if (typeof value === "boolean") return value;
|
|
3722
|
+
if (value !== void 0 && typeof console !== "undefined") {
|
|
3723
|
+
console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. Nothing is masked by this option.`);
|
|
3724
|
+
}
|
|
3725
|
+
return DEFAULTS[name];
|
|
3726
|
+
}
|
|
3717
3727
|
|
|
3718
3728
|
// src/events.ts
|
|
3719
3729
|
var TRACK_TAG = "anyreplay.track";
|
|
@@ -3743,6 +3753,170 @@ function byteLength(text) {
|
|
|
3743
3753
|
return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
|
|
3744
3754
|
}
|
|
3745
3755
|
|
|
3756
|
+
// src/masking.ts
|
|
3757
|
+
var maskValue = (text) => "*".repeat(text.length);
|
|
3758
|
+
var MASK_EVERY_FIELD = {
|
|
3759
|
+
color: true,
|
|
3760
|
+
date: true,
|
|
3761
|
+
"datetime-local": true,
|
|
3762
|
+
email: true,
|
|
3763
|
+
month: true,
|
|
3764
|
+
number: true,
|
|
3765
|
+
range: true,
|
|
3766
|
+
search: true,
|
|
3767
|
+
tel: true,
|
|
3768
|
+
text: true,
|
|
3769
|
+
time: true,
|
|
3770
|
+
url: true,
|
|
3771
|
+
week: true,
|
|
3772
|
+
textarea: true,
|
|
3773
|
+
select: true,
|
|
3774
|
+
password: true,
|
|
3775
|
+
input: true
|
|
3776
|
+
};
|
|
3777
|
+
var SENSITIVE_AUTOCOMPLETE = /* @__PURE__ */ new Set([
|
|
3778
|
+
"cc-number",
|
|
3779
|
+
"cc-csc",
|
|
3780
|
+
"cc-exp",
|
|
3781
|
+
"cc-exp-month",
|
|
3782
|
+
"cc-exp-year",
|
|
3783
|
+
"cc-name",
|
|
3784
|
+
"cc-type",
|
|
3785
|
+
"cc-given-name",
|
|
3786
|
+
"cc-family-name",
|
|
3787
|
+
"cc-additional-name",
|
|
3788
|
+
"current-password",
|
|
3789
|
+
"new-password",
|
|
3790
|
+
"one-time-code"
|
|
3791
|
+
]);
|
|
3792
|
+
var PAYMENT_FIELD = new RegExp(
|
|
3793
|
+
"(?:credit|debit|payment)[\\s_-]*card|card[\\s_-]*(?:number|num|no|nr|code|pin|cvv|cvc|csc)|(?:^|[^a-z])cc[\\s_-]*(?:number|num|no|nr|csc|cvv|cvc|exp)|(?:^|[^a-z])(?:cvv|cvc|cvn|csc)(?:[^a-z]|$)|security[\\s_-]*code|(?:^|[^a-z])iban(?:[^a-z]|$)|routing[\\s_-]*number|sort[\\s_-]*code",
|
|
3794
|
+
"i"
|
|
3795
|
+
);
|
|
3796
|
+
var CREDENTIAL_FIELD = /pass(?:word|wd|phrase)|(?:^|[^a-z])pwd(?:[^a-z]|$)|(?:^|[^a-z])otp(?:[^a-z]|$)|one[\s_-]*time[\s_-]*code/i;
|
|
3797
|
+
var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
|
|
3798
|
+
var MASK_SENSITIVE_FIELDS = Object.fromEntries(
|
|
3799
|
+
[...SENSITIVE_TYPES].map((type) => [type, true])
|
|
3800
|
+
);
|
|
3801
|
+
var NAMING_ATTRIBUTES = ["name", "id", "autocomplete", "placeholder", "aria-label", "data-testid"];
|
|
3802
|
+
function isSensitiveByAttributes(read, type) {
|
|
3803
|
+
var _a;
|
|
3804
|
+
if (read("data-rr-is-password") !== null) return true;
|
|
3805
|
+
if (SENSITIVE_TYPES.has(type)) return true;
|
|
3806
|
+
const autocomplete = ((_a = read("autocomplete")) != null ? _a : "").toLowerCase();
|
|
3807
|
+
for (const token of autocomplete.split(/\s+/)) {
|
|
3808
|
+
if (SENSITIVE_AUTOCOMPLETE.has(token)) return true;
|
|
3809
|
+
}
|
|
3810
|
+
for (const attribute of NAMING_ATTRIBUTES) {
|
|
3811
|
+
const value = read(attribute);
|
|
3812
|
+
if (value && (PAYMENT_FIELD.test(value) || CREDENTIAL_FIELD.test(value))) return true;
|
|
3813
|
+
}
|
|
3814
|
+
return false;
|
|
3815
|
+
}
|
|
3816
|
+
var asElement = (node) => node && typeof node.getAttribute === "function" ? node : null;
|
|
3817
|
+
function isSensitiveField(node) {
|
|
3818
|
+
const element = asElement(node);
|
|
3819
|
+
if (!element) return false;
|
|
3820
|
+
const type = (element.type || element.getAttribute("type") || "").toLowerCase();
|
|
3821
|
+
return isSensitiveByAttributes((name) => element.getAttribute(name), type);
|
|
3822
|
+
}
|
|
3823
|
+
function isSensitiveSerialised(attributes) {
|
|
3824
|
+
var _a;
|
|
3825
|
+
const read = (name) => typeof attributes[name] === "string" ? attributes[name] : null;
|
|
3826
|
+
return isSensitiveByAttributes(read, ((_a = read("type")) != null ? _a : "").toLowerCase());
|
|
3827
|
+
}
|
|
3828
|
+
function looksLikeCardNumber(text) {
|
|
3829
|
+
if (text.length < 13 || text.length > 24 || !/^[0-9][0-9 -]*[0-9]$/.test(text)) return false;
|
|
3830
|
+
const digits = text.replace(/[ -]/g, "");
|
|
3831
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
3832
|
+
let sum = 0;
|
|
3833
|
+
let double = false;
|
|
3834
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
3835
|
+
let digit = digits.charCodeAt(i) - 48;
|
|
3836
|
+
if (double) {
|
|
3837
|
+
digit *= 2;
|
|
3838
|
+
if (digit > 9) digit -= 9;
|
|
3839
|
+
}
|
|
3840
|
+
sum += digit;
|
|
3841
|
+
double = !double;
|
|
3842
|
+
}
|
|
3843
|
+
return sum % 10 === 0;
|
|
3844
|
+
}
|
|
3845
|
+
function isMarkedByCustomer(node, maskClass) {
|
|
3846
|
+
var _a;
|
|
3847
|
+
let element = asElement(node);
|
|
3848
|
+
while (element) {
|
|
3849
|
+
if ((_a = element.classList) == null ? void 0 : _a.contains(maskClass)) return true;
|
|
3850
|
+
element = element.parentElement;
|
|
3851
|
+
}
|
|
3852
|
+
return false;
|
|
3853
|
+
}
|
|
3854
|
+
var hasClass = (attributes, maskClass) => typeof attributes.class === "string" && attributes.class.split(/\s+/).includes(maskClass);
|
|
3855
|
+
function shouldMaskValue(text, node, policy) {
|
|
3856
|
+
if (policy.maskEveryField) return true;
|
|
3857
|
+
if (isSensitiveField(node)) return true;
|
|
3858
|
+
if (isMarkedByCustomer(node, policy.maskClass)) return true;
|
|
3859
|
+
return looksLikeCardNumber(text);
|
|
3860
|
+
}
|
|
3861
|
+
var MUTATION = 0;
|
|
3862
|
+
var INPUT = 5;
|
|
3863
|
+
var ELEMENT_NODE = 2;
|
|
3864
|
+
var TEXT_NODE = 3;
|
|
3865
|
+
var FIELD_TAGS = /* @__PURE__ */ new Set(["input", "textarea", "select"]);
|
|
3866
|
+
function maskTextChildren(node) {
|
|
3867
|
+
var _a;
|
|
3868
|
+
for (const child of (_a = node.childNodes) != null ? _a : []) {
|
|
3869
|
+
if (child.type === TEXT_NODE && typeof child.textContent === "string") {
|
|
3870
|
+
child.textContent = child.textContent.replace(/\S/g, "*");
|
|
3871
|
+
}
|
|
3872
|
+
maskTextChildren(child);
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
function scrubTree(node, policy, masked) {
|
|
3876
|
+
var _a, _b;
|
|
3877
|
+
if (!node) return;
|
|
3878
|
+
const attributes = (_a = node.attributes) != null ? _a : {};
|
|
3879
|
+
const inheritedMask = masked || hasClass(attributes, policy.maskClass);
|
|
3880
|
+
if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
|
|
3881
|
+
const value = attributes.value;
|
|
3882
|
+
const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && looksLikeCardNumber(value);
|
|
3883
|
+
if (shouldMask) {
|
|
3884
|
+
if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
|
|
3885
|
+
if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
|
|
3889
|
+
}
|
|
3890
|
+
function scrubEvent(event, getNode, policy) {
|
|
3891
|
+
var _a, _b, _c, _d, _e;
|
|
3892
|
+
if (!event.data) return;
|
|
3893
|
+
if (event.type === 2) {
|
|
3894
|
+
scrubTree(event.data.node, policy, false);
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
if (event.type !== 3) return;
|
|
3898
|
+
const data = event.data;
|
|
3899
|
+
if (data.source === INPUT) {
|
|
3900
|
+
if (typeof data.text !== "string" || data.text === "") return;
|
|
3901
|
+
const node = typeof data.id === "number" ? getNode(data.id) : null;
|
|
3902
|
+
const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
|
|
3903
|
+
if (type === "checkbox" || type === "radio") return;
|
|
3904
|
+
if (shouldMaskValue(data.text, node, policy)) data.text = maskValue(data.text);
|
|
3905
|
+
return;
|
|
3906
|
+
}
|
|
3907
|
+
if (data.source !== MUTATION) return;
|
|
3908
|
+
for (const entry of (_c = data.attributes) != null ? _c : []) {
|
|
3909
|
+
const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
|
|
3910
|
+
if (typeof value !== "string" || value === "") continue;
|
|
3911
|
+
const node = typeof entry.id === "number" ? getNode(entry.id) : null;
|
|
3912
|
+
if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
|
|
3913
|
+
}
|
|
3914
|
+
for (const add of (_e = data.adds) != null ? _e : []) {
|
|
3915
|
+
const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
|
|
3916
|
+
scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3746
3920
|
// src/sampling.ts
|
|
3747
3921
|
function hashToUnitInterval(input) {
|
|
3748
3922
|
let hash = 2166136261;
|
|
@@ -3838,7 +4012,14 @@ var Transport = class {
|
|
|
3838
4012
|
seq: this.seq,
|
|
3839
4013
|
events,
|
|
3840
4014
|
...this.pendingMeta ? { meta: this.pendingMeta } : {},
|
|
3841
|
-
...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {}
|
|
4015
|
+
...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {},
|
|
4016
|
+
// This device's clock as the request leaves, on the same clock as the
|
|
4017
|
+
// event timestamps. Ingest compares it with its own to catch a device
|
|
4018
|
+
// that runs ahead — a test phone three weeks fast would otherwise list
|
|
4019
|
+
// its sessions as starting "in 3 weeks". Stamped here, when the body is
|
|
4020
|
+
// built, so a retry or a queue flushed after a spell offline carries
|
|
4021
|
+
// the time it was actually sent rather than when it was recorded.
|
|
4022
|
+
sentAt: Date.now()
|
|
3842
4023
|
});
|
|
3843
4024
|
}
|
|
3844
4025
|
/** Sends whatever is buffered. Safe to call when there is nothing to send. */
|
|
@@ -4039,47 +4220,37 @@ function reserveSeq(session, next) {
|
|
|
4039
4220
|
function touchSession(session, now = Date.now()) {
|
|
4040
4221
|
session.set(SESSION_TS_KEY, String(now));
|
|
4041
4222
|
}
|
|
4223
|
+
function forgetVisitor(local, session) {
|
|
4224
|
+
local.remove(VISITOR_KEY);
|
|
4225
|
+
for (const key of [SESSION_KEY, SESSION_TS_KEY, SEQ_KEY, PAGE_KEY, COOLDOWN_KEY]) session.remove(key);
|
|
4226
|
+
}
|
|
4042
4227
|
|
|
4043
4228
|
// src/recorder.ts
|
|
4044
4229
|
var RAGE_CLICK_COUNT = 3;
|
|
4045
4230
|
var RAGE_CLICK_WINDOW_MS = 1e3;
|
|
4046
4231
|
var RAGE_CLICK_RADIUS_PX = 30;
|
|
4047
4232
|
var MAX_PENDING_EVENTS = 32;
|
|
4233
|
+
var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
|
|
4048
4234
|
function createRecorder(rawOptions) {
|
|
4049
4235
|
const options = resolveOptions(rawOptions);
|
|
4050
4236
|
const local = safeStorage(() => globalThis.localStorage);
|
|
4051
4237
|
const session = safeStorage(() => globalThis.sessionStorage);
|
|
4052
|
-
const identity = resolveIdentity(local, session);
|
|
4053
4238
|
let status = "idle";
|
|
4239
|
+
let identity;
|
|
4240
|
+
let transport;
|
|
4054
4241
|
let stopRecording;
|
|
4055
|
-
let pageCount =
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
sessionId: identity.sessionId,
|
|
4068
|
-
visitorId: identity.visitorId,
|
|
4069
|
-
initialSeq: identity.nextSeq
|
|
4070
|
-
},
|
|
4071
|
-
{
|
|
4072
|
-
onSeqReserved: (next) => reserveSeq(session, next),
|
|
4073
|
-
onStopped: (reason) => {
|
|
4074
|
-
status = "stopped";
|
|
4075
|
-
stopRecording == null ? void 0 : stopRecording();
|
|
4076
|
-
stopRecording = void 0;
|
|
4077
|
-
if (reason === "rejected_402") startCooldown(session);
|
|
4078
|
-
if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
|
|
4079
|
-
}
|
|
4080
|
-
}
|
|
4081
|
-
);
|
|
4082
|
-
const collectMeta = () => ({
|
|
4242
|
+
let pageCount = 0;
|
|
4243
|
+
let pendingTraits;
|
|
4244
|
+
const sendIdentity = (who, traits) => {
|
|
4245
|
+
void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
|
|
4246
|
+
method: "POST",
|
|
4247
|
+
headers: { "Content-Type": "application/json" },
|
|
4248
|
+
credentials: "omit",
|
|
4249
|
+
body: JSON.stringify({ projectKey: options.projectKey, sessionId: who.sessionId, ...traits })
|
|
4250
|
+
}).catch(() => {
|
|
4251
|
+
});
|
|
4252
|
+
};
|
|
4253
|
+
const collectMeta = (who) => ({
|
|
4083
4254
|
startedAt: Date.now(),
|
|
4084
4255
|
url: safeLocation(),
|
|
4085
4256
|
lang: typeof navigator !== "undefined" ? navigator.language : void 0,
|
|
@@ -4089,10 +4260,10 @@ function createRecorder(rawOptions) {
|
|
|
4089
4260
|
referrer: typeof document !== "undefined" ? document.referrer || void 0 : void 0,
|
|
4090
4261
|
// The cap is a decision about starting a session, so it goes only where
|
|
4091
4262
|
// ingest makes that decision: chunk 0. A resumed page starts later.
|
|
4092
|
-
...options.maxSessionsPerMonth !== void 0 &&
|
|
4263
|
+
...options.maxSessionsPerMonth !== void 0 && who.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
|
|
4093
4264
|
});
|
|
4094
4265
|
const recentClicks = [];
|
|
4095
|
-
const noteInteraction = (event) => {
|
|
4266
|
+
const noteInteraction = (event, channel) => {
|
|
4096
4267
|
var _a;
|
|
4097
4268
|
const data = event.data;
|
|
4098
4269
|
if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
|
|
@@ -4104,7 +4275,7 @@ function createRecorder(rawOptions) {
|
|
|
4104
4275
|
const clustered = recentClicks.every(
|
|
4105
4276
|
(c) => Math.abs(c.x - first.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - first.y) < RAGE_CLICK_RADIUS_PX
|
|
4106
4277
|
);
|
|
4107
|
-
if (clustered)
|
|
4278
|
+
if (clustered) channel.mergeFlags({ hasRageClick: true });
|
|
4108
4279
|
}
|
|
4109
4280
|
};
|
|
4110
4281
|
const pending = [];
|
|
@@ -4132,7 +4303,7 @@ function createRecorder(rawOptions) {
|
|
|
4132
4303
|
}
|
|
4133
4304
|
};
|
|
4134
4305
|
const emitCustom = (tag, payload) => {
|
|
4135
|
-
if (status === "stopped") return;
|
|
4306
|
+
if (status === "stopped" || status === "sampled-out") return;
|
|
4136
4307
|
if (status === "recording") {
|
|
4137
4308
|
try {
|
|
4138
4309
|
record.addCustomEvent(tag, payload);
|
|
@@ -4146,63 +4317,104 @@ function createRecorder(rawOptions) {
|
|
|
4146
4317
|
}
|
|
4147
4318
|
pending.push({ tag, payload });
|
|
4148
4319
|
};
|
|
4149
|
-
let lastUrl
|
|
4150
|
-
const noteNavigation = () => {
|
|
4320
|
+
let lastUrl;
|
|
4321
|
+
const noteNavigation = (channel) => {
|
|
4151
4322
|
const url = safeLocation();
|
|
4152
4323
|
if (!url || url === lastUrl) return;
|
|
4153
4324
|
lastUrl = url;
|
|
4154
4325
|
pageCount += 1;
|
|
4155
4326
|
notePageCount(session, pageCount);
|
|
4156
|
-
|
|
4327
|
+
channel.mergeFlags({ pageCount });
|
|
4157
4328
|
emitCustom(NAVIGATE_TAG, { url, title: typeof document !== "undefined" ? document.title : "" });
|
|
4158
4329
|
};
|
|
4159
4330
|
const begin = () => {
|
|
4160
|
-
if (status
|
|
4331
|
+
if (status !== "idle" && status !== "awaiting-consent") return;
|
|
4332
|
+
const who = resolveIdentity(local, session);
|
|
4333
|
+
identity = who;
|
|
4334
|
+
pageCount = who.pageCount;
|
|
4335
|
+
if (!isSampledIn(who.visitorId, options.sampleRate)) {
|
|
4336
|
+
status = "sampled-out";
|
|
4337
|
+
return;
|
|
4338
|
+
}
|
|
4339
|
+
if (inCooldown(session)) {
|
|
4340
|
+
status = "stopped";
|
|
4341
|
+
if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
const channel = new Transport(
|
|
4345
|
+
options,
|
|
4346
|
+
{
|
|
4347
|
+
projectKey: options.projectKey,
|
|
4348
|
+
sessionId: who.sessionId,
|
|
4349
|
+
visitorId: who.visitorId,
|
|
4350
|
+
initialSeq: who.nextSeq
|
|
4351
|
+
},
|
|
4352
|
+
{
|
|
4353
|
+
onSeqReserved: (next) => reserveSeq(session, next),
|
|
4354
|
+
onStopped: (reason) => {
|
|
4355
|
+
status = "stopped";
|
|
4356
|
+
stopRecording == null ? void 0 : stopRecording();
|
|
4357
|
+
stopRecording = void 0;
|
|
4358
|
+
if (reason === "rejected_402") startCooldown(session);
|
|
4359
|
+
if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
);
|
|
4363
|
+
transport = channel;
|
|
4161
4364
|
status = "recording";
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4365
|
+
lastUrl = safeLocation();
|
|
4366
|
+
channel.setMeta(collectMeta(who));
|
|
4367
|
+
channel.mergeFlags({ pageCount });
|
|
4368
|
+
channel.start();
|
|
4369
|
+
const policy = {
|
|
4370
|
+
maskEveryField: options.maskAllInputs || options.maskAllTyping,
|
|
4371
|
+
maskClass: options.maskClass
|
|
4372
|
+
};
|
|
4165
4373
|
stopRecording = record({
|
|
4166
4374
|
emit: (event) => {
|
|
4167
4375
|
const typed = event;
|
|
4168
|
-
|
|
4376
|
+
scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
|
|
4377
|
+
noteInteraction(typed, channel);
|
|
4169
4378
|
touchSession(session, typed.timestamp);
|
|
4170
|
-
|
|
4379
|
+
channel.push(typed);
|
|
4171
4380
|
drainPending();
|
|
4172
4381
|
},
|
|
4173
|
-
|
|
4382
|
+
maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
|
|
4174
4383
|
maskTextClass: options.maskClass,
|
|
4384
|
+
maskTextSelector: options.maskAllTyping ? EDITABLE_SELECTOR : void 0,
|
|
4175
4385
|
blockClass: options.blockClass,
|
|
4176
|
-
// Passwords are never recorded, regardless of configuration.
|
|
4177
|
-
maskInputOptions: { password: true, email: true, tel: true },
|
|
4178
4386
|
// Canvas capture is expensive and off unless a customer opts in later.
|
|
4179
4387
|
recordCanvas: false,
|
|
4180
4388
|
collectFonts: false
|
|
4181
4389
|
});
|
|
4182
4390
|
drainPending();
|
|
4183
|
-
|
|
4391
|
+
if (pendingTraits) {
|
|
4392
|
+
sendIdentity(who, pendingTraits);
|
|
4393
|
+
pendingTraits = void 0;
|
|
4394
|
+
}
|
|
4395
|
+
attachLifecycleHooks(channel);
|
|
4184
4396
|
};
|
|
4185
|
-
const attachLifecycleHooks = () => {
|
|
4397
|
+
const attachLifecycleHooks = (channel) => {
|
|
4186
4398
|
if (typeof window === "undefined") return;
|
|
4187
4399
|
window.addEventListener("pagehide", () => {
|
|
4188
|
-
|
|
4400
|
+
channel.flushWithBeacon();
|
|
4189
4401
|
}, { capture: true });
|
|
4190
4402
|
document.addEventListener("visibilitychange", () => {
|
|
4191
|
-
if (document.visibilityState === "hidden")
|
|
4403
|
+
if (document.visibilityState === "hidden") channel.flushWithBeacon();
|
|
4192
4404
|
});
|
|
4193
|
-
window.addEventListener("error", () =>
|
|
4194
|
-
window.addEventListener("unhandledrejection", () =>
|
|
4405
|
+
window.addEventListener("error", () => channel.mergeFlags({ hasError: true }));
|
|
4406
|
+
window.addEventListener("unhandledrejection", () => channel.mergeFlags({ hasError: true }));
|
|
4195
4407
|
const patch2 = (method) => {
|
|
4196
4408
|
const original = history[method].bind(history);
|
|
4197
4409
|
history[method] = function patched(...args) {
|
|
4198
4410
|
const result = original(...args);
|
|
4199
|
-
noteNavigation();
|
|
4411
|
+
noteNavigation(channel);
|
|
4200
4412
|
return result;
|
|
4201
4413
|
};
|
|
4202
4414
|
};
|
|
4203
4415
|
patch2("pushState");
|
|
4204
4416
|
patch2("replaceState");
|
|
4205
|
-
window.addEventListener("popstate", noteNavigation);
|
|
4417
|
+
window.addEventListener("popstate", () => noteNavigation(channel));
|
|
4206
4418
|
};
|
|
4207
4419
|
if (options.requireConsent) {
|
|
4208
4420
|
status = "awaiting-consent";
|
|
@@ -4211,24 +4423,28 @@ function createRecorder(rawOptions) {
|
|
|
4211
4423
|
}
|
|
4212
4424
|
return {
|
|
4213
4425
|
status: () => status,
|
|
4214
|
-
sessionId: () =>
|
|
4426
|
+
sessionId: () => {
|
|
4427
|
+
var _a;
|
|
4428
|
+
return (_a = identity == null ? void 0 : identity.sessionId) != null ? _a : null;
|
|
4429
|
+
},
|
|
4215
4430
|
consent: (granted) => {
|
|
4216
|
-
if (granted)
|
|
4217
|
-
|
|
4431
|
+
if (granted) {
|
|
4432
|
+
begin();
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
transport == null ? void 0 : transport.stop("consent_withdrawn");
|
|
4436
|
+
if (status === "idle" || status === "awaiting-consent") status = "stopped";
|
|
4437
|
+
pendingTraits = void 0;
|
|
4438
|
+
forgetVisitor(local, session);
|
|
4218
4439
|
},
|
|
4219
4440
|
identify: (traits) => {
|
|
4220
4441
|
if (!traits.userId && !traits.email) return;
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
sessionId: identity.sessionId,
|
|
4228
|
-
...traits
|
|
4229
|
-
})
|
|
4230
|
-
}).catch(() => {
|
|
4231
|
-
});
|
|
4442
|
+
if (identity && transport) {
|
|
4443
|
+
sendIdentity(identity, traits);
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
if (status === "idle" || status === "awaiting-consent") pendingTraits = traits;
|
|
4447
|
+
else if (options.debug) console.warn("[anyreplay] identify() ignored: not recording");
|
|
4232
4448
|
},
|
|
4233
4449
|
track: (name, properties) => {
|
|
4234
4450
|
const payload = validateTrack(name, properties);
|
|
@@ -4238,8 +4454,15 @@ function createRecorder(rawOptions) {
|
|
|
4238
4454
|
}
|
|
4239
4455
|
emitCustom(TRACK_TAG, payload);
|
|
4240
4456
|
},
|
|
4241
|
-
stop: () =>
|
|
4242
|
-
|
|
4457
|
+
stop: () => {
|
|
4458
|
+
transport == null ? void 0 : transport.stop("stopped_by_page");
|
|
4459
|
+
if (status === "idle" || status === "awaiting-consent") status = "stopped";
|
|
4460
|
+
pendingTraits = void 0;
|
|
4461
|
+
},
|
|
4462
|
+
flush: () => {
|
|
4463
|
+
var _a;
|
|
4464
|
+
return (_a = transport == null ? void 0 : transport.flush()) != null ? _a : Promise.resolve();
|
|
4465
|
+
}
|
|
4243
4466
|
};
|
|
4244
4467
|
}
|
|
4245
4468
|
function safeLocation() {
|
|
@@ -4249,22 +4472,6 @@ function safeLocation() {
|
|
|
4249
4472
|
return void 0;
|
|
4250
4473
|
}
|
|
4251
4474
|
}
|
|
4252
|
-
function inertHandle(status, sessionId) {
|
|
4253
|
-
return {
|
|
4254
|
-
status: () => status,
|
|
4255
|
-
sessionId: () => sessionId,
|
|
4256
|
-
consent: () => {
|
|
4257
|
-
},
|
|
4258
|
-
identify: () => {
|
|
4259
|
-
},
|
|
4260
|
-
track: () => {
|
|
4261
|
-
},
|
|
4262
|
-
stop: () => {
|
|
4263
|
-
},
|
|
4264
|
-
flush: async () => {
|
|
4265
|
-
}
|
|
4266
|
-
};
|
|
4267
|
-
}
|
|
4268
4475
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4269
4476
|
0 && (module.exports = {
|
|
4270
4477
|
ConfigError,
|
|
@@ -4275,6 +4482,7 @@ function inertHandle(status, sessionId) {
|
|
|
4275
4482
|
TRACK_TAG,
|
|
4276
4483
|
Transport,
|
|
4277
4484
|
createRecorder,
|
|
4485
|
+
forgetVisitor,
|
|
4278
4486
|
hashToUnitInterval,
|
|
4279
4487
|
isSampledIn,
|
|
4280
4488
|
resolveIdentity,
|