Files
pl_mail/assets/client_errors.js
T
Paul LütznerandClaude Opus 5 d53e7c4963 Report browser errors to the admin panel, in a card of their own
A JavaScript fault was visible only to whoever had the console open, which on a
self-hosted mailbox means nobody. Uncaught exceptions and rejected promises from
plMail's own scripts now reach Admin → Logs, together with whatever the
Content-Security-Policy blocked.

Its own card rather than rows in the log, because it holds a different kind of
thing. A server error is one event in one request; a broken line in a controller
runs on every page load for every user. So a row is a distinct fault — message,
file, line — with a count and a lifetime, and the same fault reported again
increments it. Merged into the log, one bug would be four hundred rows and the
log would be unreadable past it.

THE FILTER IS THE FEATURE. Most of what window.onerror sees is not ours: the
console screenshot that prompted this was an ad-attribution metrics script
failing inside requestIdleCallback, injected by an extension. A panel that
accepted that fills with other people's bugs and stops being read, which is the
same as not having one. A report is kept only when it can be attributed to a
script this server sent — an injected script has no URL (Chrome's `VM947`), and
a cross-origin one arrives as the bare string `Script error.` with no file, line
or message. The rule is applied in the browser and again in the recorder,
because the browser half is part of the code being reported on.

CSP violations come to the same card through /csp-report. That endpoint cannot
be CSRF-protected and is public in security.yaml, because the browser generates
the request itself: no token, no header we chose, and on a login page no session.
The rate limiter is the bound. It is worth having because the full policy is
enforced in production, so what arrives is what this installation actually
refused to load in a real browser.

The count means page loads, not executions — the client reports each fault once
per page, capped at five. Measured rather than assumed: throwing the same error
twice on one page records one sighting. Documented that way on both sides after
the first version claimed the server would count what the client never sent.

`column` is a reserved word in Postgres, so the field is `column_number`. The
quoted DDL created the table happily and every insert into it was a syntax
error — same trap User::$usr already avoids.

Verified on a running stack by throwing real errors in a real browser: an error
from a script on this origin recorded, an anonymous injected one refused, the
card rendering both kinds grouped with counts.

Its first catch was a false alarm, and a useful one to record. It reported the
Mercure connection as blocked by `connect-src` — which was true, and was me
browsing `localhost` while MERCURE_PUBLIC_URL names `127.0.0.1`. Different
origins, so the app's own policy refused its own hub. Browsing the host the
config actually names produces no violation at all. The compose comment calling
that URL "same-origin" is correct on its own terms; it just stops being true if
you reach the stack by another name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:02:42 +02:00

130 lines
5.0 KiB
JavaScript

/**
* Report our own uncaught errors to the admin panel, and nobody else's.
*
* WHY THE FILTER IS MOST OF THIS FILE. `window.onerror` fires for every script
* running in the page, and on a real browser most of them are not ours:
* extensions inject analytics, password managers inject form scrapers, and
* those scripts throw. The console screenshot that prompted this feature was an
* ad-attribution metrics script failing inside `requestIdleCallback` — nothing
* to do with plMail, and indistinguishable from a real bug in a list that
* accepted everything. A panel full of other people's faults is a panel nobody
* reads, which is the same as not having one.
*
* Two things separate ours from theirs, and the browser hands both over:
*
* - `filename` is the script's URL. Ours is on this origin; an extension's is
* `chrome-extension://…`, and an injected or eval'd script has none at all
* — that is what Chrome's `VM947` means.
* - a cross-origin script is reported as the literal `Script error.` with no
* file, no line and no message, because the browser will not describe
* scripts it did not serve to a page. There is nothing there to keep.
*
* The server applies the same rule again (see ClientErrorRecorder). Not
* belt-and-braces: this file is part of the thing being reported on, and a
* filter that lives only in the code that is broken is a filter that stops
* working exactly when it is needed.
*
* WHAT IS SENT: message, script, line, column, stack, page. No DOM, no
* breadcrumbs, nothing gathered. The panel groups by message and position, so
* what makes a report useful is precision about where, not volume.
*/
import { csrfToken } from "./csrf.js";
/**
* Reports per page load.
*
* A fault inside an animation frame or a Turbo navigation fires thousands of
* times a minute; the first few say everything the rest do.
*
* Together with the de-duplication below, this makes the panel's count mean
* "how many page loads hit this", not "how many times it fired" — the same
* fault thrown twice on one page is reported once. That is the deliberate
* trade: the alternative is a page that posts a thousand requests describing
* one broken line, and a count of page loads is the more useful number anyway,
* because it says how many people are running into it.
*/
const MAX_PER_PAGE = 5;
/** The browser's word for "a script I will not describe to you". */
const OPAQUE = "Script error.";
const seen = new Set();
let sent = 0;
/** A script this server sent, as opposed to one somebody injected. */
function ours(filename) {
return "string" === typeof filename && filename.startsWith(location.origin + "/");
}
function send(report) {
// Deduplicated on the same key the server groups by, so one fault in a
// loop is one request rather than five identical ones.
const key = [report.message, report.source, report.line, report.column].join(" ");
if (MAX_PER_PAGE <= sent || seen.has(key)) {
return;
}
seen.add(key);
sent += 1;
// keepalive, because the most interesting errors are the ones thrown on the
// way out of a page — a plain fetch is cancelled by the navigation that
// follows it, and the report never arrives.
fetch("/client-error", {
method: "POST",
keepalive: true,
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() },
body: JSON.stringify(report),
}).catch(() => {
// Reporting that the error reporter failed would need the error
// reporter. Nothing useful to do here, and a rejected promise thrown
// from this handler would be caught below and reported, which is a loop.
});
}
window.addEventListener("error", (event) => {
// `error` also fires for a failed <img> or <script> load, where `message`
// is empty and the target is the element. Those are not exceptions and have
// nothing to report; a CSP violation covers the interesting half of them.
if ("string" !== typeof event.message || "" === event.message || OPAQUE === event.message) {
return;
}
if (false === ours(event.filename)) {
return;
}
send({
kind: "error",
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack ?? null,
url: location.href,
});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const stack = reason?.stack ?? null;
// No filename on a rejection — it carries a reason, not a file — so the
// stack is the only handle on where it came from. Without one there is
// nothing to attribute and nothing anybody could act on.
if ("string" !== typeof stack || false === stack.includes(location.origin + "/")) {
return;
}
send({
kind: "unhandledrejection",
message: String(reason?.message ?? reason),
source: null,
line: null,
column: null,
stack,
url: location.href,
});
});