diff --git a/apps/frontend/app/error.tsx b/apps/frontend/app/error.tsx new file mode 100644 index 0000000..c664c5b --- /dev/null +++ b/apps/frontend/app/error.tsx @@ -0,0 +1,52 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import Link from 'next/link'; +import { useEffect } from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Next.js sets `digest` on errors that originated on the server, and the + // Sentry server SDK has already reported those. Only report genuine + // client-side failures here, so a single error is never billed twice. + if (error.digest === undefined) { + Sentry.captureException(error); + } + }, [error]); + + return ( +
+
+

Something went wrong

+

+ This page failed to load. It is usually temporary — trying again + often works. +

+
+ +
+ + + Back to home + +
+ + {error.digest !== undefined && ( +

+ Reference: {error.digest} +

+ )} +
+ ); +} diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index b1dc3e1..8aa8348 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs'; import Image from 'next/image'; import './global.css'; import Link from 'next/link'; @@ -23,22 +24,29 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const defaultGameTypes = await prisma.gameType.findMany({ - select: { - name: true, - playerCount: true, - mapCount: true, - }, - orderBy: [ - { - playerCount: 'desc', + // error.tsx cannot catch root layout errors, so a failure here would take + // down every page at once. Degrade to empty tabs instead. + const defaultGameTypes = await prisma.gameType + .findMany({ + select: { + name: true, + playerCount: true, + mapCount: true, }, - { - mapCount: 'desc', - }, - ], - take: 2, - }); + orderBy: [ + { + playerCount: 'desc', + }, + { + mapCount: 'desc', + }, + ], + take: 2, + }) + .catch((error) => { + Sentry.captureException(error); + return []; + }); return ( diff --git a/apps/frontend/app/server/[ip]/[port]/schema.ts b/apps/frontend/app/server/[ip]/[port]/schema.ts index 44c78e3..6f2a2e5 100644 --- a/apps/frontend/app/server/[ip]/[port]/schema.ts +++ b/apps/frontend/app/server/[ip]/[port]/schema.ts @@ -3,5 +3,5 @@ import { decodeIp } from "../../../../utils/encoding"; export const paramsSchema = z.object({ ip: z.string().transform(decodeIp), - port: z.coerce.number().int().positive(), + port: z.coerce.number().int().positive().max(65535).catch(0), }); diff --git a/apps/frontend/sentry.client.config.ts b/apps/frontend/sentry.client.config.ts index 459b4f6..8384be4 100644 --- a/apps/frontend/sentry.client.config.ts +++ b/apps/frontend/sentry.client.config.ts @@ -8,17 +8,15 @@ if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + tracesSampleRate: 0.01, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, - replaysOnErrorSampleRate: 1.0, - - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, + // Replay is the tightest quota on the free plan (~50/month), so record only + // sessions that actually hit an error, never a percentage of all sessions. + replaysOnErrorSampleRate: 0.1, + replaysSessionSampleRate: 0, // You can remove this option if you're not planning to use the Sentry Session Replay feature: integrations: [ diff --git a/apps/frontend/sentry.edge.config.ts b/apps/frontend/sentry.edge.config.ts index 5bcbdf3..f710458 100644 --- a/apps/frontend/sentry.edge.config.ts +++ b/apps/frontend/sentry.edge.config.ts @@ -5,13 +5,15 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "./utils/prisma"; +import { ignoreTransactions, tracesSampleRate, tracesSampler } from "./utils/sentrySampling"; if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + tracesSampleRate, + tracesSampler, + ignoreTransactions, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, diff --git a/apps/frontend/sentry.server.config.ts b/apps/frontend/sentry.server.config.ts index b2327b2..9ef5a33 100644 --- a/apps/frontend/sentry.server.config.ts +++ b/apps/frontend/sentry.server.config.ts @@ -4,13 +4,15 @@ import * as Sentry from "@sentry/nextjs"; import prisma from "./utils/prisma"; +import { ignoreTransactions, tracesSampleRate, tracesSampler } from "./utils/sentrySampling"; if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + tracesSampleRate, + tracesSampler, + ignoreTransactions, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, diff --git a/apps/frontend/utils/encoding.ts b/apps/frontend/utils/encoding.ts index 9cf08e4..1164aed 100644 --- a/apps/frontend/utils/encoding.ts +++ b/apps/frontend/utils/encoding.ts @@ -21,10 +21,16 @@ export function encodeString(str: string) { } } +// Never throws: malformed percent-escapes fall back to the raw segment, which +// matches no record, so the page 404s instead of raising a 500. export function decodeString(str: string) { - if (str.startsWith('_')) { - return base64url.decode(str.slice(1)); - } else { - return decodeURIComponent(str); + try { + if (str.startsWith('_')) { + return base64url.decode(str.slice(1)); + } else { + return decodeURIComponent(str); + } + } catch { + return str; } } diff --git a/apps/frontend/utils/sentrySampling.ts b/apps/frontend/utils/sentrySampling.ts new file mode 100644 index 0000000..296dd4b --- /dev/null +++ b/apps/frontend/utils/sentrySampling.ts @@ -0,0 +1,28 @@ +import { isbot } from 'isbot'; +import { z } from 'zod'; + +const DEFAULT_TRACES_SAMPLE_RATE = 0.01; + +export const tracesSampleRate = z + .string() + .trim() + .min(1) + .pipe(z.coerce.number().min(0).max(1)) + .catch(DEFAULT_TRACES_SAMPLE_RATE) + .parse(process.env.SENTRY_TRACES_SAMPLE_RATE); + +export const ignoreTransactions = [ + '/monitoring', + '/_next/', + '/favicon.ico', + '/robots.txt', + '/sitemap', +]; + +type SamplingContext = { + request?: { headers?: Record }; +}; + +export function tracesSampler({ request }: SamplingContext) { + return isbot(request?.headers?.['user-agent']) ? 0 : tracesSampleRate; +} diff --git a/apps/scheduler/src/schedulers/gameServerScheduler.ts b/apps/scheduler/src/schedulers/gameServerScheduler.ts index fcd3227..ece1f18 100644 --- a/apps/scheduler/src/schedulers/gameServerScheduler.ts +++ b/apps/scheduler/src/schedulers/gameServerScheduler.ts @@ -6,7 +6,6 @@ import { import { minutesToMilliseconds } from "date-fns"; import { prisma } from "../prisma"; import { schedule, scheduleWithSpread } from "../utils"; -import { captureMessage } from "@sentry/node"; let lastId = 0; let queuesFull = false; @@ -45,8 +44,6 @@ export async function gameServerScheduler() { for (const gameServer of gameServers) { scheduleWithSpread(minutesToMilliseconds(5), async () => { if (queuesFull) { - console.log('Queues are full, skipping game server poll'); - captureMessage('Queues are full, skipping game server poll'); return; } diff --git a/package-lock.json b/package-lock.json index e522b58..4da3e54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "fuse.js": "^7.0.0", "ioredis": "^5.5.0", "ioredis-mock": "^8.9.0", + "isbot": "^5.2.1", "lodash": "^4.17.21", "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0", @@ -13791,6 +13792,15 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, + "node_modules/isbot": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz", + "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", diff --git a/package.json b/package.json index bfc8dd7..2670615 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "fuse.js": "^7.0.0", "ioredis": "^5.5.0", "ioredis-mock": "^8.9.0", + "isbot": "^5.2.1", "lodash": "^4.17.21", "lodash.groupby": "^4.6.0", "lodash.isequal": "^4.5.0",