@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 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 26 KB gzipped
7
- - Every input masked by default; opt elements out with `ar-mask` / `ar-block`
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; turn off deliberately
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
- Inputs are masked before they leave the page; `password` fields are never
52
- recorded at all. Add `class="ar-block"` to remove an element's contents from the
53
- recording entirely, or `class="ar-mask"` to keep layout but hide text.
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
- // Default-deny. Recording a customer's password field once is a breach; the
3678
- // cost of the safe default is a configuration line for the rare field that
3679
- // genuinely needs to be visible.
3680
- maskAllInputs: true,
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
- // A caller who explicitly passes false gets what they asked for; anything
3714
- // else (including undefined) keeps masking on.
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);
@@ -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
- maskAllInputs: options.maskAllInputs,
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