Server

Receiving a report

handleReport is the whole endpoint. It validates every field with the helpers below, optionally scrubs, decides what happens to the screenshot, stores the report and runs the sinks you configured — and answers with a Response:

ts
import { handleReport, toResend } from "bugbottle/server";

export const POST = (req: Request) =>
  handleReport(req, { sinks: [toResend({ apiKey, from: "[email protected]", to: "[email protected]" })] });

That works unchanged in a Next.js route handler, Hono, Cloudflare Workers, Bun and Deno: they all speak the web Request. Nothing about it is magic, and everything is an option:

ts
export const POST = (req: Request) =>
  handleReport(req, {
    authorize: async (r) => Boolean(await getUser(r)),     // false → 401
    maxBodyBytes: 4 * 1024 * 1024,                         // over it → 413
    bodyTimeoutMs: 15_000,                                 // slower than that → 408
    scrub: true,                                           // redact on the way in
    screenshot: async (bytes) => await putPrivate(bytes),  // returns a URL
    store: async (report, screenshot) => await db.reports.insert(report),
    sinks: [toGithub({ token, owner: "acme", repo: "app", labels: ["bug"] })],
    sinkTimeoutMs: 10_000,                                 // a hung sink is a failed sink
    onSinkError: (err) => logger.warn({ err }, "sink failed"),
    cors: "https://app.acme.com",                          // answers OPTIONS too
    rateLimit: { limit: 20, windowMs: 60_000 },
    dedupe: { windowMs: 60_000 },                          // the same report twice → 200
  });

Only a POST carries a report: anything else is answered with 405, and an OPTIONS preflight is answered before that when cors is set — reflecting the Access-Control-Request-Headers the browser asked for, so your own header (a CSRF token, a tracing id) needs no configuration here. Every answer that carries Access-Control-Allow-Origin carries Vary: Origin beside it, so a shared cache in front of the endpoint cannot hand one origin's answer to another; a respond of your own that already varies on something keeps it.

The body is bounded in two directions. maxBodyBytes is counted on the stream as well as read from content-length, which is a claim rather than a fact, and bodyTimeoutMs (15 s by default) bounds the whole read, so a sender that dribbles one byte at a time is answered with 408 instead of holding the connection open. sinkTimeoutMs (10 s by default) does the same for a delivery: a sink that has not answered by then is abandoned and counted in sinkErrors and onSinkError exactly like one that threw, and the sink is handed an AbortSignal in its context that it can pass to fetch.

store receives a ValidatedReport{ type, message, context, console, elements, breadcrumbs, network, extra, receivedAt }, plus contact when the form asked for one and the reporter answered — and the decoded PNG when there was one. extra is every top-level key the client sent that bugbottle does not know about, so a tenant id or a build number arrives without a schema change; strings are clipped to 500 characters, numbers and booleans pass, and nested objects are dropped, as are __proto__, constructor and prototype, which mean something to the language rather than to you. The reply is 201 { id } when store returned an id and 202 {} when it did not; respond replaces it.

screenshot decides what happens to the picture: "keep" (the default) hands the bytes to store and to the sinks, "drop" never decodes it, and a function stores it and returns a URL that reaches toMarkdown and the sinks as screenshotUrl. Only "keep" hands bytes on — with a function, store and the sinks see the URL and no bytes, so the same picture is never both uploaded and attached. A rejected picture never fails the report, and neither does a storage bucket that is down: a screenshot function that throws reaches onError and the report is stored and delivered without a screenshotUrl.

rateLimit counts in memory by default, so it is per instance: fine per serverless isolate against one looping browser, and not a shared limit across a fleet — see Running more than one instance below when it has to be.

The key is the caller's address, and which address that is is a decision you have to make. A web Request carries no peer address, so hand in the one your runtime knows as remoteAddressexpressHandler reads req.socket.remoteAddress for you, and the inbox example passes the same socket — and then say with trustProxy whether a forwarding header may name somebody else instead:

ts
handleReport(request, {
  remoteAddress: req.socket.remoteAddress,
  // false (the default) — the connection address alone
  // true               — the last entry of X-Forwarded-For
  // { hops: 2 }        — two entries in from the right
  // { header: "CF-Connecting-IP" } — a header the platform writes itself
  trustProxy: true,
  rateLimit: { limit: 20, windowMs: 60_000 },
});

