Reporting UI Errors Without Blowing Up Prometheus

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.