/** * Decides which server-side errors are worth sending to Sentry. * * Automated scanners probe for CMS paths that have never existed here * (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router * throws for each one — a 404 when no route matches, a 405 when a route has no * `action` — and every throw reaches `handleError` in `app/entry.server.tsx`. * Reporting those burns the Sentry quota without ever describing a real bug. */ import { isRouteErrorResponse } from "react-router"; /** * Statuses React Router uses to say "nothing here matched this request": * 404 when no route matches the URL, 405 when the route has no `action` or the * method is invalid. Its other internal statuses (400 "did not provide a * `loader`", 403 "Route does not match URL") describe a misconfigured route * rather than an unrecognised request, so those keep reporting. */ const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]); /** * Static assets 404 in bulk for reasons that are never actionable: scanners * guessing filenames, and clients running stale HTML that still references the * previous deploy's hashed bundles. */ const ASSET_EXT_RE = /\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i; /** React Router stamps `internal: true` on the errors it generates itself. */ function isInternalRouterError(error: unknown): boolean { return (error as { internal?: unknown }).internal === true; } /** * True when the request was linked from a page on this same site. * * Compares host rather than origin on purpose. Production terminates TLS * upstream and serves plain HTTP in the container, so `request.url` — which * `@react-router/express` builds from `req.protocol` — says `http` while the * browser sends an `https` referer. Comparing full origins would therefore * never match in production. (`app/routes/leagues/$leagueId.server.ts` works * around the same mismatch for invite URLs.) Protocol tells us nothing about * whether the link was ours; host does. */ function hasSameHostReferer(request: Request): boolean { const referer = request.headers.get("referer"); if (!referer) return false; try { return new URL(referer).host === new URL(request.url).host; } catch { // Scanners send garbage in this header; a referer we can't parse isn't ours. return false; } } /** * Whether `error` should be reported to Sentry. * * Drops the 404s and 405s React Router generated for a request that matched * nothing. Everything else is reported: real exceptions, 5xx, React Router's * other internal statuses, and responses the app threw deliberately * (`internal: false`), so a 403 from an ownership check still shows up. * * The exception is a request carrying a same-host `Referer`: a 404 reached from * one of our own pages is a broken internal link, not a scanner, and stays * visible in Sentry. Asset paths are excluded from that exception — a stale * client requesting last deploy's bundle sends a same-host referer too, and * would otherwise spike Sentry on every release. */ export function shouldReportServerError( error: unknown, request: Request, ): boolean { if (!isRouteErrorResponse(error)) return true; if (!isInternalRouterError(error)) return true; if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true; let pathname: string; try { pathname = new URL(request.url).pathname; } catch { pathname = ""; } if (ASSET_EXT_RE.test(pathname)) return false; return hasSameHostReferer(request); }