Screenshots

How the opt-in screenshot works, how to mask anything it must never capture, and the things it cannot reproduce.

What actually happens

A reporter who presses "Attach a screenshot" gets one still image of the page they are on. Nothing is captured before that press and nothing is captured after it. There is no recorder running in between, and no capture happens at all on a page nobody presses the button on.

The sequence is fixed. The widget hides its own panel, blanks everything marked private, renders the page to an image, restores the page exactly as it was, and shows the reporter a preview. Attach keeps it; Remove throws it away and the report is sent without it.

The image is produced by rendering the page's own DOM to a canvas in the browser. The widget never asks for a screen-sharing permission, which means it structurally cannot see another tab, another window, the browser chrome, or anything else on the reporter's desktop. It sees the page, and only the page.

One honest detail about the order of operations: the preview the reporter looks at is served from an image already uploaded to BugLoop, because that upload is what makes the preview possible. Until they press Attach, that image is linked to nothing — no report references it, the bucket it sits in is private, and deleting the project deletes it along with every other object under that project.

Only the people who use it pay for it

The capture library lives in a separate file, fetched with a dynamic import the first time the button is pressed. A visitor who never presses it downloads the always-on bundle and nothing else — no extra request, no extra bytes.

Both files carry their own gzip budget and the build fails if either exceeds it: 12 kB for the always-on bundle, 60 kB for the capture chunk.

Masking: two attributes on your markup

Masking happens on the page, before the image is rendered. There is no unmasked version of the screenshot anywhere — not in the browser, not on our side — because the pixels were never drawn.

Every input and textarea on the page has its value replaced with bullets, whether or not you marked it. Field types whose value is not readable text — checkbox, radio, file, colour, range and the like — are left alone, since blanking them would hide nothing and break something. On top of that automatic pass, two attributes are yours:

  • data-bugloop-mask — the element is not drawn at all. Its space is preserved; its contents are blank. Use it on regions: a table of customer records, an invoice, a chat thread
  • data-bugloop-private — the element's text is replaced with bullets. On an input or textarea it replaces the value instead
html
<!-- Nothing inside this is drawn into the image -->
<section data-bugloop-mask>
  <table id="customer-records">…</table>
</section>

<!-- Rendered as bullets -->
<span data-bugloop-private>4111 1111 1111 1111</span>

Mark regions, and mind your shadow roots

Prefer marking a container over marking each field inside it. A region marked once keeps working when somebody adds a field to it six months from now; a list of individually marked fields quietly stops being complete.

The automatic pass walks the main document. It does not reach inside a shadow root of your own — so if you render sensitive fields inside a web component, put data-bugloop-mask on the host element. Nothing else masks it for you.

What it cannot capture

Rendering a DOM to an image is an approximation, and it is better to know where the edges are than to be surprised by a blank rectangle in a ticket:

  • Cross-origin images served without CORS headers render blank rather than failing the capture
  • The contents of an iframe — embedded video, maps, hosted payment fields — cannot be read at all
  • A canvas tainted by cross-origin content cannot be read either
  • A few CSS effects, backdrop-filter among them, are approximated rather than reproduced
  • Anything outside the page: other tabs, other windows, browser chrome, the desktop

Size, format, and where the image goes

The capture is scaled so its longest edge is at most 1600 pixels and encoded as WebP. Anything over 5 MB is refused, by the widget and again by the server.

The upload route repeats the ingest gates in the same order — project key, origin allowlist, rate limit — then adds two of its own. The image type is read from the file's leading bytes rather than believed from the request header, and the size ceiling is enforced server-side.

Storage is a private Supabase bucket with no client policies at all, which denies every browser role by construction. Objects are written server-side with the service role and read only through signed URLs minted at the moment they are needed.

How it reaches your tracker

GitHub's REST API cannot attach a binary when it creates an issue, so a GitHub issue embeds a signed link plus the pixel dimensions. The link is signed for a year, because an issue outlives any session-length window and a dead image in a year-old ticket helps nobody.

Jira can take a real file, so the Jira adapter creates the ticket and then uploads the image as an attachment. The picture lives in the ticket rather than behind a URL.

Deleting a report deletes its image, and deleting a project deletes every image under it. Storage sits outside Postgres, so this is an explicit step in the delete path rather than a database cascade.

Turning it off

Screenshots are a switch on Widget appearance, per project. Turn it off and the button never renders, so the capture library is never fetched and no image can be produced.