true reads the last entry of X-Forwarded-For, because that is the hop the nearest proxy appended and so the only one it wrote itself; everything to the left of it was written further out, up to and including the caller. Count the proxies you actually control and set hops to that number — a CDN in front of your own load balancer is { hops: 2 }. A chain shorter than hops falls back to the connection address rather than reaching further left.

Be honest with yourself about which way you would rather be wrong. Too little trust puts every visitor behind the proxy in one bucket, so the limit is 20 a minute for the whole site instead of per client; too much trust lets any caller pick their own key with one header and never meet the limit at all. The default is the first of those, because a shared bucket is a limit that is too strict and a trusted header nobody sets is no limit whatsoever.

The key is clipped to 64 characters and the bucket map is capped at 10 000 entries (expired buckets evicted first, then the oldest) so that a forged header cannot grow the map. Better still, count something you issued — your own key is handed the resolved address as its second argument:

ts
rateLimit: {
  limit: 20,
  windowMs: 60_000,
  key: (req, address) => sessionIdFrom(req.headers.get("cookie")) ?? address,
}

dedupe does not use the address at all: it keys on the report, not on who sent it. trustProxy changes the rate limit and nothing else.

dedupe answers a repeat of the same report with 200 { id, duplicate: true } — the id of the first one — without running store or the sinks again. What counts as the same report is fingerprint(report) from bugbottle: the type, the message and the first console error, hashed. The client computes it the same way from the same function, so a fingerprint written down by a sink means the same thing on both sides. It is compared after scrubbing, so two reports that differ only in what was redacted are one report. Pass key to decide for yourself. Like the rate limit it is in memory by default, so it is per instance: it stops one browser sending the same crash forty times, not two instances behind a load balancer storing it twice. Running more than one instance is how you make it the second thing.

For Express, expressHandler builds the Request and writes the Response back:

ts
import express from "express";
import { expressHandler, toWebhook } from "bugbottle/server";

app.post(
  "/api/bug-report",
  express.json({ limit: "5mb" }),
  expressHandler({ sinks: [toWebhook({ endpoint: process.env.SLACK_WEBHOOK_URL!, format: "slack" })] }),
);

It reads an already-parsed req.body when a parser ran and the raw stream when none did, so express.json() is convenient rather than required. A raw stream is counted against maxBodyBytes as it arrives: over the ceiling the adapter answers 413 and calls req.destroy() rather than buffering the rest of a body it has already refused.

Knowing what it decided #

An endpoint that refuses a report says so to the browser and to nobody else. onDecision is the other half: one call per request, with what was decided and why, so an audit line or a metric costs no response parsing.

ts
handleReport(request, {
  store,
  onDecision: (decision) => {
    console.log(JSON.stringify({ event: "bugbottle.decision", ...decision }));
  },
});
json
{"event":"bugbottle.decision","id":"9d1c…","status":201,"reason":"stored",
 "address":"203.0.113.7","fingerprint":"a41f…","at":1757260800000}

The reason is one closed set of words, and it is what the handler actually answers rather than a catalogue of HTTP:

reason Status What happened
stored 201 The report went through and store gave it an id.
accepted 202 It went through with no store to name it.
duplicate 200 The same fingerprint, inside the dedupe window.
not-post 405 Something that was never a report.
rate-limited 429 The caller over its rateLimit.
unauthorised 401 authorize said no.
too-large 413 A body over maxBodyBytes.
timeout 408 A body that stopped arriving inside bodyTimeoutMs.
bad-signature 401 The signature check did not verify.
invalid 400 Malformed JSON, or a report with nothing written in it.
error 500 Something unexpected. onError has already seen it.

Every answer goes through it, a respond of your own included — the status is read back off the response that went out, so a custom 204 is logged as 204 and not as the 201 it replaced. The one request it says nothing about is the CORS preflight, which decides nothing about a report.

address is the caller as trustProxy resolves it, which is the same address the rate limit counted against: the connection by default, the trusted forwarding header when you have said one may name the caller, and "unknown" when nothing could establish either. fingerprint is there once there is a valid report to fingerprint — the same fingerprint(report) the client and dedupe compute — so two lines about the same report tie together.

A decision carries no report. No message, no contact line, no picture, no console. That is deliberate: an audit line is written where logs are kept and copied where logs are shipped, and a bug report is somebody's data. If you want the report in a second place, that is what store and the sinks are for.

A hook that throws is caught, passed to onError and forgotten. The answer is already decided by then, and an audit sink being down is not the reporter losing their report.

A directory of files #

