From c131b9b7eed39de85b97228d05dc67542e7262eb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:52:12 +0800 Subject: [PATCH 1/4] feat(observability): server-only Sentry capture, inert without a DSN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @sentry/node behind an optional SENTRY_DSN env var. Without a DSN the SDK is never imported and nothing changes; with one, init happens last in the instrumentation boot guard with privacy scrubbing (no request bodies, headers, breadcrumbs, or PII — clinical query text must never leave the box). Capture points: - onRequestError instrumentation hook for uncaught route/RSC/action errors (path stripped of query strings) - answer + answer/stream route catches, filtered to server-fault (>=500) errors only; client aborts and expected sub-500 degradations are skipped - rag.ts generation-fallback catch reports answer_generation_fallback as a warning event — this silent source-only degradation is invisible to route captures and was the exact signature of the GEN-C1 token-starvation incident All capture flows through a no-op-when-disabled helper (src/lib/observability/error-capture.ts) so routes never import the SDK directly and capture failures can never break a request. Co-Authored-By: Claude Fable 5 --- .env.example | 6 + package-lock.json | 358 ++++++++++++++++++++++++- package.json | 1 + src/app/api/answer/route.ts | 13 + src/app/api/answer/stream/route.ts | 11 + src/instrumentation.ts | 46 +++- src/lib/env.ts | 5 + src/lib/observability/error-capture.ts | 57 ++++ src/lib/rag.ts | 9 + tests/error-capture.test.ts | 74 +++++ tests/instrumentation.test.ts | 93 ++++++- 11 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 src/lib/observability/error-capture.ts create mode 100644 tests/error-capture.test.ts 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 98f1101c1..040aaae36 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", @@ -62,6 +63,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/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1550,7 +1594,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": { @@ -2013,6 +2056,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", @@ -2353,6 +2509,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", @@ -2835,7 +3106,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": { @@ -3975,6 +4245,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", @@ -4362,6 +4641,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", @@ -4607,7 +4892,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" @@ -4894,10 +5178,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": { @@ -5415,7 +5698,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" @@ -5441,7 +5723,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" @@ -6157,6 +6438,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", @@ -7316,7 +7611,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" @@ -7416,6 +7710,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", @@ -7463,6 +7766,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", @@ -7477,7 +7786,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": { @@ -8559,6 +8867,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", @@ -8770,6 +9091,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", @@ -9014,6 +9341,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 da84b2f3d..991c02f5f 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,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..fd27f249c 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"; @@ -193,8 +194,14 @@ export async function POST(request: Request) { return jsonError(error, 400); } 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) { + void captureServerException(error, { route: "api/answer", status: error.status }); + } return jsonError(error, error.status); } + const clientAborted = (error instanceof DOMException && error.name === "AbortError") || request.signal.aborted; if (error instanceof Error) { const fallbackBody = body; const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null; @@ -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..190b3f54d 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"; @@ -98,6 +99,11 @@ function streamErrorPayload(error: unknown) { function logStreamError(error: unknown) { 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") return; + if (error instanceof PublicApiError && error.status < 500) return; + void captureServerException(error, { route: "api/answer/stream", source: "stream" }); } function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { @@ -301,11 +307,16 @@ export async function POST(request: Request) { return jsonError(error, 400); } if (error instanceof PublicApiError) { + if (error.status >= 500) { + void captureServerException(error, { route: "api/answer/stream", status: error.status }); + } return jsonError(error, error.status); } if (error instanceof Error) { + void captureServerException(error, { route: "api/answer/stream", status: 500 }); return jsonError(new PublicApiError("Answer processing failed.", 500, { code: error.name }), 500); } + 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 e2d849e4d..af265e79f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,6 +16,11 @@ 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.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..a3f018f13 --- /dev/null +++ b/src/lib/observability/error-capture.ts @@ -0,0 +1,57 @@ +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 { + sentry.captureException(error, context ? { extra: context } : undefined); + } 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 7bdc52455..52cee5cf5 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -121,6 +121,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, @@ -5171,6 +5172,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. + void 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..629783610 --- /dev/null +++ b/tests/error-capture.test.ts @@ -0,0 +1,74 @@ +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("forwards the error and 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).toBe(error); + expect(hint).toEqual({ extra: { route: "api/answer", status: 500 } }); + }); + + 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..246c0b870 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,68 @@ 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).toBe(error); + expect(hint.extra.path).toBe("/api/documents"); + expect(hint.extra.routeType).toBe("route"); + expect(JSON.stringify(hint)).not.toContain("lithium"); + }); +}); From 79fa8944dba89b0bee8d4739ab5b3d04e98d6434 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:03:42 +0800 Subject: [PATCH 2/4] fix(observability): scrub clinical errors and abort noise --- src/app/api/answer/route.ts | 4 ++-- src/app/api/answer/stream/route.ts | 17 +++++++++++------ src/lib/env.ts | 5 ++++- src/lib/observability/error-capture.ts | 7 ++++++- src/lib/rag.ts | 2 +- tests/error-capture.test.ts | 15 ++++++++++++--- tests/instrumentation.test.ts | 6 +++++- 7 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index fd27f249c..7b71745a7 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -193,15 +193,15 @@ 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) { + if (error.status >= 500 && !clientAborted) { void captureServerException(error, { route: "api/answer", status: error.status }); } return jsonError(error, error.status); } - const clientAborted = (error instanceof DOMException && error.name === "AbortError") || request.signal.aborted; if (error instanceof Error) { const fallbackBody = body; const fallbackReason = fallbackBody ? nonProductionSupabaseDemoFallbackReason(error) : null; diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 190b3f54d..ece45fe3b 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -97,11 +97,11 @@ 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") return; + 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" }); } @@ -249,7 +249,7 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa sourceGovernanceWarnings: warnings, }); } catch (error) { - logStreamError(error); + logStreamError(error, signal); // 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. @@ -306,17 +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) { + if (error.status >= 500 && !clientAborted) { void captureServerException(error, { route: "api/answer/stream", status: error.status }); } return jsonError(error, error.status); } if (error instanceof Error) { - void captureServerException(error, { route: "api/answer/stream", status: 500 }); + if (!clientAborted) { + void captureServerException(error, { route: "api/answer/stream", status: 500 }); + } return jsonError(new PublicApiError("Answer processing failed.", 500, { code: error.name }), 500); } - void captureServerException(error, { route: "api/answer/stream", status: 500 }); + if (!clientAborted) { + void captureServerException(error, { route: "api/answer/stream", status: 500 }); + } return jsonError("Answer processing failed.", 500); } } diff --git a/src/lib/env.ts b/src/lib/env.ts index 658a62cdb..b54a6015f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,7 +20,10 @@ const envSchema = z.object({ // 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.string().url().optional(), + 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 index a3f018f13..c5f30479c 100644 --- a/src/lib/observability/error-capture.ts +++ b/src/lib/observability/error-capture.ts @@ -39,7 +39,12 @@ export async function captureServerException(error: unknown, context?: CaptureCo const sentry = await loadSentry(); if (!sentry) return; try { - sentry.captureException(error, context ? { extra: context } : undefined); + // Clinical errors can embed query or document text in their message and + // stack. Preserve only the safe error class as operational context. + const errorType = error instanceof Error ? error.name : "UnknownError"; + sentry.captureException(new Error("Server request failed"), { + extra: { ...(context ?? {}), errorType }, + }); } catch { // Capture is best-effort observability; the request outcome must not change. } diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 976da7ee9..3c2d49c8f 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -5223,7 +5223,7 @@ ${qualityRetryInstruction}` // 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. - void captureServerEvent("answer_generation_fallback", { + await captureServerEvent("answer_generation_fallback", { reason: sanitizedReason, queryClass, routeMode: route.mode ?? "unknown", diff --git a/tests/error-capture.test.ts b/tests/error-capture.test.ts index 629783610..396355d44 100644 --- a/tests/error-capture.test.ts +++ b/tests/error-capture.test.ts @@ -34,15 +34,24 @@ describe("captureServerException", () => { expect(sentryMocks.captureException).not.toHaveBeenCalled(); }); - it("forwards the error and operational context when a DSN is set", async () => { + 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).toBe(error); - expect(hint).toEqual({ extra: { route: "api/answer", status: 500 } }); + 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, errorType: "Error" } }); }); it("never propagates a failure inside the SDK", async () => { diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 246c0b870..043439569 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -177,9 +177,13 @@ describe("instrumentation onRequestError", () => { expect(sentryMocks.captureException).toHaveBeenCalledTimes(1); const [captured, hint] = sentryMocks.captureException.mock.calls[0]; - expect(captured).toBe(error); + 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(hint.extra.errorType).toBe("Error"); expect(JSON.stringify(hint)).not.toContain("lithium"); }); }); From 85300fccc2879923896364b43fce2042e06994c7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:08:01 +0800 Subject: [PATCH 3/4] fix(privacy): omit mutable error names from telemetry --- src/lib/observability/error-capture.ts | 9 ++++----- tests/error-capture.test.ts | 12 +++++++++++- tests/instrumentation.test.ts | 1 - 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/lib/observability/error-capture.ts b/src/lib/observability/error-capture.ts index c5f30479c..c13827dcf 100644 --- a/src/lib/observability/error-capture.ts +++ b/src/lib/observability/error-capture.ts @@ -35,15 +35,14 @@ async function loadSentry(): Promise { } /** Report a server-side exception. Swallows its own failures — capture must never break a request. */ -export async function captureServerException(error: unknown, context?: CaptureContext) { +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 and - // stack. Preserve only the safe error class as operational context. - const errorType = error instanceof Error ? error.name : "UnknownError"; + // 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 ?? {}), errorType }, + extra: context ?? {}, }); } catch { // Capture is best-effort observability; the request outcome must not change. diff --git a/tests/error-capture.test.ts b/tests/error-capture.test.ts index 396355d44..2ffcee228 100644 --- a/tests/error-capture.test.ts +++ b/tests/error-capture.test.ts @@ -51,7 +51,17 @@ describe("captureServerException", () => { 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, errorType: "Error" } }); + 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 () => { diff --git a/tests/instrumentation.test.ts b/tests/instrumentation.test.ts index 043439569..bb7b73c0a 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -183,7 +183,6 @@ describe("instrumentation onRequestError", () => { expect(captured.stack).not.toContain("boom"); expect(hint.extra.path).toBe("/api/documents"); expect(hint.extra.routeType).toBe("route"); - expect(hint.extra.errorType).toBe("Error"); expect(JSON.stringify(hint)).not.toContain("lithium"); }); }); From 1eb6a4b7cc0ba05bca18eee0ab00dafba2833904 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:22:53 +0800 Subject: [PATCH 4/4] fix: suppress expected demo fallback telemetry --- src/app/api/answer/stream/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index ece45fe3b..361083200 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -249,7 +249,6 @@ function streamAnswer(body: AnswerBody, accessScope: RetrievalAccessScope, signa sourceGovernanceWarnings: warnings, }); } catch (error) { - logStreamError(error, signal); // 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. @@ -258,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 {