/** * 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 or