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.

The bugbottle panel open over a page: a message about a save button that does nothing, the element the reporter pointed at, and a send button.

bash
npm install bugbottle
npm install html-to-image   # optional, only if you want screenshots

Without npm, install the tagged release straight from GitHub, or import the built files from the jsDelivr CDN — dist/ is committed for exactly that:

bash
npm install github:mahope/bugbottle#v1.0.1
js
import { initConsoleBuffer, buildReport, sendReport } from "https://cdn.jsdelivr.net/gh/mahope/bugbottle@v1.0.1/dist/index.js";

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:

ts
// 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:

ts
// 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.

Edit this page on GitHub