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 #
- No cookies. The library sets none and reads none. The one place a cookie
is touched at all is
initPerf, which lists cookie names so you can see what the page was carrying; a cookie value is never included, on any setting. - No fingerprinting. The context 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. That is the whole list. No canvas, no fonts, no device enumeration, no identifier of any kind, and nothing stored anywhere to recognise the same browser twice.
- No third party. The package has no runtime dependency, and the client
makes one request: the POST to your endpoint.
html-to-imageandrrwebare optional, are handed in by you, and run inside your page. The sinks run on your server and speak to the services you configured, with the keys you passed as arguments — never read from the environment behind your back.
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.