Frontend errors are invisible to your backend by default. A component throws, the error boundary catches it, the user sees a fallback — and you never hear about it. So you add an endpoint the SPA can POST errors to. Simple. Then you realize every field in that payload is attacker-controlled, the endpoint is public, and you just wired user input straight into your metrics and logs.
TL;DR: A public UI-error sink needs three guards: (1) an unlabeled counter for volume, plus bounded/allowlisted labels for the novelty signal — never raw error strings as Prometheus labels; (2) sanitize and truncate every field before logging (log-injection and log-bloat defense); (3) rate-limit per IP. Get those right and you get real frontend observability without the footguns.
The Setup
The SPA's error boundary posts failures fire-and-forget to a public endpoint. The response is intentionally empty (204) and the client ignores it — it's telemetry, not a transaction:
@PostMapping
public ResponseEntity<Void> reportError(@RequestBody(required = false) ErrorLogRequest req) {
if (req == null) return ResponseEntity.noContent().build();
uiErrorCounter.increment();
// ...
return ResponseEntity.noContent().build();
}
A public, unauthenticated POST sink. Which means name, message, url, stack — all of it — is whatever a script wants to send. Treat every field as hostile from the first line.
Guard 1: Don't Label Metrics With User Input
The instinct is counter.increment("error_name", req.getName()) so you can slice by error type in Grafana. That's a metric-DoS: every distinct name string becomes a new Prometheus time series, and the values are unbounded and attacker-controlled. Send a million unique names, exhaust Prometheus.
So the raw volume counter carries no labels at all:
// Deliberately UNLABELED. Alert on increase(ui_errors_total[1h]).
this.uiErrorCounter = Counter.builder("ui.errors").register(meterRegistry);
For the "a new kind of error appeared" signal, project the attacker input into bounded dimensions first — the error name into a closed allowlist bucket, the URL into a normalized route with dynamic segments collapsed — and only then use them as labels:
String errorName = labels.errorName(req.getName()); // → allowlist bucket
String route = labels.route(req.getUrl()); // → normalized route
boolean firstSeen = noveltyDetector.isFirstSeen(UI_ERROR,
Map.of("name", errorName, "route", route));
if (firstSeen) {
meterRegistry.counter("ui.novel.errors",
"error_name", errorName, "route", route).increment();
}
Both labels are bounded by construction — errorName maps to a fixed allowlist, route to a normalized path with dynamic segments collapsed — and as a defense-in-depth backstop the label set is additionally capped by a MeterFilter so even a bug in the bucketing can't runaway. Your dashboard can template the labels — "new TypeError on the settings route" — without ever touching raw input.
Guard 2: Sanitize Before You Log
You do want the raw message and stack in the logs (that's the debugging value). But raw user input in a log line is a log-injection vector — embedded newlines forge fake log entries, control chars corrupt your pipeline. So every field is stripped of control characters and truncated before it's logged:
log.warn("UI error reported: name={} message={} url={} stack={} ...",
clean(req.getName(), MAX_SHORT), // 512
clean(req.getMessage(), MAX_LONG), // 4000
clean(req.getUrl(), MAX_SHORT),
clean(req.getStack(), MAX_LONG));
clean(...) strips line-breaks and control chars (log-injection defense) and truncates (log-bloat defense) — including the Unicode separators like NEL (U+0085) that a naive \p{Cntrl} filter misses.
Guard 3: Rate-Limit the Public Endpoint
Register the endpoint with your per-IP rate limiter. Without it, a single bad actor — or, far more commonly, a buggy client stuck in a render-error loop hammering the sink dozens of times a second — floods your logs and metrics. The rate limit bounds the blast radius and, as a bonus, bounds the novelty detector's keyspace too. Set the limit high enough that a real burst of distinct errors still gets through (that's the data you want most), but low enough that one looping client can't drown everyone else. Log-and-drop over the limit rather than erroring back; the client is fire-and-forget and won't do anything useful with a 429 anyway.
A Note on Response Shape
Return 204 No Content and nothing else. It's tempting to echo back an id or a "thanks," but this is telemetry — the client discards the response, and any body you return is just bytes on the wire plus a surface for someone to probe. An empty, uniform response also means the endpoint gives an attacker no signal: they can't tell whether their payload was interesting, sanitized, or dropped. Silence is the correct API here.
Lessons Learned
- User input is never a metric label. Bucket it into a closed allowlist first, or you've built a cardinality bomb with an open API.
- Logs need sanitizing, not just truncating. Newlines and control chars forge and corrupt log entries. Strip them — including the Unicode separators the obvious regex skips.
- Public telemetry endpoints get rate-limited. Fire-and-forget from the client means a loop on the client is a flood on the server.
How do you surface frontend errors on the backend? Share your setup below.
Building jo4.io - a URL shortener with analytics and end-to-end observability.