store is a function you write, and for a great many deployments the function you write first is "put it on the disk". fileStore is that written once:

ts
import { fileStore, handleReport } from "bugbottle/server";

const reports = fileStore({ dir: "./reports", maxReports: 2000 });

export const POST = (req: Request) => handleReport(req, { store: reports.store });

One JSON file per report, named <arrival time>-<id>.json so the directory sorts by age, with the decoded PNG beside it as <id>.png. The split keeps the JSON readable — a megabyte of base64 in the middle of a file makes it unopenable — and deleting a report is deleting two files nobody has to parse. maxReports (2000 by default) deletes the oldest once the directory is over it, because an inbox with no ceiling is a disk that fills; 0 keeps everything, which is a decision about a disk rather than a default. screenshots: false keeps the JSON and never writes a picture at all.

Five more calls are there for whoever builds a page over the directory:

ts
const listed = await reports.list();          // newest first, one small entry each
const found = await reports.read(id, { screenshot: true });
await reports.remove(id);                     // the JSON and the picture
const deleted = await reports.prune();        // retention, and how much it took
await reports.refresh();                      // walk the directory again

list() answers { id, file, title, type, url, receivedAt, screenshot } per report — the strings a list shows, without reading every file for them. The directory is walked once, on the first call that needs it, and kept up to date by every write and delete after that; read(id) then reads exactly the one file that was asked for, and only fetches the picture when you ask for it.

An entry read finds nothing behind is dropped from the listing then and there, so a report deleted by something outside this process stops being a link that answers 404 for the rest of the run. refresh() is the deliberate version of the same thing: it walks the directory again and answers with what is there now, which is what to call after a backup is restored underneath the inbox or when something else has been writing to the directory. Nothing calls it on its own — a walk on every request is the cost this index exists to avoid.

Retention. "Store it like personal data" needs a way to stop storing it. maxAgeDays is how long a report is kept, and prune() is what applies it: everything that arrived longer ago than that goes first, then everything over maxReports, JSON and picture together, and the number it answers with is how many reports went. It is off by default, because how long you may keep somebody's screenshot is a question about the promise you made them and the law where they live, not one a library can answer.

ts
const reports = fileStore({ dir: "./reports", maxReports: 2000, maxAgeDays: 90 });

await reports.prune();                                       // at start
setInterval(() => void reports.prune(), 60 * 60 * 1000).unref();  // and hourly

Nothing schedules it for you — a library owning a timer is a library that keeps a process alive — and the cap is the only rule a write applies on its own, so an inbox nobody is posting to only empties if something calls prune() with no request behind it. It is safe to call at any time, including beside a store halfway through a write, and the first call walks the directory, so what an earlier run left behind is pruned too. A report whose arrival time cannot be parsed is left alone rather than deleted on a guess, and a file this store did not name is never touched at all: a directory that also holds a note, a backup or somebody's export keeps all three. examples/inbox is this wired up, with the interval and a RETENTION_DAYS to set.

A queue of unsent reports in the browser has a lifetime of its own and this is not it: a report bugbottle/queue is holding because the endpoint was unreachable sits in that browser's storage until it is delivered or the queue's own limits drop it. Retention here is about what has arrived.

Two of its properties are worth saying out loud, because they are the reasons not to write this yourself:

It is a directory, not a database: one process is assumed to be the only thing writing to it, because the index is held in memory. Two instances over one volume want the real thing. examples/inbox is fileStore plus a password, a list and a detail page, and is the shortest way to see it working.

Signing requests #

An endpoint that anybody can POST to will eventually be found by somebody with a loop. bugbottle/sign puts an HMAC-SHA-256 on the body and handleReport checks it, so the obvious rubbish is refused before a row is written.

Read this paragraph before you turn it on. A key that ships to a browser is public. It is in the bundle, and anyone who opens the network tab or the JavaScript can copy it. Signing is spam deterrence, not authentication: it raises the price of posting to your endpoint from "curl in a loop" to "read their bundle and implement HMAC", and it makes a captured body unusable a second time. It is worth having beside rateLimit and authorize. It is not a reason to skip either of them, and it is no protection at all against somebody who wants in.

In the browser, pass the signer as sign wherever the endpoint is configured — the hook, the composable, the store, the panel, sendReport directly:

ts
import { createSigner } from "bugbottle/sign";

useBugReport({
  endpoint: "/api/bug-report",
  sign: createSigner({ key: import.meta.env.VITE_BUGBOTTLE_SIGN_KEY }),
});

