Privacy

Privacy: please read this part

A screenshot of your application contains whatever the reporter could see. In a clinical system that can mean a patient photograph; in a payroll tool, a salary; in yours, perhaps somebody's inbox or a half-written message they had not sent yet.

Three things follow, and the library cannot do them for you:

  1. Put screenshots somewhere private. If your object storage bucket has a public read policy — many media buckets do — anything you write to it can be fetched by anyone holding the URL. Use a separate bucket with no public policy.
  2. Serve them back through an authenticated route. Never give a screenshot a public URL. Look the storage key up from the row rather than taking it from the request, so an id cannot be used to walk your bucket.
  3. Say so before the picture is taken. Put it in the form, next to the checkbox — not in a policy nobody opens.

Requiring people to be signed in is worth considering too. An anonymous screenshot is one nobody can be asked about later, and nobody can be told has been deleted.

The optional contact field is personal data you asked for: store it like one — keep it where the rest of the report is kept, delete it when the report goes, and pass scrubReport(report, { contact: true }) if reports end up anywhere more public than the inbox.

The context is the mild part of a report by comparison. It is the page path and query, the viewport, the user agent, and — when the browser offers them — the language, the time zone, the screen size and pixel ratio, the colour scheme, whether the browser thought it was online, and the effective connection type. Together they say which environment the bug happened in; none of them says more about the person than the user agent already does, and nothing is collected beyond that list: no canvas, no fonts, no device enumeration, no identifier of any kind. The origin and the fragment of the URL are still left out.

A privacy checklist is the field-by-field version of this section: what each field of a report can carry, what turns it off, how long it is kept, and a paragraph to adapt for your privacy policy. It is on the site in Danish too, as Privatliv i bugbottle.

Masking #

Screenshots are masked before they are taken. Every input and textarea value becomes bullets of the same length, placeholders are cleared, and contenteditable text is bulleted too — so a picture of a checkout or an intake form shows a filled-in form of the right shape without carrying the card number or the diagnosis. Two attributes, borrowed from rrweb so a team that already annotated its templates does not annotate them twice:

html
<p data-bugbottle-mask>Ada Lovelace, born 1815</p>   <!-- text -> bullets -->
<div data-bugbottle-block><canvas id="revenue"></canvas></div>  <!-- covered -->

data-bugbottle-mask bullets the text of the element and everything inside it. data-bugbottle-block covers the element with a solid rectangle of its exact size, for a region whose shape says as much as its text: a chart, a photograph, an avatar. The colour is the element's --bb-mask custom property, or #999.

The element stays where it is either way, so the layout of the screenshot is unchanged. That is the difference from exclude, which removes the node and takes the layout with it.

It is on by default and restored the moment the renderer returns, including when it throws. Narrow it or switch it off per capture:

ts
captureScreenshot(htmlToImage, {
  mask: { inputs: true, selector: "[data-private]", block: false },
});
captureScreenshot(htmlToImage, { mask: false });   // the page as it is

useBugReport({ mask }) and mountBugbottle({ mask }) pass the same option through, and the script tag switches it off with data-mask="off".

Masking is not encryption and it is not a substitute for the three points above: it hides the fields it knows about, and a value your application paints into a div is only hidden if you mark it. Buttons, checkboxes and the other inputs that hold no typed text are left readable on purpose, because a screenshot of a form with every label blacked out helps nobody.

Three limits worth knowing before you rely on it:

Scrubbing #

The text of a report is written in a hurry, and it arrives carrying whatever was on the clipboard: the failing request, the token somebody was debugging with, a customer's email address. The page URL brings its own ?token=. scrubReport walks a report and replaces those with [redacted].

ts
import { buildReport, sendReport, scrubReport } from "bugbottle";

const report = buildReport({ type: "bug", message, scrub: scrubReport });
await sendReport("/api/feedback", report);

The same function works in the route handler, which is the safer place to put it — it also covers reports from an older client:

ts
import { scrubReport } from "bugbottle/server";

const clean = scrubReport(await request.json());

On by default: email addresses, Bearer <token>, JWTs (eyJ…), 13 to 19 digit card numbers that pass the Luhn check, IBANs, and query values whose key matches /token|key|secret|password|auth/i. They are applied to message, console[].message, context.url, elements[].text, an element's href and data-* attributes, and breadcrumbs if you add them. The pixels of the screenshot are a separate job, done by the masking below.

An order number of 16 digits is kept, because it fails Luhn. Prose that happens to say key=value is kept, because the query pattern only runs on URLs.

scrubReport(report, options) takes patterns (extra global regexes, redacted whole), keep (built-ins to switch off by name), replacement, and contact:

ts
scrubReport(report, {
  patterns: [/\bACME-\d+\b/g],
  keep: ["email"],          // "email" | "bearer" | "jwt" | "card" | "iban" | "query"
  replacement: "[redacted]",
  contact: true,            // redact the contact line, whole. Off by default.
});

contact is the one scrubber that is off unless you ask, and the only one that replaces a whole field rather than what matched: an address the reporter typed into a field asking for one is not a leak, and redacting it by default would break the feature it belongs to — but a phone number or a handle is not caught by any pattern, so when reports go somewhere more public than the inbox, the line goes whole or not at all.

The scrubber is its own module and nothing else imports it, so a bundle that does not use it does not carry it. Pattern matching is not a guarantee: it catches the shapes it knows, and the reporter can still type something no regex recognises. Treat it as one layer, not as the reason it is safe to store the report anywhere.

beforeSend #

The last look at a report before it leaves the browser. Return it, return a changed copy, or return null to drop it — nothing is requested, and sendReport resolves { dropped: true, body: null, response: null }. The name and the contract are Sentry's.

ts
useBugReport({
  endpoint: "/api/feedback",
  scrub: scrubReport,
  beforeSend: (report) => {
    if (report.message.includes("password")) return null;   // dropped silently
    return { ...report, tenant: currentTenant };
  },
});

The reporter sees the ordinary thank-you either way. They wrote the report in good faith, and a message telling them it was discarded helps nobody. If you want to know, count it yourself inside the hook.

Edit this page on GitHub