diff --git a/.env.example b/.env.example index 1bd84acf..8dba92c3 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,9 @@ # Production: absolute URLs MUST include the /api path segment VITE_API_URL=https://api.seahavenindustries.com/api +# Sentry browser DSN. This value is public configuration and is baked into the bundle. +# Leave blank to disable telemetry for that build. +VITE_SENTRY_DSN= + # Development proxy target (used by vite.config.ts) #VITE_API_TARGET=http://localhost:5141 diff --git a/.env.production b/.env.production index 1183d018..205c2f1a 100644 --- a/.env.production +++ b/.env.production @@ -2,3 +2,6 @@ # NOTE: baked into the build at `vite build`, so this is the DEV value. Staging # and prod builds must override VITE_API_URL per environment (api.staging..., etc.). VITE_API_URL=https://api.dev.seahaven.com/api + +# Public browser configuration; deployment must provide the environment's Sentry DSN. +VITE_SENTRY_DSN= diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md new file mode 100644 index 00000000..d29ee772 --- /dev/null +++ b/docs/adr/0002-sentry-observability.md @@ -0,0 +1,56 @@ +# 0002. Browser error and transaction telemetry with Sentry + +## Status + +Accepted + +## Context + +The SeaHaven admin SPA has no centralized browser error reporting or distributed +transaction tracing. Operational failures therefore have to be reconstructed +from user reports and isolated backend logs. We need browser navigation and API +request transactions to join the backend trace while preserving the existing UI, +routing, authentication, and server-state behavior. + +## Decision + +Use the pinned `@sentry/react` 10.73.0 SDK. Initialize it before the application +router is created, and wrap `createBrowserRouter` with Sentry's current React +Router compatibility API. Configure the browser tracing integration with a 100% +transaction sample rate, and propagate trace headers only to the SPA origin and +the configured `VITE_API_URL` base. + +The DSN comes from `VITE_SENTRY_DSN`. It is intentionally treated as public +browser configuration because every `VITE_` value is embedded in the built +JavaScript. A missing or blank DSN leaves telemetry inactive without preventing +the application from starting. Default personally identifiable information +collection remains disabled. + +Source-map upload is outside this change. It requires a Sentry organization, +project, and scoped upload token that are not currently available, plus an +explicit deployment design for handling that credential without exposing it in +the browser bundle. + +### Alternatives considered + +- **Manual error and timing calls** — rejected because they would miss route + transitions and distributed request context, and would require every feature + to maintain its own instrumentation. +- **Propagate tracing headers to every request** — rejected because third-party + requests must not receive SeaHaven trace metadata and broad propagation can + introduce cross-origin request failures. +- **Add the Sentry Vite plugin now** — rejected until the organization, project, + upload credential, and deployment ownership are defined. + +## Consequences + +- Browser navigations and API requests are recorded as connected transactions + when the deployment supplies a valid DSN. +- Every transaction is sampled. This meets the current requirement but increases + event volume and should be reviewed against Sentry quota and retention after + real traffic is measured. +- Cross-origin API tracing depends on the backend accepting `sentry-trace` and + `baggage` request headers; the current API CORS policy allows request headers. +- Production stack traces remain minified until source-map upload is designed. +- Rollback is isolated: remove `@sentry/react`, the initialization module and + import, restore the unwrapped router factory, and remove the Sentry env entries. diff --git a/package-lock.json b/package-lock.json index 33da7afe..6fa1e42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", + "@sentry/react": "10.73.0", "@tanstack/query-broadcast-client-experimental": "5.101.2", "@tanstack/react-query": "5.101.2", "@tanstack/react-query-devtools": "5.101.2", @@ -1974,6 +1975,112 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@sentry/browser": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", + "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/feedback": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/replay-canvas": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", + "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", + "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", + "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", + "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", + "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", + "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0", + "@sentry/replay": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", diff --git a/package.json b/package.json index fc91c435..fc2ecbf4 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.2.0", + "@sentry/react": "10.73.0", "@tanstack/query-broadcast-client-experimental": "5.101.2", "@tanstack/react-query": "5.101.2", "@tanstack/react-query-devtools": "5.101.2", diff --git a/src/main.tsx b/src/main.tsx index 6643603c..deebc060 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,5 @@ +import "@/observability/sentry"; + import React from "react"; import ReactDOM from "react-dom/client"; import { AppRoot } from "@/app-root"; diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts new file mode 100644 index 00000000..9a37cf51 --- /dev/null +++ b/src/observability/sentry.ts @@ -0,0 +1,30 @@ +import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from "react-router"; + +import { env } from "@/lib/env"; + +const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined; +const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString(); + +Sentry.init({ + dsn: sentryDsn, + environment: import.meta.env.MODE, + integrations: [ + Sentry.reactRouterBrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ], + sendDefaultPii: false, + tracesSampleRate: 1.0, + tracePropagationTargets: [window.location.origin, apiBaseUrl], +}); diff --git a/src/routing/router.tsx b/src/routing/router.tsx index bd11fd73..5fd7bf58 100644 --- a/src/routing/router.tsx +++ b/src/routing/router.tsx @@ -1,8 +1,10 @@ -import { createBrowserRouter } from "react-router"; +import * as Sentry from "@sentry/react"; +import { createBrowserRouter as createBrowserRouterBase } from "react-router"; import { RouterProvider } from "react-router/dom"; import { routes } from "@/routing/build-route-tree"; +const createBrowserRouter = Sentry.wrapCreateBrowserRouter(createBrowserRouterBase); const router = createBrowserRouter(routes); export function Routes() {