With the script tag, it is one attribute:

html
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bugbottle.js"
        data-endpoint="/api/bug-report"
        data-sign-key="the-key-your-server-knows"></script>

On the server:

ts
export const POST = (req: Request) =>
  handleReport(req, {
    signature: { key: process.env.BUGBOTTLE_SIGN_KEY! },
    rateLimit: { limit: 20, windowMs: 60_000 },
    store: async (report) => await db.reports.insert(report),
  });

The header is X-Bugbottle-Signature: t=<unix ms>,v1=<hex>, where the hex is the HMAC-SHA-256 of <t>.<body> — the timestamp, a full stop, and the exact JSON that was sent. The timestamp is inside the signed message rather than merely beside it, so moving it to slip past the window breaks the signature. Any language can verify it: it is a plain HMAC.

Everything about the check is an option:

ts
signature: {
  key: [newKey, oldKey],   // any key in the list is accepted, which is how you rotate one
  header: "X-Sig",         // default X-Bugbottle-Signature
  maxSkewMs: 5 * 60_000,   // default; the clock may be wrong in either direction
  require: true,           // default whenever `signature` is set
  store,                   // optional; the default is in memory, per instance
}

t is digits and nothing else — t=0x1, t= 1 and t=1e12 are bad signatures, not clever spellings of a timestamp — and the digest is checked over the characters that arrived rather than over our idea of the same number.

Missing when required, wrong, outside the skew window, or already seen: all four answer 401 { error: "Bad signature" }, and they answer it identically, because telling a caller which part they got wrong is telling them how to get it right.

What the replay cache actually promises. Every accepted signature is remembered until the timestamp it signed ages out of the skew window — which is later than its arrival, because the window runs in both directions. By default that memory is a Map in the process, bounded at 128 digests per signed second and 640 seconds at a time. The per-second bound is the part worth understanding: the key is public, so anybody can mint valid signatures as fast as they can compute HMACs, and against one global ceiling that was a way to push your reporter's digest out of the cache and post their captured body again. Making room inside one second means a flood can only displace digests dated the same second it floods. So the guarantee is: a body captured from an honest report cannot be replayed at the instance that accepted it, unless the attacker also floods the exact second that report was signed in — and even then only within the window, and only at that one instance. Two instances behind a load balancer do not share the cache, and a serverless isolate that has just started has an empty one.

Hand in a signature.store when that is not enough — several instances, or a window wider than 640 seconds. It is one of the three seams in Running more than one instance below, and the only one that fails closed: a store that throws answers 500 rather than accept a signature nobody managed to check. Only a signature that already verified is ever written, so nobody can fill your store with digests of their own choosing.

Two things will surprise you if nobody says them:

With Express, mount the signed route without a body parser:

ts
app.post("/api/bug-report", expressHandler({ signature: { key: signKey } }));

The signature covers the exact text the browser sent. express.json() hands the adapter an object, which it has to re-serialise, and JSON.stringify(JSON.parse(x)) is not x — key order, spacing and number formatting all move, and the HMAC moves with them. Without a parser the adapter reads the raw stream itself and verifies what actually arrived.

Mount it the other way and every signed report is a 401 that looks exactly like a forged one. So the adapter says so: the first such request calls onError with a line naming express.json(), once per handler, and still answers the same 401. Give expressHandler an onError — it is where the explanation goes.

Running more than one instance #

Three things handleReport remembers between requests are a Map in the process: the rate-limit buckets, the dedupe fingerprints and the replay cache. That is honest and it is per instance. Two containers behind a load balancer each hand out the whole allowance, store the same crash twice and keep separate replay caches, and a serverless isolate that has just started remembers nothing at all.

Each of them is a seam, and nothing is bundled: the store is your client, and its methods may be synchronous or return promises.

