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:
co-authored by
Claude Opus 5.5
parent
9a1c98ca55
commit
956c908867
@@ -113,6 +113,46 @@ const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
|||||||
/** Screenshots for the desk: a few per address per hour, like reports. */
|
/** Screenshots for the desk: a few per address per hour, like reports. */
|
||||||
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||||
const IMAGE_MAX_BYTES = 2_500_000;
|
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
|
/** 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
|
* 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") {
|
if (req.method === "POST") {
|
||||||
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
||||||
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
||||||
|
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
|
||||||
res.writeHead(404).end();
|
res.writeHead(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -380,6 +421,8 @@ const httpServer = createServer((req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.setHeader("x-content-type-options", "nosniff");
|
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) {
|
if (!staticRoot) {
|
||||||
res.writeHead(404).end("client not built");
|
res.writeHead(404).end("client not built");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
|||||||
environment: "production",
|
environment: "production",
|
||||||
// Facebook's in-app browser injects its own scripts (iabjs://) and
|
// Facebook's in-app browser injects its own scripts (iabjs://) and
|
||||||
// reports their failures as ours when the page unloads.
|
// 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"],
|
ignoreErrors: ["Java object is gone"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReplayStep } from "./catchup";
|
|||||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
||||||
import { receiptFor } from "./receipt";
|
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. */
|
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
|
||||||
const API_URL =
|
const API_URL =
|
||||||
@@ -572,6 +573,8 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "room":
|
case "room":
|
||||||
this.roomId = msg.roomId;
|
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.roomColors = msg.colors ?? {};
|
||||||
this.roomBots = msg.bots ?? {};
|
this.roomBots = msg.bots ?? {};
|
||||||
this.standIns = msg.standIns ?? [];
|
this.standIns = msg.standIns ?? [];
|
||||||
@@ -1001,6 +1004,7 @@ class Net {
|
|||||||
* line, and wiping it would erase the only notice of why. */
|
* line, and wiping it would erase the only notice of why. */
|
||||||
leaveLocal(): void {
|
leaveLocal(): void {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
Sentry.setTags({ room: "none", seat: "none" });
|
||||||
this.missedSteps = null;
|
this.missedSteps = null;
|
||||||
this.catchUp = null;
|
this.catchUp = null;
|
||||||
this.pending = null;
|
this.pending = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user