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:
- 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.
- 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.
- 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:
<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:
captureScreenshot(htmlToImage, {
mask: { inputs: true, selector: "[data-private]", block: false },
});
captureScreenshot(htmlToImage, { mask: false }); // the page as it isuseBugReport({ 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:
- A closed shadow root cannot be masked. Masking walks open shadow roots,
so an input inside an ordinary web component is covered. A component that
attached its root with
{ mode: "closed" }exposes noshadowRootto walk, and nothing inside it can be reached — by us or by anything else. Useexcludeon the host element instead. - A focused textarea loses its caret position during the capture. Swapping the value for bullets and back moves the selection to the end of the field. The text is unchanged; the cursor is not where it was.
- A blocked replaced element is hidden rather than overlaid. An
img,canvas,video,iframe,input,embed,objectorsvgrenders no children, sodata-bugbottle-blockhides the element for the length of the render and draws the rectangle over the box it occupied. The layout still holds, but the covered area is a flat rectangle rather than the element with a rectangle on top of 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].
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:
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:
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.
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.