Broken Here

Installing the widget

One script tag, what it costs, and how to confirm it is live.

The tag

<script async src="https://brokenhere.com/w.js" data-wq-key="wq_a7k9vd9pnw5imawe"></script>

Put it before </body> on every page you want reportable. Keep async, because the widget must never delay your page.

The key is public. It sits in a script tag on your website where anyone can read it, so treat it as an identifier and not as a secret. Allowed origins are what stop it being abused.

If a tag attribute is awkward in your setup, the key can go in the URL instead:

<script async src="https://brokenhere.com/w.js?k=wq_a7k9vd9pnw5imawe"></script>

The project's Setup (the first section of its Settings) prints both with your own key in them, under its first step, Install the widget, one tab per place you might paste it.

Where to put it

Once, in the template every page shares:

Where you buildWhere it goes
HTMLJust before </body>.
Next.jsIn app/layout.tsx, inside <body>, as a next/script (below). With the Pages Router, in pages/_app.tsx the same way.
React / ViteIn index.html, just before </body>.
WordPressThe Broken Here plugin: install it, then paste your key under Settings › Broken Here. It adds the pre-hook too.
ShopifyOnline Store > Themes > Edit code > layout/theme.liquid, before </body>.
WebflowSite settings > Custom code > Footer code, then publish.
Google Tag Manager (GTM)A Custom HTML tag, trigger All Pages, holding the ?k= form of the tag.
Coding agentA prompt to paste into the agent in your repository, which adds the tag and fills in the settings. See below.

For Next.js:

import Script from "next/script";

<Script
  src="https://brokenhere.com/w.js?k=wq_a7k9vd9pnw5imawe"
  strategy="afterInteractive"
/>

Setup remembers the tab you pick in that browser, so somebody on Shopify sees Shopify first on their next project too. Under the tabs, Also catch errors thrown while the page loads holds the optional pre-hook.

What it costs your visitors

FileSizeWhen it loads
the pre-hook0.5 kB, inlineImmediately, if you add it
w.js18.6 kB gzipOn page load, async
w-shot.js11 kB gzipOnly once someone starts a report

The screenshot renderer and the selector generator live in w-shot.js. A visitor who never reports a bug never downloads them.

A common performance budget is 170 kB of compressed JavaScript for a whole page on mobile. The widget is about a tenth of that, and it is off the critical path.

Confirming it is live

The widget calls home once on boot to fetch its configuration, and that first call doubles as the install check. Setup looks for it on its own. While nothing has arrived, the install step says "Waiting for the first page load" and checks again every few seconds, so all you do is open the site after deploying the tag. When the call lands, the step turns to Live, first seen and how long ago, and its tick counts towards the checklist. Check now asks at once.

After that, the app notes a boot at most once an hour, and the line also says when the widget was last seen. A tag not seen for seven days gets a warning saying for how long and asking you to check it is still on the site, because a redesign that dropped it looks exactly like a quiet week. Once setup is done, the Setup item in the settings menu says the same in two words (live, not seen for 9 days, a site was refused), whichever section you are on.

When your site is refused

The app refuses a boot from an origin the project does not accept, and Setup names the origin in a warning like this one: https://staging.acme.com tried to load the widget 2 minutes ago and was refused because it is not in your allowed origins. That is usually the whole reason a correct tag shows nothing.

On a refused site, or with a key the app does not recognise, visitors see no Report issue button and no prompt; your own data-wq-trigger button opens a panel saying bug reports are not switched on for this site yet, and the browser console says why. On a refused site, the widget tells a signed-in team member to add its origin under Settings › Sites & access; with an unrecognised key, it tells them to copy the tag again from Setup in the project's settings. If our side is down, nothing is refused and the button stays.

When the refused site is on a domain the project already names (its site URL or an allowed origin, such as staging.acme.com beside acme.com), the notice has an Allow button that adds it to the allowed origins, after asking. Allow it only if the site is yours. An allowed origin is also where a team member's sign-in from the widget is handed over, and the refused origin is whatever the caller claimed. A site on none of the project's domains gets no button, since anyone holding your public tag can name any site. Type it into allowed origins yourself if it is yours, or have an admin block it under Settings › Sites & access if it is not. Setup stops showing a refusal nobody has repeated for a week.

If nothing arrives

After ten minutes of waiting Setup stops checking and lists what to look at:

  • Look for w.js in the Network tab. A 404 means the src is wrong.
  • A 404 from /api/ingest/<key> means the key does not match a project; a 403 means the site is not in your allowed origins (Setup names it, above).
  • Check the tag is in the deployed HTML and not only in your local build.

If the tag ends up on the page twice

Nothing bad happens. Including the snippet in both a layout and a page is a common enough mistake that the bundle refuses to install a second time, so there is still one button and one set of captured evidence. It is worth tidying up, but not worth a deployment of its own.

Setting the project up from your codebase

The settings a new project needs (which origins it is served from, which API it calls, which field names carry secrets, which elements to blur) are answers your repository already holds, and filling the settings in from memory is how they end up wrong. Setup's install step prints a prompt for the coding agent that is already in your repository, under its last tab, Coding agent. Paste it into Cursor or Claude Code; the agent answers five questions from the code and sends the answers with one request. Nothing about your code leaves the agent but the answers.

The prompt asks what counts as an allowed origin, what counts as your API and which words name a sensitive field. It never says where to look, because where your stack keeps those is the agent's problem, and the agent knows your repository. If the repository serves more than one site, the agent lists them and stops, and you tell it which one this project is.

The request is PUT /api/projects/<id>/settings with a setup token in the Authorization header. Setup writes the token into the prompt when a member opens it. The token unlocks this one project's settings and nothing else, so a prompt pasted into the wrong chat cannot reach another project. It lasts a day, so a prompt left in a log is dead the next day. The request accepts allowedOrigins, apiOrigins, redactKeys and maskSelectors and nothing else. Blocked origins come from spam reports rather than from code, and the project's name, switches and integrations stay with the people who can sign in. Origins go through the same rules as the settings form, so a line the parser refuses comes back named in the response rather than stored. Redacted keys and masked selectors are added to the ones the project has; an agent reporting what it found must never take password off the list by not mentioning it. What it set shows up under Settings › Sites & access and Settings › Capture & privacy, and you remove a line there.

GET on the same URL, with the same header, returns what is currently set, which is why the prompt asks the agent to read before it writes.

Single-page apps

There is nothing to do. The widget reads location.href when a report is sent, so on React Router, Next.js and the like, the report records whichever route the reporter was on.

The error prompt counts per route rather than per page load. Once a failing request has been reported or dismissed on a route, it is not offered again there. An SPA never reloads, so "once per load" would mean once per session.

Keyboard and screen readers

The report form is a modal dialog and behaves like one: Escape closes it, Tab cycles inside it rather than wandering into the page behind, and closing hands focus back to whatever opened it, the floating button or your own trigger. Closing it while a report is sending gives your page back at once and cancels nothing. The report finishes behind a small notice and its receipt arrives the same way; if it is refused, the form comes back as it was. If the reporter leaves the page before it lands, the form offers their words back the next time it opens. The prompt that offers a report when your API fails is announced politely, without stealing focus mid-sentence. Anything the form refuses (a file that is not an image, one too large to send, a failed send) is announced and scrolled into view rather than written below the fold.

You can drive the element picker three ways. With a mouse, you aim it. The arrow keys step through the things worth pointing at (text, controls, images) one at a time, naming each one in the bar as they go, and Enter takes the one outlined. On a touch screen a finger presses, slides and lifts, and the outline and the name in the bar follow it, so nobody has to choose an element they cannot see under their own fingertip. Esc cancels, and so does the bar's own Cancel button, which is the way out on a phone with no Esc key.

On a phone the form is a sheet on the bottom edge of the screen, and its actions stay pinned to the bottom of that sheet rather than sitting at the end of the form, so Send report is on screen at 320×568 without anyone having to discover that the panel scrolls. The sheet is sized against the visible viewport, and it lifts clear of the on-screen keyboard while somebody is typing into it. On iOS the keyboard does not resize the window, so a form pinned to the bottom of the window would have its Send button behind the keyboard. Fields are 16px there, which is the size at which Safari stops zooming the page when a reporter taps into one, and the trigger and the sheet keep clear of the home pill and the notch.

Every colour comes from the --wq-* properties, so contrast is yours to set; the defaults meet WCAG AA on a white page. If your palette is darker, set --wq-bg and --wq-fg and check the accent against them.

Content Security Policy

If your site sends a CSP, the widget needs three things:

script-src  https://brokenhere.com;
connect-src https://brokenhere.com;
img-src     data:;

img-src data: is for the screenshot, which the browser builds as a data URL before uploading it.

That is enough for the widget itself. Its own styling is a constructed stylesheet rather than a <style> element, so style-src does not apply to it. The button, the form and the element picker all look right under a policy as strict as default-src 'self', and a report sends.

Screenshots need one more thing

The renderer that turns the page into an image writes a <style> while it works, which style-src does govern. Without style-src 'unsafe-inline', reports arrive with the description, the element, the errors and the failed requests, and no picture.

The trade is yours to make. If you would rather keep the policy than have the images, nothing breaks, and reports arrive as text only.

Errors thrown before the widget loads

The tag is async, so between your page painting and w.js finishing its download, nothing is listening. "It broke while the page was loading" is exactly the report lost in that gap, and the reporter cannot get it back by trying again.

Paste this inline, as early in <head> as you can, to close the gap. Setup prints it too, folded under the install tabs as Also catch errors thrown while the page loads:

<script>"use strict";(()=>{(function(){let o=window,r=o.__wqEarly||(o.__wqEarly=[]),s=50,t=n=>{!r.done&&r.length<s&&r.push(n)};addEventListener("error",n=>{t({kind:1,message:String(n.message??""),filename:String(n.filename??""),lineno:n.lineno||0,colno:n.colno||0,at:Date.now()})}),addEventListener("unhandledrejection",n=>{let a;try{let e=n.reason;a=e instanceof Error?`${e.name}: ${e.message}
${e.stack??""}`:String(e)}catch{a="unhandled rejection"}t({kind:2,message:a,filename:"",lineno:0,colno:0,at:Date.now()})})})();})();</script>

It is about half a kilobyte, two listeners and an array, and it decides nothing. It buffers up to 50 events, then stops. When w.js arrives it drains the buffer through the same rules as everything else, so dev-server noise is still dropped, third-party scripts are still counted rather than recorded, and each error keeps the time it actually happened rather than the time the widget booted. Draining also closes the buffer, so nothing is recorded twice.

It is inline on purpose. A separate file would have to download before it could help, and that wait is the gap it is there to close.

It covers uncaught errors and unhandled promise rejections, the things that explain a page that broke while it was loading. Console warnings from before the widget booted are still lost; wrapping console early enough to catch them would mean deciding, in the snippet, which ones are worth keeping, and that decision belongs in one place.

The pre-hook is optional. Without it, everything after boot is still recorded, exactly as before.

If you send a CSP

An inline script needs permission. Either add a nonce (<script nonce="..."> and script-src 'nonce-...') or take the SHA-256 hash of the snippet and add it to script-src. Adding 'unsafe-inline' to make this work is not worth it; skip the pre-hook instead.

On this page