@anyreplay/browser 0.2.2 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ Session replay recorder for the browser, made by [AnyReplay](https://anyreplay.c
4
4
  watch any user's session, translated into your own language.
5
5
 
6
6
  - DOM-based recording (built on rrweb), not video — about 35 KB gzipped
7
- - Records what people type; one line masks it, and passwords and payment fields never are
7
+ - Form fields are masked by default; one line records them, 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, retries that wait out an outage instead of giving up, and an optional consent gate
@@ -22,7 +22,7 @@ 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, // off by default: field values are recorded
25
+ // maskAllInputs: false, // on by default; false records what people type into fields
26
26
  // maskAllTyping: true, // the stricter switch: fields and rich-text editors
27
27
  // maxSessionsPerMonth: 5000, // optional cap for this project
28
28
  // appId: 'com.example.shop', // only inside an app shell; see below
@@ -57,7 +57,7 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
57
57
  | `createRecorder(options)` | Starts recording (or waits for consent) and returns a handle |
58
58
  | `handle.track(name, properties?)` | Tags this moment; `name` is 1–64 chars of `[A-Za-z0-9_.:-]`, properties a JSON object ≤ 4 KB |
59
59
  | `handle.trackError(error)` | Records an error you caught yourself, redacted, and marks the session as having one |
60
- | `handle.identify({ userId?, email? })` | Attaches your user id to the session |
60
+ | `handle.identify({ userId?, email? })` | Attaches your user id to the session; held until the session's first chunk is accepted, then sent once |
61
61
  | `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)` removes what was stored and stops (before recording started, a later `consent(true)` still starts it) |
62
62
  | `handle.status()` | `idle`, `awaiting-consent`, `recording`, `sampled-out` or `stopped` |
63
63
  | `handle.flush()` | Sends what is buffered now; resolves when delivered |
@@ -66,12 +66,15 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
66
66
 
67
67
  ## Privacy
68
68
 
69
- A recording contains what visitors type into forms, because a replay of a form
70
- nobody could finish is worth watching only if you can see what they were trying
71
- to enter. Masking is a switch you turn on:
69
+ By default a recording does not contain what visitors type into forms: every
70
+ form field value becomes asterisks of the same length, so the replay shows the
71
+ field being filled in without showing what went into it. To see what people
72
+ typed — a form nobody could finish is easier to understand when you can read
73
+ it — opt out:
72
74
 
73
- - `maskAllInputs: true` — every form field value becomes asterisks of the same length.
74
- - `maskAllTyping: true` — the same, plus the text of every `contenteditable` editor.
75
+ - `maskAllInputs: false` — form field values are recorded (the fields below stay masked).
76
+ - `maskAllInputs` left out — the project's masking switch in the dashboard decides. The page records masked until ingest answers the first chunk with that choice, and stays masked if no answer comes.
77
+ - `maskAllTyping: true` — the stricter switch: form fields plus the text of every `contenteditable` editor, whatever `maskAllInputs` says.
75
78
  - `class="ar-mask"` — one element, or one field, masked while the rest records.
76
79
  - `class="ar-block"` — the element's contents are not recorded at all.
77
80
 
@@ -88,8 +91,9 @@ recorded. Masking always happens in the page, before
88
91
  anything is sent.
89
92
 
90
93
  `type="email"` and `type="tel"` are not on that list. An address and a phone
91
- number are ordinary contact details, so they are recorded like any other field
92
- and masked by `maskAllInputs`, `maskAllTyping` or `class="ar-mask"`.
94
+ number are ordinary contact details, so they follow `maskAllInputs` like any
95
+ other field: masked by default, recorded with `maskAllInputs: false` unless
96
+ `maskAllTyping` or `class="ar-mask"` covers them.
93
97
 
94
98
  ## License
95
99
 
package/dist/index.cjs CHANGED
@@ -4009,11 +4009,12 @@ function sha256HexSync(bytes) {
4009
4009
  var DEFAULTS = {
4010
4010
  ingestUrl: "https://in.anyreplay.com",
4011
4011
  sampleRate: 1,
4012
- // Both masking switches are opt-in: a recording shows what people typed,
4013
- // which is the point of watching one. What that never includes is the floor
4014
- // in masking.ts — passwords, payment fields and the rest — which no option
4015
- // can switch off.
4016
- maskAllInputs: false,
4012
+ // Form fields are masked unless the page opts out with `maskAllInputs:
4013
+ // false`. Editable regions are opt-in, because masking one hides all of its
4014
+ // text, not only what was typed. Neither option reaches the floor in
4015
+ // masking.ts — passwords, payment fields and the rest — which no option can
4016
+ // switch off.
4017
+ maskAllInputs: true,
4017
4018
  maskAllTyping: false,
4018
4019
  maskClass: "ar-mask",
4019
4020
  blockClass: "ar-block",
@@ -4079,7 +4080,7 @@ function readSwitch(input, name) {
4079
4080
  const value = input[name];
4080
4081
  if (typeof value === "boolean") return value;
4081
4082
  if (value !== void 0 && typeof console !== "undefined") {
4082
- const consequence = name.startsWith("mask") ? "Nothing is masked by this option." : `${name.slice("record".length)} are recorded, as by default.`;
4083
+ const consequence = name === "maskAllInputs" ? "Form fields stay masked, as by default." : name === "maskAllTyping" ? "Nothing more is masked by this option." : `${name.slice("record".length)} are recorded, as by default.`;
4083
4084
  console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. ${consequence}`);
4084
4085
  }
4085
4086
  return DEFAULTS[name];
@@ -4636,17 +4637,20 @@ var BACKOFF_BASE_MS = 2e3;
4636
4637
  var BACKOFF_MAX_MS = 6e4;
4637
4638
  var MAX_RETRY_AFTER_MS = 10 * 6e4;
4638
4639
  var MAX_CONSECUTIVE_REFUSALS = 3;
4639
- async function staleSeqHint(response, sent) {
4640
+ async function readReply(response) {
4640
4641
  try {
4641
4642
  if (typeof response.json !== "function") return void 0;
4642
4643
  const reply = await response.json();
4643
- if (reply.duplicate !== true) return void 0;
4644
- const next = reply.nextSeq;
4645
- return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4644
+ return reply && typeof reply === "object" ? reply : void 0;
4646
4645
  } catch (e) {
4647
4646
  return void 0;
4648
4647
  }
4649
4648
  }
4649
+ function staleSeqHint(reply, sent) {
4650
+ if ((reply == null ? void 0 : reply.duplicate) !== true) return void 0;
4651
+ const next = reply.nextSeq;
4652
+ return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4653
+ }
4650
4654
  function retryAfterMs(response, now = Date.now()) {
4651
4655
  var _a, _b;
4652
4656
  let raw;
@@ -4898,7 +4902,7 @@ var Transport = class {
4898
4902
  }
4899
4903
  /** One request, and what to do next. */
4900
4904
  async send(chunk) {
4901
- var _a, _b, _c, _d, _e, _f;
4905
+ var _a, _b, _c, _d, _e, _f, _g, _h;
4902
4906
  const retry = chunk.attempts > 0;
4903
4907
  chunk.attempts += 1;
4904
4908
  (_b = (_a = this.hooks).onSeqReserved) == null ? void 0 : _b.call(_a, chunk.seq + 1);
@@ -4919,7 +4923,14 @@ var Transport = class {
4919
4923
  }
4920
4924
  if (this.state === "stopped" || this.pending !== chunk) return "stopped";
4921
4925
  if (response.ok) {
4922
- const nextSeq = retry ? void 0 : await staleSeqHint(response, chunk.seq);
4926
+ const reply = await readReply(response);
4927
+ if (typeof (reply == null ? void 0 : reply.maskAllInputs) === "boolean") {
4928
+ try {
4929
+ (_d = (_c = this.hooks).onProjectMasking) == null ? void 0 : _d.call(_c, reply.maskAllInputs);
4930
+ } catch (e) {
4931
+ }
4932
+ }
4933
+ const nextSeq = retry ? void 0 : staleSeqHint(reply, chunk.seq);
4923
4934
  if (nextSeq !== void 0 && this.staleRecoveries < MAX_STALE_RECOVERIES) {
4924
4935
  this.staleRecoveries += 1;
4925
4936
  chunk.seq = nextSeq;
@@ -4933,7 +4944,7 @@ var Transport = class {
4933
4944
  this.consecutiveFailures = 0;
4934
4945
  this.consecutiveRefusals = 0;
4935
4946
  this.backoffUntil = 0;
4936
- (_d = (_c = this.hooks).onSent) == null ? void 0 : _d.call(_c, chunk.seq, chunk.events.length);
4947
+ (_f = (_e = this.hooks).onSent) == null ? void 0 : _f.call(_e, chunk.seq, chunk.events.length);
4937
4948
  return "sent";
4938
4949
  }
4939
4950
  const status = response.status;
@@ -4945,7 +4956,7 @@ var Transport = class {
4945
4956
  this.stop(`rejected_${status}`);
4946
4957
  return "stopped";
4947
4958
  }
4948
- (_f = (_e = this.hooks).onWarning) == null ? void 0 : _f.call(_e, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4959
+ (_h = (_g = this.hooks).onWarning) == null ? void 0 : _h.call(_g, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4949
4960
  this.afterGap();
4950
4961
  return "again";
4951
4962
  }
@@ -5120,7 +5131,7 @@ var Transport = class {
5120
5131
 
5121
5132
  // src/version.ts
5122
5133
  var SDK_NAME = "@anyreplay/browser";
5123
- var SDK_VERSION = true ? "0.2.2" : "0.0.0-dev";
5134
+ var SDK_VERSION = true ? "0.3.0" : "0.0.0-dev";
5124
5135
  var PLATFORM = "web";
5125
5136
 
5126
5137
  // src/visitor.ts
@@ -5229,6 +5240,7 @@ function forgetVisitor(local, session) {
5229
5240
  // src/recorder.ts
5230
5241
  var RRWEB_DOM_CONTENT_LOADED = 0;
5231
5242
  var RRWEB_LOAD = 1;
5243
+ var EVENT_FULL_SNAPSHOT3 = 2;
5232
5244
  var RAGE_CLICK_COUNT = 3;
5233
5245
  var RAGE_CLICK_WINDOW_MS = 1e3;
5234
5246
  var RAGE_CLICK_RADIUS_PX = 30;
@@ -5236,6 +5248,7 @@ var MAX_PENDING_EVENTS = 32;
5236
5248
  var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
5237
5249
  function createRecorder(rawOptions, shell) {
5238
5250
  const options = resolveOptions(rawOptions);
5251
+ let awaitingProjectMasking = typeof rawOptions.maskAllInputs !== "boolean" && !options.maskAllTyping;
5239
5252
  const local = safeStorage(() => globalThis.localStorage);
5240
5253
  const session = safeStorage(() => globalThis.sessionStorage);
5241
5254
  let status = "idle";
@@ -5245,8 +5258,13 @@ function createRecorder(rawOptions, shell) {
5245
5258
  let diagnostics;
5246
5259
  let assets;
5247
5260
  let pageCount = 0;
5261
+ let applyProjectMasking = () => {
5262
+ };
5248
5263
  let pendingTraits;
5249
- const sendIdentity = (who, traits) => {
5264
+ let traitsAwaitingSession;
5265
+ let traitsToRetry;
5266
+ let sessionDelivered = false;
5267
+ const postIdentity = (who, traits, retryIfUnapplied) => {
5250
5268
  void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
5251
5269
  method: "POST",
5252
5270
  headers: { "Content-Type": "application/json" },
@@ -5257,9 +5275,33 @@ function createRecorder(rawOptions, shell) {
5257
5275
  ...traits,
5258
5276
  ...options.appId ? { appId: options.appId } : {}
5259
5277
  })
5278
+ }).then(async (response) => {
5279
+ if (!retryIfUnapplied || response.status !== 202) return;
5280
+ const reply = await response.json();
5281
+ if (reply.applied === false && identity === who && !traitsAwaitingSession && !traitsToRetry) {
5282
+ traitsToRetry = traits;
5283
+ }
5260
5284
  }).catch(() => {
5261
5285
  });
5262
5286
  };
5287
+ const sendIdentity = (who, traits) => {
5288
+ traitsToRetry = void 0;
5289
+ if (!sessionDelivered) {
5290
+ traitsAwaitingSession = { ...traitsAwaitingSession, ...traits };
5291
+ return;
5292
+ }
5293
+ postIdentity(who, traits, true);
5294
+ };
5295
+ const noteDelivered = (channel) => {
5296
+ if (channel !== transport || !identity) return;
5297
+ sessionDelivered = true;
5298
+ const waiting = traitsAwaitingSession;
5299
+ const retry = traitsToRetry;
5300
+ traitsAwaitingSession = void 0;
5301
+ traitsToRetry = void 0;
5302
+ if (waiting) postIdentity(identity, waiting, true);
5303
+ else if (retry) postIdentity(identity, retry, false);
5304
+ };
5263
5305
  const collectMeta = (who) => {
5264
5306
  var _a, _b;
5265
5307
  return {
@@ -5368,6 +5410,7 @@ function createRecorder(rawOptions, shell) {
5368
5410
  initialSeq: who.nextSeq
5369
5411
  },
5370
5412
  {
5413
+ onSent: () => noteDelivered(channel),
5371
5414
  onSeqReserved: (next) => {
5372
5415
  var _a;
5373
5416
  reserveSeq(session, next);
@@ -5390,6 +5433,9 @@ function createRecorder(rawOptions, shell) {
5390
5433
  onWarning: (message) => {
5391
5434
  if (options.debug) console.warn(`[anyreplay] ${message}`);
5392
5435
  },
5436
+ // Out of the transport's own call stack, like a resync: restarting
5437
+ // rrweb emits a snapshot into the channel that is answering.
5438
+ onProjectMasking: (masked) => setTimeout(() => applyProjectMasking(channel, masked), 0),
5393
5439
  // Events were dropped (a long outage, or one too large to send), so
5394
5440
  // the DOM changes after the gap describe nodes ingest never got. A
5395
5441
  // fresh full snapshot starts the page over. Taken on the next turn,
@@ -5430,7 +5476,8 @@ function createRecorder(rawOptions, shell) {
5430
5476
  maskEveryField: options.maskAllInputs || options.maskAllTyping,
5431
5477
  maskClass: options.maskClass
5432
5478
  };
5433
- stopRecording = record({
5479
+ let flushedEarly = false;
5480
+ const capture = () => record({
5434
5481
  emit: (event) => {
5435
5482
  const typed = event;
5436
5483
  if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
@@ -5440,6 +5487,12 @@ function createRecorder(rawOptions, shell) {
5440
5487
  touchSession(session, typed.timestamp);
5441
5488
  channel.push(typed);
5442
5489
  drainPending();
5490
+ if (awaitingProjectMasking && !flushedEarly && typed.type === EVENT_FULL_SNAPSHOT3) {
5491
+ flushedEarly = true;
5492
+ setTimeout(() => {
5493
+ void channel.flush();
5494
+ }, 0);
5495
+ }
5443
5496
  },
5444
5497
  maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
5445
5498
  maskTextClass: options.maskClass,
@@ -5452,6 +5505,23 @@ function createRecorder(rawOptions, shell) {
5452
5505
  recordCanvas: false,
5453
5506
  collectFonts: false
5454
5507
  });
5508
+ applyProjectMasking = (from, masked) => {
5509
+ if (!awaitingProjectMasking || from !== transport) return;
5510
+ awaitingProjectMasking = false;
5511
+ if (masked || status !== "recording" || !stopRecording) return;
5512
+ policy.maskEveryField = false;
5513
+ try {
5514
+ stopRecording();
5515
+ stopRecording = capture();
5516
+ if (!stopRecording) throw new Error("rrweb did not start");
5517
+ } catch (e) {
5518
+ stopRecording = void 0;
5519
+ from.stop("restart_failed");
5520
+ return;
5521
+ }
5522
+ if (options.debug) console.warn("[anyreplay] recording typed text: the project chose to (maskAllInputs: false)");
5523
+ };
5524
+ stopRecording = capture();
5455
5525
  drainPending();
5456
5526
  if (pendingTraits) {
5457
5527
  sendIdentity(who, pendingTraits);
@@ -5511,6 +5581,8 @@ function createRecorder(rawOptions, shell) {
5511
5581
  identity = void 0;
5512
5582
  }
5513
5583
  pendingTraits = void 0;
5584
+ traitsAwaitingSession = void 0;
5585
+ traitsToRetry = void 0;
5514
5586
  pending.length = 0;
5515
5587
  forgetVisitor(local, session);
5516
5588
  },