Open source · MIT · zero dependencies

bugbottle

Headless in-app bug reports that arrive with the evidence attached. Your UI, your endpoint, a few kilobytes.

npm i bugbottle

What it is

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.

Headless

You render the form. bugbottle owns the state, the capture and the submit — not your markup.

Bring your own backend

No dashboard, no hosted service. A report is a JSON body on a fetch; the receiving end is a route you write, with validation helpers shipped alongside.

Tiny

Core ~0.8 kB gzipped; with picker and React hook 3.9 kB; ready-made panel 6.9 kB; the everything script tag 11.6 kB.

Sends itself onward

Email via Resend, Slack, Discord or plain webhook, or a GitHub issue — server-side helpers, 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.

Install

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

Without npm: install the tagged release straight from GitHub (npm install github:mahope/bugbottle#v0.4.0) or import the built files from jsDelivr — dist/ is committed for exactly that.

1. Record console errors, then build a form (React)

import { initConsoleBuffer } from "bugbottle";
import { useBugReport } from "bugbottle/react";
import { htmlToImage } from "bugbottle/html-to-image"; // optional

initConsoleBuffer(); // once, as early as your app can manage

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)} />
      <button type="submit" disabled={report.status === "sending"}>Send</button>
    </form>
  );
}

2. Anything else: 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);

Would rather not build a form at all? bugbottle/ui mounts a floating button and a small dialog in a shadow root, and dist/bugbottle.js does the same from a single <script> tag with data-endpoint, data-locale and data-brand attributes.

3. Receive it on the server

import {
  decodeScreenshotDataUrl, InvalidScreenshotError, isReportType,
  normaliseConsole, normaliseContext, normaliseElements, normaliseMessage,
} from "bugbottle/server";

export async function POST(req: Request) {
  const payload = await req.json();
  const message = normaliseMessage(payload.message);
  if (!message) return Response.json({ error: "Write a message first" }, { status: 400 });

  const type = isReportType(payload.type) ? payload.type : "other";
  const context = normaliseContext(payload.context);
  const console = normaliseConsole(payload.console);
  const elements = normaliseElements(payload.elements);

  let screenshot: Uint8Array | null = null;
  try {
    if (payload.screenshotDataUrl) screenshot = decodeScreenshotDataUrl(payload.screenshotDataUrl);
  } catch (err) {
    if (!(err instanceof InvalidScreenshotError)) throw err; // a bad picture must not fail the report
  }

  const { id } = await save({ type, message, context, console, elements, screenshot });
  return Response.json({ id }, { status: 201 });
}

Works unchanged in a Next.js route handler, Hono, Cloudflare Workers, Bun, Deno or anything else built on the web Request. The helpers never trust the browser: they trim, clip, strip null bytes, keep the most recent 50 console entries and verify the real PNG signature and size before a screenshot reaches your storage.

Read more