About

A privacy checklist

Installing bugbottle raises three questions before a lawyer asks them: what does it collect, where does it go, and how long is it kept. This section answers them field by field, against the code rather than against a promise, so that the inventory you hand to whoever writes your policy is one you can check. It is not legal advice; it is the list of facts a piece of legal advice would need.

The second question has the shortest answer, so it goes first: a report goes to the endpoint you wrote, and nowhere else. There is no bugbottle server, no account and no key to register. The library is one fetch to a URL you own, and from there the report ends up wherever your store and your sinks put it — your database, your directory, your Slack, your issue tracker. Nothing in this package speaks to a host you did not name.

What a report can carry #

Every row below is a top-level field of the payload. "On by default" means: with the panel or the hook mounted and no further option set. The switches are the ones that keep a field out of the report in the first place; scrubbing, beforeSend and the receiver's own settings come after it, and are listed here only where they are the main control.

Field On by default Can hold personal data The switch that turns it off Where it ends up
type Always No — one of bug, idea, other None; it is the reporter's choice between three words Your endpoint, and the title line of every sink
message Always Yes — the reporter writes freely, and pastes None: it is the report. scrubReport redacts the shapes it knows, and beforeSend can drop the report whole Your endpoint, then your store and your sinks
contact No — off in the hook, the panel and the script tag until you ask for it Yes, by definition: you asked how to reach a person Leave contact unset, and data-contact off the tag. scrubReport(report, { contact: true }) redacts the line whole Your endpoint; the Resend sink uses it as the reply-to when it looks like an address
context Always Sometimes — the path and query of the page, so it is personal exactly when your own URLs are (/patients/1234) None for the object. The query scrubber redacts values under token, key, secret, password and auth, and the origin and the fragment are never collected at all Your endpoint, then your store and your sinks
console Only while initConsoleBuffer() is recording — the script tag starts it for you Yes — an error message carries whatever was interpolated into it. console.log and console.debug are never recorded, which is where stray values usually end up Do not call initConsoleBuffer(); includeConsole: false leaves it out of one report Your endpoint, then your store and your sinks
elements No — until the reporter points at one Yes — the selector, the element's visible text and its data-* attributes elementPicker: false on the panel; nothing attaches an element on its own Your endpoint, then your store and your sinks
screenshotDataUrl No — until you hand in a renderer. With one it is armed for bug reports, and the reporter can clear the checkbox Yes, more than anything else here except a replay: it is whatever was on screen. Field values are masked first No renderer; screenshotFor; the reporter's own checkbox; handleReport({ screenshot: "drop" }) on the server Your endpoint; fileStore decodes it beside the JSON as <id>.png. Read "Please read this part" before you store it anywhere
screenshotRegion No — only when the reporter chose an area or an element rather than the whole page, which needs region handed in No — a mode, two rectangles in pixels and, for an element, its selector. The picture it describes is the personal part, and a cropped one shows less of the page than a whole one Leave region out; it never travels without a picture, and handleReport drops it when the picture is dropped Your endpoint, then your store; toMarkdown prints it as one fact
breadcrumbs Only while initBreadcrumbs() is recording — the script tag starts it Yes — a click carries the element's text, a navigation carries the path Do not call initBreadcrumbs(); includeBreadcrumbs: false for one report Your endpoint, then your store and your sinks
network No — initNetwork(), or data-network on the script tag Yes — method, URL, status and duration. Never a request body and never a header, but a URL can carry an id or a token; scrubUrl runs over it Leave it off; includeNetwork: false for one report Your endpoint, then your store and your sinks
perf No — initPerf(), or data-perf No — LCP, INP, CLS, TTFB and two load timings, all of them numbers Leave it off; includePerf: false for one report Your endpoint, then your store and your sinks
storage No — the same switch as perf Only if you ask for it: key names and value lengths, and cookie names. A value travels only for a key you listed in allowValues, and a cookie value never travels on any setting Leave initPerf off, or leave allowValues empty; includePerf: false covers both Your endpoint, then your store and your sinks
replay No — attachRrweb(record) with your own copy of rrweb Yes, and more than anything else: it is a film of a person using your software Do not call attachRrweb; rrweb's own masking, which the adapter sets to maskAllInputs: true; includeReplay: false; handleReport({ replay: "drop" }) on the server Your endpoint and your store. No sink uploads a replay anywhere
notes When there is one No — the library's own note about the report, such as a screenshot the offline queue could not store None Your endpoint, then your store and your sinks

Whatever you add yourself with extra is not in this table, because only you know what is in it. It is merged into the top level of the same JSON and travels the same way.

How long it is kept #

bugbottle keeps nothing. In the browser a report lives in memory until it is sent; the offline queue holds a failed one in localStorage until it goes out, and deletes it when it does. After the POST, retention is the receiver's job, and the receiver is you. There is no maximum age anywhere in this package and no cleanup that runs on its own.

fileStore has both a ceiling and an age: maxReports (2000 by default) deletes the oldest files once the directory is over it, maxAgeDays (off by default) deletes what is older than that, and reports.prune() applies both — the inbox example runs it at start and once an hour, from RETENTION_DAYS. If your answer is "delete after 90 days", that is maxAgeDays: 90 and a call to prune() on a schedule; reports.list() gives you receivedAt and reports.remove(id) deletes the JSON and the picture together when the rule is something else. A database store is the same job with a DELETE.

Two decisions belong in the same sitting. How long a screenshot lives is worth answering separately from how long the text lives: the picture is the part that ages badly, and deleting it early costs little. And a report that has already gone to Slack or to an issue tracker has been copied — deleting your row does not delete the copy, and from the moment a sink posts it, it lives by that system's retention rather than by yours.

