feat: add Sentry error monitoring (#132)

* feat: add Sentry error monitoring (#77)

Installs and configures @sentry/react-router with server and client
instrumentation. Disabled in development to avoid noise; only active
in production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add VSCode Sentry MCP server config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: pass vite env to sentryReactRouter plugin

sentryReactRouter requires the ConfigEnv as a second argument to read
the vite `command` property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-10 23:35:33 -07:00 committed by GitHub
parent 70c22d03fc
commit 35a3b71579
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1705 additions and 107 deletions

View file

@ -5,3 +5,4 @@ CLERK_PUBLISHABLE_KEY=""
CLERK_WEBHOOK_SECRET=""
CONTAINER_REGISTRY=""
DEV_ADMIN_CLERK_ID=""
SENTRY_AUTH_TOKEN=""

3
.gitignore vendored
View file

@ -13,3 +13,6 @@
# Cypress
/cypress/videos
/cypress/screenshots
# Sentry Config File
.env.sentry-build-plugin

View file

@ -6,6 +6,9 @@
"shadcn@latest",
"mcp"
]
},
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/chris-parsons/brackt"
}
}
}

8
.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"servers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/chris-parsons/brackt",
"type": "http"
}
}
}

21
app/entry.client.tsx Normal file
View file

@ -0,0 +1,21 @@
import * as Sentry from "@sentry/react-router";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
Sentry.init({
dsn: "https://1a366de494f1acf8fc94a4c592807b10@o1356837.ingest.us.sentry.io/4511024367861760",
enabled: import.meta.env.PROD,
sendDefaultPii: true,
integrations: [],
tracesSampleRate: 0,
});
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});

87
app/entry.server.tsx Normal file
View file

@ -0,0 +1,87 @@
import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
export const handleError = Sentry.createSentryHandleError({
logErrors: true,
});
export const streamTimeout = 5_000;
async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
// If you have middleware enabled:
// loadContext: RouterContextProvider
loadContext: AppLoadContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? "onAllReady"
: "onShellReady";
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
let timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
() => abort(),
streamTimeout + 1000,
);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough({
final(callback) {
// Clear the timeout to prevent retaining the closure and memory leak
clearTimeout(timeoutId);
timeoutId = undefined;
callback();
},
});
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
pipe(Sentry.getMetaTagTransformer(body));
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
});
}
export default Sentry.wrapSentryHandleRequest(handleRequest);

8
instrument.server.mjs Normal file
View file

@ -0,0 +1,8 @@
import * as Sentry from '@sentry/react-router';
Sentry.init({
dsn: "https://1a366de494f1acf8fc94a4c592807b10@o1356837.ingest.us.sentry.io/4511024367861760",
enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true,
tracesSampleRate: 0,
});

1612
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@
"build:server": "node scripts/build-server.mjs",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"dev": "dotenv -- tsx watch server.ts",
"start": "node dist/server.js",
"dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts",
"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
@ -41,6 +41,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1",
"@sentry/react-router": "^10.43.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"compression": "^1.8.0",

View file

@ -1,10 +1,26 @@
import { sentryOnBuildEnd } from "@sentry/react-router";
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
future: {
v8_middleware: true,
},
buildEnd: async (
{
viteConfig: viteConfig,
reactRouterConfig: reactRouterConfig,
buildManifest: buildManifest
}
) => {
await sentryOnBuildEnd({
viteConfig: viteConfig,
reactRouterConfig: reactRouterConfig,
buildManifest: buildManifest
});
}
} satisfies Config;

View file

@ -1,3 +1,4 @@
import { sentryReactRouter } from "@sentry/react-router";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
@ -7,16 +8,16 @@ import tsconfigPaths from "vite-tsconfig-paths";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig(({ isSsrBuild }) => ({
export default defineConfig((env) => ({
build: {
rollupOptions: isSsrBuild
rollupOptions: env.isSsrBuild
? {
input: "./server/app.ts",
}
: undefined,
},
plugins: [
{
plugins: [{
name: "database-context-alias",
enforce: "pre" as const,
resolveId(id, _importer, options) {
@ -26,12 +27,17 @@ export default defineConfig(({ isSsrBuild }) => ({
: path.resolve(__dirname, "database/context.browser-stub.ts");
}
},
},
tailwindcss(),
reactRouter(),
tsconfigPaths(),
],
}, tailwindcss(), reactRouter(), tsconfigPaths(), sentryReactRouter({
org: "chris-parsons",
project: "brackt",
authToken: process.env.SENTRY_AUTH_TOKEN
}, env)],
server: {
allowedHosts: true,
},
optimizeDeps: {
exclude: ["@sentry/react-router"]
}
}));