diff --git a/.env.example b/.env.example index 1bd84acf..1b59d08b 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,13 @@ # 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= + +# Sentry environment label (development, staging, production). Blank falls back +# to the Vite build mode. +#VITE_SENTRY_ENVIRONMENT= + # Development proxy target (used by vite.config.ts) #VITE_API_TARGET=http://localhost:5141 diff --git a/.env.production b/.env.production index 1183d018..6ca55486 100644 --- a/.env.production +++ b/.env.production @@ -2,3 +2,9 @@ # 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; the DSN is public by design and baked into the +# bundle. Blank disables telemetry. These are the DEV deploy values: staging +# and future prod jobs override them per environment. +VITE_SENTRY_DSN=https://902f431c6e762aa849c9a07b77f0c3ac@o4511989453029376.ingest.de.sentry.io/4511990364700752 +VITE_SENTRY_ENVIRONMENT=development diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index eed6460e..c00f749e 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -32,6 +32,8 @@ jobs: environment: staging env: VITE_API_URL: https://api.staging.seahaven.com/api + VITE_SENTRY_ENVIRONMENT: staging + VITE_APP_COMMIT_SHA: ${{ github.sha }} AWS_REGION: us-east-1 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -68,15 +70,21 @@ jobs: role-to-assume: arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging aws-region: us-east-1 - # Builds the SPA with the staging VITE_API_URL (process env overrides the - # dev value committed in .env.production), syncs to the staging bucket, - # and invalidates CloudFront. + # Builds the SPA with the staging VITE_API_URL and Sentry environment + # label (process env overrides the dev values committed in + # .env.production), syncs to the staging bucket, and invalidates + # CloudFront. - name: Build and publish SPA run: bash scripts/deploy-web.sh env: STACK_NAME: shoc-frontend-staging WAIT_FOR_INVALIDATION: "true" + - name: Upload private source maps + run: bash scripts/upload-sourcemaps.sh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + - name: Verify deployment run: | set -euo pipefail diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aa72c01d..6ad9bde2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,25 @@ jobs: post-deploy-script: scripts/deploy-web.sh secrets: deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + + upload-sourcemaps: + name: Upload private source maps + needs: deploy + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + env: + VITE_APP_COMMIT_SHA: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: npm + - name: Build exact deployed release + run: npm ci && npm run build + - name: Upload source maps to Sentry + run: bash scripts/upload-sourcemaps.sh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/config/sentry-release.test.ts b/config/sentry-release.test.ts new file mode 100644 index 00000000..15f8cc5c --- /dev/null +++ b/config/sentry-release.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { LOCAL_DEVELOPMENT_RELEASE, resolveSentryRelease } from "./sentry-release"; + +describe("resolveSentryRelease", () => { + it("maps the exact commit to the frontend release", () => { + const sha = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; + + expect(resolveSentryRelease(sha, true)).toEqual({ + commitSha: sha.toLowerCase(), + release: `shoc-frontend@${sha.toLowerCase()}`, + }); + }); + + it("fails CI when the commit identity is missing or malformed", () => { + expect(() => resolveSentryRelease(undefined, true)).toThrow(/40-character commit SHA/); + expect(() => resolveSentryRelease("not-a-sha", true)).toThrow(/40-character commit SHA/); + }); + + it("labels non-CI builds without a commit as local development", () => { + expect(resolveSentryRelease(undefined, false)).toEqual({ + commitSha: LOCAL_DEVELOPMENT_RELEASE, + release: LOCAL_DEVELOPMENT_RELEASE, + }); + }); +}); diff --git a/config/sentry-release.ts b/config/sentry-release.ts new file mode 100644 index 00000000..8bf0c90a --- /dev/null +++ b/config/sentry-release.ts @@ -0,0 +1,32 @@ +export const SENTRY_SERVICE_NAME = "shoc-frontend"; +export const LOCAL_DEVELOPMENT_RELEASE = "local-development"; + +const COMMIT_SHA = /^[0-9a-f]{40}$/i; + +export function resolveSentryRelease( + candidate: string | undefined, + ci: boolean, +): { + commitSha: string; + release: string; +} { + const commitSha = candidate?.trim(); + + if (commitSha && COMMIT_SHA.test(commitSha)) { + return { + commitSha: commitSha.toLowerCase(), + release: `${SENTRY_SERVICE_NAME}@${commitSha.toLowerCase()}`, + }; + } + + if (ci) { + throw new Error( + "CI builds require VITE_APP_COMMIT_SHA or GITHUB_SHA as a 40-character commit SHA", + ); + } + + return { + commitSha: LOCAL_DEVELOPMENT_RELEASE, + release: LOCAL_DEVELOPMENT_RELEASE, + }; +} diff --git a/docs/adr/0002-sentry-observability.md b/docs/adr/0002-sentry-observability.md new file mode 100644 index 00000000..3aeee86c --- /dev/null +++ b/docs/adr/0002-sentry-observability.md @@ -0,0 +1,94 @@ +# 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. Every emitted transaction +must also identify its exact build, trace, transaction, operation, function, and +opaque actor without sending credentials, request bodies, query values, or +personal data. + +## 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. Pass `Sentry.reactErrorHandler()` as the +`onCaughtError`, `onUncaughtError`, and `onRecoverableError` callbacks of +`ReactDOM.createRoot`, so React 19's root error hooks capture caught, uncaught, +and recoverable component errors as Sentry events. + +All shared API helpers, direct Ky calls, vendor-link `fetch` calls, and vendor +document `XMLHttpRequest` calls run inside explicit `http.client` transactions. +They use normalized route templates and attach `code.function`, `operation.type`, +HTTP method, `trace_id`, and `transaction_id`. Browser navigation transactions +receive the same searchable trace identifiers in `beforeSendTransaction`. +Authenticated sessions set only the application's opaque numeric/string user ID; +logout and session expiry clear it. + +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. `.env.production` commits the public DSN of the real +`shoc-frontend` Sentry project, so dev and staging deploys are live; a missing +or blank DSN in any other build leaves telemetry inactive without preventing +the application from starting. Default personally identifiable information +collection remains disabled. + +The Sentry environment label comes from `VITE_SENTRY_ENVIRONMENT` when set and +non-blank, falling back to the Vite build mode otherwise. The committed +`.env.production` labels dev deploys `development`, the staging deploy job +overrides the label to `staging`, and a future production pipeline can set +`production` without code changes. + +Every deployment embeds `shoc-frontend@<40-character commit SHA>` as the Sentry +release and exposes the same SHA as the `app.commit` tag. CI fails if it cannot +resolve that identity. Vite creates hidden source maps, and the dev/staging +deployment workflows upload them to the private `shoc-frontend` Sentry project +using the repository's `SENTRY_AUTH_TOKEN` secret. The public S3 sync explicitly +excludes `*.map`; the upload credential is never available to the browser build. + +Before-send processors reduce request data to normalized URL plus HTTP method, +reduce users to opaque ID only, attach native trace/transaction identifiers as +searchable tags, and reduce breadcrumbs to safe network method/status/normalized +URL metadata. Root and child span attributes use the same allowlist, retaining +function, operation, actor type, normalized route, method/status, service host, +timing, and native IDs while removing raw URLs and query values. Bodies, headers, +cookies, query strings/values, free-form breadcrumb messages, usernames, email +addresses, IP addresses, tokens, and filenames are not intentionally sent. +`sendDefaultPii` remains disabled. + +### 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. +- **Publish source maps with the SPA** — rejected because source code belongs in + Sentry's private artifact store, not the public application bucket. + +## Consequences + +- Browser navigations and API requests are recorded as connected transactions + when the deployment supplies a valid DSN. +- Events are labeled by `VITE_SENTRY_ENVIRONMENT` (`development` on dev + deploys, `staging` on the staging job), falling back to the Vite build mode + when unset. +- 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. +- Deployed stack traces resolve against the exact commit's private source maps + once the scoped upload secret is present and the deployment workflow runs. +- 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..f6fe736b 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", @@ -45,6 +46,7 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", + "@sentry/cli": "3.7.0", "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.17", @@ -1974,6 +1976,295 @@ "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/cli": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.7.0.tgz", + "integrity": "sha512-UV6/hUaEL7X1ATx5RHPfDEObh+nTRwOeUkNwF6R+HDo6iAki063GiLOmkfroemkVD0L4370SSvGtc5+Y7MZkhQ==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "undici": "^6.22.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 18" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "3.7.0", + "@sentry/cli-linux-arm": "3.7.0", + "@sentry/cli-linux-arm64": "3.7.0", + "@sentry/cli-linux-i686": "3.7.0", + "@sentry/cli-linux-x64": "3.7.0", + "@sentry/cli-win32-arm64": "3.7.0", + "@sentry/cli-win32-i686": "3.7.0", + "@sentry/cli-win32-x64": "3.7.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.7.0.tgz", + "integrity": "sha512-Kb6Oem+6lfxkWSH8iz8DHwYmbMtYxFoTk3VHfeTkpz60TRj+0OPga/0ZEsCCgG/Df17E7z2kbkoe5Ld+dEj3Tw==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.7.0.tgz", + "integrity": "sha512-PKEFwwRCOG92u2dXusLbRK4cZ7XXlaUY8TkhaZy/PJ/FNsST7Sh+MbdWQCgPB0FsvZrgImcKIEyCJZtc0GunJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.7.0.tgz", + "integrity": "sha512-m6VlsDJBo9/9ufASv4m4heKS0zfgUJdcSvKgIQerZaLwKOfhY8OP7zhlJPkFmnFjOjyvNyaqzoR00EnlprdwFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.7.0.tgz", + "integrity": "sha512-6NeOPqsPuyrOgVGPFunPxgqHCes+a8r6pbCgq9+qSD3oa5fk9H0KOKB0sIC0YyH43OGzdzmwn0XNbdF6jwJO6A==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.7.0.tgz", + "integrity": "sha512-74R9fuAldhC38Aoco8XQWTBvs2T2NatrDvI/ISHihej2oSnbQmL7krJF3yWn33MchQWrsFKgg7mboqemJnreZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.7.0.tgz", + "integrity": "sha512-xhmTUW3N3m8iZ4mkV8SrNOr5UN8+6zpvlvJxCK6Z15Tp8S14N1T+hfKeyL2xpLxBcd7Q7oW1dYXUl5b7BHNqjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.7.0.tgz", + "integrity": "sha512-/CvFzrCWUB0URWKYQeoNs8UbPJcBz6KD/AX27reyQxPd8S3AnTwcaYfCf8SRHEH9Iu+1IKzi5tUUNUB18b7ijg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.7.0.tgz", + "integrity": "sha512-PNW1O6sZw3JXhhJFgOiW7isFHX/qbniIDxswM7kb0wF2aVUpLXLltbezchWwTAxIRXo9gcu/oMTK0rntdqL5Eg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "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", @@ -5736,6 +6027,16 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5753,6 +6054,13 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index fc91c435..ed95210d 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", @@ -79,6 +80,7 @@ "@commitlint/config-conventional": "^21.2.0", "@eslint/js": "^10.0.1", "@playwright/test": "^1.61.1", + "@sentry/cli": "3.7.0", "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.3", "@types/react": "^19.2.17", diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 59b302ef..2dfb66a2 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -16,6 +16,7 @@ REGION="${AWS_REGION:-us-east-1}" WAIT_FOR_INVALIDATION="${WAIT_FOR_INVALIDATION:-false}" echo "Building SPA (VITE_API_URL comes from the process environment or .env.production)..." +export VITE_APP_COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" npm ci npm run build @@ -41,6 +42,7 @@ echo "Uploading hashed assets (immutable) to s3://${BUCKET}..." aws s3 sync dist/ "s3://${BUCKET}/" \ --delete \ --exclude "index.html" \ + --exclude "*.map" \ --cache-control "public,max-age=31536000,immutable" echo "Uploading index.html (never cached)..." diff --git a/scripts/upload-sourcemaps.sh b/scripts/upload-sourcemaps.sh new file mode 100755 index 00000000..c557b6c5 --- /dev/null +++ b/scripts/upload-sourcemaps.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SENTRY_AUTH_TOKEN:?SENTRY_AUTH_TOKEN is required for source-map upload}" + +SENTRY_ORG="${SENTRY_ORG:-seahaven}" +SENTRY_PROJECT="${SENTRY_PROJECT:-shoc-frontend}" +COMMIT_SHA="${VITE_APP_COMMIT_SHA:-${GITHUB_SHA:-}}" + +if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Source-map upload requires a 40-character VITE_APP_COMMIT_SHA or GITHUB_SHA." >&2 + exit 1 +fi + +COMMIT_SHA="$(printf '%s' "${COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')" +RELEASE="shoc-frontend@${COMMIT_SHA}" + +npm exec --no -- sentry-cli sourcemaps upload \ + --org "${SENTRY_ORG}" \ + --project "${SENTRY_PROJECT}" \ + --release "${RELEASE}" \ + --url-prefix "~/" \ + --validate \ + dist diff --git a/src/api/api.ts b/src/api/api.ts index 3cb8a7f0..147c0571 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -5,6 +5,7 @@ import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; import { getAccessToken } from "@/lib/auth/auth-storage"; import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { traceHttpOperation } from "@/observability/http-transaction"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -48,28 +49,92 @@ export const api: KyInstance = ky.create({ }, }); +export type ApiRawMethod = "get" | "post" | "put" | "patch" | "delete"; + +export async function apiRequestRaw( + method: ApiRawMethod, + url: string, + options?: Options, + entryPoint?: string, +): Promise { + return traceHttpOperation( + { + method: method.toUpperCase(), + url, + entryPoint: entryPoint ?? `api.${method}`, + }, + async ({ reportStatus }) => { + const response = await api[method](url, options); + reportStatus(response.status); + return response; + }, + ); +} + export async function apiGet(url: string, options?: Options): Promise { - return api.get(url, options).json(); + return traceHttpOperation( + { method: "GET", url, entryPoint: "api.apiGet" }, + async ({ reportStatus }) => { + const response = await api.get(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPost(url: string, body?: unknown, options?: Options): Promise { - return api.post(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPost" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { - return api.post(url, { ...options, body }).json(); + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPostForm" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPut(url: string, body?: unknown, options?: Options): Promise { - return api.put(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "PUT", url, entryPoint: "api.apiPut" }, + async ({ reportStatus }) => { + const response = await api.put(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { - return api.patch(url, { ...options, json: body }).json(); + return traceHttpOperation( + { method: "PATCH", url, entryPoint: "api.apiPatch" }, + async ({ reportStatus }) => { + const response = await api.patch(url, { ...options, json: body }); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiDelete(url: string, options?: Options): Promise { - return api.delete(url, options).json(); + return traceHttpOperation( + { method: "DELETE", url, entryPoint: "api.apiDelete" }, + async ({ reportStatus }) => { + const response = await api.delete(url, options); + reportStatus(response.status); + return response.json(); + }, + ); } export async function apiPostNoContent( @@ -77,31 +142,43 @@ export async function apiPostNoContent( body?: unknown, options?: Options, ): Promise { - const response = await api.post(url, { ...options, json: body }); - if (response.status === 204) { - return; - } + return traceHttpOperation( + { method: "POST", url, entryPoint: "api.apiPostNoContent" }, + async ({ reportStatus }) => { + const response = await api.post(url, { ...options, json: body }); + reportStatus(response.status); + if (response.status === 204) { + return; + } - const text = await response.text(); - if (!text.trim()) { - return; - } + const text = await response.text(); + if (!text.trim()) { + return; + } - JSON.parse(text); + JSON.parse(text); + }, + ); } export async function apiDeleteNoContent(url: string, options?: Options): Promise { - const response = await api.delete(url, options); - if (response.status === 204) { - return; - } + return traceHttpOperation( + { method: "DELETE", url, entryPoint: "api.apiDeleteNoContent" }, + async ({ reportStatus }) => { + const response = await api.delete(url, options); + reportStatus(response.status); + if (response.status === 204) { + return; + } - const text = await response.text(); - if (!text.trim()) { - return; - } + const text = await response.text(); + if (!text.trim()) { + return; + } - JSON.parse(text); + JSON.parse(text); + }, + ); } export { mapHttpStatusToMessage }; diff --git a/src/domain/auth/use-cases/use-login.ts b/src/domain/auth/use-cases/use-login.ts index 6c4617de..55766b90 100644 --- a/src/domain/auth/use-cases/use-login.ts +++ b/src/domain/auth/use-cases/use-login.ts @@ -46,11 +46,13 @@ export function useLogout(): UseMutationResult { mutationFn: authApi.logout, onSuccess: () => { stopQueryBroadcast(queryClient); + queryClient.setQueryData(queryKeys.auth.session(), null); toast.info("You have been logged out"); navigate("/login"); }, onError: (error: Error) => { stopQueryBroadcast(queryClient); + queryClient.setQueryData(queryKeys.auth.session(), null); toast.error(error.message || "Logout failed"); navigate("/login"); }, diff --git a/src/domain/uplifts/api/uplifts-api.ts b/src/domain/uplifts/api/uplifts-api.ts index 27aa1676..890d7116 100644 --- a/src/domain/uplifts/api/uplifts-api.ts +++ b/src/domain/uplifts/api/uplifts-api.ts @@ -1,6 +1,6 @@ import { HTTPError } from "ky"; import { API_PATHS } from "@/api/api-paths"; -import { api, apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiRequestRaw } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; import { mapUpliftQueueItems, mapUpliftRequests } from "@/domain/uplifts/mappers/uplift-mapper"; import type { UpliftsQueueParams, UpliftsQueueResult } from "@/domain/uplifts/types/uplift"; @@ -91,7 +91,12 @@ export const upliftsApi = { ): Promise => { let response: Response; try { - response = await api.get(evidenceUrl(id)); + response = await apiRequestRaw( + "get", + evidenceUrl(id), + undefined, + "upliftsApi.downloadEvidence", + ); } catch (error) { if (error instanceof HTTPError) { throw evidenceHttpError(error.response.status); diff --git a/src/domain/vendor-operations/api/vendor-operations-api.ts b/src/domain/vendor-operations/api/vendor-operations-api.ts index 6e8aae0d..9c982840 100644 --- a/src/domain/vendor-operations/api/vendor-operations-api.ts +++ b/src/domain/vendor-operations/api/vendor-operations-api.ts @@ -1,4 +1,4 @@ -import { api, apiGet, apiPut } from "@/api/api"; +import { apiGet, apiPut, apiRequestRaw } from "@/api/api"; import type { SiteVendorPreference, VendorAssignmentResult, @@ -27,7 +27,13 @@ type InsightParams = { }; async function saveDownload(path: string, fileName: string, params: InsightParams): Promise { - const blob = await api.get(`${base}/${path}`, { searchParams: searchParams(params) }).blob(); + const response = await apiRequestRaw( + "get", + `${base}/${path}`, + { searchParams: searchParams(params) }, + "vendorOperationsApi.saveDownload", + ); + const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; diff --git a/src/domain/vendor-portal/api/vendor-portal-api.ts b/src/domain/vendor-portal/api/vendor-portal-api.ts index a8669484..5e16c3e0 100644 --- a/src/domain/vendor-portal/api/vendor-portal-api.ts +++ b/src/domain/vendor-portal/api/vendor-portal-api.ts @@ -1,6 +1,7 @@ import { API_PATHS } from "@/api/api-paths"; import { env } from "@/lib/env"; import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; +import { traceHttpOperation } from "@/observability/http-transaction"; import type { VendorPortalComment, VendorPortalDispatchDetail, @@ -38,30 +39,45 @@ function uploadVendorDocument({ purpose, onProgress, }: UploadVendorDocumentOptions): Promise { - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest(); - request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`)); - request.setRequestHeader("X-Vendor-Token", token); - request.upload.addEventListener("progress", (event) => { - if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100)); - }); - request.addEventListener("load", () => { - const payload = JSON.parse(request.responseText || "{}") as Record; - if (request.status < 200 || request.status >= 300) { - reject(new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`))); - return; - } - resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument); - }); - request.addEventListener("error", () => reject(new Error("Upload failed"))); - const body = new FormData(); - body.append("file", file); - if (replacesDocumentId !== undefined) { - body.append("replacesDocumentId", String(replacesDocumentId)); - } - body.append("purpose", purpose ?? "Completion"); - request.send(body); - }); + return traceHttpOperation( + { + method: "POST", + url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`), + entryPoint: "vendorPortalApi.uploadDocument", + actorType: "vendor_link", + }, + ({ reportStatus }) => + new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("POST", resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents`)); + request.setRequestHeader("X-Vendor-Token", token); + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100)); + }); + request.addEventListener("load", () => { + reportStatus(request.status); + const payload = JSON.parse(request.responseText || "{}") as Record; + if (request.status < 200 || request.status >= 300) { + reject( + new Error(String(payload.message ?? payload.Message ?? `HTTP ${request.status}`)), + ); + return; + } + resolve((payload.data ?? payload.Data ?? payload) as VendorPortalDocument); + }); + request.addEventListener("error", () => { + reportStatus(request.status); + reject(new Error("Upload failed")); + }); + const body = new FormData(); + body.append("file", file); + if (replacesDocumentId !== undefined) { + body.append("replacesDocumentId", String(replacesDocumentId)); + } + body.append("purpose", purpose ?? "Completion"); + request.send(body); + }), + ); } async function downloadVendorDocument( @@ -69,19 +85,32 @@ async function downloadVendorDocument( dispatchId: string | number, document: VendorPortalDocument, ): Promise { - const response = await fetch( - resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`), - { headers: { "X-Vendor-Token": token } }, + await traceHttpOperation( + { + method: "GET", + url: resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`), + entryPoint: "vendorPortalApi.downloadDocument", + actorType: "vendor_link", + }, + async ({ reportStatus }) => { + const response = await fetch( + resolveVendorPortalUrl(`/dispatches/${dispatchId}/documents/${document.id}`), + { headers: { "X-Vendor-Token": token } }, + ); + reportStatus(response.status); + if (!response.ok) + throw new Error( + response.status === 423 ? "File is still being scanned" : "Download failed", + ); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement("a"); + anchor.href = url; + anchor.download = document.originalFileName; + anchor.click(); + URL.revokeObjectURL(url); + }, ); - if (!response.ok) - throw new Error(response.status === 423 ? "File is still being scanned" : "Download failed"); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const anchor = window.document.createElement("a"); - anchor.href = url; - anchor.download = document.originalFileName; - anchor.click(); - URL.revokeObjectURL(url); } async function vendorPortalCall( @@ -89,30 +118,41 @@ async function vendorPortalCall( path: string, options?: { method?: string; body?: unknown }, ): Promise { - const response = await fetch(resolveVendorPortalUrl(path), { - method: options?.method ?? "GET", - headers: { - "Content-Type": "application/json", - "X-Vendor-Token": token, + return traceHttpOperation( + { + method: options?.method ?? "GET", + url: resolveVendorPortalUrl(path), + entryPoint: "vendorPortalApi.call", + actorType: "vendor_link", }, - body: options?.body ? JSON.stringify(options.body) : undefined, - }); + async ({ reportStatus }) => { + const response = await fetch(resolveVendorPortalUrl(path), { + method: options?.method ?? "GET", + headers: { + "Content-Type": "application/json", + "X-Vendor-Token": token, + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + reportStatus(response.status); - if (response.status === 401) { - throw new Error("Invalid or expired token"); - } + if (response.status === 401) { + throw new Error("Invalid or expired token"); + } - const payload = (await response.json().catch(() => ({}))) as Record; + const payload = (await response.json().catch(() => ({}))) as Record; - if (!response.ok) { - const message = - (typeof payload.message === "string" ? payload.message : undefined) ?? - (typeof payload.Message === "string" ? payload.Message : undefined) ?? - `HTTP ${response.status}`; - throw new Error(message); - } + if (!response.ok) { + const message = + (typeof payload.message === "string" ? payload.message : undefined) ?? + (typeof payload.Message === "string" ? payload.Message : undefined) ?? + `HTTP ${response.status}`; + throw new Error(message); + } - return (payload.data ?? payload.Data ?? payload) as T; + return (payload.data ?? payload.Data ?? payload) as T; + }, + ); } function isNoApprovalRequired(payload: unknown): boolean { diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index ec0d9148..99c060fd 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -1,8 +1,9 @@ import { API_PATHS } from "@/api/api-paths"; import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; -import { api, apiGet, apiPost } from "@/api/api"; +import { apiGet, apiPost, apiRequestRaw } from "@/api/api"; import { handleApiResponse } from "@/api/handle-api-response"; import { uploadFormWithProgress } from "@/api/upload-form-with-progress"; +import { traceHttpOperation } from "@/observability/http-transaction"; import { BoardPatchConflictError, BoardPatchValidationError, @@ -82,10 +83,12 @@ export const workOrderBoardDocumentsApi = { commentId: string | number, text: string, ): Promise => { - const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), { - json: { text }, - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "patch", + API_PATHS.workOrder.commentItem(workOrderId, commentId), + { json: { text }, throwHttpErrors: false }, + "workOrderBoardDocumentsApi.updateComment", + ); const data = await readResponseJson(response); if (response.ok) { @@ -120,10 +123,21 @@ export const workOrderBoardDocumentsApi = { options?: { signal?: AbortSignal; onProgress?: (percent: number) => void }, ): Promise => { const formData = buildMediaFormData(file, category); - const { ok, status, data } = await uploadFormWithProgress( - API_PATHS.workOrder.media(workOrderId), - formData, - { signal: options?.signal, onProgress: options?.onProgress }, + const { ok, status, data } = await traceHttpOperation( + { + method: "POST", + url: API_PATHS.workOrder.media(workOrderId), + entryPoint: "workOrderBoardDocumentsApi.uploadMedia", + }, + async ({ reportStatus }) => { + const result = await uploadFormWithProgress( + API_PATHS.workOrder.media(workOrderId), + formData, + { signal: options?.signal, onProgress: options?.onProgress }, + ); + reportStatus(result.status); + return result; + }, ); if (ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); @@ -147,10 +161,12 @@ export const workOrderBoardDocumentsApi = { const formData = new FormData(); formData.append("category", category); formData.append("workOrderVersion", workOrderVersion); - const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), { - body: formData, - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "patch", + API_PATHS.workOrder.mediaItem(workOrderId, mediaId), + { body: formData, throwHttpErrors: false }, + "workOrderBoardDocumentsApi.updateMediaCategory", + ); const data = await readResponseJson(response); if (response.ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); @@ -178,10 +194,12 @@ export const workOrderBoardDocumentsApi = { ); } - const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), { - searchParams: { workOrderVersion }, - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "delete", + API_PATHS.workOrder.mediaItem(workOrderId, numericId), + { searchParams: { workOrderVersion }, throwHttpErrors: false }, + "workOrderBoardDocumentsApi.deleteMedia", + ); if (response.status === 204) { return; @@ -198,10 +216,12 @@ export const workOrderBoardDocumentsApi = { workOrderId: string | number, mediaId: string | number, ): Promise => { - const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), { - credentials: "include", - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "get", + API_PATHS.workOrder.mediaContent(workOrderId, mediaId), + { credentials: "include", throwHttpErrors: false }, + "workOrderBoardDocumentsApi.getMediaContent", + ); if (response.ok) { return response.blob(); @@ -232,10 +252,12 @@ export const workOrderBoardDocumentsApi = { } const formData = buildCompletionDocFormData(params); - const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), { - body: formData, - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "post", + API_PATHS.workOrder.completionDoc(workOrderId), + { body: formData, throwHttpErrors: false }, + "workOrderBoardDocumentsApi.uploadCompletionDoc", + ); const data = await readResponseJson(response); if (response.ok) { return mapCompletion(handleApiResponse(data) ?? data); diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index ddebf957..fe6068f5 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -1,6 +1,6 @@ import { API_PATHS } from "@/api/api-paths"; import { ApiError, mapHttpStatusToMessage } from "@/api/api-error"; -import { api, apiDelete, apiGet, apiPost, apiPostForm, apiPut } from "@/api/api"; +import { apiDelete, apiGet, apiPost, apiPostForm, apiPut, apiRequestRaw } from "@/api/api"; import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; import { BoardPatchConflictError, @@ -314,10 +314,12 @@ export const workOrdersApi = { id: string | number, payload: WorkOrderBoardPatchPayload, ): Promise => { - const response = await api.patch(API_PATHS.workOrder.boardPatch(id), { - json: payload, - throwHttpErrors: false, - }); + const response = await apiRequestRaw( + "patch", + API_PATHS.workOrder.boardPatch(id), + { json: payload, throwHttpErrors: false }, + "workOrdersApi.patchBoardField", + ); let data: unknown; try { diff --git a/src/main.tsx b/src/main.tsx index 6643603c..4e683029 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,7 @@ +import * as Sentry from "@sentry/react"; + +import "@/observability/sentry"; + import React from "react"; import ReactDOM from "react-dom/client"; import { AppRoot } from "@/app-root"; @@ -10,7 +14,11 @@ if (!rootElement) { throw new Error("Root element #root not found"); } -ReactDOM.createRoot(rootElement).render( +ReactDOM.createRoot(rootElement, { + onCaughtError: Sentry.reactErrorHandler(), + onRecoverableError: Sentry.reactErrorHandler(), + onUncaughtError: Sentry.reactErrorHandler(), +}).render( diff --git a/src/observability/http-transaction.ts b/src/observability/http-transaction.ts new file mode 100644 index 00000000..c6b224ab --- /dev/null +++ b/src/observability/http-transaction.ts @@ -0,0 +1,111 @@ +import { setHttpStatus, spanToJSON, startSpan, type Span } from "@sentry/react"; +import { isHTTPError } from "ky"; + +const INTEGER_SEGMENT = /^\d+$/; +const UUID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX_SEGMENT = /^[0-9a-f]+$/i; +const TOKEN_SEGMENT = /^[0-9a-z._~-]+$/i; +const OPAQUE_HEX_MIN_LENGTH = 8; +const OPAQUE_TOKEN_MIN_LENGTH = 16; + +function isIdentifierSegment(segment: string): boolean { + if (segment.length === 0) { + return false; + } + if (INTEGER_SEGMENT.test(segment)) { + return true; + } + if (UUID_SEGMENT.test(segment)) { + return true; + } + if (segment.length >= OPAQUE_HEX_MIN_LENGTH && HEX_SEGMENT.test(segment)) { + return true; + } + return ( + segment.length >= OPAQUE_TOKEN_MIN_LENGTH && + TOKEN_SEGMENT.test(segment) && + /\d/.test(segment) && + /[a-z]/i.test(segment) + ); +} + +export function normalizeHttpRoute(input: string): string { + let path: string; + try { + path = new URL(input).pathname; + } catch { + path = input.split("#")[0] ?? input; + path = path.split("?")[0] ?? path; + } + + return path + .split("/") + .map((segment) => (isIdentifierSegment(segment) ? ":id" : segment)) + .join("/"); +} + +export type HttpTraceOptions = { + method: string; + url: string; + entryPoint: string; + actorType?: string; +}; + +export type HttpTraceContext = { + reportStatus: (status: number) => void; +}; + +export async function traceHttpOperation( + options: HttpTraceOptions, + callback: (context: HttpTraceContext) => Promise, +): Promise { + const route = normalizeHttpRoute(options.url); + + return startSpan( + { + name: `${options.method.toUpperCase()} ${route}`, + op: "http.client", + kind: 2, + forceTransaction: true, + attributes: { + "http.request.method": options.method.toUpperCase(), + "http.route": route, + "code.function": options.entryPoint, + "operation.type": "http.client", + ...(options.actorType ? { "actor.type": options.actorType } : {}), + }, + }, + async (span: Span) => { + const spanJson = spanToJSON(span); + if (spanJson.trace_id) { + span.setAttribute("trace_id", spanJson.trace_id); + } + if (spanJson.span_id) { + span.setAttribute("span_id", spanJson.span_id); + span.setAttribute("transaction_id", spanJson.span_id); + } + + let statusReported = false; + const reportStatus = (status: number): void => { + if (statusReported) { + return; + } + statusReported = true; + setHttpStatus(span, status); + }; + + try { + const result = await callback({ reportStatus }); + if (!statusReported && result instanceof Response) { + reportStatus(result.status); + } + return result; + } catch (error) { + if (!statusReported) { + reportStatus(isHTTPError(error) ? error.response.status : 500); + } + throw error; + } + }, + ); +} diff --git a/src/observability/sentry-scrubber.ts b/src/observability/sentry-scrubber.ts new file mode 100644 index 00000000..558dce9f --- /dev/null +++ b/src/observability/sentry-scrubber.ts @@ -0,0 +1,151 @@ +import type { Breadcrumb, ErrorEvent, SpanJSON, TransactionEvent } from "@sentry/core"; + +import { normalizeHttpRoute } from "@/observability/http-transaction"; + +function sanitizeUrl(value: string): string { + try { + const parsed = new URL(value, window.location.origin); + return `${parsed.origin}${normalizeHttpRoute(parsed.pathname)}`; + } catch { + return normalizeHttpRoute(value); + } +} + +function keepOpaqueUserId(event: ErrorEvent | TransactionEvent): void { + event.user = event.user?.id == null ? undefined : { id: String(event.user.id) }; +} + +function scrubRequest(event: ErrorEvent | TransactionEvent): void { + if (!event.request) { + return; + } + + event.request = { + method: event.request.method, + url: typeof event.request.url === "string" ? sanitizeUrl(event.request.url) : undefined, + }; +} + +const SAFE_SPAN_DATA_KEYS = new Set([ + "actor.type", + "code.function", + "db.operation.name", + "db.system", + "http.method", + "http.request.method", + "http.response.status_code", + "http.route", + "http.status_code", + "operation.type", + "sentry.op", + "sentry.origin", + "server.address", +]); + +function safeSpanData(data: SpanJSON["data"] | undefined): SpanJSON["data"] { + return Object.fromEntries( + Object.entries(data ?? {}).filter( + ([key, value]) => + SAFE_SPAN_DATA_KEYS.has(key) && ["string", "number", "boolean"].includes(typeof value), + ), + ); +} + +function valueAsString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function sanitizeHttpDescription(description: string | undefined): string | undefined { + if (!description) { + return undefined; + } + + const match = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i.exec(description); + return match ? `${match[1]!.toUpperCase()} ${sanitizeUrl(match[2]!)}` : undefined; +} + +function scrubTransactionSpans(event: TransactionEvent): void { + event.spans = event.spans?.map((span) => ({ + ...span, + data: safeSpanData(span.data), + description: span.op?.includes("http") ? sanitizeHttpDescription(span.description) : undefined, + })); + + const trace = event.contexts?.trace; + if (trace?.data) { + trace.data = safeSpanData(trace.data); + } +} + +function setStringTag( + tags: NonNullable, + key: string, + value: unknown, +): void { + const stringValue = valueAsString(value); + if (stringValue) { + tags[key] = stringValue; + } +} + +function transactionFunction(event: ErrorEvent | TransactionEvent): string | undefined { + const traceFunction = valueAsString(event.contexts?.trace?.data?.["code.function"]); + return traceFunction ?? (event.type === "transaction" ? event.transaction : undefined); +} + +function actorType(event: ErrorEvent | TransactionEvent): string { + return ( + valueAsString(event.tags?.["actor.type"]) ?? + valueAsString(event.contexts?.trace?.data?.["actor.type"]) ?? + (event.user?.id == null ? "anonymous" : "authenticated") + ); +} + +function attachTraceIdentifiers(event: ErrorEvent | TransactionEvent): void { + const trace = event.contexts?.trace; + const tags = { ...event.tags }; + + setStringTag(tags, "trace_id", trace?.trace_id); + setStringTag(tags, "transaction_id", trace?.span_id); + setStringTag(tags, "operation.type", valueAsString(trace?.data?.["operation.type"]) ?? trace?.op); + setStringTag(tags, "code.function", transactionFunction(event)); + tags["actor.type"] = actorType(event); + event.tags = tags; +} + +export function scrubErrorEvent(event: ErrorEvent): ErrorEvent { + keepOpaqueUserId(event); + scrubRequest(event); + attachTraceIdentifiers(event); + return event; +} + +export function scrubTransactionEvent(event: TransactionEvent): TransactionEvent { + keepOpaqueUserId(event); + scrubRequest(event); + attachTraceIdentifiers(event); + scrubTransactionSpans(event); + return event; +} + +export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb { + const safe: Breadcrumb = { + timestamp: breadcrumb.timestamp, + type: breadcrumb.type, + category: breadcrumb.category, + level: breadcrumb.level, + }; + + if (breadcrumb.category === "fetch" || breadcrumb.category === "xhr") { + const method = breadcrumb.data?.method; + const statusCode = breadcrumb.data?.status_code; + const url = breadcrumb.data?.url; + safe.data = { + ...(typeof method === "string" ? { method } : {}), + ...(typeof statusCode === "number" ? { status_code: statusCode } : {}), + ...(typeof url === "string" ? { url: sanitizeUrl(url) } : {}), + }; + } + + return safe; +} diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts new file mode 100644 index 00000000..23cdbfcb --- /dev/null +++ b/src/observability/sentry.ts @@ -0,0 +1,46 @@ +import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from "react-router"; + +import { env } from "@/lib/env"; +import { + scrubBreadcrumb, + scrubErrorEvent, + scrubTransactionEvent, +} from "@/observability/sentry-scrubber"; + +const sentryDsn = import.meta.env.VITE_SENTRY_DSN?.trim() || undefined; +const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT?.trim() || import.meta.env.MODE; +const apiBaseUrl = new URL(env.apiUrl, window.location.origin).toString(); + +Sentry.init({ + dsn: sentryDsn, + environment: sentryEnvironment, + release: __SENTRY_RELEASE__, + initialScope: { + tags: { + service: "shoc-frontend", + "app.commit": __APP_COMMIT_SHA__, + }, + }, + integrations: [ + Sentry.reactRouterBrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ], + sendDefaultPii: false, + beforeSend: scrubErrorEvent, + beforeSendTransaction: scrubTransactionEvent, + beforeBreadcrumb: scrubBreadcrumb, + tracesSampleRate: 1.0, + tracePropagationTargets: [window.location.origin, apiBaseUrl], +}); diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index c5687abd..3b8b086a 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, type ReactNode } from "react"; +import * as Sentry from "@sentry/react"; import { useQueryClient } from "@tanstack/react-query"; import { useChangePassword } from "@/domain/auth/use-cases/use-change-password"; import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login"; @@ -14,6 +15,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userId = sessionQuery.data?.id; + useEffect(() => { + Sentry.setUser(userId == null ? null : { id: String(userId) }); + }, [userId]); + useEffect(() => { if (userId == null) { return; 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() { diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 925a85e6..ec323a94 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -24,6 +24,13 @@ vi.mock("@/api/api", () => ({ apiPatch: (...args: unknown[]) => apiPatch(...args), apiDelete: vi.fn(), apiPostNoContent: vi.fn(), + apiRequestRaw: (method: string, url: unknown, options?: unknown) => { + if (method === "patch") return apiPatchFn(url, options); + if (method === "post") return apiPostFn(url, options); + if (method === "delete") return apiDeleteFn(url, options); + if (method === "get") return apiGetFn(url, options); + throw new Error(`Unexpected apiRequestRaw method: ${method}`); + }, api: { patch: (...args: unknown[]) => apiPatchFn(...args), post: (...args: unknown[]) => apiPostFn(...args), diff --git a/src/test/observability/http-transaction.test.ts b/src/test/observability/http-transaction.test.ts new file mode 100644 index 00000000..c90bc9bf --- /dev/null +++ b/src/test/observability/http-transaction.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const capturedSpan = vi.hoisted(() => ({ setAttribute: vi.fn() })); + +vi.mock("@sentry/react", () => ({ + startSpan: vi.fn((options: unknown, callback: (span: unknown) => unknown) => + callback(capturedSpan), + ), + spanToJSON: vi.fn(() => ({ trace_id: "trace-1", span_id: "span-1" })), + setHttpStatus: vi.fn(), +})); + +import * as Sentry from "@sentry/react"; +import { HTTPError } from "ky"; +import { normalizeHttpRoute, traceHttpOperation } from "@/observability/http-transaction"; + +const startSpanMock = vi.mocked(Sentry.startSpan); +const setHttpStatusMock = vi.mocked(Sentry.setHttpStatus); + +function lastSpanOptions(): Record { + const calls = startSpanMock.mock.calls; + const [options] = calls[calls.length - 1] ?? []; + return (options ?? {}) as unknown as Record; +} + +describe("normalizeHttpRoute", () => { + it("replaces integer path segments with :id", () => { + expect(normalizeHttpRoute("/api/workorders/9/media")).toBe("/api/workorders/:id/media"); + }); + + it("replaces UUID path segments with :id", () => { + expect(normalizeHttpRoute("/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301/evidence")).toBe( + "/uplifts/:id/evidence", + ); + }); + + it("replaces long hex and opaque token segments with :id", () => { + expect(normalizeHttpRoute("/files/deadbeefcafe1234")).toBe("/files/:id"); + expect(normalizeHttpRoute("/share/Ab12Cd34Ef56Gh78")).toBe("/share/:id"); + }); + + it("strips query strings and fragments without using their values", () => { + expect(normalizeHttpRoute("/vendor-portal/dispatches?status=pending#x")).toBe( + "/vendor-portal/dispatches", + ); + }); + + it("keeps descriptive segments untouched", () => { + expect(normalizeHttpRoute("/vendor-operations/insights.csv")).toBe( + "/vendor-operations/insights.csv", + ); + }); + + it("uses only the pathname of absolute URLs", () => { + expect(normalizeHttpRoute("https://api.example.com/workorders/9/media?q=1")).toBe( + "/workorders/:id/media", + ); + }); +}); + +describe("traceHttpOperation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts a forced http.client transaction with safe attributes", async () => { + await traceHttpOperation( + { method: "get", url: "/workorders/9/media?include=x", entryPoint: "api.apiGet" }, + async () => "ok", + ); + + const options = lastSpanOptions(); + expect(startSpanMock).toHaveBeenCalledTimes(1); + expect(options.name).toBe("GET /workorders/:id/media"); + expect(options.op).toBe("http.client"); + expect(options.kind).toBe(2); + expect(options.forceTransaction).toBe(true); + expect(options.attributes).toEqual({ + "http.request.method": "GET", + "http.route": "/workorders/:id/media", + "code.function": "api.apiGet", + "operation.type": "http.client", + }); + }); + + it("adds actor.type only when provided and native span ids as attributes", async () => { + await traceHttpOperation( + { + method: "POST", + url: "/vendor-portal/dispatches", + entryPoint: "vendorPortalApi.call", + actorType: "vendor_link", + }, + async () => null, + ); + + expect(lastSpanOptions().attributes).toMatchObject({ + "actor.type": "vendor_link", + }); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("trace_id", "trace-1"); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("span_id", "span-1"); + expect(capturedSpan.setAttribute).toHaveBeenCalledWith("transaction_id", "span-1"); + }); + + it("reports the status of a returned Response", async () => { + const response = new Response("{}", { status: 201 }); + + await traceHttpOperation({ method: "POST", url: "/x", entryPoint: "fn" }, async () => response); + + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 201); + }); + + it("exposes a single-shot status reporter for non-Response results", async () => { + const result = await traceHttpOperation( + { method: "GET", url: "/x", entryPoint: "fn" }, + async ({ reportStatus }) => { + reportStatus(204); + reportStatus(500); + return "done"; + }, + ); + + expect(result).toBe("done"); + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 204); + }); + + it("reports the Ky HTTPError response status instead of 500", async () => { + const httpError = new HTTPError( + new Response("Unauthorized", { status: 401 }), + new Request("https://api.example.test/x"), + {} as never, + ); + + await expect( + traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => { + throw httpError; + }), + ).rejects.toThrow(); + + expect(setHttpStatusMock).toHaveBeenCalledTimes(1); + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 401); + }); + + it("reports status 500 when the callback throws", async () => { + await expect( + traceHttpOperation({ method: "GET", url: "/x", entryPoint: "fn" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(setHttpStatusMock).toHaveBeenCalledWith(capturedSpan, 500); + }); +}); diff --git a/src/test/observability/sentry-scrubber.test.ts b/src/test/observability/sentry-scrubber.test.ts new file mode 100644 index 00000000..2726d22f --- /dev/null +++ b/src/test/observability/sentry-scrubber.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { + scrubBreadcrumb, + scrubErrorEvent, + scrubTransactionEvent, +} from "@/observability/sentry-scrubber"; + +describe("Sentry telemetry scrubbing", () => { + it("keeps only an opaque user id and safe request method/path on errors", () => { + const event = scrubErrorEvent({ + type: undefined, + user: { + id: "opaque-user-123", + email: "secret.user@example.com", + username: "Secret User", + ip_address: "203.0.113.10", + }, + request: { + method: "GET", + url: "https://app.example.com/workorders/123?token=secret", + headers: { Authorization: "Bearer secret" }, + cookies: { session: "secret" }, + data: "secret-body", + }, + contexts: { + trace: { + trace_id: "0123456789abcdef0123456789abcdef", + span_id: "0123456789abcdef", + op: "http.client", + }, + }, + }); + + expect(event.user).toEqual({ id: "opaque-user-123" }); + expect(event.request).toEqual({ + method: "GET", + url: "https://app.example.com/workorders/:id", + }); + expect(event.tags).toMatchObject({ + trace_id: "0123456789abcdef0123456789abcdef", + transaction_id: "0123456789abcdef", + "operation.type": "http.client", + "actor.type": "authenticated", + }); + }); + + it("applies the same request/user contract to transactions", () => { + const event = scrubTransactionEvent({ + type: "transaction", + transaction: "GET /workorders/:id", + user: { id: "opaque-user-123", email: "secret.user@example.com" }, + request: { + method: "GET", + url: "/workorders/123?token=secret", + query_string: "token=secret", + }, + contexts: { + trace: { + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "fedcba9876543210", + op: "navigation", + data: { + "actor.type": "vendor_link", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + "http.query": "token=secret", + }, + }, + }, + spans: [ + { + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "1111111111111111", + parent_span_id: "fedcba9876543210", + op: "http.client", + start_timestamp: 1, + timestamp: 2, + description: "GET https://api.example.com/workorders/123?token=secret", + data: { + "http.request.method": "GET", + "http.route": "/workorders/:id", + "http.response.status_code": 200, + "code.function": "vendorPortalApi.call", + "http.query": "token=secret", + "url.full": "https://api.example.com/workorders/123?token=secret", + request_body: "secret-body", + }, + }, + ], + }); + + expect(event.user).toEqual({ id: "opaque-user-123" }); + expect(event.request).toEqual({ + method: "GET", + url: `${window.location.origin}/workorders/:id`, + }); + expect(event.tags).toMatchObject({ + trace_id: "fedcba9876543210fedcba9876543210", + transaction_id: "fedcba9876543210", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + "actor.type": "vendor_link", + }); + expect(event.contexts?.trace?.data).toEqual({ + "actor.type": "vendor_link", + "operation.type": "http.client", + "code.function": "vendorPortalApi.call", + }); + expect(event.spans).toEqual([ + expect.objectContaining({ + trace_id: "fedcba9876543210fedcba9876543210", + span_id: "1111111111111111", + parent_span_id: "fedcba9876543210", + op: "http.client", + start_timestamp: 1, + timestamp: 2, + description: "GET https://api.example.com/workorders/:id", + data: { + "http.request.method": "GET", + "http.route": "/workorders/:id", + "http.response.status_code": 200, + "code.function": "vendorPortalApi.call", + }, + }), + ]); + }); + + it("retains only safe network breadcrumb details", () => { + const breadcrumb = scrubBreadcrumb({ + category: "fetch", + message: "secret free-form text", + data: { + method: "POST", + status_code: 201, + url: "/uplifts/3f2504e0-4f89-11d3-9a0c-0305e82c3301?token=secret", + request_body: "secret", + }, + }); + + expect(breadcrumb.message).toBeUndefined(); + expect(breadcrumb.data).toEqual({ + method: "POST", + status_code: 201, + url: `${window.location.origin}/uplifts/:id`, + }); + }); +}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4df1f98f..820c0ca5 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,8 +3,13 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_API_TARGET: string; + readonly VITE_SENTRY_DSN?: string; + readonly VITE_SENTRY_ENVIRONMENT?: string; } interface ImportMeta { readonly env: ImportMetaEnv; } + +declare const __APP_COMMIT_SHA__: string; +declare const __SENTRY_RELEASE__: string; diff --git a/tsconfig.node.json b/tsconfig.node.json index 5fc395e4..8e08c499 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -21,6 +21,8 @@ "vitest.config.ts", "config/api-url-contract.ts", "config/api-url-contract.test.ts", + "config/sentry-release.ts", + "config/sentry-release.test.ts", "config/pwa-manifest.test.ts" ] } diff --git a/vite.config.ts b/vite.config.ts index 57ee9fcf..79ced947 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,33 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import { defineConfig, loadEnv } from "vite"; import { assertAbsoluteApiUrlHasApiSegment } from "./config/api-url-contract"; +import { resolveSentryRelease } from "./config/sentry-release"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); assertAbsoluteApiUrlHasApiSegment(env.VITE_API_URL); + let gitCommit: string | undefined; + try { + gitCommit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + } catch { + gitCommit = undefined; + } + const sentryRelease = resolveSentryRelease( + env.VITE_APP_COMMIT_SHA || process.env.GITHUB_SHA || gitCommit, + process.env.CI === "true", + ); + return { plugins: [react(), tailwindcss()], + define: { + __APP_COMMIT_SHA__: JSON.stringify(sentryRelease.commitSha), + __SENTRY_RELEASE__: JSON.stringify(sentryRelease.release), + }, resolve: { alias: { "@": path.resolve(__dirname, "src"), @@ -28,6 +45,7 @@ export default defineConfig(({ mode }) => { }, build: { outDir: "dist", + sourcemap: "hidden", }, }; });