When somebody asks for the data you already have #

The question above is answered by a setting. This one arrives as a person, and it has three parts: which reports are theirs, what you delete, and what you cannot reach. None of it is a feature, because no call in this package finds a person's reports for you — the package never knew who sent one. What there is, is an honest account of where the copies are and what each one takes.

Finding them. The only handle a report carries on a person is contact, and it is off by default in the panel, the hook and the script tag, because it is personal data the moment it is on. That is the right default and it has a consequence: a reporter who never filled it in can only be found by what they wrote, which means reading. So the decision is not "may we ask for an address" — it is "when somebody writes to us in eight months, can we find their reports at all". If the answer is no, do not promise them a deletion you cannot carry out.

fileStore does not help here, and the reason is in the type. list() answers id, file, title, type, url, receivedAt and screenshot — everything a listing page needs, and nothing that identifies a person. contact is not in it, so finding one person's reports in a directory means read()ing them one at a time. A database store with an index on contact is the same job done once, and that is the whole of it: the library stores the field, and the index is yours.

The id is the better handle, and it works today. handleReport answers 201 with {"id": …} when your store gave back one, sendReport reads it, and the hook and the panel hand it to you as onSent(id) and as status.id on the state machine. The panel does not print it, so a reporter cannot quote a report number unless you put it in your own confirmation — which is the whole of "B-4711" as a reference in your inbox.

Deleting the copy you hold. reports.remove(id) deletes the JSON and the picture together and answers whether there was one; a database store is a DELETE. The inbox example wires it to a POST /r/<id>/delete behind the same password as the rest of it, so the last twenty lines are copyable.

The copies you made without deciding to. This is the part that decides how honest an answer you can give. A sink is a copy, and it outlives your row:

Where the copy is Who can delete it What it takes
Your store — a database, or the fileStore directory You DELETE, or reports.remove(id). The picture goes with the JSON; a directory nothing prunes keeps the older ones
Slack, Discord The platform, with a bot token A chat.delete or a DELETE on the message. Not the webhook URL a sink is given: an incoming webhook can post and cannot delete, and it does not learn the message's id
Microsoft Teams The workflow, not the sink A Workflows run keeps the card. Whether it can be removed is a permission question in the workflow, not a call this package can make
Mail — Resend, or your own SMTP server Nobody A sent mail is in your provider's log, in the recipient's inbox and in every backup either side keeps. There is no recall; the honest answer is a retraction
Sentry, GlitchTip, Bugsink The provider Its own retention, and its own organisation's setting. sentrySink uploads the screenshot as an attachment, so the picture is there too
GitHub, GitLab, Jira, Linear The platform, and only partly Close the issue, comment on it, delete the attachment. The body is a copy of your report and the tracker keeps its own history of what was edited
The reporter's own browser The reporter The queue deletes a report as soon as it is delivered. One that never went out sits in localStorage until it does, and createQueue().clear() is a button in your page, not something a server can call
Your backups Nobody, until they age out A row deleted today is in yesterday's dump. Whatever you promise has to name the backup retention as well as the live one

Four decisions follow, and they are cheaper now than after the first request. Decide before you have reports whether a sink may carry anything personal, or its retention is now part of your answer. Prefer a sink you can delete from. Give the screenshot its own, shorter life than the text — it is the part that ages badly, and deleting it early costs little. And write down the backup retention, because it is the one that is always forgotten.

Log the erasure as carefully as you logged the intake. onDecision prints one line per accepted report, carrying the fingerprint and nothing out of the report itself, and a deletion is not a report — so the line you keep for "who asked, which ids, when, what we answered" is one you write.

No cookies, no fingerprinting, no third party #

The site at bugbottle.dev makes the same promise about itself, and there it is enforced rather than asserted. site/security-headers.conf puts this on every response:

Content-Security-Policy: default-src 'self'; img-src 'self' data:;
  style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none';
  base-uri 'self'; form-action 'self'

default-src 'self' means no script, style, font, image or connection may come from anywhere but that host: the two typefaces are served from it, there is no analytics to block, and a browser refuses the first request that would break the claim. The two directives that are wider are the demo panel rather than the page — data: images because a screenshot arrives as a data URL, and inline style because the panel puts its stylesheet inside a shadow root. This is the site's policy and not the library's: your own application writes its own, and bugbottle needs nothing in it beyond a connect-src that allows your endpoint.

What to write in your privacy policy #

Adapt this, then walk the table above and strike the sentences about the fields you left off:

Problem reports. When you report a problem in [Application], we receive the message you write and, if you fill it in, the way to reach you that you give us. With it we receive technical details about the page you were on: its address within [Application], the size of your window, your browser's user agent, and the error messages the page had already logged. If you attach a screenshot, we receive a picture of the page as you saw it, with the contents of form fields hidden. We use this only to find and fix the problem you reported. It is stored on our own systems, [is shared with [issue tracker], which we use to track fixes,] and is deleted after [90] days. You can ask us at any time for a copy of the reports you have sent us, or for them to be deleted, by writing to [address]; we answer within [30] days.

Three habits are worth more than the paragraph itself. Say it where the reporter is, next to the button, rather than only in a policy nobody opens. Describe what you turned on rather than what the library can do. And when the answer changes — turning screenshots or a replay on is a change of answer — change the paragraph in the same release.

The last sentence is the one people leave out, and it is the only one the library can help you keep. It promises an answer, and an answer needs the section above behind it: without a contact to search on you may not be able to say which reports were theirs. Write the promise only where the index behind it exists.

Edit this page on GitHub