@anyreplay/browser 0.2.1 → 0.2.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 +15 -11
- package/dist/index.cjs +89 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +89 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
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
|
|
6
|
+
- DOM-based recording (built on rrweb), not video — about 35 KB gzipped
|
|
7
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
|
-
- Deterministic sampling per visitor,
|
|
10
|
+
- Deterministic sampling per visitor, retries that wait out an outage instead of giving up, and an optional consent gate
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -25,7 +25,7 @@ const recorder = createRecorder({
|
|
|
25
25
|
// maskAllInputs: true, // off by default: field values are recorded
|
|
26
26
|
// maskAllTyping: true, // the stricter switch: fields and rich-text editors
|
|
27
27
|
// maxSessionsPerMonth: 5000, // optional cap for this project
|
|
28
|
-
// appId: 'com.example.shop', // only inside an app shell
|
|
28
|
+
// appId: 'com.example.shop', // only inside an app shell; see below
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
recorder.track('checkout_clicked', { plan: 'growth' });
|
|
@@ -34,13 +34,15 @@ recorder.identify({ userId: 'u_42', email: 'ada@example.com' });
|
|
|
34
34
|
|
|
35
35
|
### Inside an app
|
|
36
36
|
|
|
37
|
-
A page running inside a Capacitor, Cordova or
|
|
38
|
-
origin — its web view reports `capacitor://localhost`, `https://localhost`
|
|
39
|
-
`file://` — so a project that lists allowed domains refuses it. Add the app's
|
|
37
|
+
A page running inside a Capacitor, Cordova, Electron or Tauri app has no real
|
|
38
|
+
web origin — its web view reports `capacitor://localhost`, `https://localhost`
|
|
39
|
+
or `file://` — so a project that lists allowed domains refuses it. Add the app's
|
|
40
40
|
bundle id or package name under **Allowed apps** in the project's settings,
|
|
41
41
|
and pass the same value as `appId`. Leave `appId` out on a website: from a
|
|
42
42
|
real web origin it is ignored, so it cannot open a web project's domain list.
|
|
43
|
-
It is a filter, not a secret.
|
|
43
|
+
It is a filter, not a secret. The `@anyreplay/capacitor`, `@anyreplay/cordova`,
|
|
44
|
+
`@anyreplay/electron` and `@anyreplay/tauri` packages (a preview) wrap this SDK
|
|
45
|
+
and read the app id for you.
|
|
44
46
|
|
|
45
47
|
Every session also reports `platform: 'web'` and this package's name and
|
|
46
48
|
version, so the dashboard can say what recorded it.
|
|
@@ -55,8 +57,8 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
|
|
|
55
57
|
| `createRecorder(options)` | Starts recording (or waits for consent) and returns a handle |
|
|
56
58
|
| `handle.track(name, properties?)` | Tags this moment; `name` is 1–64 chars of `[A-Za-z0-9_.:-]`, properties a JSON object ≤ 4 KB |
|
|
57
59
|
| `handle.trackError(error)` | Records an error you caught yourself, redacted, and marks the session as having one |
|
|
58
|
-
| `handle.identify({ userId?, email? })` | Attaches your user id to the session |
|
|
59
|
-
| `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)`
|
|
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
|
+
| `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) |
|
|
60
62
|
| `handle.status()` | `idle`, `awaiting-consent`, `recording`, `sampled-out` or `stopped` |
|
|
61
63
|
| `handle.flush()` | Sends what is buffered now; resolves when delivered |
|
|
62
64
|
| `handle.stop()` | Stops for this page |
|
|
@@ -79,8 +81,10 @@ one-time codes; card numbers, security codes, expiry dates and cardholder names
|
|
|
79
81
|
one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
|
|
80
82
|
passes the card-number check — and, because a number is typed one key at a
|
|
81
83
|
time, any value that could still become one: digits only, starting 2–6,
|
|
82
|
-
grouped like a card, more than six digits long.
|
|
83
|
-
|
|
84
|
+
grouped like a card, more than six digits long. A card number written inside
|
|
85
|
+
longer text ("my card is 4242 4242 …" in a note) has its digits starred and the
|
|
86
|
+
rest of the text kept. At most the first six digits of a card number are ever
|
|
87
|
+
recorded. Masking always happens in the page, before
|
|
84
88
|
anything is sent.
|
|
85
89
|
|
|
86
90
|
`type="email"` and `type="tel"` are not on that list. An address and a phone
|
package/dist/index.cjs
CHANGED
|
@@ -4198,6 +4198,39 @@ function shouldMaskValue(text, node, policy) {
|
|
|
4198
4198
|
return cardShaped(text);
|
|
4199
4199
|
}
|
|
4200
4200
|
var cardShaped = (text) => looksLikeCardNumber(text) || mayBecomeCardNumber(text);
|
|
4201
|
+
var DIGIT_RUN = /[0-9]+(?:[ -][0-9]+)*/g;
|
|
4202
|
+
function maskCardRuns(text) {
|
|
4203
|
+
if (!/[0-9]/.test(text)) return text;
|
|
4204
|
+
return text.replace(DIGIT_RUN, (run) => {
|
|
4205
|
+
const groups = run.split(/[ -]/);
|
|
4206
|
+
const starts = [];
|
|
4207
|
+
let at = 0;
|
|
4208
|
+
for (const group of groups) {
|
|
4209
|
+
starts.push(at);
|
|
4210
|
+
at += group.length + 1;
|
|
4211
|
+
}
|
|
4212
|
+
const chars2 = run.split("");
|
|
4213
|
+
let i = 0;
|
|
4214
|
+
while (i < groups.length) {
|
|
4215
|
+
let matched = -1;
|
|
4216
|
+
for (let j = groups.length - 1; j >= i; j -= 1) {
|
|
4217
|
+
const end2 = starts[j] + groups[j].length;
|
|
4218
|
+
if (cardShaped(run.slice(starts[i], end2).replace(/ /g, " "))) {
|
|
4219
|
+
matched = j;
|
|
4220
|
+
break;
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
if (matched < 0) {
|
|
4224
|
+
i += 1;
|
|
4225
|
+
continue;
|
|
4226
|
+
}
|
|
4227
|
+
const end = starts[matched] + groups[matched].length;
|
|
4228
|
+
for (let k = starts[i]; k < end; k += 1) if (chars2[k] !== " " && chars2[k] !== "\xA0" && chars2[k] !== "-") chars2[k] = "*";
|
|
4229
|
+
i = matched + 1;
|
|
4230
|
+
}
|
|
4231
|
+
return chars2.join("");
|
|
4232
|
+
});
|
|
4233
|
+
}
|
|
4201
4234
|
var EDITABLE = '[contenteditable]:not([contenteditable="false"])';
|
|
4202
4235
|
function insideEditable(node) {
|
|
4203
4236
|
var _a;
|
|
@@ -4208,6 +4241,7 @@ function insideEditable(node) {
|
|
|
4208
4241
|
var isEditableSerialised = (attributes) => attributes.contenteditable !== void 0 && attributes.contenteditable !== "false";
|
|
4209
4242
|
var typedCardShaped = (text) => cardShaped(text.replace(/\u00a0/g, " ").trim());
|
|
4210
4243
|
var maskText = (text) => text.replace(/\S/g, "*");
|
|
4244
|
+
var maskTyped = (text) => typedCardShaped(text) ? maskText(text) : maskCardRuns(text);
|
|
4211
4245
|
var MUTATION = 0;
|
|
4212
4246
|
var INPUT = 5;
|
|
4213
4247
|
var ELEMENT_NODE = 2;
|
|
@@ -4222,14 +4256,20 @@ function maskTextChildren(node) {
|
|
|
4222
4256
|
maskTextChildren(child);
|
|
4223
4257
|
}
|
|
4224
4258
|
}
|
|
4259
|
+
function maskCardRunsInChildren(node) {
|
|
4260
|
+
var _a;
|
|
4261
|
+
for (const child of (_a = node.childNodes) != null ? _a : []) {
|
|
4262
|
+
if (child.type === TEXT_NODE && typeof child.textContent === "string") child.textContent = maskCardRuns(child.textContent);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4225
4265
|
function scrubTree(node, policy, masked, editable = false) {
|
|
4226
4266
|
var _a, _b;
|
|
4227
4267
|
if (!node) return;
|
|
4228
4268
|
const attributes = (_a = node.attributes) != null ? _a : {};
|
|
4229
4269
|
const inheritedMask = masked || hasClass(attributes, policy.maskClass);
|
|
4230
4270
|
const inheritedEditable = editable || node.type === ELEMENT_NODE && isEditableSerialised(attributes);
|
|
4231
|
-
if (editable && node.type === TEXT_NODE && typeof node.textContent === "string"
|
|
4232
|
-
node.textContent =
|
|
4271
|
+
if (editable && node.type === TEXT_NODE && typeof node.textContent === "string") {
|
|
4272
|
+
node.textContent = maskTyped(node.textContent);
|
|
4233
4273
|
}
|
|
4234
4274
|
if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
|
|
4235
4275
|
const value = attributes.value;
|
|
@@ -4237,6 +4277,9 @@ function scrubTree(node, policy, masked, editable = false) {
|
|
|
4237
4277
|
if (shouldMask) {
|
|
4238
4278
|
if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
|
|
4239
4279
|
if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
|
|
4280
|
+
} else {
|
|
4281
|
+
if (typeof value === "string") attributes.value = maskCardRuns(value);
|
|
4282
|
+
if (node.tagName.toLowerCase() === "textarea") maskCardRunsInChildren(node);
|
|
4240
4283
|
}
|
|
4241
4284
|
}
|
|
4242
4285
|
for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask, inheritedEditable);
|
|
@@ -4255,7 +4298,7 @@ function scrubEvent(event, getNode, policy) {
|
|
|
4255
4298
|
const node = typeof data.id === "number" ? getNode(data.id) : null;
|
|
4256
4299
|
const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
|
|
4257
4300
|
if (type === "checkbox" || type === "radio") return;
|
|
4258
|
-
|
|
4301
|
+
data.text = shouldMaskValue(data.text, node, policy) ? maskValue(data.text) : maskCardRuns(data.text);
|
|
4259
4302
|
return;
|
|
4260
4303
|
}
|
|
4261
4304
|
if (data.source !== MUTATION) return;
|
|
@@ -4263,15 +4306,15 @@ function scrubEvent(event, getNode, policy) {
|
|
|
4263
4306
|
const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
|
|
4264
4307
|
if (typeof value !== "string" || value === "") continue;
|
|
4265
4308
|
const node = typeof entry.id === "number" ? getNode(entry.id) : null;
|
|
4266
|
-
|
|
4309
|
+
entry.attributes.value = shouldMaskValue(value, node, policy) ? maskValue(value) : maskCardRuns(value);
|
|
4267
4310
|
}
|
|
4268
4311
|
for (const add of (_e = data.adds) != null ? _e : []) {
|
|
4269
4312
|
const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
|
|
4270
4313
|
scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass), insideEditable(parent));
|
|
4271
4314
|
}
|
|
4272
4315
|
for (const change of (_f = data.texts) != null ? _f : []) {
|
|
4273
|
-
if (typeof (change == null ? void 0 : change.value) !== "string" ||
|
|
4274
|
-
if (typeof change.id === "number" && insideEditable(getNode(change.id))) change.value =
|
|
4316
|
+
if (typeof (change == null ? void 0 : change.value) !== "string" || !/[0-9]/.test(change.value)) continue;
|
|
4317
|
+
if (typeof change.id === "number" && insideEditable(getNode(change.id))) change.value = maskTyped(change.value);
|
|
4275
4318
|
}
|
|
4276
4319
|
}
|
|
4277
4320
|
|
|
@@ -5077,7 +5120,7 @@ var Transport = class {
|
|
|
5077
5120
|
|
|
5078
5121
|
// src/version.ts
|
|
5079
5122
|
var SDK_NAME = "@anyreplay/browser";
|
|
5080
|
-
var SDK_VERSION = true ? "0.2.
|
|
5123
|
+
var SDK_VERSION = true ? "0.2.3" : "0.0.0-dev";
|
|
5081
5124
|
var PLATFORM = "web";
|
|
5082
5125
|
|
|
5083
5126
|
// src/visitor.ts
|
|
@@ -5203,7 +5246,10 @@ function createRecorder(rawOptions, shell) {
|
|
|
5203
5246
|
let assets;
|
|
5204
5247
|
let pageCount = 0;
|
|
5205
5248
|
let pendingTraits;
|
|
5206
|
-
|
|
5249
|
+
let traitsAwaitingSession;
|
|
5250
|
+
let traitsToRetry;
|
|
5251
|
+
let sessionDelivered = false;
|
|
5252
|
+
const postIdentity = (who, traits, retryIfUnapplied) => {
|
|
5207
5253
|
void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
|
|
5208
5254
|
method: "POST",
|
|
5209
5255
|
headers: { "Content-Type": "application/json" },
|
|
@@ -5214,9 +5260,33 @@ function createRecorder(rawOptions, shell) {
|
|
|
5214
5260
|
...traits,
|
|
5215
5261
|
...options.appId ? { appId: options.appId } : {}
|
|
5216
5262
|
})
|
|
5263
|
+
}).then(async (response) => {
|
|
5264
|
+
if (!retryIfUnapplied || response.status !== 202) return;
|
|
5265
|
+
const reply = await response.json();
|
|
5266
|
+
if (reply.applied === false && identity === who && !traitsAwaitingSession && !traitsToRetry) {
|
|
5267
|
+
traitsToRetry = traits;
|
|
5268
|
+
}
|
|
5217
5269
|
}).catch(() => {
|
|
5218
5270
|
});
|
|
5219
5271
|
};
|
|
5272
|
+
const sendIdentity = (who, traits) => {
|
|
5273
|
+
traitsToRetry = void 0;
|
|
5274
|
+
if (!sessionDelivered) {
|
|
5275
|
+
traitsAwaitingSession = { ...traitsAwaitingSession, ...traits };
|
|
5276
|
+
return;
|
|
5277
|
+
}
|
|
5278
|
+
postIdentity(who, traits, true);
|
|
5279
|
+
};
|
|
5280
|
+
const noteDelivered = (channel) => {
|
|
5281
|
+
if (channel !== transport || !identity) return;
|
|
5282
|
+
sessionDelivered = true;
|
|
5283
|
+
const waiting = traitsAwaitingSession;
|
|
5284
|
+
const retry = traitsToRetry;
|
|
5285
|
+
traitsAwaitingSession = void 0;
|
|
5286
|
+
traitsToRetry = void 0;
|
|
5287
|
+
if (waiting) postIdentity(identity, waiting, true);
|
|
5288
|
+
else if (retry) postIdentity(identity, retry, false);
|
|
5289
|
+
};
|
|
5220
5290
|
const collectMeta = (who) => {
|
|
5221
5291
|
var _a, _b;
|
|
5222
5292
|
return {
|
|
@@ -5325,7 +5395,15 @@ function createRecorder(rawOptions, shell) {
|
|
|
5325
5395
|
initialSeq: who.nextSeq
|
|
5326
5396
|
},
|
|
5327
5397
|
{
|
|
5328
|
-
|
|
5398
|
+
onSent: () => noteDelivered(channel),
|
|
5399
|
+
onSeqReserved: (next) => {
|
|
5400
|
+
var _a;
|
|
5401
|
+
reserveSeq(session, next);
|
|
5402
|
+
try {
|
|
5403
|
+
(_a = shell == null ? void 0 : shell.onStateChanged) == null ? void 0 : _a.call(shell);
|
|
5404
|
+
} catch (e) {
|
|
5405
|
+
}
|
|
5406
|
+
},
|
|
5329
5407
|
onStopped: (reason) => {
|
|
5330
5408
|
status = "stopped";
|
|
5331
5409
|
stopRecording == null ? void 0 : stopRecording();
|
|
@@ -5461,6 +5539,8 @@ function createRecorder(rawOptions, shell) {
|
|
|
5461
5539
|
identity = void 0;
|
|
5462
5540
|
}
|
|
5463
5541
|
pendingTraits = void 0;
|
|
5542
|
+
traitsAwaitingSession = void 0;
|
|
5543
|
+
traitsToRetry = void 0;
|
|
5464
5544
|
pending.length = 0;
|
|
5465
5545
|
forgetVisitor(local, session);
|
|
5466
5546
|
},
|