diff --git a/.env.example b/.env.example index 9bb20861b..090b779cb 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret # Secret required for /api/health?deep=1 Supabase probe (x-health-deep-token header). HEALTH_DEEP_PROBE_SECRET=your-long-random-health-deep-probe-secret +# Optional server-side Sentry error capture (answer pipeline + uncaught route errors). +# Fully inert when unset: the SDK is never imported and no events leave the server. +# Events are scrubbed at init (no request bodies/headers/breadcrumbs) — see +# src/instrumentation.ts and src/lib/observability/error-capture.ts. +#SENTRY_DSN= + # Local-only no-auth mode (development only). # Enable one or both of these to load real data without per-request browser auth: # - NEXT_PUBLIC_LOCAL_NO_AUTH (client + server) diff --git a/package-lock.json b/package-lock.json index 27fc24b2b..44ce56d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@next/env": "16.2.10", + "@sentry/node": "^10.65.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", @@ -63,6 +64,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", + "integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", + "integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.1.tgz", + "integrity": "sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@axe-core/playwright": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", @@ -1564,7 +1608,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2027,6 +2070,119 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -2367,6 +2523,121 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.65.0.tgz", + "integrity": "sha512-t35dcdyksysVch/m/XdLgGJqGKJhr9eMD30Ctn3TeQ8yMB0wNXySfjPR5Yg93fpjmfaHtzc6iYIXRAvgNVfrvA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0", + "@sentry/node-core": "10.65.0", + "@sentry/opentelemetry": "10.65.0", + "@sentry/server-utils": "10.65.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.65.0.tgz", + "integrity": "sha512-U01X9mPT+jZnsLPmPWfBU67Ka+t/Sdd9RGAuvGoKdrI6N47a/9PDkM9oCW+kj0fmZwogZHTgSnzJU5oi3pImgA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0", + "@sentry/opentelemetry": "10.65.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.65.0.tgz", + "integrity": "sha512-8C6FPvm3XBvUrkM52dX3Gz0p2H0Ij8t4sahUA+GTiCz0WM0fnyPeQPGC/b6I4jamV9UXyCZRnE1UEEGCoD+c7A==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.65.0.tgz", + "integrity": "sha512-80toEFD6s+0Le7jrYB6pHWLF703WSg0WyavAWqrBGWG8JkREHgedAxzFYgoY5GlMI756qk6Ea7UzhJTHd2zAXA==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/tracing-hooks": "^0.10.1", + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0", + "magic-string": "~0.30.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", @@ -2849,7 +3120,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3989,6 +4259,15 @@ "dev": true, "license": "MIT" }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4376,6 +4655,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4621,7 +4906,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4908,10 +5192,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -5429,7 +5712,6 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -5455,7 +5737,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -6171,6 +6452,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", + "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -7330,7 +7625,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7430,6 +7724,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7477,6 +7780,12 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -7491,7 +7800,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8573,6 +8881,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -8784,6 +9105,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9028,6 +9355,15 @@ "node": ">= 10" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 683023ce3..a4ce43aae 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ }, "dependencies": { "@next/env": "16.2.10", + "@sentry/node": "^10.65.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 1d815afd1..7b71745a7 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -24,6 +24,7 @@ import { parseJsonBody } from "@/lib/validation/body"; import { toClientAnswerPayload } from "@/lib/answer-client-payload"; import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; +import { captureServerException } from "@/lib/observability/error-capture"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import * as serverAuth from "@/lib/supabase/auth"; @@ -192,7 +193,13 @@ export async function POST(request: Request) { if (error instanceof z.ZodError) { return jsonError(error, 400); } + const clientAborted = (error instanceof DOMException && error.name === "AbortError") || request.signal.aborted; if (error instanceof PublicApiError) { + // Expected degradations (rate limits, provider quota/timeouts mapped < 500) + // are operational noise; only server-fault statuses are reported. + if (error.status >= 500 && !clientAborted) { + void captureServerException(error, { route: "api/answer", status: error.status }); + } return jsonError(error, error.status); } if (error instanceof Error) { @@ -203,11 +210,17 @@ export async function POST(request: Request) { headers: { "X-Clinical-KB-Fallback": fallbackReason }, }); } + if (!clientAborted) { + void captureServerException(error, { route: "api/answer", status: 500 }); + } return jsonError( new PublicApiError("Answer generation failed. Retry with a narrower question.", 500, { code: error.name }), 500, ); } + if (!clientAborted) { + void captureServerException(error, { route: "api/answer", status: 500 }); + } return jsonError("Answer generation failed.", 500); } } diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index d47e89346..361083200 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -23,6 +23,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { createAdminClient } from "@/lib/supabase/admin"; +import { captureServerException } from "@/lib/observability/error-capture"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -96,8 +97,13 @@ function streamErrorPayload(error: unknown) { }; } -function logStreamError(error: unknown) { +function logStreamError(error: unknown, signal?: AbortSignal) { logger.error("Search stream failed", safeErrorLogDetails(error)); + // Report only server-fault failures: client aborts (Stop button / watchdog) and + // expected sub-500 degradations are operational noise, not incidents. + if ((error instanceof DOMException && error.name === "AbortError") || signal?.aborted) return; + if (error instanceof PublicApiError && error.status < 500) return; + void captureServerException(error, { route: "api/answer/stream", source: "stream" }); } function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { @@ -243,7 +249,6 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa sourceGovernanceWarnings: warnings, }); } catch (error) { - logStreamError(error); // Parity with /api/answer (PR #315): outside production, a misconfigured // Supabase API key degrades to a visible demo answer instead of a stream // error — the UI's answer search uses this route, not /api/answer. @@ -252,6 +257,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa send("final", buildDemoStreamAnswer(body, fallbackReason)); return; } + logStreamError(error, signal); const streamError = streamErrorPayload(error); send("error", { error: streamError.message, status: streamError.status, details: streamError.details }); } finally { @@ -300,12 +306,22 @@ export async function POST(request: Request) { if (error instanceof z.ZodError) { return jsonError(error, 400); } + const clientAborted = (error instanceof DOMException && error.name === "AbortError") || request.signal.aborted; if (error instanceof PublicApiError) { + if (error.status >= 500 && !clientAborted) { + void captureServerException(error, { route: "api/answer/stream", status: error.status }); + } return jsonError(error, error.status); } if (error instanceof Error) { + if (!clientAborted) { + void captureServerException(error, { route: "api/answer/stream", status: 500 }); + } return jsonError(new PublicApiError("Answer processing failed.", 500, { code: error.name }), 500); } + if (!clientAborted) { + void captureServerException(error, { route: "api/answer/stream", status: 500 }); + } return jsonError("Answer processing failed.", 500); } } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 1b22a6ccd..3613719ab 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,3 +1,5 @@ +import type { Instrumentation } from "next"; + // Next.js calls register() once when a server instance starts, before it serves // any requests. We use it to fail fast: a clinical production server must be fully // and correctly configured rather than silently degrading — or, worse, serving @@ -16,7 +18,7 @@ export async function register() { throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); } - const { isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireServerEnv } = await import("@/lib/env"); + const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireServerEnv } = await import("@/lib/env"); // A clinical production server must run against real, configured backends — never // in demo mode, which bypasses auth and serves canned content. @@ -34,4 +36,46 @@ export async function register() { // A keyed HMAC secret must be present so clinical-query hashes written to the log // tables are not reversible (PIA-2). Fail closed rather than degrade to weak SHA-256. requireQueryHashSecret(); + + // Optional server-side error capture. Initialized last, deliberately: a + // misconfigured server must fail the guards above, not report a half-configured + // boot to Sentry. Fully inert without a DSN (see error-capture.ts). + if (env.SENTRY_DSN) { + const Sentry = await import("@sentry/node"); + Sentry.init({ + dsn: env.SENTRY_DSN, + sendDefaultPii: false, + tracesSampleRate: 0, + // Privacy boundary (clinical app): clinical queries and document content must + // never leave the box. Strip request payloads/headers and breadcrumbs (which + // could echo console lines); events carry only the error and the small + // operational context supplied by error-capture.ts callers. + beforeSend(event) { + delete event.request; + delete event.breadcrumbs; + return event; + }, + beforeBreadcrumb() { + return null; + }, + }); + } } + +// Uncaught request errors (route handlers, RSC renders, server actions). Errors the +// answer routes catch and convert to degraded responses never reach this hook — those +// are captured explicitly at the catch sites via error-capture.ts. +export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + if (!process.env.SENTRY_DSN) return; + const { captureServerException } = await import("@/lib/observability/error-capture"); + await captureServerException(error, { + source: "onRequestError", + // Path only — query strings could carry user input. + path: request.path.split("?")[0], + method: request.method, + routerKind: context.routerKind, + routePath: context.routePath, + routeType: context.routeType, + }); +}; diff --git a/src/lib/env.ts b/src/lib/env.ts index 1ba265d8b..b54a6015f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,6 +16,14 @@ const envSchema = z.object({ SUPABASE_SERVICE_ROLE_KEY: z.string().optional(), SUPABASE_DB_URL: z.string().url().optional(), HEALTH_DEEP_PROBE_SECRET: z.string().min(16).optional(), + // Optional server-side Sentry error capture (answer pipeline + uncaught route + // errors). Fully inert when unset: @sentry/node is never imported and no event + // egress occurs. Deliberately NOT part of requireServerEnv — a missing DSN must + // never block boot. See src/lib/observability/error-capture.ts. + SENTRY_DSN: z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), + z.string().url().optional(), + ), NEXT_PUBLIC_LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), diff --git a/src/lib/observability/error-capture.ts b/src/lib/observability/error-capture.ts new file mode 100644 index 000000000..c13827dcf --- /dev/null +++ b/src/lib/observability/error-capture.ts @@ -0,0 +1,61 @@ +import "server-only"; + +import { env } from "@/lib/env"; + +// Server-only Sentry capture, fully inert unless SENTRY_DSN is configured: without a +// DSN, @sentry/node is never imported, so tests and DSN-less deployments never touch +// the SDK and no event egress can occur. +// +// Privacy boundary (clinical app): context values must be short operational strings — +// route names, status codes, sanitized failure reasons. Never pass query text, document +// content, headers, or request bodies. Event-level scrubbing is enforced again at init +// (see src/instrumentation.ts beforeSend), but callers are the first line of defense. + +type CaptureContext = Record; + +type SentryLike = { + captureException: (error: unknown, context?: { extra?: CaptureContext }) => unknown; + captureMessage: (message: string, context?: { level?: "warning"; extra?: CaptureContext }) => unknown; +}; + +let sentryModule: Promise | null = null; + +function sentryEnabled() { + return Boolean(env.SENTRY_DSN) && process.env.NEXT_RUNTIME !== "edge"; +} + +async function loadSentry(): Promise { + if (!sentryEnabled()) return null; + sentryModule ??= import("@sentry/node").then( + (mod) => mod as SentryLike, + // A missing/broken SDK must degrade to the console-only logger, never break a request. + () => null, + ); + return sentryModule; +} + +/** Report a server-side exception. Swallows its own failures — capture must never break a request. */ +export async function captureServerException(_error: unknown, context?: CaptureContext) { + const sentry = await loadSentry(); + if (!sentry) return; + try { + // Clinical errors can embed query or document text in their message, + // mutable name, and stack. Preserve only caller-supplied safe context. + sentry.captureException(new Error("Server request failed"), { + extra: context ?? {}, + }); + } catch { + // Capture is best-effort observability; the request outcome must not change. + } +} + +/** Report a non-exception degradation signal (e.g. generation fell back to source-only). */ +export async function captureServerEvent(message: string, context?: CaptureContext) { + const sentry = await loadSentry(); + if (!sentry) return; + try { + sentry.captureMessage(message, { level: "warning", ...(context ? { extra: context } : {}) }); + } catch { + // Same best-effort contract as captureServerException. + } +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index b3d19b5a6..3c2d49c8f 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -122,6 +122,7 @@ import { } from "@/lib/clinical-search"; import { env, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; +import { captureServerEvent } from "@/lib/observability/error-capture"; import { answerPrivacyMetadata, answerTextForStorage, @@ -5219,6 +5220,14 @@ ${qualityRetryInstruction}` }); const baseFallbackAnswer = await buildGenerationFallbackAnswer(error, relatedDocuments); const sanitizedReason = summarizeGenerationFailureReason(error); + // This degradation is invisible to route-level capture (the request still + // succeeds with a source-only answer), so report it here. The token-starvation + // incident (GEN-C1) lived exclusively in this branch for weeks. + await captureServerEvent("answer_generation_fallback", { + reason: sanitizedReason, + queryClass, + routeMode: route.mode ?? "unknown", + }); const comparisonFallbackAnswer = queryClass === "comparison" ? (buildComparisonAnswer({ diff --git a/tests/error-capture.test.ts b/tests/error-capture.test.ts new file mode 100644 index 000000000..2ffcee228 --- /dev/null +++ b/tests/error-capture.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The capture helper (src/lib/observability/error-capture.ts) must be fully inert +// without SENTRY_DSN — no SDK import, no calls — and must never let a capture +// failure propagate into the request path. env is parsed at import time, so each +// case re-imports the module with a fresh, stubbed environment. + +const sentryMocks = vi.hoisted(() => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), +})); + +vi.mock("@sentry/node", () => sentryMocks); + +const TEST_DSN = "https://publickey@o0.ingest.sentry.io/0"; + +async function loadCapture(dsn: string | undefined) { + vi.resetModules(); + vi.stubEnv("SENTRY_DSN", dsn); + return import("../src/lib/observability/error-capture"); +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe("captureServerException", () => { + it("is a no-op without a DSN", async () => { + const { captureServerException } = await loadCapture(undefined); + await expect(captureServerException(new Error("boom"), { route: "api/answer" })).resolves.toBeUndefined(); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + }); + + it("treats a blank DSN as disabled", async () => { + const { captureServerException } = await loadCapture(" "); + await captureServerException(new Error("boom")); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + }); + + it("redacts the error and forwards only operational context when a DSN is set", async () => { + const { captureServerException } = await loadCapture(TEST_DSN); + const error = new Error("boom"); + await captureServerException(error, { route: "api/answer", status: 500 }); + + expect(sentryMocks.captureException).toHaveBeenCalledTimes(1); + const [captured, hint] = sentryMocks.captureException.mock.calls[0]; + expect(captured).toBeInstanceOf(Error); + expect(captured).not.toBe(error); + expect(captured.message).toBe("Server request failed"); + expect(captured.stack).not.toContain("boom"); + expect(hint).toEqual({ extra: { route: "api/answer", status: 500 } }); + }); + + it("does not forward a mutable error name", async () => { + const { captureServerException } = await loadCapture(TEST_DSN); + const error = new Error("private message"); + error.name = "lithium query from document 123"; + await captureServerException(error, { route: "api/answer" }); + + const [, hint] = sentryMocks.captureException.mock.calls[0]; + expect(JSON.stringify(hint)).not.toContain(error.name); + }); + + it("never propagates a failure inside the SDK", async () => { + const { captureServerException } = await loadCapture(TEST_DSN); + sentryMocks.captureException.mockImplementationOnce(() => { + throw new Error("sdk exploded"); + }); + await expect(captureServerException(new Error("boom"))).resolves.toBeUndefined(); + }); +}); + +describe("captureServerEvent", () => { + it("is a no-op without a DSN", async () => { + const { captureServerEvent } = await loadCapture(undefined); + await expect(captureServerEvent("answer_generation_fallback")).resolves.toBeUndefined(); + expect(sentryMocks.captureMessage).not.toHaveBeenCalled(); + }); + + it("reports a warning-level message with context when a DSN is set", async () => { + const { captureServerEvent } = await loadCapture(TEST_DSN); + await captureServerEvent("answer_generation_fallback", { reason: "max_output_tokens", queryClass: "dose" }); + + expect(sentryMocks.captureMessage).toHaveBeenCalledTimes(1); + const [message, options] = sentryMocks.captureMessage.mock.calls[0]; + expect(message).toBe("answer_generation_fallback"); + expect(options.level).toBe("warning"); + expect(options.extra).toEqual({ reason: "max_output_tokens", queryClass: "dose" }); + }); +}); diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 69b22ebee..bb7b73c0a 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -8,6 +8,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const MATCHING_URL = "https://sjrfecxgysukkwxsowpy.supabase.co"; +const sentryMocks = vi.hoisted(() => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), +})); + +vi.mock("@sentry/node", () => sentryMocks); + const ENV_KEYS = [ "NEXT_RUNTIME", "NODE_ENV", @@ -20,22 +28,38 @@ const ENV_KEYS = [ "RAG_QUERY_HASH_SECRET", "NEXT_PUBLIC_LOCAL_NO_AUTH", "LOCAL_NO_AUTH", + "SENTRY_DSN", ] as const; -async function loadRegister(overrides: Partial>) { +async function loadInstrumentation(overrides: Partial>) { vi.resetModules(); for (const key of ENV_KEYS) { vi.stubEnv(key, overrides[key]); } - const mod = await import("../src/instrumentation"); + return import("../src/instrumentation"); +} + +async function loadRegister(overrides: Partial>) { + const mod = await loadInstrumentation(overrides); return mod.register; } const PRODUCTION_NODE = { NEXT_RUNTIME: "nodejs", NODE_ENV: "production" } as const; +const FULLY_CONFIGURED = { + ...PRODUCTION_NODE, + NEXT_PUBLIC_SUPABASE_URL: MATCHING_URL, + SUPABASE_SERVICE_ROLE_KEY: "service-role-key", + OPENAI_API_KEY: "openai-key", + RAG_QUERY_HASH_SECRET: "test-secret-at-least-16-chars", +} as const; + +const TEST_DSN = "https://publickey@o0.ingest.sentry.io/0"; + afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); + vi.clearAllMocks(); }); describe("instrumentation boot guard", () => { @@ -94,3 +118,71 @@ describe("instrumentation boot guard", () => { await expect(register()).resolves.toBeUndefined(); }); }); + +describe("instrumentation Sentry init", () => { + it("does not initialize Sentry without a DSN", async () => { + const register = await loadRegister(FULLY_CONFIGURED); + await expect(register()).resolves.toBeUndefined(); + expect(sentryMocks.init).not.toHaveBeenCalled(); + }); + + it("initializes Sentry with privacy scrubbing when a DSN is configured", async () => { + const register = await loadRegister({ ...FULLY_CONFIGURED, SENTRY_DSN: TEST_DSN }); + await expect(register()).resolves.toBeUndefined(); + + expect(sentryMocks.init).toHaveBeenCalledTimes(1); + const options = sentryMocks.init.mock.calls[0][0]; + expect(options.dsn).toBe(TEST_DSN); + expect(options.sendDefaultPii).toBe(false); + + // The scrubbers must strip request payloads/headers and breadcrumbs so + // clinical query text can never ride along on an event. + const event = { + request: { url: "https://x/api/answer", headers: { cookie: "secret" }, data: "clinical query" }, + breadcrumbs: [{ message: "console line" }], + exception: {}, + }; + const scrubbed = options.beforeSend(event); + expect(scrubbed.request).toBeUndefined(); + expect(scrubbed.breadcrumbs).toBeUndefined(); + expect(options.beforeBreadcrumb()).toBeNull(); + }); +}); + +describe("instrumentation onRequestError", () => { + const REQUEST = { path: "/api/documents?q=lithium", method: "GET", headers: {} }; + const CONTEXT = { + routerKind: "App Router", + routePath: "/api/documents", + routeType: "route", + revalidateReason: undefined, + } as const; + + it("is a no-op without a DSN", async () => { + const mod = await loadInstrumentation({ ...PRODUCTION_NODE }); + await expect(mod.onRequestError(new Error("boom"), REQUEST, CONTEXT)).resolves.toBeUndefined(); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + }); + + it("is a no-op outside the Node.js runtime", async () => { + const mod = await loadInstrumentation({ NEXT_RUNTIME: "edge", NODE_ENV: "production", SENTRY_DSN: TEST_DSN }); + await expect(mod.onRequestError(new Error("boom"), REQUEST, CONTEXT)).resolves.toBeUndefined(); + expect(sentryMocks.captureException).not.toHaveBeenCalled(); + }); + + it("captures uncaught request errors with query strings stripped", async () => { + const mod = await loadInstrumentation({ ...PRODUCTION_NODE, SENTRY_DSN: TEST_DSN }); + const error = new Error("boom"); + await mod.onRequestError(error, REQUEST, CONTEXT); + + expect(sentryMocks.captureException).toHaveBeenCalledTimes(1); + const [captured, hint] = sentryMocks.captureException.mock.calls[0]; + expect(captured).toBeInstanceOf(Error); + expect(captured).not.toBe(error); + expect(captured.message).toBe("Server request failed"); + expect(captured.stack).not.toContain("boom"); + expect(hint.extra.path).toBe("/api/documents"); + expect(hint.extra.routeType).toBe("route"); + expect(JSON.stringify(hint)).not.toContain("lithium"); + }); +});