diff --git a/.env.example b/.env.example index f1ee74c744..0b31c11059 100644 --- a/.env.example +++ b/.env.example @@ -15,12 +15,6 @@ 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/docs/site-map.md b/docs/site-map.md index 84183b1e8a..90a90483f4 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -596,7 +596,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/local-project-id` - Local project identity guard. Source: `src/app/api/local-project-id/route.ts`. - `/api/medications` - Route discovered from app directory Source: `src/app/api/medications/route.ts`. - `/api/medications/[slug]` - Route discovered from app directory Source: `src/app/api/medications/[slug]/route.ts`. -- `/api/monitoring` - Route discovered from app directory Source: `src/app/api/monitoring/route.ts`. - `/api/registry/records` - Registry record collection. Source: `src/app/api/registry/records/route.ts`. - `/api/registry/records/[slug]` - Registry record detail. Source: `src/app/api/registry/records/[slug]/route.ts`. - `/api/search` - Search endpoint. Source: `src/app/api/search/route.ts`. diff --git a/next.config.ts b/next.config.ts index 50de22b079..2654b9e1dd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -36,24 +36,12 @@ const nextConfig: NextConfig = { turbopack: { root: projectRoot, }, - webpack(config, { webpack }) { + webpack(config) { // Avoid a Next/webpack WasmHash worker crash observed on Node 24 during local production builds. config.output = { ...config.output, hashFunction: "sha256", }; - // Build-time flag so the browser Sentry SDK (@sentry/browser) is fully - // tree-shaken out unless a public DSN is set at build. Next does NOT fold an - // UNSET NEXT_PUBLIC_* var to a compile-time constant, so a plain - // `if (process.env.NEXT_PUBLIC_SENTRY_DSN)` gate leaves the dynamic import (and - // its chunk) on disk. This literal boolean lets webpack dead-code-eliminate the - // whole block, so an unconfigured build ships zero Sentry bytes. See - // src/instrumentation-client.ts. - config.plugins.push( - new webpack.DefinePlugin({ - __SENTRY_ENABLED__: JSON.stringify(Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN)), - }), - ); return config; }, async headers() { diff --git a/package-lock.json b/package-lock.json index 964fa98b07..e539268473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,6 @@ "hasInstallScript": true, "dependencies": { "@next/env": "16.2.10", - "@sentry/browser": "^10.65.0", - "@sentry/node": "^10.65.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", @@ -3500,189 +3498,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@sentry/browser": { - "version": "10.65.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.65.0.tgz", - "integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==", - "license": "MIT", - "dependencies": { - "@sentry/browser-utils": "10.65.0", - "@sentry/conventions": "^0.15.1", - "@sentry/core": "10.65.0", - "@sentry/feedback": "10.65.0", - "@sentry/replay": "10.65.0", - "@sentry/replay-canvas": "10.65.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser-utils": { - "version": "10.65.0", - "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.65.0.tgz", - "integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==", - "license": "MIT", - "dependencies": { - "@sentry/conventions": "^0.15.1", - "@sentry/core": "10.65.0" - }, - "engines": { - "node": ">=18" - } - }, - "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/feedback": { - "version": "10.65.0", - "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.65.0.tgz", - "integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.65.0" - }, - "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/replay": { - "version": "10.65.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.65.0.tgz", - "integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==", - "license": "MIT", - "dependencies": { - "@sentry/browser-utils": "10.65.0", - "@sentry/core": "10.65.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/replay-canvas": { - "version": "10.65.0", - "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.65.0.tgz", - "integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.65.0", - "@sentry/replay": "10.65.0" - }, - "engines": { - "node": ">=18" - } - }, - "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", diff --git a/package.json b/package.json index 77a7cf27d0..020e2e6d9a 100644 --- a/package.json +++ b/package.json @@ -141,8 +141,6 @@ }, "dependencies": { "@next/env": "16.2.10", - "@sentry/browser": "^10.65.0", - "@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 43f6071fa2..de7559f8d8 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -25,7 +25,6 @@ import { } from "@/lib/answer-response"; 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"; @@ -146,13 +145,7 @@ 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) { @@ -164,17 +157,11 @@ 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 52416b8c34..eff39611a7 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -24,7 +24,6 @@ import { resolveSearchScope } from "@/lib/search-scope"; import { resolveRetrievalAccessScope, type RetrievalAccessScope } from "@/lib/owner-scope"; 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"; @@ -94,12 +93,12 @@ function streamAnswerFeedbackMetadata(interactionId: string, answer: string) { } 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. + // Client aborts (Stop button / watchdog) and expected sub-500 degradations are + // operational noise, not failures — the caller still surfaces them to the client + // via the SSE error event. Only genuine server-fault stream failures are logged. 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" }); + logger.error("Search stream failed", safeErrorLogDetails(error)); } function buildDemoStreamAnswer(body: AnswerRequestBody, fallbackReason?: string) { @@ -301,22 +300,12 @@ 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/app/api/monitoring/route.ts b/src/app/api/monitoring/route.ts deleted file mode 100644 index a5ff9b802a..0000000000 --- a/src/app/api/monitoring/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Same-origin Sentry tunnel. The browser SDK is configured with -// `tunnel: "/api/monitoring"` (src/instrumentation-client.ts) so event envelopes -// are POSTed here instead of directly to Sentry's ingest host. This keeps the -// strict clinical CSP intact — `connect-src 'self'` already allows this route, so -// no Sentry ingest host has to be added to the policy — and lets ad-blockers that -// block *.sentry.io not silently drop client error reports. -// -// The route forwards the raw envelope to Sentry server-side (server fetches are not -// bound by CSP). Hardening: it (1) validates the envelope's embedded DSN against the -// project's own configured DSN so the endpoint cannot relay to arbitrary projects, -// (2) caps the buffered body at 1 MB (Content-Length preflight + streamed byte -// count) since this is an unauthenticated public POST under a large proxy body -// allowance, and (3) bounds the upstream relay with a timeout. Inert (404) until -// SENTRY_DSN / NEXT_PUBLIC_SENTRY_DSN is set. - -export const runtime = "nodejs"; - -const MAX_ENVELOPE_BYTES = 1_000_000; // 1 MB — Sentry event envelopes are far smaller. -const UPSTREAM_TIMEOUT_MS = 5_000; - -// Lightweight per-IP throttle. This is an unauthenticated public POST, so a -// spammer with a valid envelope could otherwise burn the project's Sentry ingest -// quota. A per-instance in-memory window is intentionally simple (unlike the -// durable answer/upload limiters this is best-effort telemetry relay, not a paid -// or state-changing path); Sentry's own ingest rate limits are the backstop. -const RATE_LIMIT_MAX = 120; -const RATE_LIMIT_WINDOW_MS = 60_000; -type RateWindow = { count: number; resetAt: number }; -const rateLimitByIp = new Map(); - -function isRateLimited(ip: string): boolean { - const now = Date.now(); - if (rateLimitByIp.size > 5_000) { - for (const [key, window] of rateLimitByIp) if (now >= window.resetAt) rateLimitByIp.delete(key); - } - const existing = rateLimitByIp.get(ip); - if (!existing || now >= existing.resetAt) { - rateLimitByIp.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return false; - } - existing.count += 1; - return existing.count > RATE_LIMIT_MAX; -} - -type ParsedDsn = { host: string; projectId: string }; - -function parseDsn(dsn: string): ParsedDsn { - const url = new URL(dsn); - const projectId = url.pathname.replace(/^\//, ""); - if (!url.host || !projectId) throw new Error("incomplete dsn"); - return { host: url.host, projectId }; -} - -// Reads the request body as text but aborts once it exceeds `maxBytes`, returning -// null. Streams so an oversized body is never fully buffered. -async function readCappedText(request: Request, maxBytes: number): Promise { - const reader = request.body?.getReader(); - if (!reader) { - const text = await request.text(); - return text.length > maxBytes ? null : text; - } - const decoder = new TextDecoder(); - let total = 0; - let out = ""; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel(); - return null; - } - out += decoder.decode(value, { stream: true }); - } - out += decoder.decode(); - return out; -} - -export async function POST(request: Request): Promise { - const configuredDsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; - if (!configuredDsn) return new Response(null, { status: 404 }); - - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - if (isRateLimited(ip)) { - return new Response("too many requests", { status: 429, headers: { "Retry-After": "60" } }); - } - - let expected: ParsedDsn; - try { - expected = parseDsn(configuredDsn); - } catch { - // Misconfigured server DSN — do not leak detail, just refuse. - return new Response(null, { status: 404 }); - } - - // Reject oversized payloads before buffering when the length is declared. - const declaredLength = Number(request.headers.get("content-length")); - if (Number.isFinite(declaredLength) && declaredLength > MAX_ENVELOPE_BYTES) { - return new Response("payload too large", { status: 413 }); - } - - const envelope = await readCappedText(request, MAX_ENVELOPE_BYTES); - if (envelope === null) return new Response("payload too large", { status: 413 }); - - const firstNewline = envelope.indexOf("\n"); - if (firstNewline === -1) return new Response("invalid envelope", { status: 400 }); - - let header: { dsn?: unknown }; - try { - header = JSON.parse(envelope.slice(0, firstNewline)); - } catch { - return new Response("invalid envelope header", { status: 400 }); - } - - if (typeof header.dsn !== "string") return new Response("missing dsn", { status: 400 }); - - let incoming: ParsedDsn; - try { - incoming = parseDsn(header.dsn); - } catch { - return new Response("invalid dsn", { status: 400 }); - } - - // Reject anything not addressed to this project's own DSN (no open relay). - if (incoming.host !== expected.host || incoming.projectId !== expected.projectId) { - return new Response("forbidden", { status: 403 }); - } - - let upstream: Response; - try { - upstream = await fetch(`https://${expected.host}/api/${expected.projectId}/envelope/`, { - method: "POST", - body: envelope, - headers: { "Content-Type": "application/x-sentry-envelope" }, - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); - } catch { - // Upstream slow/unreachable — fail fast without tying up the request thread. - return new Response("bad gateway", { status: 502 }); - } - - const body = await upstream.text(); - return new Response(body, { status: upstream.status }); -} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 797cdbe488..0b9bd1dd4e 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,8 +2,6 @@ import { useEffect } from "react"; -import { captureClientException } from "@/lib/observability/sentry-client"; - /** * Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces * the root layout entirely, so it is the ONLY thing that can recover from an @@ -15,8 +13,6 @@ import { captureClientException } from "@/lib/observability/sentry-client"; export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { useEffect(() => { console.error("Fatal error captured by global-error boundary:", error); - // No-op unless the browser Sentry SDK was initialized (NEXT_PUBLIC_SENTRY_DSN set at build). - captureClientException(error); }, [error]); return ( diff --git a/src/components/route-error-boundary.tsx b/src/components/route-error-boundary.tsx index 91139ca35c..ae99577336 100644 --- a/src/components/route-error-boundary.tsx +++ b/src/components/route-error-boundary.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { TriangleAlert, RefreshCw } from "lucide-react"; import { cn, primaryControl } from "@/components/ui-primitives"; -import { captureClientException } from "@/lib/observability/sentry-client"; export type RouteErrorBoundaryProps = { /** The error thrown by the segment, forwarded by Next.js. */ @@ -41,8 +40,6 @@ export function RouteErrorBoundary({ }: RouteErrorBoundaryProps) { useEffect(() => { console.error(logLabel, error); - // No-op unless the browser Sentry SDK was initialized (NEXT_PUBLIC_SENTRY_DSN set at build). - captureClientException(error); }, [error, logLabel]); return ( diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 8d0de20a88..42c22f74f7 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -10,43 +10,5 @@ // (validation stays correct, just interpreted rather than compiled). The server // has no CSP, so it keeps the faster JIT path — this is client-only by design. import { config } from "zod"; -import { registerSentryClient } from "@/lib/observability/sentry-client"; config({ jitless: true }); - -// Gated browser error tracking, the client counterpart to the server-side -// @sentry/node capture in src/instrumentation.ts. `__SENTRY_ENABLED__` is a -// build-time literal boolean injected by DefinePlugin (next.config.ts) — true only -// when a public DSN is set at build. As a real compile-time constant it lets the -// webpack production build dead-code-eliminate this whole block (and the entire -// @sentry/browser chunk) when false, so an unconfigured build ships ZERO Sentry -// bytes. The `typeof` guard keeps it safe under the Turbopack dev server, which -// does not run the webpack DefinePlugin and so leaves the identifier undefined. -// Events go through the same-origin tunnel (/api/monitoring) so the strict -// clinical CSP (connect-src 'self' …) needs no Sentry ingest host. -declare const __SENTRY_ENABLED__: boolean; - -if (typeof __SENTRY_ENABLED__ !== "undefined" && __SENTRY_ENABLED__) { - void (async () => { - const [Sentry, { scrubClientSentryEvent }] = await Promise.all([ - import("@sentry/browser"), - import("@/lib/observability/sentry-scrub"), - ]); - Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - tunnel: "/api/monitoring", - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || process.env.NODE_ENV, - // Errors only: no performance tracing (avoids shipping navigation-timing/PII). - tracesSampleRate: 0, - sendDefaultPii: false, - // Same privacy boundary as the server init: strip request + breadcrumbs, and - // never record breadcrumbs in the first place. - beforeSend: scrubClientSentryEvent, - beforeBreadcrumb: () => null, - }); - registerSentryClient(Sentry); - })().catch(() => { - // Loading/initializing the SDK is best-effort observability; a failed dynamic - // import (e.g. blocked chunk) must never surface as an unhandled rejection. - }); -} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 3613719ab2..1b22a6ccd2 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,3 @@ -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 @@ -18,7 +16,7 @@ export async function register() { throw new Error("Refusing to start: local no-auth mode is enabled in a production build."); } - const { env, isDemoMode, requireOpenAIEnv, requireQueryHashSecret, requireServerEnv } = await import("@/lib/env"); + const { 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. @@ -36,46 +34,4 @@ 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 07049cf5cb..76b2b64d55 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,24 +16,6 @@ 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(), - ), - // Optional client-side (browser) Sentry error capture, the counterpart to the - // server SENTRY_DSN above. This public DSN is safe to expose to the browser. Fully - // inert when unset: the @sentry/browser SDK is tree-shaken out of the client bundle - // (see src/instrumentation-client.ts). Blank is normalized to undefined so an empty - // NEXT_PUBLIC_SENTRY_DSN="" never fails envSchema.parse at startup. - NEXT_PUBLIC_SENTRY_DSN: z.preprocess( - (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), - z.string().url().optional(), - ), - NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().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 deleted file mode 100644 index c13827dcf2..0000000000 --- a/src/lib/observability/error-capture.ts +++ /dev/null @@ -1,61 +0,0 @@ -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/observability/sentry-client.ts b/src/lib/observability/sentry-client.ts deleted file mode 100644 index bb8df24c9a..0000000000 --- a/src/lib/observability/sentry-client.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Client-side Sentry access indirection. Error boundaries call -// captureClientException() without ever statically importing @sentry/browser, so -// a build WITHOUT NEXT_PUBLIC_SENTRY_DSN ships zero Sentry code: instrumentation- -// client.ts only loads the SDK (and registers it here) when a DSN is present at -// build time. Until then every call here is a no-op. -type SentryClientModule = { - captureException: (error: unknown) => string; -}; - -let client: SentryClientModule | null = null; - -export function registerSentryClient(mod: SentryClientModule): void { - client = mod; -} - -export function captureClientException(error: unknown): void { - client?.captureException(error); -} diff --git a/src/lib/observability/sentry-scrub.ts b/src/lib/observability/sentry-scrub.ts deleted file mode 100644 index dd1a8c0df3..0000000000 --- a/src/lib/observability/sentry-scrub.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ErrorEvent } from "@sentry/browser"; - -// Client-side Sentry event scrubber. Mirrors the server-side privacy boundary in -// src/instrumentation.ts (beforeSend): in this clinical app, queries, document -// content, page URLs and headers must never leave the browser. -// -// The browser SDK populates `event.request.url` with the full current URL (which -// can carry `q`/`query` search params on clinical flows) and records breadcrumbs -// from console/fetch/navigation that can echo the same text. Both are dropped -// wholesale rather than field-by-field — over-deletion only loses debugging -// detail, under-deletion leaks patient data. `event.user` is dropped too so no -// IP/email/id is attached. Events then carry only the error itself. -export function scrubClientSentryEvent(event: ErrorEvent): ErrorEvent { - delete event.request; - delete event.breadcrumbs; - delete event.user; - return event; -} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 7ebc5c23ca..ee963a569d 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -139,7 +139,6 @@ import { } from "@/lib/clinical-search"; import { env, requestedOpenAIAnswerModels } from "@/lib/env"; import { ragAnswerPromptVersion, ragQueryClassifierPromptVersion, ragSummaryPromptVersion } from "@/lib/rag-versioning"; -import { captureServerEvent } from "@/lib/observability/error-capture"; import { answerPrivacyMetadata, answerTextForStorage, @@ -4471,14 +4470,6 @@ ${qualityRetryInstruction}` generationFallbackArtifacts, ); 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 comparisonMatrixFallbackAnswer = queryClass === "comparison" ? buildComparisonAnswer({ diff --git a/tests/error-capture.test.ts b/tests/error-capture.test.ts deleted file mode 100644 index 2ffcee228a..0000000000 --- a/tests/error-capture.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -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 bb7b73c0a7..1956912cb2 100644 --- a/tests/instrumentation.test.ts +++ b/tests/instrumentation.test.ts @@ -8,14 +8,6 @@ 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", @@ -28,7 +20,6 @@ const ENV_KEYS = [ "RAG_QUERY_HASH_SECRET", "NEXT_PUBLIC_LOCAL_NO_AUTH", "LOCAL_NO_AUTH", - "SENTRY_DSN", ] as const; async function loadInstrumentation(overrides: Partial>) { @@ -54,8 +45,6 @@ const FULLY_CONFIGURED = { 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(); @@ -98,13 +87,7 @@ describe("instrumentation boot guard", () => { }); it("starts a fully configured production server", async () => { - const register = await loadRegister({ - ...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", - }); + const register = await loadRegister(FULLY_CONFIGURED); await expect(register()).resolves.toBeUndefined(); }); @@ -118,71 +101,3 @@ 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"); - }); -}); diff --git a/tests/sentry-client.test.ts b/tests/sentry-client.test.ts deleted file mode 100644 index 0abf86abdb..0000000000 --- a/tests/sentry-client.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ErrorEvent } from "@sentry/browser"; -import { scrubClientSentryEvent } from "@/lib/observability/sentry-scrub"; -import { captureClientException, registerSentryClient } from "@/lib/observability/sentry-client"; - -describe("scrubClientSentryEvent — clinical PII contract", () => { - it("drops request (url/headers/query), breadcrumbs and user; keeps the error", () => { - const event = { - message: "boom", - exception: { values: [{ type: "Error", value: "boom" }] }, - request: { url: "https://app.example/differentials?q=patient+with+chest+pain", headers: { cookie: "s=1" } }, - breadcrumbs: [{ message: "GET /api/answer?query=patient" }], - user: { email: "patient@example.com", ip_address: "1.2.3.4" }, - } as unknown as ErrorEvent; - - const scrubbed = scrubClientSentryEvent(event); - - expect(scrubbed.request).toBeUndefined(); - expect(scrubbed.breadcrumbs).toBeUndefined(); - expect(scrubbed.user).toBeUndefined(); - // The error itself is preserved for debugging. - expect(scrubbed.exception?.values?.[0].value).toBe("boom"); - }); -}); - -describe("captureClientException — inert until a client is registered", () => { - afterEach(() => { - registerSentryClient(undefined as unknown as { captureException: (e: unknown) => string }); - }); - - it("no-ops (does not throw) when no Sentry client is registered", () => { - registerSentryClient(undefined as unknown as { captureException: (e: unknown) => string }); - expect(() => captureClientException(new Error("boom"))).not.toThrow(); - }); - - it("forwards to the registered client when the browser SDK is initialized", () => { - const captureException = vi.fn(() => "event-id"); - registerSentryClient({ captureException }); - const error = new Error("boom"); - - captureClientException(error); - - expect(captureException).toHaveBeenCalledWith(error); - }); -}); - -describe("/api/monitoring tunnel — gated, relay-safe, size-capped", () => { - const ORIGINAL_DSN = process.env.SENTRY_DSN; - const ORIGINAL_PUBLIC_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - - function restoreEnv(key: "SENTRY_DSN" | "NEXT_PUBLIC_SENTRY_DSN", value: string | undefined) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - - afterEach(() => { - restoreEnv("SENTRY_DSN", ORIGINAL_DSN); - restoreEnv("NEXT_PUBLIC_SENTRY_DSN", ORIGINAL_PUBLIC_DSN); - vi.unstubAllGlobals(); - }); - - async function loadRoute() { - return import("@/app/api/monitoring/route"); - } - - function envelope(dsn: string) { - return `${JSON.stringify({ dsn })}\n${JSON.stringify({ type: "event" })}\n{}`; - } - - it("returns 404 when no DSN is configured (inert)", async () => { - // The route reads SENTRY_DSN || NEXT_PUBLIC_SENTRY_DSN — clear both so an - // ambient public DSN in the environment can't make this a false pass/fail. - delete process.env.SENTRY_DSN; - delete process.env.NEXT_PUBLIC_SENTRY_DSN; - const { POST } = await loadRoute(); - const res = await POST( - new Request("https://app.example/api/monitoring", { - method: "POST", - body: envelope("https://k@o1.ingest.sentry.io/42"), - }), - ); - expect(res.status).toBe(404); - }); - - it("rejects an envelope addressed to a different project (no open relay)", async () => { - process.env.SENTRY_DSN = "https://key@o1.ingest.sentry.io/42"; - const { POST } = await loadRoute(); - const res = await POST( - new Request("https://app.example/api/monitoring", { - method: "POST", - body: envelope("https://key@evil.ingest.sentry.io/999"), - }), - ); - expect(res.status).toBe(403); - }); - - it("rejects an oversized envelope with 413", async () => { - process.env.SENTRY_DSN = "https://key@o1.ingest.sentry.io/42"; - const { POST } = await loadRoute(); - const oversized = `${JSON.stringify({ dsn: "https://key@o1.ingest.sentry.io/42" })}\n${"x".repeat(1_100_000)}`; - const res = await POST(new Request("https://app.example/api/monitoring", { method: "POST", body: oversized })); - expect(res.status).toBe(413); - }); - - it("forwards a matching envelope to the configured Sentry ingest host (with a timeout signal)", async () => { - process.env.SENTRY_DSN = "https://key@o1.ingest.sentry.io/42"; - const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - const { POST } = await loadRoute(); - - const res = await POST( - new Request("https://app.example/api/monitoring", { - method: "POST", - body: envelope("https://key@o1.ingest.sentry.io/42"), - }), - ); - - expect(res.status).toBe(200); - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe("https://o1.ingest.sentry.io/api/42/envelope/"); - expect(init.method).toBe("POST"); - expect(init.signal).toBeInstanceOf(AbortSignal); - }); -});