When a web app breaks on someone's phone, the server logs show nothing: the error happened in the browser. The usual fix is an error tracking SDK, but for an encrypted chat or a personal finance app the hard question is what that report is allowed to contain. This post describes the small error reporting endpoint I ended up writing three times: for an end-to-end encrypted chat and a finance planner (both Fastify), and for a time management app (Spring Boot). It covers what the browser sends, why the endpoint takes no token, and how it stays cheap under a flood.
All three apps are local-first or encrypted, so user data either never reaches the server or reaches it as ciphertext. An error reporter that shipped page state, inputs or full URLs would quietly undo that. The design rule was simple to state: send enough to find and fix the bug, and nothing that describes the user.
The contract: five fields with hard limits
The report is a Zod schema shared by the web app and the API, so both sides agree on the same limits:
export const clientError = z.object({
kind: z.enum(["render", "error", "rejection", "csp"]),
message: z.string().max(500),
stack: z.string().max(4_000).optional(),
/** The path of the page (no query: it can hold what was typed). */
route: z.string().max(200),
appVersion: z.string().max(40),
});
Each field has a reason:
| Field | Why it is there | What it leaves out |
|---|---|---|
kind |
Which handler caught it: a React error screen, an uncaught error, an unhandled promise rejection, or a blocked resource | — |
message, stack |
Where in the code | Cut to 500 and 4,000 characters |
route |
Which page | location.pathname only, never the query or hash |
appVersion |
Which build, as the commit hash | — |
There is no user id, no device id and no session. The route is the subtle one: search boxes, filters and OAuth callbacks all put user input or tokens in the query string, so the report takes the path and drops the rest.
The browser side: catch, cap and send once
Four sources feed the reporter: the React error boundary that shows the error screen, window's error event, unhandledrejection, and securitypolicyviolation. All four go through one function (shortened from finance-planner):
const MAX_REPORTS = 10;
const sent = new Set<string>();
export const reportError = (kind: ClientError["kind"], error: unknown, extra?: string) => {
const { message, stack } = describe(error);
const key = `${kind} ${message}`;
if (!env.VITE_API_URL || sent.has(key) || sent.size >= MAX_REPORTS) return;
sent.add(key);
void fetch(`${env.VITE_API_URL}/v1/client-errors`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ kind, message: clip(message, 500), /* ... */ route: clip(location.pathname, 200), appVersion }),
keepalive: true,
}).catch(() => undefined);
};
Three details matter more than they look:
- Each error once per page load, ten at most. A render loop or a timer that throws every second would otherwise send hundreds of identical reports from one tab.
keepalivelets the request finish while the page is being reloaded or closed, which is exactly when the error screen's "Reload" button is pressed.- Reporting never fails loudly. Offline, or with no API configured, the error stays in the console. An error reporter that throws is worse than none.
One kind of error is deliberately not reported: a lazily loaded chunk that is gone because a deploy replaced it. That is not a bug but an old tab, and the app reloads into the new version instead, as described in the post on installed PWAs and deploys.
Why the endpoint takes no token
Almost every other route in these APIs requires a Keycloak access token. This one does not, because the errors worth knowing about most often happen before or during sign-in: a broken callback page, a failed token refresh, a crash on the welcome screen for someone who has not signed in yet. An endpoint that required a valid session would miss exactly those.
The cost is that anyone on the internet can post to it. That is acceptable only because of what the server does with a report, which is almost nothing (shortened from finance-planner, with the real threshold left out):
app.post("/client-errors", {
schema: { body: clientError, hide: true },
bodyLimit: 16 * 1024,
config: { rateLimit: { max: REPORTS_PER_MINUTE, timeWindow: "1 minute" } },
}, async (req, reply) => {
counter.inc({ kind: req.body.kind });
req.log.warn({ clientError: req.body, userAgent: req.headers["user-agent"]?.slice(0, 300) }, "client error");
return reply.code(204).send();
});
Nothing is stored in the database. The report is validated against the shared schema, written to the JSON log, and counted in a Prometheus counter labelled by kind. hide: true keeps the route out of the generated OpenAPI docs, since it is not part of the API anyone should build on.
Keeping a flood cheap
With no authentication, cost control comes from limits that apply before any work is done:
- 16 KB body limit. The schema allows 4,740 characters of text in all; even at three bytes per character in UTF-8 that fits, so in practice anything bigger is not a real report and is rejected while it is still being read.
- A small per-minute limit for each client address, in the same range as the cap the browser puts on itself per page load. A real browser never needs more.
- One counter with four label values. Labels come from the
kindenum, never from the message, so a flood cannot create new time series. The Prometheus naming guide warns that every label value is a new time series, and that unbounded values such as user IDs dramatically increase the data stored.
The rate limit is only as good as the address it is keyed on. Behind a reverse proxy, the real client address arrives in a header such as Cloudflare's CF-Connecting-IP. Trusting that header from anyone would let an attacker choose a new address for every request and never hit the limit. So it is trusted only when the connection itself comes from a private address, which is where the proxy runs:
static String clientAddress(HttpServletRequest request) {
String peer = request.getRemoteAddr();
String reported = request.getHeader("CF-Connecting-IP");
if (reported != null && isIpLiteral(reported) && isPrivate(peer)) return reported;
return peer == null ? "unknown" : peer;
}
In the Spring version, the limiter key also has its own prefix, client-errors: plus the address, so a burst of error reports from one device never eats into that user's quota for syncing.
The same contract in Spring Boot
The time management app's API is Spring Boot, and porting the endpoint showed which parts were framework features and which were decisions. Fastify gives schema validation, a body limit and a rate limit as route options. In Spring the controller does the same steps by hand:
- Check the rate limit first, before reading anything, and answer
429withRetry-After: 60. - Read at most 16 KB plus one byte, and answer
413if the extra byte arrived. - Validate against the same five fields and the same lengths as the frontend schema.
- Count with Micrometer, log, and answer
204.
The route is opened explicitly in the security configuration (permitAll() for POST /api/client-errors only), so a later change to the default rules cannot silently make it require a token again.
Reports that report on the security policy too
The fourth kind, csp, exists because two of the apps set their Content Security Policy in a <meta> tag, which cannot carry a report-uri. The page listens for securitypolicyviolation and sends the blocked directive through the same endpoint. The blocked URL is cut down to its origin first, because a full URL can carry a query string like any other.
What this does not protect against
- Error messages can quote data. V8's
JSON.parseerror includes the start of the input:JSON.parse("hello world")throwsUnexpected token 'h', "hello world" is not valid JSON. If the code parses something the user typed, the message carries it. Clipping limits the size of the leak, not its existence, so the real defense is not putting user data into error messages in your own code. - CSP samples. A violation's
sampleholds the first characters of the blocked inline code. It is code, not user data, unless the page builds code from user data. - Minified stacks. A production stack points at hashed chunk files. Reading it needs source maps for that exact build, so either keep them private per release or rebuild from the commit that
appVersionnames. - A determined spammer. Limits make a flood cheap, not impossible. A botnet with many addresses can still fill the logs with noise; it cannot fill the database, because nothing is stored.
Summary
| Decision | Reason |
|---|---|
| Shared schema with hard length limits | Both sides agree on what a report can be |
| Path only, no query, no user or device id | Reports describe the code, not the person |
Once per error, ten per page load, keepalive |
One broken tab cannot flood, and reports survive a reload |
| No token | The errors that matter most happen before sign-in works |
| Body limit, rate limit, nothing stored | An open endpoint stays cheap to abuse |
| Proxy header trusted only from the private network | The rate limit cannot be dodged by forging an address |
If you report client errors from an app that handles sensitive data, I'd love to compare notes on what you decided to leave out. My email is on the home page.