@anyreplay/browser 0.1.2 → 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 +196 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -1
- package/dist/index.d.ts +35 -1
- package/dist/index.js +196 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.cts
CHANGED
|
@@ -5,8 +5,42 @@ interface AnyReplayOptions {
|
|
|
5
5
|
ingestUrl?: string;
|
|
6
6
|
/** Fraction of visitors recorded, 0–1. Decided once per visitor, not per page. */
|
|
7
7
|
sampleRate?: number;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Mask the value of every form control.
|
|
10
|
+
*
|
|
11
|
+
* Off by default: a recording shows what a visitor typed into inputs,
|
|
12
|
+
* textareas and rich-text editors, because a replay of a form nobody could
|
|
13
|
+
* finish is worth watching only if you can see what they were trying to put
|
|
14
|
+
* in it. Turn this on for a site whose forms carry anything you would not
|
|
15
|
+
* want a teammate to read, and mark individual fields with the `maskClass`
|
|
16
|
+
* when it is only some of them.
|
|
17
|
+
*
|
|
18
|
+
* Some fields are masked whatever this says, in every mode: passwords,
|
|
19
|
+
* one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
|
|
20
|
+
* number, sort code), anything inside an element with the mask class, and any
|
|
21
|
+
* value that looks like a card number. Email and telephone fields are not on
|
|
22
|
+
* that list — they are ordinary fields, recorded by default and masked by this
|
|
23
|
+
* option. See `isSensitiveField` in `masking.ts` for the exact list.
|
|
24
|
+
*/
|
|
9
25
|
maskAllInputs?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Mask everything a person types, wherever they type it.
|
|
28
|
+
*
|
|
29
|
+
* `maskAllInputs` covers form controls. This covers those *and* the other
|
|
30
|
+
* place a person can type — an element with `contenteditable`, which is what
|
|
31
|
+
* a rich-text editor, a comment box or a chat composer is made of. Their text
|
|
32
|
+
* is not an input value but ordinary page text, so input masking does not
|
|
33
|
+
* reach it. With this on, the text of every editable region is replaced with
|
|
34
|
+
* asterisks in the first snapshot and in every change after it.
|
|
35
|
+
*
|
|
36
|
+
* The stricter of the two options, and a superset of it: `maskAllTyping: true`
|
|
37
|
+
* masks form fields too, whatever `maskAllInputs` says. It is separate
|
|
38
|
+
* because masking an editable region masks *all* of its text, including
|
|
39
|
+
* whatever the page put there before anyone typed — a loaded document, a
|
|
40
|
+
* quoted message — which is a bigger thing to ask for than hiding field
|
|
41
|
+
* values.
|
|
42
|
+
*/
|
|
43
|
+
maskAllTyping?: boolean;
|
|
10
44
|
/** Elements with this class have their text masked. */
|
|
11
45
|
maskClass?: string;
|
|
12
46
|
/** Elements with this class are not recorded at all. */
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,42 @@ interface AnyReplayOptions {
|
|
|
5
5
|
ingestUrl?: string;
|
|
6
6
|
/** Fraction of visitors recorded, 0–1. Decided once per visitor, not per page. */
|
|
7
7
|
sampleRate?: number;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Mask the value of every form control.
|
|
10
|
+
*
|
|
11
|
+
* Off by default: a recording shows what a visitor typed into inputs,
|
|
12
|
+
* textareas and rich-text editors, because a replay of a form nobody could
|
|
13
|
+
* finish is worth watching only if you can see what they were trying to put
|
|
14
|
+
* in it. Turn this on for a site whose forms carry anything you would not
|
|
15
|
+
* want a teammate to read, and mark individual fields with the `maskClass`
|
|
16
|
+
* when it is only some of them.
|
|
17
|
+
*
|
|
18
|
+
* Some fields are masked whatever this says, in every mode: passwords,
|
|
19
|
+
* one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
|
|
20
|
+
* number, sort code), anything inside an element with the mask class, and any
|
|
21
|
+
* value that looks like a card number. Email and telephone fields are not on
|
|
22
|
+
* that list — they are ordinary fields, recorded by default and masked by this
|
|
23
|
+
* option. See `isSensitiveField` in `masking.ts` for the exact list.
|
|
24
|
+
*/
|
|
9
25
|
maskAllInputs?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Mask everything a person types, wherever they type it.
|
|
28
|
+
*
|
|
29
|
+
* `maskAllInputs` covers form controls. This covers those *and* the other
|
|
30
|
+
* place a person can type — an element with `contenteditable`, which is what
|
|
31
|
+
* a rich-text editor, a comment box or a chat composer is made of. Their text
|
|
32
|
+
* is not an input value but ordinary page text, so input masking does not
|
|
33
|
+
* reach it. With this on, the text of every editable region is replaced with
|
|
34
|
+
* asterisks in the first snapshot and in every change after it.
|
|
35
|
+
*
|
|
36
|
+
* The stricter of the two options, and a superset of it: `maskAllTyping: true`
|
|
37
|
+
* masks form fields too, whatever `maskAllInputs` says. It is separate
|
|
38
|
+
* because masking an editable region masks *all* of its text, including
|
|
39
|
+
* whatever the page put there before anyone typed — a loaded document, a
|
|
40
|
+
* quoted message — which is a bigger thing to ask for than hiding field
|
|
41
|
+
* values.
|
|
42
|
+
*/
|
|
43
|
+
maskAllTyping?: boolean;
|
|
10
44
|
/** Elements with this class have their text masked. */
|
|
11
45
|
maskClass?: string;
|
|
12
46
|
/** Elements with this class are not recorded at all. */
|
package/dist/index.js
CHANGED
|
@@ -3641,10 +3641,12 @@ record.mirror = mirror;
|
|
|
3641
3641
|
var DEFAULTS = {
|
|
3642
3642
|
ingestUrl: "https://in.anyreplay.com",
|
|
3643
3643
|
sampleRate: 1,
|
|
3644
|
-
//
|
|
3645
|
-
//
|
|
3646
|
-
//
|
|
3647
|
-
|
|
3644
|
+
// Both masking switches are opt-in: a recording shows what people typed,
|
|
3645
|
+
// which is the point of watching one. What that never includes is the floor
|
|
3646
|
+
// in masking.ts — passwords, payment fields and the rest — which no option
|
|
3647
|
+
// can switch off.
|
|
3648
|
+
maskAllInputs: false,
|
|
3649
|
+
maskAllTyping: false,
|
|
3648
3650
|
maskClass: "ar-mask",
|
|
3649
3651
|
blockClass: "ar-block",
|
|
3650
3652
|
requireConsent: false,
|
|
@@ -3677,11 +3679,18 @@ function resolveOptions(input) {
|
|
|
3677
3679
|
projectKey: input.projectKey,
|
|
3678
3680
|
sampleRate,
|
|
3679
3681
|
ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
maskAllInputs: input.maskAllInputs === false ? false : true
|
|
3682
|
+
maskAllInputs: readSwitch(input, "maskAllInputs"),
|
|
3683
|
+
maskAllTyping: readSwitch(input, "maskAllTyping")
|
|
3683
3684
|
};
|
|
3684
3685
|
}
|
|
3686
|
+
function readSwitch(input, name) {
|
|
3687
|
+
const value = input[name];
|
|
3688
|
+
if (typeof value === "boolean") return value;
|
|
3689
|
+
if (value !== void 0 && typeof console !== "undefined") {
|
|
3690
|
+
console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. Nothing is masked by this option.`);
|
|
3691
|
+
}
|
|
3692
|
+
return DEFAULTS[name];
|
|
3693
|
+
}
|
|
3685
3694
|
|
|
3686
3695
|
// src/events.ts
|
|
3687
3696
|
var TRACK_TAG = "anyreplay.track";
|
|
@@ -3711,6 +3720,170 @@ function byteLength(text) {
|
|
|
3711
3720
|
return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
|
|
3712
3721
|
}
|
|
3713
3722
|
|
|
3723
|
+
// src/masking.ts
|
|
3724
|
+
var maskValue = (text) => "*".repeat(text.length);
|
|
3725
|
+
var MASK_EVERY_FIELD = {
|
|
3726
|
+
color: true,
|
|
3727
|
+
date: true,
|
|
3728
|
+
"datetime-local": true,
|
|
3729
|
+
email: true,
|
|
3730
|
+
month: true,
|
|
3731
|
+
number: true,
|
|
3732
|
+
range: true,
|
|
3733
|
+
search: true,
|
|
3734
|
+
tel: true,
|
|
3735
|
+
text: true,
|
|
3736
|
+
time: true,
|
|
3737
|
+
url: true,
|
|
3738
|
+
week: true,
|
|
3739
|
+
textarea: true,
|
|
3740
|
+
select: true,
|
|
3741
|
+
password: true,
|
|
3742
|
+
input: true
|
|
3743
|
+
};
|
|
3744
|
+
var SENSITIVE_AUTOCOMPLETE = /* @__PURE__ */ new Set([
|
|
3745
|
+
"cc-number",
|
|
3746
|
+
"cc-csc",
|
|
3747
|
+
"cc-exp",
|
|
3748
|
+
"cc-exp-month",
|
|
3749
|
+
"cc-exp-year",
|
|
3750
|
+
"cc-name",
|
|
3751
|
+
"cc-type",
|
|
3752
|
+
"cc-given-name",
|
|
3753
|
+
"cc-family-name",
|
|
3754
|
+
"cc-additional-name",
|
|
3755
|
+
"current-password",
|
|
3756
|
+
"new-password",
|
|
3757
|
+
"one-time-code"
|
|
3758
|
+
]);
|
|
3759
|
+
var PAYMENT_FIELD = new RegExp(
|
|
3760
|
+
"(?: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",
|
|
3761
|
+
"i"
|
|
3762
|
+
);
|
|
3763
|
+
var CREDENTIAL_FIELD = /pass(?:word|wd|phrase)|(?:^|[^a-z])pwd(?:[^a-z]|$)|(?:^|[^a-z])otp(?:[^a-z]|$)|one[\s_-]*time[\s_-]*code/i;
|
|
3764
|
+
var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
|
|
3765
|
+
var MASK_SENSITIVE_FIELDS = Object.fromEntries(
|
|
3766
|
+
[...SENSITIVE_TYPES].map((type) => [type, true])
|
|
3767
|
+
);
|
|
3768
|
+
var NAMING_ATTRIBUTES = ["name", "id", "autocomplete", "placeholder", "aria-label", "data-testid"];
|
|
3769
|
+
function isSensitiveByAttributes(read, type) {
|
|
3770
|
+
var _a;
|
|
3771
|
+
if (read("data-rr-is-password") !== null) return true;
|
|
3772
|
+
if (SENSITIVE_TYPES.has(type)) return true;
|
|
3773
|
+
const autocomplete = ((_a = read("autocomplete")) != null ? _a : "").toLowerCase();
|
|
3774
|
+
for (const token of autocomplete.split(/\s+/)) {
|
|
3775
|
+
if (SENSITIVE_AUTOCOMPLETE.has(token)) return true;
|
|
3776
|
+
}
|
|
3777
|
+
for (const attribute of NAMING_ATTRIBUTES) {
|
|
3778
|
+
const value = read(attribute);
|
|
3779
|
+
if (value && (PAYMENT_FIELD.test(value) || CREDENTIAL_FIELD.test(value))) return true;
|
|
3780
|
+
}
|
|
3781
|
+
return false;
|
|
3782
|
+
}
|
|
3783
|
+
var asElement = (node) => node && typeof node.getAttribute === "function" ? node : null;
|
|
3784
|
+
function isSensitiveField(node) {
|
|
3785
|
+
const element = asElement(node);
|
|
3786
|
+
if (!element) return false;
|
|
3787
|
+
const type = (element.type || element.getAttribute("type") || "").toLowerCase();
|
|
3788
|
+
return isSensitiveByAttributes((name) => element.getAttribute(name), type);
|
|
3789
|
+
}
|
|
3790
|
+
function isSensitiveSerialised(attributes) {
|
|
3791
|
+
var _a;
|
|
3792
|
+
const read = (name) => typeof attributes[name] === "string" ? attributes[name] : null;
|
|
3793
|
+
return isSensitiveByAttributes(read, ((_a = read("type")) != null ? _a : "").toLowerCase());
|
|
3794
|
+
}
|
|
3795
|
+
function looksLikeCardNumber(text) {
|
|
3796
|
+
if (text.length < 13 || text.length > 24 || !/^[0-9][0-9 -]*[0-9]$/.test(text)) return false;
|
|
3797
|
+
const digits = text.replace(/[ -]/g, "");
|
|
3798
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
3799
|
+
let sum = 0;
|
|
3800
|
+
let double = false;
|
|
3801
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
3802
|
+
let digit = digits.charCodeAt(i) - 48;
|
|
3803
|
+
if (double) {
|
|
3804
|
+
digit *= 2;
|
|
3805
|
+
if (digit > 9) digit -= 9;
|
|
3806
|
+
}
|
|
3807
|
+
sum += digit;
|
|
3808
|
+
double = !double;
|
|
3809
|
+
}
|
|
3810
|
+
return sum % 10 === 0;
|
|
3811
|
+
}
|
|
3812
|
+
function isMarkedByCustomer(node, maskClass) {
|
|
3813
|
+
var _a;
|
|
3814
|
+
let element = asElement(node);
|
|
3815
|
+
while (element) {
|
|
3816
|
+
if ((_a = element.classList) == null ? void 0 : _a.contains(maskClass)) return true;
|
|
3817
|
+
element = element.parentElement;
|
|
3818
|
+
}
|
|
3819
|
+
return false;
|
|
3820
|
+
}
|
|
3821
|
+
var hasClass = (attributes, maskClass) => typeof attributes.class === "string" && attributes.class.split(/\s+/).includes(maskClass);
|
|
3822
|
+
function shouldMaskValue(text, node, policy) {
|
|
3823
|
+
if (policy.maskEveryField) return true;
|
|
3824
|
+
if (isSensitiveField(node)) return true;
|
|
3825
|
+
if (isMarkedByCustomer(node, policy.maskClass)) return true;
|
|
3826
|
+
return looksLikeCardNumber(text);
|
|
3827
|
+
}
|
|
3828
|
+
var MUTATION = 0;
|
|
3829
|
+
var INPUT = 5;
|
|
3830
|
+
var ELEMENT_NODE = 2;
|
|
3831
|
+
var TEXT_NODE = 3;
|
|
3832
|
+
var FIELD_TAGS = /* @__PURE__ */ new Set(["input", "textarea", "select"]);
|
|
3833
|
+
function maskTextChildren(node) {
|
|
3834
|
+
var _a;
|
|
3835
|
+
for (const child of (_a = node.childNodes) != null ? _a : []) {
|
|
3836
|
+
if (child.type === TEXT_NODE && typeof child.textContent === "string") {
|
|
3837
|
+
child.textContent = child.textContent.replace(/\S/g, "*");
|
|
3838
|
+
}
|
|
3839
|
+
maskTextChildren(child);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
function scrubTree(node, policy, masked) {
|
|
3843
|
+
var _a, _b;
|
|
3844
|
+
if (!node) return;
|
|
3845
|
+
const attributes = (_a = node.attributes) != null ? _a : {};
|
|
3846
|
+
const inheritedMask = masked || hasClass(attributes, policy.maskClass);
|
|
3847
|
+
if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
|
|
3848
|
+
const value = attributes.value;
|
|
3849
|
+
const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && looksLikeCardNumber(value);
|
|
3850
|
+
if (shouldMask) {
|
|
3851
|
+
if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
|
|
3852
|
+
if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
|
|
3856
|
+
}
|
|
3857
|
+
function scrubEvent(event, getNode, policy) {
|
|
3858
|
+
var _a, _b, _c, _d, _e;
|
|
3859
|
+
if (!event.data) return;
|
|
3860
|
+
if (event.type === 2) {
|
|
3861
|
+
scrubTree(event.data.node, policy, false);
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
if (event.type !== 3) return;
|
|
3865
|
+
const data = event.data;
|
|
3866
|
+
if (data.source === INPUT) {
|
|
3867
|
+
if (typeof data.text !== "string" || data.text === "") return;
|
|
3868
|
+
const node = typeof data.id === "number" ? getNode(data.id) : null;
|
|
3869
|
+
const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
|
|
3870
|
+
if (type === "checkbox" || type === "radio") return;
|
|
3871
|
+
if (shouldMaskValue(data.text, node, policy)) data.text = maskValue(data.text);
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
if (data.source !== MUTATION) return;
|
|
3875
|
+
for (const entry of (_c = data.attributes) != null ? _c : []) {
|
|
3876
|
+
const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
|
|
3877
|
+
if (typeof value !== "string" || value === "") continue;
|
|
3878
|
+
const node = typeof entry.id === "number" ? getNode(entry.id) : null;
|
|
3879
|
+
if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
|
|
3880
|
+
}
|
|
3881
|
+
for (const add of (_e = data.adds) != null ? _e : []) {
|
|
3882
|
+
const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
|
|
3883
|
+
scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3714
3887
|
// src/sampling.ts
|
|
3715
3888
|
function hashToUnitInterval(input) {
|
|
3716
3889
|
let hash = 2166136261;
|
|
@@ -3806,7 +3979,14 @@ var Transport = class {
|
|
|
3806
3979
|
seq: this.seq,
|
|
3807
3980
|
events,
|
|
3808
3981
|
...this.pendingMeta ? { meta: this.pendingMeta } : {},
|
|
3809
|
-
...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {}
|
|
3982
|
+
...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {},
|
|
3983
|
+
// This device's clock as the request leaves, on the same clock as the
|
|
3984
|
+
// event timestamps. Ingest compares it with its own to catch a device
|
|
3985
|
+
// that runs ahead — a test phone three weeks fast would otherwise list
|
|
3986
|
+
// its sessions as starting "in 3 weeks". Stamped here, when the body is
|
|
3987
|
+
// built, so a retry or a queue flushed after a spell offline carries
|
|
3988
|
+
// the time it was actually sent rather than when it was recorded.
|
|
3989
|
+
sentAt: Date.now()
|
|
3810
3990
|
});
|
|
3811
3991
|
}
|
|
3812
3992
|
/** Sends whatever is buffered. Safe to call when there is nothing to send. */
|
|
@@ -4017,6 +4197,7 @@ var RAGE_CLICK_COUNT = 3;
|
|
|
4017
4197
|
var RAGE_CLICK_WINDOW_MS = 1e3;
|
|
4018
4198
|
var RAGE_CLICK_RADIUS_PX = 30;
|
|
4019
4199
|
var MAX_PENDING_EVENTS = 32;
|
|
4200
|
+
var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
|
|
4020
4201
|
function createRecorder(rawOptions) {
|
|
4021
4202
|
const options = resolveOptions(rawOptions);
|
|
4022
4203
|
const local = safeStorage(() => globalThis.localStorage);
|
|
@@ -4152,19 +4333,23 @@ function createRecorder(rawOptions) {
|
|
|
4152
4333
|
channel.setMeta(collectMeta(who));
|
|
4153
4334
|
channel.mergeFlags({ pageCount });
|
|
4154
4335
|
channel.start();
|
|
4336
|
+
const policy = {
|
|
4337
|
+
maskEveryField: options.maskAllInputs || options.maskAllTyping,
|
|
4338
|
+
maskClass: options.maskClass
|
|
4339
|
+
};
|
|
4155
4340
|
stopRecording = record({
|
|
4156
4341
|
emit: (event) => {
|
|
4157
4342
|
const typed = event;
|
|
4343
|
+
scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
|
|
4158
4344
|
noteInteraction(typed, channel);
|
|
4159
4345
|
touchSession(session, typed.timestamp);
|
|
4160
4346
|
channel.push(typed);
|
|
4161
4347
|
drainPending();
|
|
4162
4348
|
},
|
|
4163
|
-
|
|
4349
|
+
maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
|
|
4164
4350
|
maskTextClass: options.maskClass,
|
|
4351
|
+
maskTextSelector: options.maskAllTyping ? EDITABLE_SELECTOR : void 0,
|
|
4165
4352
|
blockClass: options.blockClass,
|
|
4166
|
-
// Passwords are never recorded, regardless of configuration.
|
|
4167
|
-
maskInputOptions: { password: true, email: true, tel: true },
|
|
4168
4353
|
// Canvas capture is expensive and off unless a customer opts in later.
|
|
4169
4354
|
recordCanvas: false,
|
|
4170
4355
|
collectFonts: false
|