2026-09-14 20:11:46 +00:00
|
|
|
/**
|
|
|
|
|
* 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";
|
|
|
|
|
|
2026-09-14 21:18:33 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-09-14 20:11:46 +00:00
|
|
|
/** React Router stamps `internal: true` on the errors it generates itself. */
|
|
|
|
|
function isInternalRouterError(error: unknown): boolean {
|
|
|
|
|
return (error as { internal?: unknown }).internal === true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 21:18:33 +00:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-09-14 20:11:46 +00:00
|
|
|
const referer = request.headers.get("referer");
|
|
|
|
|
if (!referer) return false;
|
|
|
|
|
try {
|
2026-09-14 21:18:33 +00:00
|
|
|
return new URL(referer).host === new URL(request.url).host;
|
2026-09-14 20:11:46 +00:00
|
|
|
} 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.
|
|
|
|
|
*
|
2026-09-14 21:18:33 +00:00
|
|
|
* 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.
|
2026-09-14 20:11:46 +00:00
|
|
|
*
|
2026-09-14 21:18:33 +00:00
|
|
|
* 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.
|
2026-09-14 20:11:46 +00:00
|
|
|
*/
|
|
|
|
|
export function shouldReportServerError(
|
|
|
|
|
error: unknown,
|
|
|
|
|
request: Request,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!isRouteErrorResponse(error)) return true;
|
|
|
|
|
if (!isInternalRouterError(error)) return true;
|
2026-09-14 21:18:33 +00:00
|
|
|
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);
|
2026-09-14 20:11:46 +00:00
|
|
|
}
|