From ee7916c29ab83602dabaee2dd9ce36d593cc7e81 Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:46:57 +0200 Subject: [PATCH] Cut Sentry event volume: sample traces, stop bot URLs causing 500s The frontend ran tracesSampleRate: 1 on the server, client and edge, so every request became a transaction with a span per Prisma query. Nothing absorbed that traffic: / and /status are force-dynamic and every other page reads searchParams. Sample 1% instead (SENTRY_TRACES_SAMPLE_RATE), drop crawlers via isbot, and drop the tunnel route and static paths via ignoreTransactions. Separately, two unguarded parses turned ordinary bot traffic into unhandled 500s, each one a billed error event: - decodeString called decodeURIComponent on user-controlled path segments, which throws URIError on any malformed percent-escape (/player/%). Used by the player, clan, gametype and map routes. - The [port] param coerced without a fallback, so /server/1.2.3.4/x threw. generateMetadata hit this even though the page already guarded with safeParse. Both now fall back to a value that matches no record, so these 404 rather than throwing. Added app/error.tsx, which reports only client-side errors (server ones already arrive reported, keyed off error.digest), and made the root layout's query degrade to empty tabs, since error.tsx cannot catch root layout errors. Also removed the per-game-server captureMessage in gameServerScheduler: it fired once per server per 5-minute poll, which at ~900 servers is ~10k events an hour for a single global condition already logged once a minute. It was inert only because apps/scheduler/src/sentry.ts is never imported. Replay session sampling goes 0.1 -> 0 (free plan allows ~50/month). Co-Authored-By: Claude Opus 5 (1M context) --- apps/frontend/app/error.tsx | 52 +++++++++++++++++++ apps/frontend/app/layout.tsx | 38 ++++++++------ .../frontend/app/server/[ip]/[port]/schema.ts | 2 +- apps/frontend/sentry.client.config.ts | 12 ++--- apps/frontend/sentry.edge.config.ts | 6 ++- apps/frontend/sentry.server.config.ts | 6 ++- apps/frontend/utils/encoding.ts | 14 +++-- apps/frontend/utils/sentrySampling.ts | 28 ++++++++++ .../src/schedulers/gameServerScheduler.ts | 3 -- package-lock.json | 10 ++++ package.json | 1 + 11 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 apps/frontend/app/error.tsx create mode 100644 apps/frontend/utils/sentrySampling.ts diff --git a/apps/frontend/app/error.tsx b/apps/frontend/app/error.tsx new file mode 100644 index 00000000..c664c5b9 --- /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 b1dc3e11..8aa8348a 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 44c78e3e..6f2a2e54 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 459b4f61..8384be4b 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 5bcbdf34..f7104581 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 b2327b2d..9ef5a335 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 9cf08e4a..1164aed5 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 00000000..296dd4b8 --- /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 fcd32276..ece1f187 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 e522b584..4da3e547 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 bfc8dd7a..2670615c 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",