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:
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:
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 remoteAddress — expressHandler 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:
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:
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:
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.
handleReport(request, {
store,
onDecision: (decision) => {
console.log(JSON.stringify({ event: "bugbottle.decision", ...decision }));
},
});{"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:
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:
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 againlist() 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.
const reports = fileStore({ dir: "./reports", maxReports: 2000, maxAgeDays: 90 });
await reports.prune(); // at start
setInterval(() => void reports.prune(), 60 * 60 * 1000).unref(); // and hourlyNothing 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:
- Every write is a rename. The bytes go to a temporary name and are renamed
into place, which is atomic within a directory. A process killed halfway
through four megabytes leaves a
.tmpfile that no listing looks at, never a truncated report or half a screenshot. - An id is a UUID or it is nothing.
readandremovetake an id that came out of a URL, which makes it attacker-controlled text on its way to a path. It is matched against the shapecrypto.randomUUID()writes before any path is built, so../../etc/passwdis answered withnullrather than normalised and hoped about. The picture is checked the same way: what gets written under a.pngname has a PNG signature in its bytes, whatever the caller called it, and one that does not is dropped while the report is stored — screenshots fail open here as everywhere else.
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:
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:
<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:
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:
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:
- A browser without
crypto.subtlesends the report unsigned. WebCrypto is missing on very old browsers and on any page served over plain HTTP. Nothing throws and nothing is queued; the report goes out without the header, and a server withrequireon refuses it. Setrequire: falsewhile you find out whether that is anybody, and note that a signature which is present is verified whateverrequiresays — a wrong one is a claim, not an omission. - The offline queue signs at delivery, not at enqueue.
bugbottle/queuere-POSTs a finished body with afetchof its own, so it needs the signer too:createQueue({ endpoint, sign }), the same function you give the form. Every attempt — the first one and every retry after a backoff — signs the bytes it is about to send with a timestamp made at that moment, so a report written during an hour-long outage arrives inside the skew window rather than an hour outside it. A queue built withoutsignstill posts unsigned, and a server withrequireon refuses exactly the reports the queue existed to save; that was the shape before 1.0.0 and it is why this is now wired. The script tag does the wiring itself:data-sign-keybesidedata-queuesigns the queued reports as well.mountBugbottlecannot, because it is handed a queue that is already built — passsigntocreateQueueand tomountBugbottleboth.
With Express, mount the signed route without a body parser:
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.
// 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:
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.
curl -s https://bugbottle.dev/schema/openapi.json | jq .pathsimport 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.