From f15b122acf0ce0c7cdd32a7d7ecb057a83f492a9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:13:02 +0800 Subject: [PATCH 1/2] feat(observability): gated client-side Sentry via same-origin tunnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds browser error capture as the client counterpart to main's server-side @sentry/node capture. Uses @sentry/browser (not @sentry/nextjs) to avoid overlapping the existing server SDK. Fully inert until NEXT_PUBLIC_SENTRY_DSN is set — the SDK is tree-shaken out of the client bundle (zero bytes shipped). - src/instrumentation-client.ts: gated Sentry.init behind a build-time __SENTRY_ENABLED__ literal (DefinePlugin in next.config.ts) so webpack DCEs the whole block when no public DSN is set; typeof-guarded for Turbopack dev. - src/lib/observability/sentry-scrub.ts: mirrors the server privacy boundary — drops event.request (URL/query/headers), breadcrumbs, and user; keeps only the error. beforeBreadcrumb also returns null. - src/lib/observability/sentry-client.ts: lazy holder so error boundaries call captureClientException() without statically importing the SDK. - src/app/api/monitoring/route.ts: same-origin tunnel (keeps CSP connect-src 'self' intact). Hardened: DSN-match validation (no open relay), 1 MB envelope cap (Content-Length preflight + streamed byte count → 413), and a 5s upstream timeout. - Wires captureClientException into the route + global error boundaries. - env.ts: NEXT_PUBLIC_SENTRY_DSN/ENVIRONMENT (blank normalized to undefined). Verified: build exit 0 with @sentry/browser tree-shaken out of client chunks when DSN unset; typecheck / lint / format clean; new tests cover scrubbing, the inert no-op, and tunnel 404/403/413/forward behavior. 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 | 115 ++++++++++++++++++++++++ src/app/global-error.tsx | 4 + src/components/route-error-boundary.tsx | 3 + src/instrumentation-client.ts | 35 ++++++++ src/lib/env.ts | 10 +++ src/lib/observability/sentry-client.ts | 18 ++++ src/lib/observability/sentry-scrub.ts | 18 ++++ tests/sentry-client.test.ts | 115 ++++++++++++++++++++++++ 12 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 src/app/api/monitoring/route.ts create mode 100644 src/lib/observability/sentry-client.ts create mode 100644 src/lib/observability/sentry-scrub.ts create mode 100644 tests/sentry-client.test.ts diff --git a/docs/site-map.md b/docs/site-map.md index 90a90483f..84183b1e8 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -596,6 +596,7 @@ 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 2654b9e1d..50de22b07 100644 --- a/next.config.ts +++ b/next.config.ts @@ -36,12 +36,24 @@ const nextConfig: NextConfig = { turbopack: { root: projectRoot, }, - webpack(config) { + webpack(config, { webpack }) { // 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 d69ab1376..ba5ad060e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -3498,6 +3499,36 @@ "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", @@ -3519,6 +3550,18 @@ "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", @@ -3596,6 +3639,32 @@ "@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 f5ade4773..2e3bfeaa8 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ }, "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 new file mode 100644 index 000000000..d9726c4d5 --- /dev/null +++ b/src/app/api/monitoring/route.ts @@ -0,0 +1,115 @@ +// 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; + +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 }); + + 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 0b9bd1dd4..797cdbe48 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,6 +2,8 @@ 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 @@ -13,6 +15,8 @@ import { useEffect } from "react"; 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 ae9957733..91139ca35 100644 --- a/src/components/route-error-boundary.tsx +++ b/src/components/route-error-boundary.tsx @@ -4,6 +4,7 @@ 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. */ @@ -40,6 +41,8 @@ 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 42c22f74f..76a0c56a5 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -10,5 +10,40 @@ // (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); + })(); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 3cfa77427..07049cf5c 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,6 +24,16 @@ 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 new file mode 100644 index 000000000..bb8df24c9 --- /dev/null +++ b/src/lib/observability/sentry-client.ts @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 000000000..dd1a8c0df --- /dev/null +++ b/src/lib/observability/sentry-scrub.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..e88e4d3cd --- /dev/null +++ b/tests/sentry-client.test.ts @@ -0,0 +1,115 @@ +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; + + afterEach(() => { + if (ORIGINAL_DSN === undefined) delete process.env.SENTRY_DSN; + else process.env.SENTRY_DSN = ORIGINAL_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 () => { + delete process.env.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); + }); +}); From beaab0bf2f0b1d27c6253004d400ac7be3ade19e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:36:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(monitoring):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20rate=20limit,=20init=20catch,=20test=20env=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the CodeRabbit review threads on the client-Sentry PR: - Rate-limit the /api/monitoring tunnel: lightweight per-IP in-memory window (120/min → 429). It's an unauthenticated public POST, so a valid-envelope spammer could otherwise burn the project's Sentry ingest quota. Kept per- instance/best-effort (not the durable answer/upload limiter) since this is a telemetry relay, not a paid or state-changing path. - Guard the client Sentry init IIFE with .catch() so a failed dynamic import of @sentry/browser can never surface as an unhandled rejection. - Make the tunnel tests clear NEXT_PUBLIC_SENTRY_DSN too (the route reads SENTRY_DSN || NEXT_PUBLIC_SENTRY_DSN), so an ambient public DSN can't cause a false pass/fail; restore both in afterEach. Verified: typecheck / lint / format clean; tunnel + scrub + inert tests pass (7/7). Co-Authored-By: Claude Opus 4.8 --- src/app/api/monitoring/route.ts | 29 +++++++++++++++++++++++++++++ src/instrumentation-client.ts | 5 ++++- tests/sentry-client.test.ts | 13 +++++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/app/api/monitoring/route.ts b/src/app/api/monitoring/route.ts index d9726c4d5..a5ff9b802 100644 --- a/src/app/api/monitoring/route.ts +++ b/src/app/api/monitoring/route.ts @@ -18,6 +18,30 @@ 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 { @@ -56,6 +80,11 @@ 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); diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 76a0c56a5..8d0de20a8 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -45,5 +45,8 @@ if (typeof __SENTRY_ENABLED__ !== "undefined" && __SENTRY_ENABLED__) { 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/tests/sentry-client.test.ts b/tests/sentry-client.test.ts index e88e4d3cd..0abf86abd 100644 --- a/tests/sentry-client.test.ts +++ b/tests/sentry-client.test.ts @@ -46,10 +46,16 @@ describe("captureClientException — inert until a client is registered", () => 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(() => { - if (ORIGINAL_DSN === undefined) delete process.env.SENTRY_DSN; - else process.env.SENTRY_DSN = ORIGINAL_DSN; + restoreEnv("SENTRY_DSN", ORIGINAL_DSN); + restoreEnv("NEXT_PUBLIC_SENTRY_DSN", ORIGINAL_PUBLIC_DSN); vi.unstubAllGlobals(); }); @@ -62,7 +68,10 @@ describe("/api/monitoring tunnel — gated, relay-safe, size-capped", () => { } 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", {