@anyreplay/browser 0.1.2 → 0.1.4
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 +204 -19
- 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 +204 -19
- 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
|
@@ -3674,10 +3674,12 @@ record.mirror = mirror;
|
|
|
3674
3674
|
var DEFAULTS = {
|
|
3675
3675
|
ingestUrl: "https://in.anyreplay.com",
|
|
3676
3676
|
sampleRate: 1,
|
|
3677
|
-
//
|
|
3678
|
-
//
|
|
3679
|
-
//
|
|
3680
|
-
|
|
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,
|
|
3681
3683
|
maskClass: "ar-mask",
|
|
3682
3684
|
blockClass: "ar-block",
|
|
3683
3685
|
requireConsent: false,
|
|
@@ -3710,11 +3712,18 @@ function resolveOptions(input) {
|
|
|
3710
3712
|
projectKey: input.projectKey,
|
|
3711
3713
|
sampleRate,
|
|
3712
3714
|
ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
maskAllInputs: input.maskAllInputs === false ? false : true
|
|
3715
|
+
maskAllInputs: readSwitch(input, "maskAllInputs"),
|
|
3716
|
+
maskAllTyping: readSwitch(input, "maskAllTyping")
|
|
3716
3717
|
};
|
|
3717
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
|
+
}
|
|
3718
3727
|
|
|
3719
3728
|
// src/events.ts
|
|
3720
3729
|
var TRACK_TAG = "anyreplay.track";
|
|
@@ -3744,6 +3753,170 @@ function byteLength(text) {
|
|
|
3744
3753
|
return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
|
|
3745
3754
|
}
|
|
3746
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
|
+
|
|
3747
3920
|
// src/sampling.ts
|
|
3748
3921
|
function hashToUnitInterval(input) {
|
|
3749
3922
|
let hash = 2166136261;
|
|
@@ -3839,7 +4012,14 @@ var Transport = class {
|
|
|
3839
4012
|
seq: this.seq,
|
|
3840
4013
|
events,
|
|
3841
4014
|
...this.pendingMeta ? { meta: this.pendingMeta } : {},
|
|
3842
|
-
...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()
|
|
3843
4023
|
});
|
|
3844
4024
|
}
|
|
3845
4025
|
/** Sends whatever is buffered. Safe to call when there is nothing to send. */
|
|
@@ -4050,6 +4230,7 @@ var RAGE_CLICK_COUNT = 3;
|
|
|
4050
4230
|
var RAGE_CLICK_WINDOW_MS = 1e3;
|
|
4051
4231
|
var RAGE_CLICK_RADIUS_PX = 30;
|
|
4052
4232
|
var MAX_PENDING_EVENTS = 32;
|
|
4233
|
+
var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
|
|
4053
4234
|
function createRecorder(rawOptions) {
|
|
4054
4235
|
const options = resolveOptions(rawOptions);
|
|
4055
4236
|
const local = safeStorage(() => globalThis.localStorage);
|
|
@@ -4085,17 +4266,17 @@ function createRecorder(rawOptions) {
|
|
|
4085
4266
|
const noteInteraction = (event, channel) => {
|
|
4086
4267
|
var _a;
|
|
4087
4268
|
const data = event.data;
|
|
4088
|
-
if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
|
|
4269
|
+
if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || data.type !== 2 || typeof data.x !== "number") return;
|
|
4089
4270
|
const now = event.timestamp;
|
|
4090
4271
|
recentClicks.push({ x: data.x, y: (_a = data.y) != null ? _a : 0, t: now });
|
|
4091
4272
|
while (recentClicks.length > 0 && now - recentClicks[0].t > RAGE_CLICK_WINDOW_MS) recentClicks.shift();
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
}
|
|
4273
|
+
const near = recentClicks.filter(
|
|
4274
|
+
(c) => {
|
|
4275
|
+
var _a2;
|
|
4276
|
+
return Math.abs(c.x - data.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - ((_a2 = data.y) != null ? _a2 : 0)) < RAGE_CLICK_RADIUS_PX;
|
|
4277
|
+
}
|
|
4278
|
+
);
|
|
4279
|
+
if (near.length >= RAGE_CLICK_COUNT) channel.mergeFlags({ hasRageClick: true });
|
|
4099
4280
|
};
|
|
4100
4281
|
const pending = [];
|
|
4101
4282
|
let draining = false;
|
|
@@ -4185,19 +4366,23 @@ function createRecorder(rawOptions) {
|
|
|
4185
4366
|
channel.setMeta(collectMeta(who));
|
|
4186
4367
|
channel.mergeFlags({ pageCount });
|
|
4187
4368
|
channel.start();
|
|
4369
|
+
const policy = {
|
|
4370
|
+
maskEveryField: options.maskAllInputs || options.maskAllTyping,
|
|
4371
|
+
maskClass: options.maskClass
|
|
4372
|
+
};
|
|
4188
4373
|
stopRecording = record({
|
|
4189
4374
|
emit: (event) => {
|
|
4190
4375
|
const typed = event;
|
|
4376
|
+
scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
|
|
4191
4377
|
noteInteraction(typed, channel);
|
|
4192
4378
|
touchSession(session, typed.timestamp);
|
|
4193
4379
|
channel.push(typed);
|
|
4194
4380
|
drainPending();
|
|
4195
4381
|
},
|
|
4196
|
-
|
|
4382
|
+
maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
|
|
4197
4383
|
maskTextClass: options.maskClass,
|
|
4384
|
+
maskTextSelector: options.maskAllTyping ? EDITABLE_SELECTOR : void 0,
|
|
4198
4385
|
blockClass: options.blockClass,
|
|
4199
|
-
// Passwords are never recorded, regardless of configuration.
|
|
4200
|
-
maskInputOptions: { password: true, email: true, tel: true },
|
|
4201
4386
|
// Canvas capture is expensive and off unless a customer opts in later.
|
|
4202
4387
|
recordCanvas: false,
|
|
4203
4388
|
collectFonts: false
|