Chromium's crash reports reach the server, which logs each and files it in Sentry as a warning, and the page's own errors name the room and seat and leave extensions' failures out

A tab that crashes takes the page's error reporting down with it, so the
page never says so. Every page now names a default Reporting API endpoint,
/api/reports, where Chromium sends a crash report after the fact; the
server logs the room, the reason when one is given, and the browser, and
files a warning that rings no bell. Other report types are ignored, and an
address may send ten an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-10-16 17:03:14 -04:00
co-authored by Claude Opus 5.5
parent 9a1c98ca55
commit 956c908867
3 changed files with 49 additions and 1 deletions
+43
View File
@@ -113,6 +113,46 @@ const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
/** Screenshots for the desk: a few per address per hour, like reports. */
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const IMAGE_MAX_BYTES = 2_500_000;
/** Crash reports from browsers: a crashing tab reloads and crashes again, so a few per address per hour say all there is. */
const crashReportsPerAddress = new SlidingLimit(10, 60 * 60 * 1000);
const CRASH_REPORT_MAX_BYTES = 64 * 1024;
/** Chromium sends the Reporting API's crash reports here, after the fact,
* from the browser itself: a tab that crashes takes the page's own error
* reporting down with it. Each crash is logged and filed in Sentry as a
* warning, which rings no bell. Other report types are ignored. */
function receiveBrowserReports(req: IncomingMessage, res: ServerResponse): void {
const address = clientAddress(req.headers, req.socket.remoteAddress);
let raw = "";
let tooBig = false;
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
if (tooBig) return;
raw += chunk;
if (raw.length > CRASH_REPORT_MAX_BYTES) tooBig = true;
});
req.on("end", () => {
res.writeHead(204).end();
if (tooBig) return;
let reports: unknown;
try { reports = JSON.parse(raw); } catch { return; }
if (!Array.isArray(reports)) return;
for (const r of reports as { type?: unknown; url?: unknown; user_agent?: unknown; body?: Record<string, unknown> }[]) {
if (r?.type !== "crash" || !crashReportsPerAddress.allow(address)) continue;
const url = String(r.url ?? "").slice(0, 200);
const room = url.match(/\/join\/([A-Za-z0-9]{4})/)?.[1]?.toUpperCase() ?? "none";
const reason = typeof r.body?.reason === "string" ? r.body.reason.slice(0, 40) : "unknown";
const userAgent = String(r.user_agent ?? "").slice(0, 300);
console.warn(`browser tab crashed: room ${room}, reason ${reason}, ${userAgent}`);
Sentry.captureMessage("A player's browser tab crashed", {
level: "warning",
fingerprint: ["browser-crash", reason],
tags: { room, reason },
extra: { url, userAgent, body: r.body ?? null },
});
}
});
}
/** A picture for a report, POSTed right after the report is filed: one
* per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept
@@ -372,6 +412,7 @@ const httpServer = createServer((req, res) => {
if (req.method === "POST") {
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
res.writeHead(404).end();
return;
}
@@ -380,6 +421,8 @@ const httpServer = createServer((req, res) => {
return;
}
res.setHeader("x-content-type-options", "nosniff");
// Where Chromium sends a crash report about a tab that showed this page.
res.setHeader("reporting-endpoints", `default="${requestBase(req)}/api/reports"`);
if (!staticRoot) {
res.writeHead(404).end("client not built");
return;
+2 -1
View File
@@ -10,7 +10,8 @@ if (import.meta.env.VITE_SENTRY_DSN) {
environment: "production",
// Facebook's in-app browser injects its own scripts (iabjs://) and
// reports their failures as ours when the page unloads.
denyUrls: [/^iabjs:\/\//],
// Extensions run their own scripts in the page, and their failures are not ours.
denyUrls: [/^iabjs:\/\//, /^(chrome|moz|safari(-web)?)-extension:\/\//],
ignoreErrors: ["Java object is gone"],
});
}
+4
View File
@@ -4,6 +4,7 @@ import type { ReplayStep } from "./catchup";
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef, isPermanentDuration } from "@wizwar/engine";
import { receiptFor } from "./receipt";
import * as Sentry from "@sentry/browser";
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
const API_URL =
@@ -572,6 +573,8 @@ class Net {
break;
case "room":
this.roomId = msg.roomId;
// An error report names the table it happened at, and from which seat.
Sentry.setTags({ room: msg.roomId, seat: this.spectating ? "gallery" : (this.you ?? "none") });
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.standIns = msg.standIns ?? [];
@@ -1001,6 +1004,7 @@ class Net {
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
Sentry.setTags({ room: "none", seat: "none" });
this.missedSteps = null;
this.catchUp = null;
this.pending = null;