From 8d54ddc980a0c883d1f8013e05aa1f96a85e622a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:14:49 +0800 Subject: [PATCH] revert: remove client-side Sentry (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the gated client-side @sentry/browser integration merged in #642: tunnel route (/api/monitoring), client init/scrubber/holder, error-boundary capture wiring, NEXT_PUBLIC_SENTRY_DSN env, the DefinePlugin gate, and the @sentry/browser dependency. Server-side error capture (@sentry/node via src/lib/observability/error-capture.ts + src/instrumentation.ts, gated on SENTRY_DSN) is unaffected — it predates #642. Co-Authored-By: Claude Opus 4.8 --- docs/site-map.md | 1 - next.config.ts | 14 +-- package-lock.json | 69 ------------ package.json | 1 - src/app/api/monitoring/route.ts | 144 ------------------------ src/app/global-error.tsx | 4 - src/components/route-error-boundary.tsx | 3 - src/instrumentation-client.ts | 38 ------- src/lib/env.ts | 10 -- src/lib/observability/sentry-client.ts | 18 --- src/lib/observability/sentry-scrub.ts | 18 --- tests/sentry-client.test.ts | 124 -------------------- 12 files changed, 1 insertion(+), 443 deletions(-) delete mode 100644 src/app/api/monitoring/route.ts delete mode 100644 src/lib/observability/sentry-client.ts delete mode 100644 src/lib/observability/sentry-scrub.ts delete mode 100644 tests/sentry-client.test.ts diff --git a/docs/site-map.md b/docs/site-map.md index 84183b1e8..90a90483f 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 50de22b07..2654b9e1d 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 964fa98b0..29f6f33c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +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", @@ -3500,36 +3499,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", @@ -3551,18 +3520,6 @@ "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", @@ -3640,32 +3597,6 @@ "@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", diff --git a/package.json b/package.json index 77a7cf27d..fc4629090 100644 --- a/package.json +++ b/package.json @@ -141,7 +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", diff --git a/src/app/api/monitoring/route.ts b/src/app/api/monitoring/route.ts deleted file mode 100644 index a5ff9b802..000000000 --- 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 797cdbe48..0b9bd1dd4 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 91139ca35..ae9957733 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 8d0de20a8..42c22f74f 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/lib/env.ts b/src/lib/env.ts index 07049cf5c..3cfa77427 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,16 +24,6 @@ const envSchema = z.object({ (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/sentry-client.ts b/src/lib/observability/sentry-client.ts deleted file mode 100644 index bb8df24c9..000000000 --- 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 dd1a8c0df..000000000 --- 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/tests/sentry-client.test.ts b/tests/sentry-client.test.ts deleted file mode 100644 index 0abf86abd..000000000 --- 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); - }); -});