Sessions · Guides
Mask what a recording shows
A recording is masked in the browser before anything leaves it, and checked again when it arrives. Only your own UI strings stay readable.
What is masked
A recording is masked unless its token says mask: false in so many words. Masking fails closed: when in doubt, a word is starred. In a masked recording:
- Every input's value is masked, and typing shows as stars. Hidden and file inputs' values are dropped.
- Images, video, audio, canvas, frames and every
svgthat isn'taria-hiddenare blocked. - Inside a private part, every word is masked, and a control's checked or selected state is dropped.
- Other text stays only if it's one of your UI strings. Digits alone are always masked.
- Attributes are allow-listed. URLs keep their path, never the
?queryor#hash;mailto:andtel:links keep only the scheme. - Ids are taken out of every URL and attribute. A frame's document, console logs, canvas content and fonts never leave the browser, and an event the SDK can't read is dropped.
The layout keeps its shape: a masked word becomes as many stars as it had characters, so you can still see what was where.
Keep your UI strings readable
Give the recorder your UI strings as its dictionary: an array, nested objects such as i18next's resources (one per language), or a function returning either, read once on first use. A text matches whatever its case and spacing. A template such as {{count}} open keeps its own words and masks what was filled in.
uiStrings() collects the strings from your translation files, and leaves out placeholders, made-up examples and whatever its skip pattern names:
import { createRecorder, uiStrings } from '@mybeast/sessions';
import en from './lang/en.json';
import fi from './lang/fi.json';
const recorder = createRecorder(client, {
// Every string of your translations, without the ones that would say
// something about the person they're shown beside.
dictionary: () => uiStrings([en, fi], { skip: /greeting|display_name/ }),
});Leave out any string that would say something about the person it's shown beside, such as a greeting with their name or a status only some people have. For your own judgement on top, readable(text) is asked before the dictionary: return true to keep a text as it is.
Private and blocked parts
<!-- Every word inside is masked, your UI strings too. -->
<section data-mybeast-private>
<h2>Billing address</h2>
…
</section>
<!-- Only an empty box of the same size is recorded. -->
<div data-mybeast-block class="revenue-chart">…</div>data-mybeast-private(ortranslate="no") masks every word inside the element, dictionary words too. Add your own selectors withprivateSelectors.data-mybeast-blockkeeps only an empty box of the same size. Add your own selectors withblockSelectors.
URLs and ids
UUIDs, ULIDs and runs of digits in a URL, an attribute or a message become :id. Add your own with idPatterns, such as /INV-\d{4}-\d+/ for invoice numbers.
By default, any lowercase word in a path stays. If your URLs carry name slugs, such as /u/liisa-virtanen, set routeWords to the segments that may stay, as a list or a function.
Checked again on arrival
Sessions never trusts the browser's masking alone. Before anything of a masked recording is stored, the service looks at the whole chunk and refuses it if it still holds one of these:
| Kind | Meaning |
|---|---|
email | An e-mail address. |
personal-id | A Finnish personal id (henkilötunnus), with a valid check character. |
iban | An IBAN of a European country or a neighbour, with valid check digits. |
phone | A phone number in international form: +, then 8 to 15 digits. |
card | A payment card number written in groups, that passes the Luhn check. |
The browser gets 422 with {"refused": "leak", "kinds": […]} and drops the chunk; nothing of it is kept. The refusal is written in your project's audit log with the kinds found, never the values. The examples pages often show, such as name@example.com, are let through.
A leak is most often one of your own UI strings in the dictionary, such as a support address or phone number. Take it out of the dictionary, or make it private.
Where masking is required
In Sessions, the project's Settings → Masked in lists its environments. A production environment is always ticked and can't be unticked: unmasked, a recording keeps what people type. Tick any other environment to require masking there too.
Where masking isn't required, clientConfig(mask: false) records without masking, for debugging on your own machine. Unmasked, a recording hides only password fields and data-mybeast-block. Sessions refuses an unmasked recording in any environment where masking is required (403, mask-required), so none is ever kept unmasked there.
Never unmasked where real people are
mask: false only for your own test accounts, in an environment no customer uses.Check your masking before you ship
A deploy check can record a synthetic person, made with clientConfig(..., test: true), and read back what Sessions stored, to search it for that person's real values:
// A deploy check: a browser run has recorded the canary person, made with test: true.
$recordings = Sessions::testRecordings($canary->id);
foreach ($recordings as $recording) {
if ($recording['unreadable_chunks'] > 0) {
throw new RuntimeException('Some chunks could not be read, so they were not checked.');
}
foreach ($recording['chunks'] as $chunk) {
if (str_contains($chunk, $canary->email) || str_contains($chunk, $canary->last_name)) {
throw new RuntimeException('The masking let one of the canary\'s values through.');
}
}
}testRecordings() needs the server secret. Only recordings made under test: true come back, never another of the person's or anyone else's, newest first, each chunk the events' JSON exactly as the browser sent it. Count unreadable_chunks as a failure. It throws ExportFailed rather than return part of them, for example past 200 recordings or 32 MiB of events: then erase the test person's recordings and record again. Each read-back is written in the audit log.
More on the methods: the Sessions facade and the recorder's options.