Skip to main content
The widget is one script tag. It reads its configuration from that tag’s own attributes, so the same built file behaves differently per site with no build step and no bundler.

Attributes

Three of these behave in ways worth knowing before you debug them:
  • An unrecognised data-mode silently becomes on. The widget does not warn. A typo in this attribute leaves the button visible to everyone, which is the opposite of what a typo in data-mode="manual" was meant to achieve. The admin panel is stricter and rejects an invalid mode outright, so this only bites in the embed.
  • data-console responds to the exact string off and nothing else. data-console="false" and data-console="0" both leave capture switched on.
  • data-dry-run is enabled by presence. <script ... data-dry-run> is enough. The one value that turns it off again is data-dry-run="false", which exists so a template can emit the attribute unconditionally and decide with a variable.
  • data-consent defers on any value it does not recognise, and logs an error saying so. This is the one attribute that fails toward doing less. A typo in data-mode leaves a button visible; a typo here would otherwise leave collection running on every visitor, so it does the opposite. An empty value is the exception and means “not set”, so a template can emit the attribute with nothing in it.

Visibility modes

data-mode decides when the launcher appears. It is a separate axis from data-dry-run, which decides whether anything is sent, and every combination of the two is meaningful. on shows the launcher to everyone. This is the default. manual never shows it on its own. Call window.BurnsideSteps.show() when you want it, which is the mode to use if you are putting feedback behind your own button or your own permission check. query shows it once the page is loaded with ?feedback=on. The grant is then remembered in sessionStorage, so the visitor keeps the widget as they navigate the rest of the site without carrying the query string around. It lasts for the browser session, not forever: a new tab starts clean. key shows it only to visitors holding a valid access key. The widget posts the page’s whole query string to the relay’s /api/gate, and the relay decides, so the key never has to be readable in your page source. A successful check is remembered per project in sessionStorage. Which query parameter carries the key, and what its value must be, are set per project on the relay: see configuration.

JavaScript API

Once mounted, the widget exposes these methods on window.BurnsideSteps:
These are available regardless of mode, and they are how manual becomes useful. If you run a consent banner, add data-consent="deferred":
The script loads and then does nothing at all. It makes no request to us, inserts no element, reads no storage, and does not touch the page’s console. Call start() from your banner’s accept handler:
start() is safe to call more than once, so you do not need to track whether you already have. If you run the admin panel, tick “This site uses a consent banner” on the project instead of editing the snippet by hand. The panel then emits the attribute in every copy of the snippet it gives out, including the one a colleague copies next month. That setting changes the snippet rather than being something the widget looks up, and it has to work that way: consent is decided before the first request, so a setting the widget had to fetch could not gate the fetch. open(), show() and hide() do nothing before start() and log an error telling you so. That is deliberate: your page might call open() from a route change or a keyboard shortcut rather than because someone clicked, and treating any of those as consent would defeat the point of asking.

What deferring actually stops

Worth being specific, because “the widget is hidden” and “the widget has not started” are different things and only one of them is a consent gate: data-mode="manual" is not a substitute. A manual widget is mounted and invisible: it has already patched the console and read the visitor’s storage, and it is waiting for a call. Deferred means not begun.

What a submission contains

The issue that arrives in your tracker carries: The widget also sends its own version with every report, so an embed left on an old build is identifiable from the issue rather than by guesswork. It looks like 2.0.0+904c2659: the package version, then an id derived from the built file itself. Compare it with reported in /widget-manifest.json on your relay. If they differ, that page is running an older build than the one you are serving, and a reload picks up the current one. Device is worth one note: the widget does not send it. The relay reads the User-Agent header off the submission and reduces it to an operating system and a browser name, so the issue says macOS · Safari rather than carrying a full user agent string into your tracker.

Keeping things out of the screenshot

The widget captures the visible page. That is the point of it, and it is also the reason you need a way to say “not that part”. There are two, and they work the same way underneath.

data-mask, on the embed

A CSS selector list. Anything matching it is omitted from the capture:
Matching elements are omitted, not blurred. The screenshot has a blank space where they were, which is unambiguous in a way a blur is not: nobody has to decide whether the smudge was legible. Set it once on the embed, and it applies to every capture on every page that embed is on. The page is only modified for the duration of the capture, and it is put back afterwards even if the capture fails or times out. An invalid selector masks nothing and logs an error to the console. It does not stop the capture, so a typo here is loud rather than silently protective.

data-capture, on the element

If it is easier to mark the element than to name it in a selector, put the attribute on the element itself:
This is the same mechanism data-mask uses, and the widget uses it on its own UI so the widget never appears in its own screenshots. An element marked this way stays excluded permanently, and data-mask will not disturb it.

What to mask

