diff --git a/docs/architecture.md b/docs/architecture.md index 27b1c0a42..d48dbc14f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,7 @@ Historical provider pricing evidence is retained under [`docs/pricing-snapshots/ ## Observability -Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. +Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values. diff --git a/docs/development.md b/docs/development.md index 39a378f8f..241f4444f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,6 +57,8 @@ The checked-in [`.env.example`](../.env.example) contains optional client observ - `VITE_POSTHOG_KEY` - `VITE_POSTHOG_HOST` +Production builds also read the Sentry build variables `SENTRY_AUTH_TOKEN`, `SENTRY_ORG` and `SENTRY_PROJECT`. Local builds leave them unset and skip source map upload. See [operations.md](operations.md#secrets). + The frontend Supabase environment is resolved by [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts), not by those variables. Public browser configuration is distinct from server secrets. Never put service-role keys, AI provider keys, Stripe secrets, webhook secrets, demo-code peppers, or integrity secrets in Vite variables. Any `VITE_*` value can reach the browser bundle. diff --git a/docs/operations.md b/docs/operations.md index 4e39fd7dd..76e008f03 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -115,6 +115,8 @@ Never place server secrets in `VITE_*`, screenshots, traces, logs, comments, com GitHub Actions secrets: `LIVE_OPENROUTER_API_KEY` and `LIVE_EXA_API_KEY` for live provider tests, and `PROD_CANARY_EMAIL` and `PROD_CANARY_PASSWORD` for the production canary account. The public anon key is the `PROD_SUPABASE_ANON_KEY` repository variable. To rotate the canary password, set it with the Supabase Auth admin API and pipe it into `gh secret set` over stdin. +Vercel build variables for Sentry source maps: `SENTRY_AUTH_TOKEN` (an organization token with release and source map upload scope), `SENTRY_ORG` and `SENTRY_PROJECT` (slugs). When all three are set, [`vite.config.ts`](../vite.config.ts) builds hidden source maps, uploads them for the release, and deletes them from `dist/`. When any is missing, the build emits no source maps. A failed upload logs an error but does not fail the build. The release is `VERCEL_GIT_COMMIT_SHA` (then `GITHUB_SHA`, then `git rev-parse HEAD`), and the Sentry environment is `VERCEL_ENV`, so preview deployments report as `preview`. Both need Vercel's system environment variables enabled. + ## Rollback Choose rollback before changing production: diff --git a/package-lock.json b/package-lock.json index 8095eea51..11f0edf01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@playwright/test": "^1.60.0", + "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", @@ -135,7 +136,6 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -145,6 +145,160 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -165,6 +319,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", @@ -190,6 +368,40 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -1233,6 +1445,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -3290,6 +3513,224 @@ "node": ">=18" } }, + "node_modules/@sentry/bundler-plugins": { + "version": "10.75.3", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.75.3.tgz", + "integrity": "sha512-WViQ6CqeI2RAZZWEr0Ohz4kfbNRcb/XhLqlG+JmsYPTbipwfOnCUK4jOPiKKWtnl7IisBB7fhL5ZIqNTSvbY4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/cli": "^2.58.6", + "@sentry/core": "10.75.3", + "dotenv": "^17.4.2", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0", + "webpack": ">=5.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@sentry/bundler-plugins/node_modules/@sentry/core": { + "version": "10.75.3", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.3.tgz", + "integrity": "sha512-QHgzobetM4UgEVbyqx9WPJzRq0YzyNg2CeeoM1Bf84ivYJXXiiVWxjzc0RHk3c0DHY5/6O6nJqos1xVL55dgZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@sentry/conventions": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", @@ -3366,6 +3807,19 @@ "node": ">=18" } }, + "node_modules/@sentry/vite-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.4.0.tgz", + "integrity": "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugins": "^10.64.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6596,6 +7050,19 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", @@ -7294,6 +7761,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -8082,6 +8559,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8103,6 +8593,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -9190,6 +9693,52 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -10000,6 +10549,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "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/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -10188,6 +10747,13 @@ "prosemirror-transform": "^1.1.0" } }, + "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/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", diff --git a/package.json b/package.json index 29f651f63..bb6e16075 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@playwright/test": "^1.60.0", + "@sentry/vite-plugin": "^5.4.0", "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", @@ -131,7 +132,8 @@ "allowScripts": { "@swc/core@1.16.1": true, "esbuild@0.28.2": true, - "core-js": false + "core-js": false, + "@sentry/cli@2.58.6": false }, "overrides": { "uuid": "11.1.1" diff --git a/src/App.tsx b/src/App.tsx index 14bf0fae4..0d1fe67a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { useOnboardingStore } from "@/hooks/use-onboarding"; import { OfflineIndicator } from "@/components/OfflineIndicator"; -import { captureException, captureProductEvent } from "@/lib/observability"; +import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; import { routeLoaders } from "@/lib/route-modules"; import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton"; import { useTranslation } from "react-i18next"; @@ -141,9 +141,9 @@ export class RouteErrorBoundary extends Component(bread return isReactCaughtRenderError ? null : breadcrumb; } +const SAFE_ERROR_NAME = /^[A-Z][A-Za-z]{0,40}Error$/; +const CODE_LOCATION = String.raw`(?:[a-z][a-z0-9+.-]*:\/\/|\/)[^\s()]+:\d+:\d+`; +const V8_STACK_FRAME = new RegExp(String.raw`^\s+at (?:.+ \(${CODE_LOCATION}\)|${CODE_LOCATION})$`); +const GECKO_STACK_FRAME = new RegExp(String.raw`^[^\s@]*@${CODE_LOCATION}$`); + +/** Removes V8's leading `name: message` block, which can span several lines. */ +function stackWithoutMessage(error: Error): string { + const stack = error.stack ?? ""; + const header = String(error); + if (stack.startsWith(header)) return stack.slice(header.length); + const messageEnd = error.message ? stack.indexOf(error.message) : -1; + return messageEnd === -1 ? stack : stack.slice(messageEnd + error.message.length); +} + +/** + * Keeps an error's type and code locations but replaces its message, which can + * carry user-controlled text. The original message block is cut off, and only + * lines that point at a URL or path survive. `cause` is dropped so linked-error + * capture cannot reach the original message. + */ +export function toTelemetrySafeError(error: unknown, message: string): Error { + const name = error instanceof Error && SAFE_ERROR_NAME.test(error.name) ? error.name : "Error"; + const frames = error instanceof Error + ? stackWithoutMessage(error).split("\n").filter((line) => V8_STACK_FRAME.test(line) || GECKO_STACK_FRAME.test(line)) + : []; + const safeError = new Error(message); + safeError.name = name; + safeError.stack = [`${name}: ${message}`, ...frames].join("\n"); + return safeError; +} + const PRODUCT_EVENT_NAMES = new Set([ "ai_request_failed", "ai_request_started", @@ -106,7 +137,8 @@ async function loadSentry(): Promise { sentryModulePromise = import("@sentry/react").then((Sentry) => { Sentry.init({ dsn: sentryDsn, - environment: import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE || undefined, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE, ignoreErrors: [/^Synthetic observability verification$/], // No sendDefaultPii: deprecated, and Sentry ignores it whenever // dataCollection is set. The block below is the only PII control. diff --git a/src/test/app-route-analytics-error-boundary.test.tsx b/src/test/app-route-analytics-error-boundary.test.tsx index be75296cb..6ac0c0efd 100644 --- a/src/test/app-route-analytics-error-boundary.test.tsx +++ b/src/test/app-route-analytics-error-boundary.test.tsx @@ -162,12 +162,13 @@ describe("App route observability", () => { }); it("captures route errors with a fixed, non-sensitive boundary context", () => { - const error = new Error("route failed"); + let error = new Error("route failed"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const suppressWindowError = (event: ErrorEvent) => event.preventDefault(); window.addEventListener("error", suppressWindowError); function ExplodingRoute(): ReactNode { + error = new Error("route failed"); throw error; } @@ -184,6 +185,10 @@ describe("App route observability", () => { { boundary: "route" }, ); expect(JSON.stringify(observability.captureException.mock.calls)).not.toContain(error.message); + const captured = observability.captureException.mock.calls[0][0] as Error; + expect(captured.stack).toMatch(/^Error: Route rendering failed\n/); + expect(captured.stack).toContain("ExplodingRoute"); + expect(captured.stack).not.toContain(error.message); expect(observability.captureException.mock.calls[0][1]).not.toHaveProperty("componentStack"); expect(JSON.stringify(consoleError.mock.calls)).not.toContain(error.message); const retainedBreadcrumbs = consoleError.mock.calls diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index 4a959036a..8a84b1409 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -47,6 +47,7 @@ import { filterObservabilityBreadcrumb, initializeObservability, resetObservabilityForTests, + toTelemetrySafeError, } from "@/lib/observability"; describe("observability primitives", () => { @@ -56,6 +57,8 @@ describe("observability primitives", () => { vi.stubEnv("VITE_POSTHOG_KEY", undefined); vi.stubEnv("VITE_POSTHOG_HOST", undefined); vi.stubEnv("VITE_SENTRY_DSN", undefined); + vi.stubEnv("VITE_SENTRY_RELEASE", undefined); + vi.stubEnv("VITE_SENTRY_ENVIRONMENT", undefined); }); afterEach(() => { @@ -256,4 +259,103 @@ describe("observability primitives", () => { persistence: "memory", })); }); + + it("tags Sentry events with the build release and deployment environment", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + vi.stubEnv("VITE_SENTRY_RELEASE", "abc123"); + vi.stubEnv("VITE_SENTRY_ENVIRONMENT", "preview"); + + await initializeObservability(); + + expect(sentryInitMock).toHaveBeenCalledWith(expect.objectContaining({ + release: "abc123", + environment: "preview", + })); + }); + + it("falls back to the build mode when no deployment environment is known", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); + + await initializeObservability(); + + expect(sentryInitMock.mock.calls[0][0].release).toBeUndefined(); + expect(sentryInitMock.mock.calls[0][0].environment).toBe(import.meta.env.MODE); + }); + + it("keeps V8 stack frames but drops a multi-line private message", () => { + const original = new TypeError("secret debate text\n still secret"); + original.stack = [ + "TypeError: secret debate text", + " still secret", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + " at renderWithHooks (https://debate.example/assets/vendor.js:20:7)", + ].join("\n"); + + const safe = toTelemetrySafeError(original, "Route rendering failed"); + + expect(safe.name).toBe("TypeError"); + expect(safe.message).toBe("Route rendering failed"); + expect(safe.stack).toBe([ + "TypeError: Route rendering failed", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + " at renderWithHooks (https://debate.example/assets/vendor.js:20:7)", + ].join("\n")); + expect(safe).not.toHaveProperty("cause"); + }); + + it("drops message lines shaped like stack frames", () => { + const original = new TypeError("my speech\n at the rally 10:30:45\n at secret (native)\nme@home.org:12:30\n at leak (https://evil.example/a.js:1:2)"); + original.stack = [ + "TypeError: my speech", + " at the rally 10:30:45", + " at secret (native)", + "me@home.org:12:30", + " at leak (https://evil.example/a.js:1:2)", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + ].join("\n"); + + expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([ + "TypeError: Route rendering failed", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + ].join("\n")); + }); + + it("drops frame-shaped text that is not a code location when the header is unknown", () => { + const original = new Error("changed later"); + original.stack = [ + " at the rally 10:30:45", + " at secret (native)", + "me@home.org:12:30", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + ].join("\n"); + + expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([ + "Error: Route rendering failed", + " at ExplodingRoute (https://debate.example/assets/index.js:10:5)", + ].join("\n")); + }); + + it("keeps Firefox and Safari stack frames", () => { + const original = new Error("secret"); + original.stack = [ + "ExplodingRoute@https://debate.example/assets/index.js:10:5", + "@https://debate.example/assets/vendor.js:20:7", + ].join("\n"); + + expect(toTelemetrySafeError(original, "Route rendering failed").stack).toBe([ + "Error: Route rendering failed", + "ExplodingRoute@https://debate.example/assets/index.js:10:5", + "@https://debate.example/assets/vendor.js:20:7", + ].join("\n")); + }); + + it("replaces custom or non-error names and ignores non-error values", () => { + const named = new Error("secret"); + named.name = "secret user text"; + expect(toTelemetrySafeError(named, "Route rendering failed").name).toBe("Error"); + + const safe = toTelemetrySafeError("secret thrown string", "Route rendering failed"); + expect(safe.name).toBe("Error"); + expect(safe.stack).toBe("Error: Route rendering failed"); + }); }); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 728f0c534..d43656ed9 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,4 +5,6 @@ interface ImportMetaEnv { readonly VITE_LOCAL_SUPABASE_ANON_KEY?: string; readonly VITE_LOCAL_SUPABASE_URL?: string; readonly VITE_COLLABORATION_URL?: string; + readonly VITE_SENTRY_ENVIRONMENT?: string; + readonly VITE_SENTRY_RELEASE?: string; } diff --git a/vite.config.ts b/vite.config.ts index 943638cae..76bbe5b31 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,38 @@ -import { defineConfig, type Plugin } from "vite"; +import { defineConfig, type Plugin, type PluginOption } from "vite"; import react from "@vitejs/plugin-react-swc"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; +import { execSync } from "node:child_process"; import path from "path"; +function buildRelease(): string | undefined { + const fromEnv = process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA; + if (fromEnv) return fromEnv; + try { + return execSync("git rev-parse HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || undefined; + } catch { + // Docker builds exclude .git; events then carry no release. + return undefined; + } +} + +/** + * Uploads hidden source maps to Sentry and deletes them from the output. Runs + * only when all three Sentry build variables are set; otherwise the build emits + * no source maps, as before. + */ +function sentrySourceMaps(release: string | undefined): PluginOption[] { + const { SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT } = process.env; + if (!SENTRY_AUTH_TOKEN || !SENTRY_ORG || !SENTRY_PROJECT || !release) return []; + return [sentryVitePlugin({ + authToken: SENTRY_AUTH_TOKEN, + org: SENTRY_ORG, + project: SENTRY_PROJECT, + release: { name: release, setCommits: false }, + sourcemaps: { filesToDeleteAfterUpload: ["./dist/**/*.map"] }, + telemetry: false, + })]; +} + function bundleGraph(): Plugin { return { name: 'bundle-graph', @@ -23,7 +54,10 @@ function bundleGraph(): Plugin { } // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => ({ +export default defineConfig(({ mode }) => { + const release = buildRelease(); + const uploadSourceMaps = sentrySourceMaps(release); + return { base: mode === 'capacitor' ? './' : '/', server: { host: "::", @@ -32,7 +66,11 @@ export default defineConfig(({ mode }) => ({ overlay: false, }, }, - plugins: [react(), bundleGraph()], + plugins: [react(), bundleGraph(), ...uploadSourceMaps], + define: { + "import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(release ?? ""), + "import.meta.env.VITE_SENTRY_ENVIRONMENT": JSON.stringify(process.env.VERCEL_ENV ?? ""), + }, optimizeDeps: { // Narrows dev prebundling to the SPA entry. Without this, Vite crawls all // 84 HTML files under the root, including the prerendered pages in public/. @@ -46,5 +84,7 @@ export default defineConfig(({ mode }) => ({ build: { manifest: true, chunkSizeWarningLimit: 700, + sourcemap: uploadSourceMaps.length > 0 ? "hidden" : false, }, -})); + }; +});