ts
// Redis-shaped, but any key-value store with a TTL does. `redis` here is
// whatever client you already have; bugbottle does not depend on one.
handleReport(req, {
  rateLimit: {
    limit: 20,
    windowMs: 60_000,
    store: {
      // Count this request and answer with the total inside the window.
      hit: async (key, windowMs) => {
        const count = await redis.incr(`bb:rl:${key}`);
        if (count === 1) await redis.pexpire(`bb:rl:${key}`, windowMs);
        return count;
      },
    },
  },
  dedupe: {
    windowMs: 60_000,
    store: {
      // Anything `get` answers with is a duplicate; expiry is the store's job.
      get: async (key) => {
        const value = await redis.get(`bb:dup:${key}`);
        return value === null ? undefined : (JSON.parse(value) as { id?: string });
      },
      set: async (key, entry, expiresAt) =>
        void (await redis.set(`bb:dup:${key}`, JSON.stringify(entry), "PXAT", expiresAt)),
    },
  },
  signature: {
    key: process.env.BUGBOTTLE_SIGN_KEY!,
    store: {
      has: async (digest) => (await redis.exists(`bb:sig:${digest}`)) === 1,
      // `expiresAt` is the epoch millisecond the signature stops being
      // acceptable anyway, so it is exactly how long the row needs to live.
      add: async (digest, expiresAt) =>
        void (await redis.set(`bb:sig:${digest}`, "1", "PXAT", expiresAt)),
    },
  },
});

All three are called store, inside rateLimit, dedupe and signature. HandleReportOptions.store, the top-level one that persists a report, is a different option.

Two of them fail open and one fails closed, and that is deliberate. A rate-limit store that throws lets the report through: refusing an honest reporter with a 429 because Redis blinked loses the one report that was worth having, and the error reaches onError so you find out. So does one whose hit answers with anything but a finite number — "3", null, nothing at all — because "3" > 30 is false and so is NaN > 30, and a limit switched off in silence is worse than one that says so. A dedupe store that throws lets it through as well, on both halves — a duplicate costs a row and an email, a refusal costs the report — and so does one whose get answers with something that is not an entry, since a raw unparsed value taken at face value would make every report a duplicate. A signature store that throws answers 500, because the alternative is accepting a signature nobody managed to check against what has already been seen, which is exactly the replay the cache exists to stop.

Nothing else in handleReport keeps state between requests, so with all three handed in, a fleet answers as one endpoint. Give each of them a key prefix of its own, as above, and let the store expire the rows: every write says when it stops mattering.

The manual path #

If you want to see and control every step — or you already have a handler — call the validators yourself. This is the same sequence handleReport runs:

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

export async function POST(req: Request) {
  const user = await getUser();           // your auth
  if (!user) return new Response(null, { status: 401 });

  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) {
    // A rejected picture must not fail the report — the message is the
    // valuable part.
    if (!(err instanceof InvalidScreenshotError)) throw err;
  }

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

This too works unchanged in a Next.js route handler, Hono, Cloudflare Workers, Bun, Deno, or anything else built on the web Request. For Express, read req.body instead.

The helpers never trust the browser. normaliseMessage and normaliseContext trim, clip and strip null bytes (which Postgres refuses). normaliseConsole drops anything that is not a well-formed entry and keeps the most recent 50; normaliseElements does the same for pointed-at elements, keeping at most 10. decodeScreenshotDataUrl checks the declared type, the real PNG signature in the decoded bytes, and a size ceiling — so a JPEG wearing a PNG label, a login page returned as HTML, or a 40 MB payload never reaches your storage.

If your response has an id field, the client hands it to onSent. If a failed response has an error or message field, it is shown to the reporter.

An OpenAPI document #

If your APIs are gated on an OpenAPI description, you do not have to write this one. bugbottle/openapi.json is an OpenAPI 3.1 document generated at build time from the same two sources as everything else on this page: the report schema is its request body, ceilings and all, and its responses are the ones handleReport gives — 201 { id }, 202 {} without a store, 200 for a duplicate, and the 400, 401, 405, 408, 413, 429 and 500 answers with the { error } they carry. The preflight is an operation of its own, with the 204 it answers when cors is set and the 405 { error } it answers when it is not, and every answer that allows an origin documents the Access-Control-Allow-Origin and Vary headers that come with it. The X-Bugbottle-Signature header is a security scheme described for what it is: spam deterrence, not authentication. The document lints clean under Redocly — no errors and no warnings — and CI runs that linter on every push, so it stays that way.

bash
curl -s https://bugbottle.dev/schema/openapi.json | jq .paths
ts
import openapi from "bugbottle/openapi.json" with { type: "json" };

The path in it is /api/bug-report, the one this page's examples use. Yours is wherever you mounted the route, so rename it after importing; nothing in the library reads it. The only server listed is /, your own origin, because there is no bugbottle server to list — the endpoint is yours. The address the document is served from travels as x-bugbottle-id rather than $id, which OpenAPI 3.1 does not allow at the root.

Edit this page on GitHub