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:
parent
70c22d03fc
commit
35a3b71579
12 changed files with 1705 additions and 107 deletions
|
|
@ -4,4 +4,5 @@ CLERK_SECRET_KEY=""
|
||||||
CLERK_PUBLISHABLE_KEY=""
|
CLERK_PUBLISHABLE_KEY=""
|
||||||
CLERK_WEBHOOK_SECRET=""
|
CLERK_WEBHOOK_SECRET=""
|
||||||
CONTAINER_REGISTRY=""
|
CONTAINER_REGISTRY=""
|
||||||
DEV_ADMIN_CLERK_ID=""
|
DEV_ADMIN_CLERK_ID=""
|
||||||
|
SENTRY_AUTH_TOKEN=""
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,3 +13,6 @@
|
||||||
# Cypress
|
# Cypress
|
||||||
/cypress/videos
|
/cypress/videos
|
||||||
/cypress/screenshots
|
/cypress/screenshots
|
||||||
|
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
"shadcn@latest",
|
"shadcn@latest",
|
||||||
"mcp"
|
"mcp"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Sentry": {
|
||||||
|
"url": "https://mcp.sentry.dev/mcp/chris-parsons/brackt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal 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
21
app/entry.client.tsx
Normal 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
87
app/entry.server.tsx
Normal 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);
|
||||||
|
|
@ -180,4 +180,4 @@ function statusIcon(status: number) {
|
||||||
if (status === 404) return <FileQuestion className={cls} />;
|
if (status === 404) return <FileQuestion className={cls} />;
|
||||||
if (status >= 500) return <ServerCrash className={cls} />;
|
if (status >= 500) return <ServerCrash className={cls} />;
|
||||||
return <AlertCircle className={cls} />;
|
return <AlertCircle className={cls} />;
|
||||||
}
|
}
|
||||||
8
instrument.server.mjs
Normal file
8
instrument.server.mjs
Normal 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
1612
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,8 @@
|
||||||
"build:server": "node scripts/build-server.mjs",
|
"build:server": "node scripts/build-server.mjs",
|
||||||
"db:generate": "dotenv -- drizzle-kit generate",
|
"db:generate": "dotenv -- drizzle-kit generate",
|
||||||
"db:migrate": "dotenv -- drizzle-kit migrate",
|
"db:migrate": "dotenv -- drizzle-kit migrate",
|
||||||
"dev": "dotenv -- tsx watch server.ts",
|
"dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest --coverage",
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@react-router/express": "^7.7.1",
|
"@react-router/express": "^7.7.1",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
|
"@sentry/react-router": "^10.43.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
|
|
@ -96,4 +97,4 @@
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,26 @@
|
||||||
|
import { sentryOnBuildEnd } from "@sentry/react-router";
|
||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Config options...
|
// Config options...
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
future: {
|
future: {
|
||||||
v8_middleware: true,
|
v8_middleware: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildEnd: async (
|
||||||
|
{
|
||||||
|
viteConfig: viteConfig,
|
||||||
|
reactRouterConfig: reactRouterConfig,
|
||||||
|
buildManifest: buildManifest
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
await sentryOnBuildEnd({
|
||||||
|
viteConfig: viteConfig,
|
||||||
|
reactRouterConfig: reactRouterConfig,
|
||||||
|
buildManifest: buildManifest
|
||||||
|
});
|
||||||
|
}
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sentryReactRouter } from "@sentry/react-router";
|
||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -7,31 +8,36 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export default defineConfig(({ isSsrBuild }) => ({
|
export default defineConfig((env) => ({
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: isSsrBuild
|
rollupOptions: env.isSsrBuild
|
||||||
? {
|
? {
|
||||||
input: "./server/app.ts",
|
input: "./server/app.ts",
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
plugins: [
|
|
||||||
{
|
plugins: [{
|
||||||
name: "database-context-alias",
|
name: "database-context-alias",
|
||||||
enforce: "pre" as const,
|
enforce: "pre" as const,
|
||||||
resolveId(id, _importer, options) {
|
resolveId(id, _importer, options) {
|
||||||
if (id === "~/database/context") {
|
if (id === "~/database/context") {
|
||||||
return options?.ssr
|
return options?.ssr
|
||||||
? path.resolve(__dirname, "database/context.ts")
|
? path.resolve(__dirname, "database/context.ts")
|
||||||
: path.resolve(__dirname, "database/context.browser-stub.ts");
|
: path.resolve(__dirname, "database/context.browser-stub.ts");
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tailwindcss(),
|
}, tailwindcss(), reactRouter(), tsconfigPaths(), sentryReactRouter({
|
||||||
reactRouter(),
|
org: "chris-parsons",
|
||||||
tsconfigPaths(),
|
project: "brackt",
|
||||||
],
|
authToken: process.env.SENTRY_AUTH_TOKEN
|
||||||
|
}, env)],
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["@sentry/react-router"]
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue