Privacy guide

Session replay without recording personal data

A session replay looks like a screen recording, but it's a copy of the page itself: every piece of text, every attribute, every value typed into a form. That's what makes it useful for debugging, and why it's the riskiest thing an error tool can collect. Here's what gets recorded, where personal data hides in it, and how to mask it.

A replay is the page, not a video

Session replay doesn't record pixels. The recorder takes a snapshot of the page's DOM, then records every change to it, along with clicks, mouse movement, scrolling and resizing, and the player rebuilds the page from that. Some tools record console output and network requests alongside. Each of those can carry personal data:

  • Rendered text: names, addresses, balances, messages, search results, anything the page displays
  • Form inputs, as they're typed, including fields that are never submitted
  • Attributes: placeholder, alt and title text, an aria-label, a data- attribute holding a record's details, an href with an email or an id in it
  • Checked boxes and selected options, which can say as much as text: a state, a plan, an answer to a health question
  • The page's URL, query string included
  • Images, which a replay usually shows by loading them again from their URL: an avatar, a scanned document
  • Network requests and console output, in tools that record them: headers, bodies and whatever was logged

Masking strategies

There are a handful of ways to keep personal data out of a recording, and they aren't equally safe:

  • Mask all text by default, replacing characters while keeping the layout, and unmask the parts that are safe
  • Mask every input's value, its length included, with no exceptions for fields that look harmless
  • Block sensitive elements entirely, recording an empty box of the same size instead of their contents
  • Mask by selector: list the elements that hold personal data, and mask only those
  • Mask attributes as well as text, or drop the ones that can carry data
  • Leave network bodies and headers out, or record them only for a list of endpoints you've checked
  • Record less: only around an error, for a short window, and keep a recording no longer than the error it explains

Masking by selector is the one that fails. It works the day it's set up, and then a new component shows a name somewhere nobody listed. Masking everything by default fails the other way: something you wanted to see shows as bullets, which you notice and fix.

What the ForgeOps recorder masks

The ForgeOps browser recorder masks first, with no setting to turn masking off. Before anything is recorded:

  • Every text node is recorded with each character that isn't whitespace replaced by a bullet, so the layout survives and the words don't.
  • The value of every text input and textarea, password fields included, is recorded as one fixed placeholder of eight bullets, whatever was typed, so a card number and a two letter answer look the same.
  • Checkboxes and radio buttons are recorded as unchecked, whatever was ticked, and a select's chosen option as the same eight bullets, with no option marked as selected.
  • Text-bearing attributes are masked the same way as text: placeholder, title, alt, label, aria-label, aria-description, aria-placeholder and aria-valuetext, the value attribute of buttons, checkboxes, radios and options, a meta tag's content, and every data- attribute except the recorder's own data-forgeops- ones.
  • Every address keeps only its scheme, host, port and path: the page's address sent with the recording, and every href, src, srcset, action, formaction, poster and cite in it. The query string, the fragment and any user name and password are never recorded. ForgeOps cuts the page's address down the same way on arrival, so a recording from an older SDK is stored without them too.

JavaScript, Node (mask.mjs)

// The masking rules the ForgeOps browser recorder applies, runnable on their own.

// Text on the page, and text-bearing attributes such as placeholder, title, alt and aria-label:
// every character that isn't whitespace becomes a bullet, so the layout survives and the words don't.
const maskText = (text) => text.replace(/\S/g, "•");

// A text input or textarea, and a select's chosen option: one fixed placeholder, whatever it
// holds, so not even the length is recorded. An empty field stays empty.
const maskInputValue = (value) => (value ? "••••••••" : "");

