diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 6d7ae09..5db5d23 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -8,28 +8,6 @@ import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; -const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); -const sentryEnabled = process.env.NODE_ENV === "production" && !process.env.CI; - -Sentry.init({ - dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", - release: process.env.SENTRY_RELEASE, - environment: sentryEnvironment, - tracesSampleRate: 1.0, - enabled: sentryEnabled, - sendDefaultPii: false, - beforeSend(event) { - // Drop 404s — they're expected (scanners, typos), not bugs - const serialized = event.extra?.__serialized__ as Record | undefined; - if (serialized?.status === 404) return null; - return event; - }, -}); - -if (!sentryEnabled && !process.env.CI) { - console.debug(`[sentry] journal SSR init inert (env=${sentryEnvironment}); would send to Sentry in production`); -} - export const streamTimeout = 5_000; export default function handleRequest( diff --git a/apps/journal/app/lib/sentry.client.ts b/apps/journal/app/lib/sentry.client.ts index 9c39f7b..a4f3546 100644 --- a/apps/journal/app/lib/sentry.client.ts +++ b/apps/journal/app/lib/sentry.client.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/react"; import { useEffect } from "react"; import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { browserSentryConfig } from "@trails-cool/sentry-config"; let initialized = false; @@ -8,10 +9,6 @@ export function initSentryClient() { if (initialized) return; initialized = true; - const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? - (import.meta.env.PROD ? "production" : "development"); - const enabled = import.meta.env.PROD && sentryEnvironment !== "ci"; - Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", integrations: [ @@ -23,16 +20,6 @@ export function initSentryClient() { matchRoutes, }), ], - environment: sentryEnvironment, - tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - enabled, - sendDefaultPii: false, - // Session Replay is not installed; document the intent explicitly. - replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: 0, + ...browserSentryConfig("journal client", import.meta.env), }); - - if (!enabled && !import.meta.env.PROD) { - console.debug(`[sentry] journal client init inert (env=${sentryEnvironment}); would send to Sentry in production`); - } } diff --git a/apps/journal/package.json b/apps/journal/package.json index ccb61f9..0f12f8e 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -24,6 +24,7 @@ "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", "@trails-cool/map": "workspace:*", + "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", "@trails-cool/ui": "workspace:*", "drizzle-orm": "catalog:", diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 8495dd0..132d28e 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -1,3 +1,5 @@ +import * as Sentry from "@sentry/node"; +import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config"; import { createRequestListener } from "@react-router/node"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createReadStream, statSync } from "node:fs"; @@ -7,6 +9,12 @@ import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts"; import { createBoss, startWorker } from "@trails-cool/jobs"; import postgres from "postgres"; +Sentry.init({ + dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", + ...nodeSentryConfig("journal server"), + beforeSend: drop404s, +}); + const port = Number(process.env.PORT ?? 3000); const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); diff --git a/apps/mobile/lib/sentry.ts b/apps/mobile/lib/sentry.ts index 67238bd..c21d983 100644 --- a/apps/mobile/lib/sentry.ts +++ b/apps/mobile/lib/sentry.ts @@ -1,22 +1,13 @@ import * as Sentry from "@sentry/react-native"; +import { mobileSentryConfig } from "@trails-cool/sentry-config"; const SENTRY_DSN = "https://af50ac7aea803216adbe21e9dd4b896b@o4509530546634752.ingest.de.sentry.io/4511209742532688"; export function initSentry() { - const enabled = !__DEV__; Sentry.init({ dsn: SENTRY_DSN, - tracesSampleRate: __DEV__ ? 0 : 1.0, - enabled, - sendDefaultPii: false, - // Session Replay is not installed; document the intent explicitly. - replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: 0, + ...mobileSentryConfig(__DEV__), }); - - if (!enabled) { - console.debug("[sentry] mobile init inert in dev; would send to Sentry in production"); - } } export { Sentry }; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8aa036c..a3b13dc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -38,6 +38,7 @@ "@trails-cool/gpx": "workspace:*", "@trails-cool/i18n": "workspace:*", "@trails-cool/map-core": "workspace:*", + "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", "expo": "~55.0.15", "expo-constants": "~55.0.14", diff --git a/apps/planner/app/entry.server.tsx b/apps/planner/app/entry.server.tsx index 9220df5..5db5d23 100644 --- a/apps/planner/app/entry.server.tsx +++ b/apps/planner/app/entry.server.tsx @@ -1,3 +1,4 @@ +import * as Sentry from "@sentry/node"; import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "react-router"; import { createReadableStreamFromReadable } from "@react-router/node"; @@ -71,6 +72,7 @@ export default function handleRequest( responseStatusCode = 500; if (shellRendered) { console.error(error); + Sentry.captureException(error); } }, }, diff --git a/apps/planner/package.json b/apps/planner/package.json index 38da702..50f89b5 100644 --- a/apps/planner/package.json +++ b/apps/planner/package.json @@ -27,6 +27,7 @@ "@trails-cool/i18n": "workspace:*", "@trails-cool/map": "workspace:*", "@trails-cool/map-core": "workspace:*", + "@trails-cool/sentry-config": "workspace:*", "@trails-cool/types": "workspace:*", "@trails-cool/ui": "workspace:*", "codemirror": "^6.0.2", diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 89be908..f1b072c 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -1,4 +1,5 @@ import * as Sentry from "@sentry/node"; +import { nodeSentryConfig, drop404s } from "@trails-cool/sentry-config"; import { logger } from "./app/lib/logger.server.ts"; import { httpRequestDuration } from "./app/lib/metrics.server.ts"; import { createRequestListener } from "@react-router/node"; @@ -10,26 +11,12 @@ import { createBoss, startWorker } from "@trails-cool/jobs"; import { expireSessionsJob } from "./app/jobs/expire-sessions.ts"; import postgres from "postgres"; -const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); -const sentryEnabled = process.env.NODE_ENV === "production" && !process.env.CI; - Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", - release: process.env.SENTRY_RELEASE, - environment: sentryEnvironment, - tracesSampleRate: 1.0, - enabled: sentryEnabled, - beforeSend(event) { - const serialized = event.extra?.__serialized__ as Record | undefined; - if (serialized?.status === 404) return null; - return event; - }, + ...nodeSentryConfig("planner server"), + beforeSend: drop404s, }); -if (!sentryEnabled && !process.env.CI) { - console.debug(`[sentry] planner server init inert (env=${sentryEnvironment}); would send to Sentry in production`); -} - const port = Number(process.env.PORT ?? 3001); const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); diff --git a/packages/sentry-config/package.json b/packages/sentry-config/package.json new file mode 100644 index 0000000..1b8bdb4 --- /dev/null +++ b/packages/sentry-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@trails-cool/sentry-config", + "version": "0.0.1", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint .", + "typecheck": "tsc" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/packages/sentry-config/src/index.ts b/packages/sentry-config/src/index.ts new file mode 100644 index 0000000..d53202e --- /dev/null +++ b/packages/sentry-config/src/index.ts @@ -0,0 +1,100 @@ +/** + * Shared Sentry configuration helpers for all trails.cool apps. + * + * Each app still calls its own SDK-specific `Sentry.init(...)` (they use + * three different SDKs: `@sentry/node`, `@sentry/react`, `@sentry/react-native`), + * but the common options are centralised here so privacy defaults (`sendDefaultPii: false`, + * replay off) and enable logic stay consistent. + */ + +/** + * Drop 404 errors — they come from scanners/typos and aren't bugs. + * Pass as `beforeSend` to `Sentry.init(...)` on server-side init sites. + * + * Typed loosely on purpose: the `@sentry/node` and `@sentry/react` SDKs + * each have their own `ErrorEvent` / `Event` types, and we don't want this + * package to depend on either SDK. + */ +export function drop404s }>(event: E): E | null { + const serialized = event.extra?.__serialized__ as Record | undefined; + if (serialized?.status === 404) return null; + return event; +} + +/** + * Shared Sentry options for Node-side runtimes (the HTTP servers). + * Spread into `Sentry.init({...nodeSentryConfig("journal server"), dsn, beforeSend: drop404s, integrations})`. + * + * Logs a debug message at init time when the config is inert (local dev) + * so developers can see that Sentry would send in production. + */ +export function nodeSentryConfig(appContext: string) { + const environment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); + const enabled = process.env.NODE_ENV === "production" && !process.env.CI; + + if (!enabled && !process.env.CI) { + console.debug( + `[sentry] ${appContext} init inert (env=${environment}); would send to Sentry in production`, + ); + } + + return { + environment, + enabled, + release: process.env.SENTRY_RELEASE, + tracesSampleRate: 1.0, + sendDefaultPii: false as const, + }; +} + +interface BrowserEnv { + PROD: boolean; + VITE_SENTRY_ENVIRONMENT?: string; +} + +/** + * Shared Sentry options for Vite/browser runtimes. + * Spread into `Sentry.init({...browserSentryConfig("journal client", import.meta.env), dsn, integrations})`. + * + * Replay sample rates are explicitly 0 — replay integration is not installed, + * but the options document the intent and prevent accidental enablement. + */ +export function browserSentryConfig(appContext: string, env: BrowserEnv) { + const environment = env.VITE_SENTRY_ENVIRONMENT ?? (env.PROD ? "production" : "development"); + const enabled = env.PROD && environment !== "ci"; + + if (!enabled && !env.PROD) { + console.debug( + `[sentry] ${appContext} init inert (env=${environment}); would send to Sentry in production`, + ); + } + + return { + environment, + enabled, + tracesSampleRate: environment === "ci" ? 0 : 1.0, + sendDefaultPii: false as const, + replaysSessionSampleRate: 0 as const, + replaysOnErrorSampleRate: 0 as const, + }; +} + +/** + * Shared Sentry options for React Native. + * Spread into `Sentry.init({...mobileSentryConfig(__DEV__), dsn})`. + */ +export function mobileSentryConfig(dev: boolean) { + const enabled = !dev; + + if (dev) { + console.debug("[sentry] mobile init inert in dev; would send to Sentry in production"); + } + + return { + enabled, + tracesSampleRate: dev ? 0 : 1.0, + sendDefaultPii: false as const, + replaysSessionSampleRate: 0 as const, + replaysOnErrorSampleRate: 0 as const, + }; +} diff --git a/packages/sentry-config/tsconfig.json b/packages/sentry-config/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/sentry-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2bf550..b10a7d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@trails-cool/map': specifier: workspace:* version: link:../../packages/map + '@trails-cool/sentry-config': + specifier: workspace:* + version: link:../../packages/sentry-config '@trails-cool/types': specifier: workspace:* version: link:../../packages/types @@ -327,6 +330,9 @@ importers: '@trails-cool/map-core': specifier: workspace:* version: link:../../packages/map-core + '@trails-cool/sentry-config': + specifier: workspace:* + version: link:../../packages/sentry-config '@trails-cool/types': specifier: workspace:* version: link:../../packages/types @@ -478,6 +484,9 @@ importers: '@trails-cool/map-core': specifier: workspace:* version: link:../../packages/map-core + '@trails-cool/sentry-config': + specifier: workspace:* + version: link:../../packages/sentry-config '@trails-cool/types': specifier: workspace:* version: link:../../packages/types @@ -635,6 +644,12 @@ importers: packages/map-core: {} + packages/sentry-config: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.17 + packages/types: {} packages/ui: {}