bugbottle
v1.0.1
Published
Headless in-app bug reports that arrive with the evidence attached: console errors, page context and an optional screenshot. Your UI, your endpoint.
Maintainers
Readme
bugbottle
bugbottle.dev — the page, with a live demo of the panel (in Danish).
Headless in-app bug reports that arrive with the evidence attached. Your UI, your endpoint, a few kilobytes.
Error trackers catch what throws. They cannot catch what merely looks wrong, and they never tell you what the person was doing when it did. "The save button does nothing" is not a report anyone can act on.
bugbottle collects the context at the moment someone notices — the page, the viewport, the recent console errors, the element they point at, optionally a picture of what they were looking at — and POSTs it as JSON to a route you already own.
npm install bugbottle
npm install html-to-image # optional, only if you want screenshotsWithout npm, install the tagged release straight from GitHub, or import the
built files from the jsDelivr CDN — dist/ is committed for exactly that:
npm install github:mahope/bugbottle#v1.0.1import { initConsoleBuffer, buildReport, sendReport } from "https://cdn.jsdelivr.net/gh/mahope/[email protected]/dist/index.js";- Headless, in your framework. You render the form — with the React hook, the Vue composable, the Svelte store, the Solid accessors, or the three plain functions underneath them. The chrome around a feedback widget is exactly the part that differs between applications, so this owns the state, the capture and the submit — not your markup.
- Bring your own backend. There is no dashboard and no hosted service to
sign up for. A report is a JSON body on a
fetch; the receiving end is a route handler you write, with the validation helpers shipped alongside. - Nothing in your bundle you did not ask for. Zero dependencies. The core
is about 1.5 kB gzipped; with the element picker and the React, Vue, Svelte
or Solid adapter, 5.7 kB; the optional ready-made panel, 11.6 kB; the picture
annotator 1.4 kB on top of it, and only for the applications that ask for it;
breadcrumbs 1.3 kB; the network log
1.2 kB; the timings and storage snapshot 1.2 kB; the offline queue 1.5 kB;
shake-to-report 0.6 kB;
the everything script tag, 24.6 kB, and the slim one 21.0 kB.
html-to-imageis only pulled in by the module that imports it, the annotator only by the panel you handed it to, and the scrubber only by the code that calls it. - Sends itself onward. Email through Resend, a Slack, Discord or plain webhook, an issue in GitHub, GitLab, Jira or Linear, or an event in Sentry — eleven server-side sinks over one Markdown rendering, keys never in the browser.
- Your language, your brand. Eight bundled locales, every string overridable, and a panel themed with a handful of CSS variables.
- Server helpers included. Every field a browser sends is checked before it reaches your database, because that is where the sharp edges are.
What it is not
Not a dashboard, not session replay, not a hosted service. If you want
annotated issues filed in Jira by a vendor, look at Marker.io or Jam. If you
want to record everything a user does, look at rrweb. bugbottle is the smallest
thing that turns "it's broken" into a reproducible payload, and stays out of
the way otherwise. The optional panel in bugbottle/ui is a convenience over
the same core, not the product.
Recording console errors
Call this once, from client-side code, as early as your app can manage. Anything that happens before it is not in the buffer.
import { initConsoleBuffer } from "bugbottle";
initConsoleBuffer();Only error and warn are recorded, along with uncaught errors and unhandled
promise rejections. log and debug are deliberately left alone: they are
noisy, and in most applications they are where stray user data ends up. The
original console functions are always called through, so nothing disappears
from your devtools.
An uncaught error or a rejection also carries its stack, normalised to at most
ten frames of { file, line, col, fn? } — V8, Firefox and Safari all write it
differently, and one small parser reads all three. A frame is a position and
nothing else: no line of source is ever read or sent, so resolving it stays
your side, with your own source maps. Entries from console.error carry no
frames; the browser gives none.
In a server-rendered app, make sure this runs in the browser only — it patches
whichever console it finds.
Every recorder in the package returns its own stop, so a hot-reloaded module or a test can undo what it started without importing a second name:
const stop = initConsoleBuffer();
stop(); // the real console back, the buffer emptyThe form (React)
import { useBugReport } from "bugbottle/react";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
function ReportForm() {
const report = useBugReport({
endpoint: "/api/feedback",
screenshot: htmlToImage, // leave out to disable screenshots
});
return (
<form data-bugbottle onSubmit={(e) => { e.preventDefault(); void report.submit(); }}>
{report.types.map((t) => (
<button key={t} type="button" onClick={() => report.setType(t)}>
{t}
</button>
))}
<textarea
value={report.message}
onChange={(e) => report.setMessage(e.target.value)}
/>
{report.canScreenshot && (
<label>
<input
type="checkbox"
checked={report.includeScreenshot}
onChange={(e) => report.toggleScreenshot(e.target.checked)}
/>
Attach a picture of this page
</label>
)}
{report.screenshot && <img src={report.screenshot} alt="" />}
<button type="button" onClick={() => void report.pickElement()}>
{report.isPicking ? "Click anything to attach it — Esc to stop" : "Point at the element"}
</button>
<ul>
{report.elements.map((el, i) => (
<li key={i}>
<code>{el.selector}</code> {el.text}
<button type="button" onClick={() => report.removeElement(i)}>×</button>
</li>
))}
</ul>
<p role="status">{report.statusMessage}</p>
<button disabled={report.isSending}>Send</button>
</form>
);
}Call report.open() when the form appears, so the screenshot shows what they
were looking at rather than the form on top of it. Anything marked
data-bugbottle is left out of the picture and cannot be picked — put it on
your panel and your trigger button.
Every adapter also carries contact and setContact, for a form that asks
how to reach the reporter — an ordinary input bound the way message is:
<input
type="email"
value={report.contact}
onChange={(e) => report.setContact(e.target.value)}
/>Nothing validates it, and an empty one is left out of the body entirely, so a
form without such a field sends no contact key at all. In Vue it is a
writable ref (v-model="contact"), in Svelte $form.contact with
form.setContact(…), in Solid the accessor contact(). It is personal data
once you ask for it: see Please read this part.
Other options: initialType, screenshotFor and consoleFor (which report
types get a picture and the console; bugs only by default), extra (fields
merged into the body — an app version, a tenant id), headers and
credentials (for an authenticated or cross-origin endpoint), timeoutMs,
onSent, parseError, and messages for translated strings. The defaults are English.
The form (Vue)
The same state machine, as refs. type and message are writable, so
v-model works on them directly.
<script setup lang="ts">
import { useBugReport } from "bugbottle/vue";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
const report = useBugReport({
endpoint: "/api/feedback",
screenshot: htmlToImage, // leave out to disable screenshots
});
</script>
<template>
<form data-bugbottle @submit.prevent="report.submit()">
<button v-for="t in report.types" :key="t" type="button" @click="report.setType(t)">
{{ t }}
</button>
<textarea v-model="report.message" />
<label v-if="report.canScreenshot">
<input
type="checkbox"
:checked="report.includeScreenshot"
@change="report.toggleScreenshot(($event.target as HTMLInputElement).checked)"
/>
Attach a picture of this page
</label>
<img v-if="report.screenshot" :src="report.screenshot" alt="" />
<button type="button" @click="report.pickElement()">
{{ report.isPicking ? "Click anything to attach it — Esc to stop" : "Point at the element" }}
</button>
<ul>
<li v-for="(el, i) in report.elements" :key="i">
<code>{{ el.selector }}</code> {{ el.text }}
<button type="button" @click="report.removeElement(i)">×</button>
</li>
</ul>
<p role="status">{{ report.statusMessage }}</p>
<button :disabled="report.isSending">Send</button>
</form>
</template>Call report.open() when the form appears. The subscription is torn down with
the effect scope the composable was called in — the component, normally; call
report.destroy() yourself if you called it outside one. vue is an optional
peer dependency, so nothing about it reaches a project that does not use it.
The form (Svelte)
The same state machine, as a readable store: $form for the values, the
methods on form for everything the reporter does.
<script lang="ts">
import { createBugReport } from "bugbottle/svelte";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
import { onDestroy, onMount } from "svelte";
const form = createBugReport({
endpoint: "/api/feedback",
screenshot: htmlToImage, // leave out to disable screenshots
});
onMount(() => form.open());
onDestroy(() => form.destroy());
</script>
<form data-bugbottle on:submit|preventDefault={() => form.submit()}>
{#each $form.types as t}
<button type="button" on:click={() => form.setType(t)}>{t}</button>
{/each}
<textarea value={$form.message} on:input={(e) => form.setMessage(e.currentTarget.value)} />
{#if $form.canScreenshot}
<label>
<input
type="checkbox"
checked={$form.includeScreenshot}
on:change={(e) => form.toggleScreenshot(e.currentTarget.checked)}
/>
Attach a picture of this page
</label>
{/if}
{#if $form.screenshot}<img src={$form.screenshot} alt="" />{/if}
<button type="button" on:click={() => form.pickElement()}>
{$form.isPicking ? "Click anything to attach it — Esc to stop" : "Point at the element"}
</button>
<ul>
{#each $form.elements as el, i}
<li><code>{el.selector}</code> {el.text}
<button type="button" on:click={() => form.removeElement(i)}>×</button>
</li>
{/each}
</ul>
<p role="status">{$form.statusMessage}</p>
<button disabled={$form.isSending}>Send</button>
</form>svelte is an optional peer dependency, and only its Readable type is used:
the store contract is one function, implemented here, so the adapter adds no
runtime dependency at all.
The form (Solid)
The same state machine, as accessors: every value is a function, so the JSX tracks exactly what it reads.
import { createBugReport } from "bugbottle/solid";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
import { For, Show, onMount } from "solid-js";
function FeedbackForm() {
const form = createBugReport({
endpoint: "/api/feedback",
screenshot: htmlToImage, // leave out to disable screenshots
});
onMount(() => form.open());
return (
<form data-bugbottle onSubmit={(e) => (e.preventDefault(), form.submit())}>
<For each={form.types}>
{(t) => (
<button type="button" onClick={() => form.setType(t)}>
{t}
</button>
)}
</For>
<textarea value={form.message()} onInput={(e) => form.setMessage(e.currentTarget.value)} />
<Show when={form.canScreenshot()}>
<label>
<input
type="checkbox"
checked={form.includeScreenshot()}
onChange={(e) => form.toggleScreenshot(e.currentTarget.checked)}
/>
Attach a picture of this page
</label>
</Show>
<Show when={form.screenshot()}>{(src) => <img src={src()} alt="" />}</Show>
<button type="button" onClick={() => form.pickElement()}>
{form.isPicking() ? "Click anything to attach it — Esc to stop" : "Point at the element"}
</button>
<ul>
<For each={form.elements()}>
{(el, i) => (
<li>
<code>{el.selector}</code> {el.text}
<button type="button" onClick={() => form.removeElement(i())}>
×
</button>
</li>
)}
</For>
</ul>
<p role="status">{form.statusMessage()}</p>
<button disabled={form.isSending()}>Send</button>
</form>
);
}The subscription is torn down with the owner the function was called in — the
component, normally; call form.destroy() yourself if you called it outside
one. solid-js is an optional peer dependency, so nothing about it reaches a
project that does not use it.
Catching render errors (React)
When a component throws, there is no screen left to point at — but there is a
message, a stack and a component stack, which is the best evidence a bug report
ever carries. BugReportBoundary catches it and hands your fallback the error
and a report() function:
import { BugReportBoundary } from "bugbottle/react";
<BugReportBoundary
endpoint="/api/feedback"
fallback={(error, report, sending) => (
<div role="alert">
<p>This part of the page stopped working.</p>
<button onClick={report} disabled={sending}>
{sending ? "Sending…" : "Tell us what happened"}
</button>
</div>
)}
>
<Orders />
</BugReportBoundary>;report() sends a bug report whose message is the error, its stack and the
component stack, and resolves true when the endpoint accepted it — so the
button can say "sent". Nothing is sent until it is called: a report is a
message from a person, and sending one on their behalf without asking is
telemetry, which this library is not. onReport(error, id) and
onError(error) are there for the surrounding application.
Calling it twice files one report: a second call while the first is in flight
gets the same promise, and a call after a successful send does nothing, because
it is the same render error either way. A send that failed can be tried again.
The third argument, sending, is true while one is in flight, for a button
that should say so.
For the errors that reach the root, React 19 takes two handlers, and
createRootErrorHandlers builds both:
import { createRoot } from "react-dom/client";
import { createRootErrorHandlers } from "bugbottle/react";
createRoot(node, createRootErrorHandlers({ endpoint: "/api/feedback" })).render(<App />);These do send by themselves, because there is nobody left to ask. Each distinct
error is sent once per dedupeMs (60 000 by default, by the same fingerprint
the client and the server share), so a component that throws on every render
sends one report rather than a thousand. Say so in your privacy notice, and
pass scrub: scrubReport if a message could carry anything personal.
Opening it without a button
A form nobody can find is a form nobody uses, and a floating button is not
always wanted. bugbottle/triggers is two listeners, under 1.3 kB gzipped
together and importing nothing but the fingerprint hash:
import { onShortcut, onUncaughtError } from "bugbottle/triggers";
const offKeys = onShortcut("mod+shift+b", () => widget.open());
const offErrors = onUncaughtError((error) => {
console.warn("uncaught", error.fingerprint);
widget.open();
});mod is Command on a Mac and Control everywhere else, so one string covers
both. The shortcut never fires while the reporter is typing in a field or a
contenteditable region, and a match is preventDefaulted so the browser does
not also act on it. "Typing" includes typing inside a shadow root: a keystroke
that crosses a shadow boundary is retargeted to the host on the way out, so the
check reads event.composedPath()[0] as well as target, and follows
document.activeElement down through every shadowRoot.activeElement. That
covers the panel this package ships, which lives in one. onUncaughtError
listens for error and
unhandledrejection, describes each one the same way, and calls you at most
once per fingerprint (message plus the first stack frame) per dedupeMs —
60 000 by default — which is what makes it safe to open a panel from. Pass
ignore to drop the ones you already know about. Both return the unsubscribe.
The ready-made panel wires both for you. The shortcut is on by default:
const widget = mountBugbottle({
endpoint: "/api/feedback",
shortcut: "mod+shift+b", // the default; `false` installs no listener
openOnError: { prefill: true },
});The shortcut opens the panel and closes it again — except while the caret is in the panel's own box, where the keystroke belongs to the reporter and Escape or the close button is the way out.
openOnError is off unless you ask for it: a panel that appears uninvited is a
decision about your product, not a default. Switched on, an uncaught error
opens the panel with the type set to bug and the locale's openedByError line
where the intro usually is — "Something went wrong on this page. Want to tell
us what you were doing?" — and { prefill: true } also puts the error message
in the box, without overwriting anything the reporter has already written. They
still have to press send. Closing the panel puts the ordinary intro back.
From the script tag it is data-shortcut (data-shortcut="off" for none) and
data-open-on-error (any value, or "prefill").
Shake to report
On a phone there is no keyboard, and shaking the device is what people already
expect from a bug reporter. bugbottle/shake is that gesture in 685 bytes
gzipped, importing nothing:
import { onShake, requestShakePermission } from "bugbottle/shake";
const off = onShake(() => widget.open());A shake is three crossings of 15 m/s² with alternating direction inside one
second, measured on whichever axis moves most once gravity has been filtered
out. Alternation is what separates a shake from a drop — falling onto a desk is
one large reading in one direction — and after a shake the detector is quiet for
three seconds, so one gesture opens one panel however long the reporter keeps
shaking. threshold and cooldownMs change both; windowMs changes the
second. Nothing is measured while the page is hidden: the listener comes off on
visibilitychange and goes back on when the page returns. On a laptop it simply
never fires, which is why no media query switches it off.
iOS needs a gesture, and only Safari has the gate. Since iOS 13, Safari
delivers no motion events at all until DeviceMotionEvent.requestPermission()
has been called from inside a user gesture — a real click or tap — and granted.
onShake never calls it: a permission prompt nobody asked for is worse than a
feature nobody found, and the browser would refuse it outside a gesture anyway.
Put it on a button of your own:
button.addEventListener("click", async () => {
const state = await requestShakePermission();
// "unsupported" is every browser but Safari, where motion simply arrives.
if (state === "denied") showTheButtonInstead();
});requestShakePermission() resolves to "granted", "denied" or
"unsupported", and a call Safari rejects because it did not come from a
gesture is reported as "denied" rather than thrown. Two more facts worth
knowing: motion is a secure-context feature, so a page served over plain HTTP
gets no events whatever the permission says; and until permission is granted
onShake is installed and silent, which is exactly what it looks like on a
desktop.
The panel takes the detector the way it takes the annotator — a function you hand in, so nobody pays for a gesture they never use:
import { onShake } from "bugbottle/shake";
mountBugbottle({
endpoint: "/api/feedback",
shake: onShake, // or { on: onShake, threshold: 12, cooldownMs: 5000 }
});It is off by default, because of the permission dance above. A shake opens the
panel; it never closes it, since the gesture that would close it is the one that
shook it open. From the script tag it is data-shake — presence enables it,
and a number tunes the threshold (data-shake="12" is a lighter flick). That
build also exposes window.bugbottle.requestShakePermission(), which is the
only way a page with no bundler can ask iOS.
The form (anything else)
Every adapter is a thin layer over three functions that work anywhere:
import { captureScreenshot, pickElement, buildReport, sendReport } from "bugbottle";
import { htmlToImage } from "bugbottle/html-to-image";
const screenshot = await captureScreenshot(htmlToImage); // PNG data URL
const element = await pickElement(); // null if they pressed Escape
const report = buildReport({
type: "bug",
message,
screenshotDataUrl: screenshot,
elements: element ? [element] : [],
});
const { id } = await sendReport("/api/feedback", report);sendReport resolves on a 2xx, throws SendFailedError (with status and
the parsed body) on anything else, gives up with SendTimeoutError after
15 seconds (timeoutMs), and lets network errors through untouched.
captureScreenshot estimates from the capture area whether a full-scale render
would be larger than a server would accept, and starts at half scale when it
would — rendering is expensive, so a page that was never going to fit should
not be rendered twice. It still retries at half scale if the estimate was
wrong, and throws ScreenshotTooLargeError if that is too big as well. Treat
either as "send without the picture". Pass pixelRatio to skip the estimate
and force a scale, bytesPerPixelEstimate to tune it for pages that compress
unusually well or badly, and onCapture to see what each capture cost:
await captureScreenshot(htmlToImage, {
onCapture: ({ pixelRatio, length, attempts, ms }) => {
console.info(`screenshot: ${length} chars at ${pixelRatio}x, ${attempts} render(s), ${ms}ms`);
},
});When the network is down
The report that matters most is the one written while the application was
broken — and that is exactly the one a failed fetch throws away.
bugbottle/queue keeps it instead:
import { createQueue } from "bugbottle/queue";
import { useBugReport } from "bugbottle/react";
const queue = createQueue({ endpoint: "/api/feedback" });
const form = useBugReport({ endpoint: "/api/feedback", queue });A send that fails is written to localStorage, and the reporter is told the
truth in their own language: "Saved — it will be sent when you are back
online". form.status.kind is "queued" rather than "error", and
statusMessage is the queued string of the locale. mountBugbottle takes
the same queue option and shows its ordinary thank-you panel with that line.
The queue drains when it is created, when the browser fires online, and when
the tab becomes visible again. A failed attempt backs off exponentially, from
one second to five minutes. A 5xx or a network error keeps the report; a 4xx
drops it, because the server has already said this report is not acceptable and
retrying it would only fail again more quietly — nothing is ever queued on a
4xx in the first place.
const queue = createQueue({
endpoint: "/api/feedback",
storageKey: "bugbottle:queue", // where in localStorage
maxEntries: 5, // the oldest is evicted first
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
headers: { Authorization: `Bearer ${token}` },
sign: createSigner({ key: SIGN_KEY }), // for a signed endpoint; see below
});
queue.size(); // how many are waiting
await queue.flush(); // resolves with how many are still waiting
queue.clear(); // throw them away
queue.destroy(); // remove the listeners; the reports stay in storageWhen the quota runs out
localStorage is a few megabytes for the whole origin, shared with whatever
else the application keeps there, and a screenshot as a data URL is a megabyte
or two on its own. A write is refused sooner than anyone expects — and until
0.13 a refused write meant the report reached storage nowhere and was gone on
the next reload, which is exactly what an outage ends in.
It costs the picture instead. When the storage refuses a write, the queue writes the same reports again without their screenshots and leaves a line on each one it took a picture from:
"notes": ["Screenshot dropped: it did not fit in the offline queue."]notes is part of the payload. The server validates it like every other field
— at most five notes, 200 characters each, normaliseNotes — and toMarkdown
prints them above the evidence, so whoever reads the report can tell "no
screenshot was taken" from "a screenshot was taken and would not fit". It is
written by the library about the report, never by the reporter.
Nothing is dropped on a guess: a picture that fits is kept whole. Only when the
second write is refused as well does the queue go memory-only for the life of
the page. If localStorage is unavailable from the start, as in Safari's
private mode, it starts there rather than refusing to work — and either way,
what is already stored is still read and still delivered.
Somewhere else to keep them
storage replaces localStorage with anything that can read the queue and
change it. One other implementation ships, in its own entry point:
import { createQueue } from "bugbottle/queue";
import { createIdbStorage } from "bugbottle/queue-idb";
const queue = createQueue({
endpoint: "/api/feedback",
storage: createIdbStorage(), // databaseName, storeName, storageKey
});IndexedDB has room for the pictures — a share of the free disk rather than five
megabytes for everything on the origin — so a 2 MB report is queued whole. Its
read-write transactions are ordered per database and across tabs, so the claim
below is decided by the database rather than by whichever tab wrote last. What
it costs is timing: every step is asynchronous, so a report queued in the last
milliseconds before the tab is closed may not reach the disk, where
localStorage always does. Which of the two matters more depends on whether
your reports carry pictures.
When another tab loads a page that wants a newer version of the database, the browser asks this connection to stand aside. It does: the connection is closed and the next write opens a fresh one. A page that holds on instead blocks the other tab's upgrade for as long as it stays open, and a page that closes without reopening throws on every transaction afterwards and is memory-only for good.
It is a separate entry point because the default must not pay for it:
bugbottle/queue is about 1.5 kB and this is another 650 bytes, only for those
who ask for it. A browser with no IndexedDB at all makes the queue memory-only,
and the reports are still sent.
Your own storage is one function:
import type { QueueStorage } from "bugbottle/queue";
const storage: QueueStorage = {
update: (change) => writeBack(change(readTheArray())), // throws when refused
};update reads, applies change and writes the result back as one step, so a
storage that can be atomic gets to be, and it answers with what is now stored.
It may return a promise. A refused write throws, or rejects, and that is what
starts the fallback above.
There is deliberately no plain read beside it: anything read outside a
read-modify-write is stale the moment another tab commits, so every path
through the queue — the flush on load included — goes through update, even
the ones that only want to look.
Two tabs, one queue
localStorage belongs to the origin, not the tab, and it cannot be changed
atomically. The queue takes that seriously: each report is given a random id
when it is queued, and every write re-reads the stored array and merges by id
rather than replacing it. Before a report is delivered it is claimed — a
timestamp written into storage that asks the other tabs to leave it alone for
30 seconds — and a successful delivery removes it by id from a freshly read
array. So a second tab does not lose your reports, deliver them again, or put
back one you have just sent.
The honest limit: this is a lease, not a lock. Two tabs that read, decide and
write within the same few milliseconds can both claim one report and post it
twice. The window is the length of one read-modify-write, the failure is a
duplicate rather than a loss, and fingerprint(report) is there if duplicates
matter to your storage. A tab closed mid-delivery leaves its claim behind, and
the next tab picks the report up 30 seconds later. createIdbStorage() closes
that window: reading the queue and writing the claim happen inside one
IndexedDB transaction, and the browser orders those across tabs.
Without a queue, a send can still survive the page closing under it:
await sendReport("/api/feedback", report, { keepalive: true });keepalive is passed to fetch only when the serialised body is under 60 kB.
The browser caps every keepalive body a page has in flight at 64 KiB together,
and a larger one makes fetch reject rather than send — so this is for a
report going out during unload, not for one with a screenshot attached. For
anything larger, the queue is the answer.
Below the two integrations, SendOptions has the seam they are built on:
onError(report, error) runs after a failed send, before the error reaches
you, and is awaited.
await sendReport("/api/feedback", report, {
onError: (failed) => queue.enqueue(failed),
});The ready-made panel
If you would rather not build a form, bugbottle/ui mounts a floating button
and a small dialog in a shadow root, so your CSS and its CSS never meet:
import { initConsoleBuffer } from "bugbottle";
import { mountBugbottle } from "bugbottle/ui";
import { htmlToImage } from "bugbottle/html-to-image"; // optional
import { da } from "bugbottle/locales";
initConsoleBuffer();
const widget = mountBugbottle({
endpoint: "/api/feedback",
screenshot: htmlToImage,
locale: da,
brand: { name: "Mahope", logo: "/logo.svg" },
theme: { primary: "#e11d48", radius: "8px", position: "bottom-left" },
extra: { appVersion: "1.4.2" },
});
// widget.open(), widget.close(), widget.setLocale(en), widget.destroy()It offers the three report types, a message, the screenshot checkbox (only
when a renderer is given), the element picker, and a thank-you state. Pass
trigger: "#my-feedback-button" to use your own button instead of the
floating one, or trigger: false and call open() yourself. About 11.3 kB
gzipped, no framework.
contact: true adds one more field, under the message: how to reach the
reporter. It is off by default, because asking for an address is a promise to
answer and that promise is yours to make. contact: "required" refuses to
send without it, through the same inline error an empty message gets. The
field is an ordinary text input with inputmode="email", for the keyboard it
brings up on a phone; the type is deliberately not email, because that plus
required would mark a phone number invalid and a screen reader would announce
it as an error. Nothing validates what is typed — "call me on 12345678" is a
perfectly good answer, and it arrives as contact on the report either way. It is
personal data once it is on: see Please read this part.
"Edit picture" over the attached screenshot is the one thing the panel does
not carry by itself: hand in createAnnotator and you get the button, leave
it out and the canvas editor is not in your bundle at all. See
Marking the picture.
| Option | Effect |
|---|---|
| endpoint | Where the report is POSTed. Required. |
| screenshot | A ScreenshotRenderer. Without it the screenshot row is not rendered. |
| annotate | createAnnotator from bugbottle/annotate renders "Edit picture"; omitted or false, nothing leads to an editor and none of it is bundled. |
| contact | true adds an optional field asking how to reach the reporter; "required" refuses to send without it. Off by default. What they type travels as contact on the report. |
| elementPicker | false leaves the picker out. Default true. |
| locale, texts, messages | The language, and per-string overrides of it. |
| theme, brand | Colours, radius, position; the name and logo in the header. |
| types, initialType | Which report types to offer, and which starts selected. |
| consoleFor, screenshotFor | Per type: attach the console, tick the screenshot box. Both default to bugs only. |
| mask | What to hide in the screenshot; false photographs the page as it is. See Masking. |
| trigger | false for no floating button, or an element or selector to use your own. |
| shortcut | The combination that opens the panel. Default mod+shift+b; false installs no listener. |
| shake | Open the panel when the phone is shaken. Off by default; hand in onShake from bugbottle/shake, or { on: onShake, threshold, cooldownMs }. See Shake to report. |
| network | Record the failed and slow requests while the panel is mounted. Off by default; hand in initNetwork from bugbottle/network, or { on: initNetwork, all, slowMs, maxEntries, ignore, beforeRequest }. The panel's endpoint is passed on unless you name one. The same switch as data-network. See What the network did. |
| perf | Record the Web Vitals and the storage snapshot while the panel is mounted. Off by default; hand in initPerf from bugbottle/perf, or { on: initPerf, vitals, storage, allowValues, maxKeys }. The same switch as data-perf. See Performance and storage. |
| openOnError | Open the panel on an uncaught error; { prefill: true } also fills the box. |
| queue, scrub, sign, beforeSend | The same seams the plain functions take. |
| extra, headers, credentials, timeoutMs, fetch, parseError | Passed through to buildReport and sendReport. |
| container, onSent, onError | Where to mount, and what to do afterwards. |
Accessibility. The panel is meant to be switched on without an
accessibility regression, so it behaves like a dialog rather than a floating
div. While it is open, focus is trapped inside the shadow root — Tab wraps at
both ends — and Escape closes it; closing puts focus back on whatever opened
it, the floating trigger or your own control. The report types are a
radiogroup the arrow keys walk through, one stop in the tab order, and so
are the drawing tools. Every
control has a name: the trigger, the close button, the group of types, the
screenshot note (as aria-describedby on the checkbox), each remove
button, which is named after the element it removes rather than being one of
several buttons called "Remove", and every control of the picture editor —
whose canvas carries a name that also says which keys work on it, since
nothing on screen mentions them. Closing the editor puts focus back on the
button that opened it. A polite live region announces status
messages, and announces the element picker starting and stopping, with the way
out — that mode hides the panel and changes the pointer, neither of which a
screen reader reports. Targets are at least 24x24, focus rings are visible in
both colour schemes, the dark scheme lightens the accent and the error red so
they hold their contrast. A reporter who asks for
prefers-reduced-motion: reduce gets every state change at once rather than
over time: the panel still opens, the picture still attaches and the status is
still announced, none of it travels there, and a smooth scroll is switched off
along with the transitions. axe-core
reports no violations on the panel open in either scheme, closed, with the
picture editor open in either scheme, with the contact field on in either
scheme, or with reduced motion asked for. That last state is read for movement
as well, because axe has no rule for it: no element in the shadow root may have
a transition or a running animation longer than zero seconds. Windows High Contrast is audited too — the panel and the picture editor
again under forced-colors: active, and then photographed, because axe reads a
stylesheet the browser has already overridden: the picture proves the trigger
still has an edge, the selected type still differs from the two beside it, its
label still reads as a word and the focus ring still shows. Run the
audit yourself with npm run build && npm run a11y (Chrome and
puppeteer-core required). All of the announced text comes from the locale,
so it is announced in the reporter's language.
One script tag
For a site with no build step — a WordPress theme, a static page, a client
site somebody else deploys — dist/bugbottle.js is a self-contained bundle
that mounts the panel from the tag itself, the annotator included. About
23.9 kB gzipped:
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bugbottle.js"
data-endpoint="/api/feedback"
data-locale="da"
data-primary="#e11d48"
data-brand="Mahope"
></script>dist/ is committed, so the same file is on jsDelivr from the git tag as well:
https://cdn.jsdelivr.net/gh/mahope/[email protected]/dist/bugbottle.js. Pin a
version in either form; @latest is a way to have a stranger's next release
run on your page.
Two builds
There are two files, and they are the same panel:
| File | Gzipped | What is in it |
|---|---|---|
| dist/bugbottle.js | 24.2 kB | Everything: the annotator, the timings and storage snapshot, shake-to-report and the network log, all switchable from an attribute. |
| dist/bugbottle.slim.js | 20.6 kB | The same panel, the console, breadcrumbs, the element picker, the offline queue, the scrubber, the signer and all eight locales — without those four. |
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bugbottle.slim.js"
data-endpoint="/api/feedback"
></script>From the git tag it is
https://cdn.jsdelivr.net/gh/mahope/[email protected]/dist/bugbottle.slim.js.
The slim build reads every attribute in the table below except four, which it
ignores because the code behind them is not in it: data-annotate,
data-perf, data-shake and data-network. Write one and it says
so on the console once, in English — that is a message to whoever wrote the
script tag, not to the reporter, so it is not translated. window.bugbottle
is the same namespace without createAnnotator, initPerf, onShake,
requestShakePermission and initNetwork.
The saving is smaller than the four modules weigh on their own, because inside one bundle they share gzip's dictionary, and two of their costs stay behind on purpose: the annotator's labels are in all eight locales, which are data, and the panel's own annotator toolbar is a static import. Every language still works in the slim build; that is the trade it makes.
| Attribute | Effect |
|---|---|
| data-endpoint | Where the report is POSTed. Required — without it nothing mounts. |
| data-locale | Language tag through resolveLocale. Defaults to <html lang>, then English. |
| data-position | bottom-right (default), bottom-left, top-right, top-left. |
| data-primary | Accent colour of the button and the primary action. |
| data-brand | Name in the panel header. |
| data-logo | Image URL shown before the title and on the trigger. |
| data-trigger | Selector for your own button. Without it, the floating one is rendered. |
| data-contact | Present, with any value, asks the reporter how to reach them; required also refuses to send without it. Off without the attribute. |
| data-scrub | Present, with any value, redacts the report with scrubReport before it is sent. |
| data-network | Present, with any value, records the failed and slow requests. The same switch as the panel's network option. See "What the network did". |
| data-perf | Present, with any value, records the Web Vitals and lists what is in the browser's stores — names and lengths, never values. The same switch as the panel's perf option. See "Performance and storage". |
| data-sign-key | Signs the body with this key. A key in the page source is public, so this deters spam rather than authenticating anybody; see Signing requests. |
| data-queue | Present, with any value, keeps a failed report in localStorage and sends it when the browser is online again. With data-sign-key the queued reports are signed at delivery too. See "When the network is down". |
| data-extra | JSON object merged into every report, e.g. data-extra='{"appVersion":"1.4.2"}'. |
| data-mask="off" | Stops masking the screenshot. Only matters once you give mount a renderer; see Masking. |
| data-annotate="off" | Leaves out "Edit picture" and its rectangle, arrow and blur. This build carries the annotator, so the attribute only switches it off; it does not make the file smaller. Only matters once you give mount a renderer; see Marking the picture. |
| data-shortcut | The combination that opens the panel. mod+shift+b unless you say otherwise; off installs no listener. |
| data-shake | Present, with any value, opens the panel when the phone is shaken; a number is the threshold in m/s² (data-shake="12" is a lighter flick). On iOS nothing arrives until the page calls window.bugbottle.requestShakePermission() from a button of its own. See "Shake to report". |
| data-open-on-error | Present, with any value, opens the panel on an uncaught error. prefill also fills the message in. |
The tag also patches the console immediately and starts breadcrumbs, so an error thrown before the page finishes loading is still in the report.
There is no screenshot in this build. A renderer means html-to-image, which
is far larger than everything else here put together, and forcing it on every
page that only wants the panel is the wrong trade. The bundle exposes the
building blocks on window.bugbottle — mount (mountBugbottle),
initConsoleBuffer, initBreadcrumbs, initNetwork, initPerf,
createQueue,
locales, resolveLocale, scrubReport, buildReport, sendReport,
pickElement, createAnnotator, onShortcut, onUncaughtError,
onShake, requestShakePermission and
version — so a
page that wants pictures can load html-to-image itself and call
window.bugbottle.mount({ endpoint, screenshot }). Leave data-endpoint off
the tag and nothing mounts on its own:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bugbottle.js"></script>
<script>
window.bugbottle.initConsoleBuffer();
window.bugbottle.mount({
endpoint: "/api/feedback",
locale: window.bugbottle.locales.da,
scrub: window.bugbottle.scrubReport,
});
</script>Subresource integrity. A CDN is a third party executing code on your site. Pin the file with its hash so a swapped file cannot run:
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bugbottle.js"
integrity="sha384-…"
crossorigin="anonymous"
data-endpoint="/api/feedback"
></script>jsDelivr shows the hash on the file's page, or compute it yourself:
curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A. The hash
changes with every version, so it has to be updated with the version.
Languages and branding
Every string a reporter sees lives in a Locale: six status messages and
the widget's ui labels. bugbottle/locales ships English, Danish, Swedish,
Norwegian, German, Dutch, French and Spanish, and resolveLocale(navigator.language)
picks one. Override any label, or write a locale of your own — the type tells
you what is required:
import { da, resolveLocale } from "bugbottle/locales";
useBugReport({ endpoint, messages: da.messages });
mountBugbottle({
endpoint,
locale: resolveLocale(navigator.language),
texts: { title: "Hjælp os med at gøre det bedre", trigger: "Fejl?" },
messages: { sent: "Tak — vi kigger på det i morgen tidlig" },
});Five more languages, imported on purpose
A locale is data, and data is carried whole: everything that imports
bugbottle/locales pays for all eight languages in it. Italian, Polish,
Portuguese, Finnish and Ukrainian are in a second entry so that a site in one of
them can have its own language without every other site growing. Import the
whole map, or the single language the site is in:
import { en, locales, resolveLocale } from "bugbottle/locales";
import { localesExtra } from "bugbottle/locales-extra";
const all = { ...locales, ...localesExtra };
mountBugbottle({ endpoint, locale: resolveLocale(navigator.language, en, all) });resolveLocale takes the map to look in as its third argument, so nothing about
this reaches a bundle that does not ask for it — not the core, and not either
script-tag build, which still carry the eight. pt is European Portuguese and
pt-BR resolves to it, the way da-DK resolves to da.
Branding and theme
The panel's look comes from theme and from brand (name, logo as an
image URL or inline SVG). Every value in theme is also a CSS custom property
on the host element, so a stylesheet can restyle the panel without touching
JavaScript:
[data-bugbottle="ui"] { --bb-primary: #0f766e; --bb-font: "Inter", sans-serif; }The whole list, and nothing else:
| CSS variable | theme key | What it is |
| --- | --- | --- |
| --bb-primary | primary | The brand colour: the button, the send action, the marks the annotator draws |
| --bb-on-primary | onPrimary | The text and icons that sit on the brand colour |
| --bb-bg | background | The panel's ground |
| --bb-text | text | The ink |
| --bb-muted | muted | Hints, notes and the status line |
| --bb-border | border | The rules around the panel and its fields |
| --bb-radius | radius | The corner radius, as a CSS length |
| --bb-font | font | The font stack the panel is set in |
| --bb-shadow | shadow | The shadow under the panel |
| --bb-z | zIndex | Where the panel sits against the application |
| — | position | Which corner it lives in: "bottom-right" (the default), "bottom-left", "top-right" or "top-left" |
| — | scheme | "light", "dark" or "auto" — "auto" follows the reader's system setting |
The last two are not custom properties: they are the data-pos and
data-scheme attributes on the host, which the panel's own stylesheet reads.
None of these survive Windows High Contrast, and they are not meant to. Under
forced-colors: active the browser throws every colour away and substitutes
one from the reporter's own palette, so the panel stops asking for its brand
colour and says the same things in system colours instead: ButtonText for the
edge of the trigger and the send button, whose background was their whole
shape; Highlight and HighlightText for the selected report type, the active
drawing tool and the armed element picker; Highlight for the focus ring; and
GrayText for a disabled action, since forced colours do not dim an opacity.
The attached picture and the annotator canvas keep their own pixels — a mark's
colour is the mark — and so does anything a mark was drawn in. There is nothing
to configure: it happens when the reporter's system asks for it.
The theme playground
on the documentation site restyles a real panel as you move these controls and
prints the mountBugbottle call and the CSS block to copy. It prints only the
keys you moved, so the block you copy leaves scheme alone and your panel goes
on following the reader's system setting.
Pointing at the element
Most reports are about one thing on the page. pickElement() turns the cursor
into a crosshair, highlights whatever is under it, and resolves with a
description of the element the reporter clicks: a short CSS selector, the tag,
the visible text, the position on the page, and the useful attributes (id,
name, role, aria-label, href, every data-*). Escape cancels. The click
is swallowed, so picking a button does not also press it.
{
"selector": "form#checkout > button:nth-of-type(2)",
"tag": "button",
"text": "Save order",
"rect": { "x": 912, "y": 640, "width": 118, "height": 36 },
"attributes": { "type": "submit", "data-testid": "save-order" }
}The selector prefers an id or a data-testid on the element or an ancestor,
then falls back to tag:nth-of-type steps, at most five deep. It is meant to
be read by a person or an agent, and to land on the right file — not to be a
stable locator for a test suite.
What happened before
bugbottle/breadcrumbs records the last few things the reporter did, so the
report says what was happening when it broke — not only what broke. It is a
separate entry point: an application that does not import it does not carry it.
import { initBreadcrumbs } from "bugbottle/breadcrumbs";
initBreadcrumbs();Four things are recorded and nothing else: clicks (a short selector and the
element's visible text), navigation (path and query, including pushState and
replaceState, which fire no event of their own), form submits (the selector
only) and visibility changes. The last 30 are kept.
[
{ "ts": "2026-09-07T08:12:29.100Z", "kind": "click", "target": "button#save-order", "text": "Save order" },
{ "ts": "2026-09-07T08:12:30.400Z", "kind": "navigation", "from": "/orders/1", "to": "/orders/2?tab=notes" },
{ "ts": "2026-09-07T08:12:31.000Z", "kind": "submit", "target": "form#checkout" },
{ "ts": "2026-09-07T08:12:34.700Z", "kind": "visibility", "to": "hidden" }
]buildReport attaches them on its own while the buffer is recording, as
breadcrumbs; pass includeBreadcrumbs: false to leave them out of one
report. toMarkdown renders them as a "What happened before" list.
Deliberately absent: input values, keystrokes, and anything read out of a field. A breadcrumb says where someone clicked, never what they typed. On top of that, three controls are yours:
initBreadcrumbs({
maxEntries: 30,
beforeBreadcrumb: (crumb) =>
crumb.to?.startsWith("/admin") ? null : { ...crumb, to: crumb.to?.replace(/\/\d+/, "/:id") },
});beforeBreadcrumbsees every breadcrumb before it is stored. Return null to drop it, or a changed one to redact it.- Anything inside
[data-bugbottle]is skipped entirely — that is the library's own furniture, including the panel frombugbottle/ui. - Anything inside
[data-bugbottle-mask]records its selector and no text, so a name, an address or an amount on a card never travels with the click.
getBreadcrumbs() returns a copy of the timeline, and resetBreadcrumbs()
empties it, removes the listeners and puts history back as it found it.
initBreadcrumbs returns that same function as its stop(), so a caller that
started the recorder can undo it without importing a second name.
What the network did
Breadcrumbs say what the reporter did; bugbottle/network says what the
browser did about it. "The save button does nothing" is a different report
when it arrives with the 500 from POST /api/orders that caused it. It is a
separate entry point too, and opt-in: it patches fetch and
XMLHttpRequest, which is a bigger promise than adding a listener.
import { initNetwork } from "bugbottle/network";
initNetwork({ endpoint: "/api/feedback" });Only the interesting requests are kept: a status of 400 or more, a request
that failed before it got a status (status: 0, error: true), and anything
slower than slowMs — 2000 ms by default. A fast 200 is the request that
worked, and there are hundreds of those in a session; recorded, they would
evict the one that explains the report. The last 30 are kept.
[
{ "ts": "2026-09-07T08:12:31.004Z", "method": "POST", "url": "/api/orders", "status": 500, "ms": 812 },
{ "ts": "2026-09-07T08:12:33.900Z", "method": "GET", "url": "/api/orders/42", "status": 0, "ms": 30, "error": true },
{ "ts": "2026-09-07T08:12:36.100Z", "method": "GET", "url": "https://api.stripe.com/v1/charges", "status": 200, "ms": 3400 }
]buildReport attaches them on its own while the recorder is active, as
network; pass includeNetwork: false to leave them out of one report.
toMarkdown renders them as a "Requests" table.
Never recorded: request or response bodies, and never headers. That is
where tokens, cookies and personal data live, and a bug report is not the
place for any of them. What is left is the method, the URL, the status and the
duration. The URL keeps its path and query with sensitive query values
redacted (?token=… becomes ?token=[redacted]); a cross-origin URL keeps
its origin, because which host failed is half the answer.
initNetwork({
endpoint: "/api/feedback",
slowMs: 2000,
all: false,
maxEntries: 30,
ignore: (url) => url.startsWith("/api/analytics"),
beforeRequest: (entry) =>
entry.url.startsWith("/admin") ? null : { ...entry, url: entry.url.replace(/\/\d+/, "/:id") },
});all: truerecords every request, not only the failed and the slow ones.beforeRequestsees every entry before it is stored. Return null to drop it, or a changed one to redact it. A hook that throws drops the entry and never reaches your application.- Requests to
endpointare skipped, so a report never describes its own delivery.ignorereplaces that check when you need a different rule.
The patched fetch always calls the original and hands back its result
untouched, rejections included; XMLHttpRequest is timed with loadend, the
one event that fires for every ending. getNetwork() returns a copy of what
has been recorded, and resetNetwork() empties it and puts both globals back
as it found them. initNetwork returns that same function as its stop().
The ready-made panel can start it for you: network: initNetwork on
mountBugbottle records for as long as the panel is mounted and stops on
destroy(), and the panel's own endpoint is passed on so a report never
describes its own delivery. { on: initNetwork, all: true } tunes it. The
panel never imports the module — you hand it in — so a page that records
nothing carries nothing. From the script tag it is data-network, which is
the same switch.
Performance and storage
Two questions a report almost never answers and almost always needs to: was it
slow, and what state was the browser in? bugbottle/perf answers both without
bundling web-vitals. It is a separate entry point and opt-in, and it should
be called as early as your app can manage — ideally in the same module that
mounts the panel.
import { initPerf } from "bugbottle/perf";
const stop = initPerf();The observers are created with buffered: true, so the LCP that was painted
while your application was still booting is delivered anyway; the browser's
buffer is finite, which is why "as early as you can" is not a formality.
report.perf carries the Web Vitals the browser has already measured, the
milestones from the navigation entry, the long tasks and — on Chromium only —
the JS heap. Every field is optional, because every field is a measurement
that may not have happened, and a figure that was never measured is left out
rather than sent as a zero.
{
"lcp": 3412, // milliseconds, the last candidate the browser reported
"cls": 0.081, // cumulative layout shift, three decimals
"inp": 210, // the worst interaction, in milliseconds
"ttfb": 128,
"domContentLoaded": 641,
"load": 1200,
"longTasks": { "count": 3, "totalMs": 480 },
"memory": { "usedMB": 32, "limitMB": 2048 }
}Two simplifications, said plainly because a number in a bug report is only
worth what its definition is. CLS here is the sum of every shift that did
not follow a recent input, where the Web Vitals definition takes the worst
session window instead: on a page that shifts repeatedly this reads high
rather than low, which is the safe direction for evidence. INP here is the
worst interaction, where the real metric is roughly the 98th percentile: on
the handful of interactions a session usually has these are the same number,
and on a long session this over-reports rather than hides. first-input is
observed too, so a browser without the event type still contributes its FID.
report.storage says what was in the browser's stores at the moment the
report was written — not when initPerf ran, because what matters is the
state the reporter was actually in.
{
"local": [{ "key": "theme", "length": 4 }, { "key": "authToken", "length": 132 }],
"session": [{ "key": "cart", "length": 7 }],
"cookies": ["session", "consent"],
"values": { "tenant": "acme" }
}Key names and value lengths, never values, and cookie names without cookie
values. That a key called authToken is present and 132 characters long is
usually the whole answer to "why was I logged out"; its contents are the
session itself, and a bug report is not a place to put one. The one exception
is values, and it is opt-in per key:
initPerf({
allowValues: ["tenant", "featureFlags"], // nothing travels unless it is named here
maxKeys: 50, // keys listed per store; 50 is also the ceiling
storage: false, // measure the timings only
vitals: false, // snapshot the storage only
});allowValues looks each name up in localStorage first and then
sessionStorage, and clips what it finds to 200 characters. A cookie value is
never included, whatever the allow-list says. Run scrubReport over the report
as well if the allow-listed keys can hold anything written by a person: the
scrubber redacts storage.values and the cookie names, and leaves the key
names and lengths alone, since those are the shape of the store and the point
of the snapshot.
buildReport attaches both blocks on its own while initPerf is measuring, as
perf and storage; pass includePerf: false to leave them out of one
report. toMarkdown renders a "Performance" table and a collapsed "Storage"
block. initPerf returns the stop() that disconnects the observers and
unregisters both — the same thing resetPerf() does. The ready-made panel
takes it the same way the network log is taken: perf: initPerf on
mountBugbottle starts it on mount and stops it on destroy(), and
{ on: initPerf, storage: false } tunes it. In the one-script-tag build it is
data-perf on the script tag, which is the same switch.
Replay with rrweb
If your application already records with rrweb,
the half-minute before the reporter opened the panel can travel with the
report. bugbottle/rrweb is an adapter and not a recorder: rrweb is not a
dependency and is never imported here, so you hand your own record in, the
same way you hand the screenshot renderer in.
import { record } from "rrweb";
import { attachRrweb } from "bugbottle/rrweb";
const stop = attachRrweb(record); // 30 s, 512 KiB
// or
attachRrweb(record, { seconds: 15, maxBytes: 256 * 1024 });Please read this part too
A replay is a recording of a person using your software: what they typed, what they had on screen, in what order and how long it took them. Everything the "Please read this part" section says about screenshots applies here, and applies twice — a screenshot is one moment and a replay is all of them. Turn this on for your own staff, on your own staging environment, or on an application whose users have been told; do not turn it on quietly on a page that shows one person's data to another.
Two specifics worth knowing before you do.
scrubReport does not look inside a replay. The scrubber works field by field
over a report it understands, and rrweb's event payload is somebody else's
format — walking it and rewriting strings would corrupt the recording as often
as it redacted anything. rrweb's own masking is the whole control here.
This adapter therefore sets maskAllInputs: true unless you override it, and
maps the markers you already use for screenshots onto rrweb's selectors:
data-bugbottle-mask becomes maskTextSelector, and data-bugbottle-block
becomes blockSelector alongside data-bugbottle, so the panel never films
itself. Everything else rrweb offers — maskTextClass, maskInputFn,
ignoreClass — goes through recordOptions:
attachRrweb(record, { recordOptions: { maskTextClass: "sensitive" } });And the receiver has the last word. handleReport takes replay: "drop",
which throws the recording away before anything is scrubbed, deduplicated,
stored or rendered — the setting for a deployment whose client has rrweb wired
up before its storage policy is decided.
How the buffer holds thirty seconds
rrweb emits events for as long as it runs, and a report wants the recent past
only. The adapter asks rrweb for a fresh full snapshot every ten seconds
(checkoutEveryNms), and a full snapshot is the only place a recording can be
cut: everything after one is a diff against it, so dropping events out of the
middle leaves something that will not play. The buffer therefore keeps whole
checkout groups and drops the oldest — first when it is entirely outside the
window, then again while the serialised buffer is over maxBytes. The newest
group is never dropped, so seconds is a floor and not a promise: you get at
least what you asked for, up to ten seconds more, and less than that only in
the first seconds after the page loaded.
If one snapshot alone is over the cap — a page too large to record at that size — nothing is attached at all, on the same principle as an oversized screenshot: half a replay is not half as useful, it is useless.
buildReport attaches the buffer on its own while attachRrweb is recording,
as replay: { events, seconds }; pass includeReplay: false to leave it out
of one report. attachRrweb returns the stop() that stops the recorder,
empties the buffer and unregisters it — the same thing resetRrweb() does.
On the server, normaliseReplay keeps the events that are objects with a
numeric type and timestamp, strips null bytes, recomputes seconds from
what survived, and drops the whole replay when it serialises to more than
MAX_REPLAY_BYTES (1 MB). That cap and maxBytes are both UTF-8 bytes, not
characters: a recording of a page written in Chinese weighs up to three times
its length. toMarkdown prints one line — Replay: 240 events
over 32 s (attached) — because the events are for a player and not for a
reader. store writes them with the rest of the report; no sink uploads them
anywhere.
The one-script-tag build does not carry this. Without a bundler there is no
record to hand in, and the adapter is three lines for anybody who has one.
Feeding reports to an agent
A report with a selector, the element's text, the page path and the last few console errors is usually enough context for a coding agent to find the code and propose a fix without a conversation. A pattern that works well: store reports as they arrive, and have a scheduled agent session each morning pull yesterday's, group them by application, and present them for a yes / no / how decision — then let it carry on from the ones approved. The reporter never had to describe where the button was; the payload already says.
toMarkdown from bugbottle/server renders a report for exactly that — or
for a GitHub issue, a Slack message, an email:
import { toMarkdown } from "bugbottle/server";
const body = toMarkdown(payload, {
facts: { App: "checkout 1.4.2", User: user.id },
screenshotUrl: await storeScreenshot(screenshot), // your private route
});## Bug: The save button does nothing
The save button does nothing
| | |
|---|---|
| Type | Bug |
| Contact | [email protected] |
| Page | `/orders/42?tab=notes` |
| Viewport | 1440x900 |
| Browser | Mozilla/5.0 … |
| Last console entry | 2026-09-07T08:12:31.004Z |
| App | checkout 1.4.2 |
### Element pointed at
- `form#checkout > button:nth-of-type(2)` — "Save order" (type="submit") at 912,640 118×36
<details><summary>Console (1 entr