// Every address: only the scheme, host, port and path. No query, fragment or credentials.
function addressWithoutQuery(address) {
  const text = address.trim();
  const hierarchical = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)([^/?#]*)([^?#]*)/i.exec(text);
  if (hierarchical) {
    const host = (hierarchical[2] ?? "").replace(/^.*@/, "");
    return `${hierarchical[1] ?? ""}${host}${hierarchical[3] ?? ""}`;
  }
  const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(text)?.[1];
  if (scheme !== undefined) {
    return scheme.toLowerCase() === "data" ? text : `${scheme}:`;
  }
  return text.split(/[?#]/)[0] ?? "";
}

console.log(maskText("Jane Doe, 14 Elm Street"));
console.log(maskText("Balance: $1,204.50"));
console.log(maskInputValue("4242 4242 4242 4242"));
console.log(maskInputValue("jo"));
console.log(JSON.stringify(maskInputValue("")));
console.log(addressWithoutQuery("https://app.example.com/patients/42?email=jane@example.com#notes"));
console.log(addressWithoutQuery("https://admin:hunter2@app.example.com/reports"));
console.log(addressWithoutQuery("/search?q=jane+doe"));
console.log(addressWithoutQuery("mailto:jane@example.com"));

It prints •••• •••• •• ••• •••••• and •••••••• ••••••••• for the two lines of text, eight bullets for both inputs and an empty string for the empty one, then https://app.example.com/patients/42, https://app.example.com/reports, /search and mailto: for the four addresses. A mailto:, tel:, sms: or javascript: address keeps only its scheme, since everything after it is the person or the code. Script, style and noscript elements and HTML comments aren't recorded at all.

What's still recorded

Be clear about what's recorded as it is, because that's where the remaining risk is:

  • Tag names, ids, class names, inline styles, and each element's name, type and role, along with any other attribute not listed above. Keep customers' details out of ids and class names on pages you record.
  • The path of every address, in full. /patients/42 is fine, but /patients/jane-doe records a name, so keep personal data out of your paths: ids, not names or emails.
  • An image written into the page as a data: address, which is kept whole because it's the image itself.
  • The page's layout, including how long each masked piece of text is, and mouse movement, clicks, scrolling and the window's size.
  • Anything inside an element marked data-forgeops-unmask="true", as described below.
  • There's no option to block an element entirely. It's recorded masked, at its real size.

Content inside an iframe isn't recorded, so a payment form or an identity check that another company serves in an iframe never appears in a replay.

Unmask on purpose

Fully masked replays can be hard to follow: you watch someone click a bulleted button and can't tell which one. The fix is to unmask the parts of the page that are the same for everyone, such as navigation, headings, button labels and help text, and nothing that comes from a customer's data. Add data-forgeops-unmask="true" to an element, and it and everything inside it are recorded as they are:

HTML

<nav data-forgeops-unmask="true">
  <a href="/dashboard">Dashboard</a>
  <a href="/statements">Statements</a>
  <button type="button">Help</button>
</nav>

<section class="account-summary">
  <h2>Jane Doe</h2>
  <p>Balance: $1,204.50</p>
</section>

<form action="/payments" method="post">
  <label for="amount">Amount</label>
  <input id="amount" name="amount" inputmode="decimal">
  <button type="submit">Pay</button>
</form>

Here the navigation is recorded as it is, while the account summary, the form's label and its button stay bullets and the amount is one fixed placeholder.

Unmasking is inherited, and it reveals everything inside the element: its text, its text-bearing attributes, which boxes are ticked and which option is chosen, and each form field's value attribute. Some frameworks, React among them, keep a field's value attribute in step with what's typed, so in those apps what someone types into a field inside an unmasked element can be recorded. Put the attribute on the smallest element that's safe to show, and never on a form, a layout wrapper or the body. Addresses are cut down to their path whether an element is unmasked or not.

When it's recorded, and how long it's kept

Recording less is a privacy measure too. The ForgeOps recorder keeps its buffer in memory and uploads nothing on its own: a recording is sent only when an error is reported on that page, attached to that error, and a visit that never errors never uploads anything. The buffer starts again from a fresh snapshot every 15 seconds, or sooner after 300 recorded events, so a recording shows the moments before the error rather than the whole visit.

A recording is kept exactly as long as the error it belongs to, under your plan's retention, and removed from storage along with it. It's played back in a sandboxed frame with scripts disabled, so nothing in a recorded page can run. Session replay is off by default in the SDK, and it's on the Enterprise plan:

TypeScript, in the browser

import * as forgeOpsTracker from "@forge-ops/tracker-web";

forgeOpsTracker.init({
  dsn: "https://<api_key>@getforgeops.net/api/v1/events",
  sessionReplay: true, // default false
});

Network requests and the console

A ForgeOps recording doesn't include network requests or console output: it's the page's DOM and the user's clicks, mouse movement, scrolling and resizing. The error a recording is attached to does carry breadcrumbs, a separate trail of console output, navigations, clicks and the method and URL of each fetch or XHR request, never request or response bodies or headers. Unlike a recording, breadcrumbs keep each navigation's and request's full address, query string included. Breadcrumbs are scrubbed for emails, card numbers, known secret formats and sensitive field names in the SDK and again on arrival.

If your pages log customer data to the console, or put it in query strings, remove it, or turn breadcrumbs off with breadcrumbs: false in init().

When the screen itself is sensitive

Some screens can't be recorded safely however well they're masked: a patient's chart, a list of transactions, a medical questionnaire. The layout alone can say too much. For those apps, turn on strict privacy. A strict browser SDK never records a session, whatever sessionReplay says, and ForgeOps won't keep a recording for a strict privacy project even if one arrives from an older SDK.

TypeScript, in the browser

import * as forgeOpsTracker from "@forge-ops/tracker-web";

forgeOpsTracker.init({
  dsn: "https://<api_key>@getforgeops.net/api/v1/events",
  sessionReplay: true, // ignored: nothing is recorded under strict privacy
  strictPrivacy: true,
  privacyAllowedFields: ["error_code", "feature"],
});

The React Native SDK doesn't record sessions at all. Whichever you choose, test it: open a recorded page with made-up customer data in staging, trigger an error, and watch the replay for anything you can read.

Session replay in ForgeOps, in short

Off by default, on the Enterprise plan, and uploaded only with an error. Text and text-bearing attributes are masked with a bullet per character, input values and the chosen option with one fixed placeholder, and checkboxes and radio buttons are recorded as unchecked, always. Every address keeps only its scheme, host, port and path. data-forgeops-unmask="true" is the only way to show an element as it is, and iframes aren't recorded.

Recordings are kept as long as the error they belong to and played back with scripts disabled. Strict privacy turns recording off in the SDK and refuses recordings on arrival, and the React Native SDK doesn't record sessions.

Read more:

More guides

Try it on your own project

Start free, and turn on strict privacy in the project's settings before your first error arrives.