@anyreplay/browser 0.2.3 → 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 +13 -9
- package/dist/index.cjs +58 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -12
- package/dist/index.d.ts +24 -12
- package/dist/index.js +58 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
-
|
|
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:
|
|
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
|
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
74
|
-
- `
|
|
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
|
|
92
|
-
|
|
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
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
//
|
|
4015
|
-
//
|
|
4016
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
(
|
|
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
|
-
(
|
|
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.
|
|
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,6 +5258,8 @@ 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
5264
|
let traitsAwaitingSession;
|
|
5250
5265
|
let traitsToRetry;
|
|
@@ -5418,6 +5433,9 @@ function createRecorder(rawOptions, shell) {
|
|
|
5418
5433
|
onWarning: (message) => {
|
|
5419
5434
|
if (options.debug) console.warn(`[anyreplay] ${message}`);
|
|
5420
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),
|
|
5421
5439
|
// Events were dropped (a long outage, or one too large to send), so
|
|
5422
5440
|
// the DOM changes after the gap describe nodes ingest never got. A
|
|
5423
5441
|
// fresh full snapshot starts the page over. Taken on the next turn,
|
|
@@ -5458,7 +5476,8 @@ function createRecorder(rawOptions, shell) {
|
|
|
5458
5476
|
maskEveryField: options.maskAllInputs || options.maskAllTyping,
|
|
5459
5477
|
maskClass: options.maskClass
|
|
5460
5478
|
};
|
|
5461
|
-
|
|
5479
|
+
let flushedEarly = false;
|
|
5480
|
+
const capture = () => record({
|
|
5462
5481
|
emit: (event) => {
|
|
5463
5482
|
const typed = event;
|
|
5464
5483
|
if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
|
|
@@ -5468,6 +5487,12 @@ function createRecorder(rawOptions, shell) {
|
|
|
5468
5487
|
touchSession(session, typed.timestamp);
|
|
5469
5488
|
channel.push(typed);
|
|
5470
5489
|
drainPending();
|
|
5490
|
+
if (awaitingProjectMasking && !flushedEarly && typed.type === EVENT_FULL_SNAPSHOT3) {
|
|
5491
|
+
flushedEarly = true;
|
|
5492
|
+
setTimeout(() => {
|
|
5493
|
+
void channel.flush();
|
|
5494
|
+
}, 0);
|
|
5495
|
+
}
|
|
5471
5496
|
},
|
|
5472
5497
|
maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
|
|
5473
5498
|
maskTextClass: options.maskClass,
|
|
@@ -5480,6 +5505,23 @@ function createRecorder(rawOptions, shell) {
|
|
|
5480
5505
|
recordCanvas: false,
|
|
5481
5506
|
collectFonts: false
|
|
5482
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();
|
|
5483
5525
|
drainPending();
|
|
5484
5526
|
if (pendingTraits) {
|
|
5485
5527
|
sendIdentity(who, pendingTraits);
|