Sessions · Reference
Browser SDK
@mybeast/sessions records your pages with rrweb, masks them before anything leaves the browser, and sends them in compressed chunks while the person's yes holds.
Install
npm install @mybeast/sessionsPlain ES modules, with no build step and no framework of its own: it works on any page, React or not. It brings @rrweb/record, pinned to one version. Pages need an evergreen browser; without CompressionStream the recorder does nothing. Build with Node 20 or later.
rrweb is about 160 KB, and only the recorder imports it. To keep it out of your main bundle, import the SDK later:
// Keep rrweb out of your main bundle: load the recorder once the page has loaded.
window.addEventListener('load', async () => {
const { init, createRecorder } = await import('@mybeast/sessions');
createRecorder(init(config), options).start();
});init and the recorder
import { init, createRecorder } from '@mybeast/sessions';
// config: what your server made with Sessions::clientConfig(): {url, key, env, token}
const client = init(config); // null when anything is malformed: the recorder then does nothing
const recorder = createRecorder(client, {
getToken: async () => (await fetch('/sessions/token')).text(), // your route: a fresh token, or null
dictionary: () => messages, // the only words a masked recording keeps readable
currentPage: () => currentRouteName, // your page name for the page marks
onChange: (state) => {}, // {status, recording, upload}
});
recorder.start();
// Each response that brings a new token, before the new page is drawn:
recorder.setToken(newToken);
// A page change without a page load:
recorder.pageChanged('Invoice/Show');init(config) reads what your server made with Sessions::clientConfig(). It returns null when anything is malformed: a service URL that isn't https (plain http only on localhost, 127.0.0.1, *.localhost and *.test), or a key that isn't a public key. A token for another project, or one it can't read, counts as no token.
createRecorder(client, options) makes the recorder of one page load: call it once. With a null client it gives a recorder that does nothing, so your code needs no checks.
Options
All optional.
| Option | Default | What it does |
|---|---|---|
getToken() | none | Async; returns a fresh token, or null. Asked on a 401 and two minutes before a token runs out. Without it, a 401 stops recording until setToken(). |
idleMinutes | 30 | With no input for this long, recording pauses. The next input starts a new recording. |
maxMinutes | 120 | The longest one recording lasts; then the next one starts. |
dictionary | none | Your UI strings, the only words a masked recording keeps readable: an array, nested objects (i18next resources), or a function returning either. |
readable(text) | none | Your own judge, asked before the dictionary: true keeps a text as it is. |
privateSelectors | [data-mybeast-private], [translate="no"] | Added to the defaults. Inside, every word is masked, your UI strings too. |
blockSelectors | media, frames, canvas, svg that isn't aria-hidden, [data-mybeast-block] | Added to the defaults. The recording keeps an empty box of the same size. |
keepAttributes | structural and ARIA ones, data-state and the like | Attribute names whose values stay, for your CSS. Others go through the dictionary. |
idPatterns | UUIDs, ULIDs, digits | RegExps of your own ids (/INV-\d{4}-\d+/): replaced with :id in URLs, attributes and messages. |
routeWords | any lowercase word | The path segments that stay, as a list or a function. Set it when your URLs carry name slugs (/u/liisa-virtanen). |
currentPage() | none | Your page name for the page mark at each page load. |
onChange(state) | none | Hears every change of state(). |
tagRequests | true | While recording, your page's own fetch and XMLHttpRequest calls to its own origin carry X-Recording-Id and X-Recording-At. false leaves them alone. |
The masking options are explained in Mask what a recording shows.
The recorder's methods
| Method | What it does |
|---|---|
start() | Records this page and the next ones while the token allows it: rec and a consent_at. |
stop() | Before a page that isn't to be recorded, or on sign-out. What was recorded is still sent. |
setToken(token) | A fresh token. Another person or account starts a new recording, and so does another mask. A token without rec stops recording until one with it comes. |
pageChanged(name, url) | A page change without a page load: a page mark, and what is buffered goes out. |
mark(tag, payload) | A mark of your own, tag at most 32 characters. In a masked recording its strings go through the dictionary. |
flush() | Sends what is buffered now. |
currentRecording() | {id, startedAt} while recording, else null. Pass it to @mybeast/errors to link an error to its moment. |
requestHeaders() | {'X-Recording-Id', 'X-Recording-At'} while recording, else {}: for requests the recorder doesn't tag itself. |
state() | {status, recording, upload}. status is off, waiting, recording, paused or refused. |
What it sends and when
- One recording per tab, carried across full page loads. A duplicated tab takes its own. A new recording starts after
idleMinuteswithout input, aftermaxMinutes, for another person or account, and for anothermask. - A chunk every 5 seconds and on every page change, one request at a time: the compressed JSON of rrweb's events, with the token it was recorded under.
- As the page goes away, the last chunk rides a
keepaliverequest when it's under 60,000 bytes. What couldn't be sent waits in the tab for the next page, without its token. - Before the yes, nothing: while the browser's clock is behind the token's
consent_at, recording waits.
Each chunk is a plain request, with no cookies and no headers of your page's:
POST {url}/v1/parts/{recording}?key=pk_…&t=<token>&doc=<n>&seq=<n>While recording, your page's own fetch and XMLHttpRequest calls to its own origin carry X-Recording-Id and X-Recording-At (milliseconds into the recording), never a request to another origin or to Sessions. A header your page set itself is kept.
It never breaks your page
The service's answers
| Answer | Means | The recorder |
|---|---|---|
| 204 | Stored, or stored before. | Goes on. |
| 401 | No token, or it doesn't hold: ran out, or signed with another key. | Asks getToken() for a fresh one and sends again; with none, stops until setToken(). |
| 402 | The project's subscription has ended. | Stops recording on this page. |
| 403 | Refused: the body's refused says why. | Stops recording on this page. |
403 before-consent | The chunk starts more than 5 seconds before the yes. | Drops that chunk only, and waits for the browser's clock to pass the yes. |
| 404 | The recording is another person's. | Starts a new recording. |
| 409 | The recording was made for another account, masked the other way, or with another test mark. | Starts a new recording. |
| 413 | Too big. | Splits the chunk and sends the halves. |
| 422 | Not a gzip of events, or a masked chunk that still holds personal data (refused: leak). | Drops the chunk. |
| 429, 5xx | Busy or down. | Tries again on the next beat, 3 times. |
| No answer | The page's origin isn't listed, a blocker, or no network. | Tries again; after 3 chunks in a row with no answer, stops recording on this page. |
A 403's body says why, in refused:
| refused | Meaning |
|---|---|
not-recording | The token doesn't allow recording: it has no consent_at. |
mask-required | An unmasked token (mask: false) for an environment the project requires masking in. |
monthly-limit | A new recording past the project's recordings this month. A recording already going on goes on. |
erased | The recording was deleted, or its person's or account's recordings erased, while the tab still recorded. |
before-consent | The chunk starts more than 5 seconds before the moment the person said yes. |
The last answer is in state().upload, and answerFor(status, refused) is exported.
Marks
The recorder marks moments in the recording on its own: env at each page load (path, screen size, language, theme, whether the browser translated it, whether it's masked), page, press (a control's spoken label), dialog (opened or closed, with its title), hidden and visible, pagehide, and error (the browser's errors and unhandled rejections: the name and masked message only).
Add your own with mark(tag, payload). A host that knows its requests and forms can add visit, refused and broken; visitPayload() and refusedPayload() build their payloads:
import { refusedPayload } from '@mybeast/sessions';
// The server sent a form back: the refused fields' names, and the first message,
// which a masked recording keeps only when it's an i18n key such as 'rules.email'.
recorder.mark('refused', refusedPayload(errors));The masking helpers
@mybeast/sessions/mask is the masking alone, without rrweb: mask your own telemetry the same way as the recordings, such as an error report's button labels. Both entry points export uiStrings, buildUiMasker, createTextMask, createMask, maskUrl, dropIds, scrubFreeText, star, urlRules, PRIVATE_SELECTORS, BLOCK_SELECTORS and EXAMPLE_KEY.
TypeScript
The types are in the package, for @mybeast/sessions and @mybeast/sessions/mask: SessionsConfig, SessionsClient, RecorderOptions, Recorder, RecorderState, CurrentRecording, UploadAnswer, Dictionary and the rest. Nothing to install.