Worth being deliberate about, because a screenshot goes into your issue tracker and inherits whatever access that tracker has:
  • Anything showing another person’s data. Customer records, order details, email addresses in a list, a support inbox.
  • Card numbers, bank details, and anything else a screenshot of a checkout page might contain.
  • API keys and tokens shown in a settings page, which are easy to forget because they look like interface rather than like secrets.
Password fields are worth a specific note. A browser renders a password input as dots rather than characters, and the capture inherits that, so a password field is not expected to reveal its contents. We have not verified that behaviour across every browser, though, and the cost of being wrong is high, so if a page has a filled-in password field on it, mask it and do not rely on the rendering.

What the capture does on the network

Nothing third-party, ever. A capture may request your own font files, from your own origin, and nothing else leaves the page except the report itself when the visitor presses Send. That needs stating because the library the capture is built on would go further by default. Left alone it resolves fonts through a built-in list of font hosts: fonts.googleapis.com, fonts.gstatic.com, use.typekit.net, p.typekit.net, kit.fontawesome.com, use.fontawesome.com and cdn.jsdelivr.net. On a site using webfonts that would mean pressing the feedback button caused the visitor’s browser to fetch from a third party. Every one of those hosts is excluded by name, so none of them is ever contacted. Why fonts are embedded at all. The capture is not a raster of what the browser drew. The library serialises your page into an SVG and has the browser lay it out a second time, in a context with no access to your font faces. Without the face embedded, that second layout falls back to different metrics and the text re-wraps: a live three-line paragraph was measured rendering as four. On a tool whose whole job is a faithful record, that made every report about spacing, wrapping or typography quietly misleading. So faces we can reach are embedded, and the third-party hosts above are refused. If your fonts are self-hosted, your captures are faithful and your visitors still contact nobody. If your fonts come from one of those hosts, the text in the capture will re-wrap, exactly as it did before. Rather than let that be silent, the report says so underneath the screenshot:
Text may wrap differently from the live page: fonts from fonts.googleapis.com could not be embedded.
Self-hosting your webfonts removes the caveat and makes the captures exact. One exception, which we cannot switch off. The library always embeds icon fonts, and offers no option to stop it. If your page uses an icon font served from a CDN, a capture may fetch that one file. It is the visitor’s browser making a request to a host your page already uses, and it carries nothing about the report, but it is a request your page would not otherwise have made at that moment. If that matters for your site, serve your icon font from your own origin, which is a good idea for other reasons anyway.

The console buffer

Capture starts when the widget loads and runs until the page is unloaded, so a report includes what happened before the reporter decided to file it. It records log, info, warn and error, plus two things that never reach console at all: uncaught window errors, and unhandled promise rejections. Both arrive tagged error. A widget that only forwarded console.error would miss the two failures most worth seeing. Three limits keep it bounded, and the oldest entries are dropped first: The buffer wraps the page’s own console, so anything your application or a third-party script logs can end up attached to an issue. If your pages log anything you would not want in your tracker, set data-console="off", which skips installing the wrapper entirely rather than capturing and discarding.

Versions and pinning

Every relay serves the widget at two URLs, and they make deliberately different promises. /widget.js is the rolling URL. It is what the install snippet gives you and what almost everyone should use. Its contents change when the relay is upgraded, and it is served with a short cache and revalidation, so a fix reaches browsers that already have the old copy within about a minute. /widget-<version>.<hash>.js is the pinned URL. The hash is derived from the file’s own bytes, so the name changes whenever the contents do, and the file is served immutable for a year. A browser that has it never asks for it again. The current build is described at /widget-manifest.json:
reported is the value the widget puts in the issues it files. build is the same id on its own. Both change whenever the widget’s code or its bundled dependencies change, and only then, so no release step has to remember to bump anything. The id in pinned is a different number: it is a digest of the finished file, taken after the build id was stamped into it, which is what lets the pinned URL promise that the name is the content. The two always move together. Read the pairing off this manifest rather than assuming it.

What pinning costs

A relay serves exactly one pinned build: the one it was deployed with. Upgrading the relay retires the previous pinned URL, and a page still pointing at it will get a 404 and load no widget at all. So pinning does not mean “this URL works forever”. It means “these exact bytes, until you choose to move”, and moving is a step you have to take yourself when the relay is upgraded. If nobody is watching for that, the rolling URL is the safer choice, which is why it is the default.

Subresource Integrity

The integrity value in the manifest is the SRI hash of the pinned build. Copy it from /widget-manifest.json on your own relay rather than from here: it changes with every build, so any value written into documentation is wrong by the time you read it. Using it requires crossorigin="anonymous", because the widget is loaded from a different origin than your page:
Both attributes are required together. An integrity attribute without crossorigin fails the integrity check and the script does not run, which looks exactly like the widget being broken. The same caveat as above applies, and harder: an SRI hash pins the bytes, so it stops matching the moment the relay serves a different build. Use it on the pinned URL, never on the rolling one.