Get started
Install bugbottle
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/bugbottle@v1.0.1/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.
A first report, end to end #
Everything above is a promise; this is the proof of it. Two files, and the
first of them is the button. initConsoleBuffer() runs once at start-up,
because a
report can only carry the console errors that were recorded before the
reporter pressed the button — that one call is the whole difference between a
report and a screenshot:
// app/report-button.ts
import { initConsoleBuffer, buildReport, sendReport } from "bugbottle";
initConsoleBuffer();
reportButton.addEventListener("click", async () => {
const report = buildReport({ type: "bug", message: messageInput.value });
const { id } = await sendReport("/api/bugbottle", report);
console.log("stored as", id);
});The receiving half is one route handler, and it needs no database — one JSON file per report, with the picture beside it when there is one:
// app/api/bugbottle/route.ts
import { handleReport, fileStore } from "bugbottle/server";
const store = fileStore({ dir: "./reports" }).store;
export async function POST(request: Request) {
return handleReport(request, { store });
}handleReport checks every field before it reaches your disk, so the file in
./reports is already a validated report and not a guess. The reply is
201 { id }, and that id is the one sendReport handed back above.
export async function POST is a Next.js route handler, a Hono handler minus
the app.post, a Cloudflare Worker and a Bun route. The same file compiles
wherever you already speak the web Request; where you do not,
expressHandler and
fastifyHandler are the translation.
The report above carries no picture, on purpose: captureScreenshot needs
html-to-image, and a report is worth sending without one. Add the capture
when you want the screenshot, reach for
bugbottle/ui when you would rather not write the
form, and read Receiving a report when you want the
sinks that turn the file into a Slack message, a GitHub issue or an email.
A working example is the same round trip you can run,
if you would rather read someone else's two files than write yours first.