diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 148fb15..cabaa9c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 }[]) { + 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; diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts index 3dfcd00..aa863a2 100644 --- a/packages/web/src/main.ts +++ b/packages/web/src/main.ts @@ -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"], }); } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index b492139..ced9c7f 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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;