From 155f014870ed6bb36b7ce0479fe001508f9fb5f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 13:07:24 +0000 Subject: [PATCH 1/4] feat(sentry): enable privacy-safe DB query tracing Turn on a 10% traces sample rate with Supabase span instrumentation so Sentry Queries can show slowest tables/operations, while keeping PostgREST filters, mutation bodies, and other clinical payloads redacted. Co-authored-by: BigSimmo --- .env.example | 10 +- docs/codebase-index.md | 12 +- docs/error-tracking.md | 21 +++- src/lib/observability/error-tracking.ts | 150 +++++++++++++++++++++++- src/lib/supabase/admin.ts | 4 + src/sentry.edge.config.ts | 14 ++- src/sentry.server.config.ts | 47 +++++++- tests/error-tracking.test.ts | 102 +++++++++++++++- 8 files changed, 340 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index b523667433..4065251bd4 100644 --- a/.env.example +++ b/.env.example @@ -117,11 +117,15 @@ RAG_PROVIDER_MODE=auto # Optional server-only production error tracking. When SENTRY_DSN is absent the # integration is inert. Events exclude request URLs/headers/bodies, user data, -# breadcrumbs, free-form context, and exception messages; tracing/log capture is off. +# breadcrumbs, free-form context, and exception messages; log/replay stay off. +# Performance tracing defaults to 10% sampling for privacy-safe Supabase DB spans +# (table/operation only — no query filters or mutation bodies). Set +# SENTRY_TRACES_SAMPLE_RATE=0 to disable tracing while keeping error capture. # Choose and document region, retention, access, and alert routing before enabling. # Never expose this value as NEXT_PUBLIC_*. SENTRY_DSN= SENTRY_ENVIRONMENT=production +# SENTRY_TRACES_SAMPLE_RATE=0.1 # Optional JSON override for app-layer ranking weights (see src/lib/ranking-config.ts). # Omit for current defaults. Example (enable diversity demotion + linear freshness): # RAG_RANKING_CONFIG={"documentDiversityPenalty":0.03,"freshness":{"mode":"linear"}} @@ -257,8 +261,10 @@ TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe # Optional privacy-safe production server error tracking (see docs/error-tracking.md). # Server-side DSN only — leave unset to keep tracking inert. Do not set a browser -# NEXT_PUBLIC_SENTRY_DSN; browser telemetry, replay, and tracing stay disabled. +# NEXT_PUBLIC_SENTRY_DSN; browser telemetry and replay stay disabled. Server +# tracing defaults on at 10% with redacted Supabase DB spans when SENTRY_DSN is set. #SENTRY_DSN= +#SENTRY_TRACES_SAMPLE_RATE=0.1 # Optional source-map upload (build/CI only). Set all three together, or leave all unset. #SENTRY_ORG= #SENTRY_PROJECT= diff --git a/docs/codebase-index.md b/docs/codebase-index.md index fb9630acf0..428cc71207 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -171,12 +171,12 @@ domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below r ### Infra helpers -| Module | Role | -| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | -| `observability/` — `answer-slo.ts`, `cache-metrics.ts`, `spend-metrics.ts` | Deep-health SLO / cache-hit / answer-spend snapshots | -| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | -| `app-modes.ts`, `document-flow-routes.ts`, `local-project-identity.ts`, `local-server-utils.mjs` | Routing and project identity | +| Module | Role | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `openai.ts`, `embedding-dimensions.ts`, `api-rate-limit.ts` | External APIs and rate limits | +| `observability/` — `answer-slo.ts`, `cache-metrics.ts`, `spend-metrics.ts`, `error-tracking.ts` | Deep-health SLO / cache-hit / answer-spend snapshots; privacy-safe Sentry error + DB-span scrubbers (`docs/error-tracking.md`) | +| `validation/` | `body.ts`, `query.ts`, `params.ts`, `http.ts`, `form-data.ts` | +| `app-modes.ts`, `document-flow-routes.ts`, `local-project-identity.ts`, `local-server-utils.mjs` | Routing and project identity | --- diff --git a/docs/error-tracking.md b/docs/error-tracking.md index d75eb30fe1..1db6134112 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -1,19 +1,36 @@ # Privacy-safe production error tracking -Production server exception tracking is an optional, provider-gated Sentry integration. It is inert unless `SENTRY_DSN` is configured. Browser telemetry, performance tracing, logs, and session replay are not enabled — there is no client Sentry bundle path. Runtime init is owned by `src/sentry.{server,edge}.config.ts` (loaded once from Next server/edge instrumentation); do not add a second `Sentry.init()` path or a browser SDK import. +Production server exception tracking is an optional, provider-gated Sentry integration. It is inert unless `SENTRY_DSN` is configured. Browser telemetry, logs, and session replay are not enabled — there is no client Sentry bundle path. Runtime init is owned by `src/sentry.{server,edge}.config.ts` (loaded once from Next server/edge instrumentation); do not add a second `Sentry.init()` path or a browser SDK import. ## Data envelope +### Errors + The application sends only an error type, scrubbed code stack-frame locations, the static Next.js route pattern, route/router type, release/environment identifiers, and an event identifier. Before export it discards exception messages, requested URLs and query strings, headers, cookies, bodies, users, breadcrumbs, arbitrary context, local variables, prompts, clinical queries, answers, and document content. Do not add those fields to the allowlist. The same `privacySafeErrorEvent` scrubber runs on server and edge `beforeSend` hooks. The static route pattern (for example `/api/documents/[id]`) is safe operational metadata; the actual request path is deliberately ignored. Error grouping uses the route pattern, a fixed JavaScript runtime error type, and scrubbed code-frame location. Custom error names are treated as untrusted free-form text and collapse to `Error`; grouping never uses an owner, patient, query, document, or request identifier. +### Performance traces (DB query visibility) + +Server/edge tracing is enabled at a low default sample rate (`tracesSampleRate` defaults to `0.1`; override with `SENTRY_TRACES_SAMPLE_RATE`, or set `0` to disable). The Supabase JS integration instruments PostgREST operations so Sentry's Queries dashboard can show slowest tables/operations. + +Privacy constraints for traces: + +- `sendOperationData` / `dataCollection.databaseQueryData` stay **false** — PostgREST filter values and mutation bodies are never attached as `db.query` / `db.body`. +- Span descriptions are rewritten to `select from()` (operation + table only) by `privacySafeTransactionEvent` before export. +- Allowed span attributes: `db.table`, `db.schema`, `db.system`, `db.operation`, `db.sdk`, `http.status_code`, and Sentry op/origin metadata. +- Breadcrumbs remain disabled (`maxBreadcrumbs: 0`). Request URLs, users, and free-form context are stripped from transactions the same way as errors. + +View samples in Sentry under **Explore → Traces**, and aggregated DB performance under **Dashboards → Sentry Built → Queries**. + ## Operator approval and rollout Before setting `SENTRY_DSN`, the operator must approve the vendor/project, data region, retention period, access roles, sampling rate, cost budget, and alert destination. Configure a server-side DSN only; never use a `NEXT_PUBLIC_*` DSN. Keep provider-side IP/user enrichment disabled and restrict project access. Start with a non-production synthetic exception and inspect the received event before enabling production alerts. +When enabling tracing, also review a sampled transaction in Sentry and confirm span descriptions contain only table/operation metadata (no filter literals, clinical text, or mutation payloads). Set `SENTRY_TRACES_SAMPLE_RATE=0` to roll tracing back without removing the DSN. + No source-map upload is configured: builds do not contact Sentry and do not require a Sentry auth token. This reduces provider coupling, at the cost of less useful minified production frames. Reconsider source maps only through a separate privacy and build-provider review. ## Disable and rollback -Remove `SENTRY_DSN` and restart the service. The tracker then makes no provider calls. Provider-side deletion and retention remain operator responsibilities under the approved Sentry project policy. +Remove `SENTRY_DSN` and restart the service. The tracker then makes no provider calls. To disable only performance tracing while keeping error capture, set `SENTRY_TRACES_SAMPLE_RATE=0` and restart. Provider-side deletion and retention remain operator responsibilities under the approved Sentry project policy. diff --git a/src/lib/observability/error-tracking.ts b/src/lib/observability/error-tracking.ts index 444dd8d652..8b13498bda 100644 --- a/src/lib/observability/error-tracking.ts +++ b/src/lib/observability/error-tracking.ts @@ -1,3 +1,5 @@ +import { createRequire } from "node:module"; +import type { SpanJSON, TransactionEvent } from "@sentry/core"; import type { ErrorEvent } from "@sentry/nextjs"; import type { Instrumentation } from "next"; @@ -14,10 +16,135 @@ const SAFE_EXCEPTION_TYPES = new Set([ "URIError", ]); +/** Span attributes safe to export for DB performance dashboards. */ +const SAFE_SPAN_DATA_KEYS = [ + "db.table", + "db.schema", + "db.system", + "db.operation", + "db.sdk", + "http.status_code", + "sentry.op", + "sentry.origin", + "sentry.source", + "sentry.sample_rate", +] as const; + +const DEFAULT_TRACES_SAMPLE_RATE = 0.1; + function privacySafeExceptionType(value: string | undefined) { return value && SAFE_EXCEPTION_TYPES.has(value) ? value : "Error"; } +function privacySafeTags(event: { tags?: ErrorEvent["tags"] | TransactionEvent["tags"] }) { + return Object.fromEntries( + SAFE_TAGS.flatMap((key) => (typeof event.tags?.[key] === "string" ? [[key, event.tags[key]]] : [])), + ); +} + +/** + * Resolve performance sampling. Defaults to 10% when unset. Operators can set + * `SENTRY_TRACES_SAMPLE_RATE=0` to disable tracing without removing the DSN. + */ +export function resolveTracesSampleRate(rawValue: string | undefined = process.env.SENTRY_TRACES_SAMPLE_RATE): number { + if (rawValue === undefined || rawValue.trim() === "") { + return DEFAULT_TRACES_SAMPLE_RATE; + } + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) { + return DEFAULT_TRACES_SAMPLE_RATE; + } + return parsed; +} + +function privacySafeSpanDescription(data: Record, fallback: string | undefined): string | undefined { + const operation = typeof data["db.operation"] === "string" ? data["db.operation"] : undefined; + const table = typeof data["db.table"] === "string" ? data["db.table"] : undefined; + + if (operation?.startsWith("auth.")) { + return typeof fallback === "string" && /^auth\b/i.test(fallback) ? fallback : `auth ${operation.slice(5)}`; + } + if (operation && table) { + return `${operation} from(${table})`; + } + if (table) { + return `from(${table})`; + } + + // Keep parameterized framework/route span names; drop free-form or query-bearing text. + if ( + typeof fallback === "string" && + !fallback.includes("?") && + !fallback.includes("=") && + (/^\//.test(fallback) || + /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+\//i.test(fallback) || + /^(middleware|start|resolve page component|Executing api route)/i.test(fallback)) + ) { + return fallback; + } + + return undefined; +} + +function privacySafeSpan(span: NonNullable[number]): SpanJSON { + const rawData = (span.data ?? {}) as Record; + const data = Object.fromEntries( + SAFE_SPAN_DATA_KEYS.flatMap((key) => (rawData[key] === undefined ? [] : [[key, rawData[key]]])), + ); + + return { + span_id: span.span_id, + trace_id: span.trace_id, + parent_span_id: span.parent_span_id, + op: span.op, + origin: span.origin, + status: span.status, + start_timestamp: span.start_timestamp, + timestamp: span.timestamp, + exclusive_time: span.exclusive_time, + description: privacySafeSpanDescription(rawData, span.description), + data: Object.keys(data).length ? data : {}, + } as SpanJSON; +} + +/** Keep timing + safe DB metadata; strip query filters, bodies, request/PII payloads. */ +export function privacySafeTransactionEvent(event: TransactionEvent): TransactionEvent { + const tags = privacySafeTags(event); + const transaction = + typeof event.transaction === "string" && !event.transaction.includes("?") && !event.transaction.includes("=") + ? event.transaction + : undefined; + + const scrubbed: TransactionEvent = { + type: "transaction", + event_id: event.event_id, + timestamp: event.timestamp, + start_timestamp: event.start_timestamp, + platform: event.platform, + level: event.level, + release: event.release, + environment: event.environment, + transaction, + transaction_info: event.transaction_info, + measurements: event.measurements, + contexts: event.contexts?.trace + ? { + trace: { + trace_id: event.contexts.trace.trace_id, + span_id: event.contexts.trace.span_id, + parent_span_id: event.contexts.trace.parent_span_id, + op: event.contexts.trace.op, + status: event.contexts.trace.status, + origin: event.contexts.trace.origin, + }, + } + : undefined, + spans: event.spans?.map(privacySafeSpan), + tags: Object.keys(tags).length ? tags : undefined, + }; + return scrubbed; +} + /** Keep code locations while removing all free-form/request data before export. */ export function privacySafeErrorEvent(event: ErrorEvent): ErrorEvent { const exceptions = event.exception?.values?.map((exception) => ({ @@ -40,9 +167,7 @@ export function privacySafeErrorEvent(event: ErrorEvent): ErrorEvent { : undefined, })); - const tags = Object.fromEntries( - SAFE_TAGS.flatMap((key) => (typeof event.tags?.[key] === "string" ? [[key, event.tags[key]]] : [])), - ); + const tags = privacySafeTags(event); const exceptionType = exceptions?.[0]?.type || "Error"; const routePath = tags.route_path; const topFrame = exceptions?.[0]?.stacktrace?.frames?.at(-1); @@ -64,6 +189,25 @@ export function privacySafeErrorEvent(event: ErrorEvent): ErrorEvent { }; } +/** + * Instrument a Supabase JS client for DB spans without shipping filter values + * or mutation bodies (`sendOperationData: false`). Safe to call for every + * client — the SDK marks the constructor prototype once. + */ +export function instrumentSupabaseClientForTracing(supabaseClient: unknown): void { + if (!process.env.SENTRY_DSN?.trim()) { + return; + } + + try { + const require = createRequire(import.meta.url); + const Sentry = require("@sentry/nextjs") as typeof import("@sentry/nextjs"); + Sentry.instrumentSupabaseClient(supabaseClient, { sendOperationData: false }); + } catch { + // Optional observability must never take down Supabase access. + } +} + /** * Status probe only. Runtime init is owned by `src/sentry.server.config.ts` * (loaded once from `instrumentation.register`) so privacy scrubbing cannot race diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index 572179baf0..e738d575c3 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -1,5 +1,6 @@ import { createClient } from "@supabase/supabase-js"; import { requireServerEnv } from "@/lib/env"; +import { instrumentSupabaseClientForTracing } from "@/lib/observability/error-tracking"; import type { Database } from "./database.types"; // Cache the admin client as a module-level singleton so that every API request @@ -18,6 +19,9 @@ export function createAdminClient() { persistSession: false, }, }); + // Constructor-level DB instrumentation (shared with SSR clients) plus admin auth spans. + // No-op when SENTRY_DSN is unset; never attaches query filters/bodies. + instrumentSupabaseClientForTracing(adminClient); } return adminClient; } diff --git a/src/sentry.edge.config.ts b/src/sentry.edge.config.ts index 31f2c0ec52..736829bb07 100644 --- a/src/sentry.edge.config.ts +++ b/src/sentry.edge.config.ts @@ -1,6 +1,10 @@ import * as Sentry from "@sentry/nextjs"; -import { privacySafeErrorEvent } from "@/lib/observability/error-tracking"; +import { + privacySafeErrorEvent, + privacySafeTransactionEvent, + resolveTracesSampleRate, +} from "@/lib/observability/error-tracking"; const sentryEnvironment = process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "development"; const sentryDsn = process.env.SENTRY_DSN?.trim(); @@ -12,13 +16,19 @@ try { ...(sentryDsn ? { dsn: sentryDsn } : {}), release: sentryRelease, environment: sentryEnvironment, - tracesSampleRate: 0, + tracesSampleRate: resolveTracesSampleRate(), sendDefaultPii: false, + dataCollection: { + databaseQueryData: false, + }, enableLogs: false, maxBreadcrumbs: 0, beforeSend(event) { return privacySafeErrorEvent(event); }, + beforeSendTransaction(event) { + return privacySafeTransactionEvent(event); + }, }); } catch { // Optional observability must never take down the clinical edge runtime. diff --git a/src/sentry.server.config.ts b/src/sentry.server.config.ts index d241355448..bced134b99 100644 --- a/src/sentry.server.config.ts +++ b/src/sentry.server.config.ts @@ -1,6 +1,11 @@ import * as Sentry from "@sentry/nextjs"; +import { createClient } from "@supabase/supabase-js"; -import { privacySafeErrorEvent } from "@/lib/observability/error-tracking"; +import { + privacySafeErrorEvent, + privacySafeTransactionEvent, + resolveTracesSampleRate, +} from "@/lib/observability/error-tracking"; const sentryEnvironment = process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "development"; const sentryDsn = process.env.SENTRY_DSN?.trim(); @@ -29,23 +34,59 @@ function isBotTrafficEvent(event: Sentry.Event): boolean { ); } +/** Bootstrap client used only to attach constructor-level Supabase DB instrumentation. */ +function supabaseTracingIntegrations() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY?.trim() || process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) { + return []; + } + + try { + const supabaseClient = createClient(url, key, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + return [ + Sentry.supabaseIntegration({ + supabaseClient, + // Never attach PostgREST filter values or mutation bodies — clinical privacy. + sendOperationData: false, + }), + ]; + } catch { + return []; + } +} + try { Sentry.init({ ...(sentryDsn ? { dsn: sentryDsn } : {}), release: sentryRelease, environment: sentryEnvironment, - // Privacy posture: no traces, logs, breadcrumbs, locals, or PII (docs/error-tracking.md). - tracesSampleRate: 0, + // Performance tracing for DB query dashboards. Override with SENTRY_TRACES_SAMPLE_RATE + // (0 disables). Query filters/bodies stay redacted — see docs/error-tracking.md. + tracesSampleRate: resolveTracesSampleRate(), sendDefaultPii: false, + dataCollection: { + databaseQueryData: false, + }, includeLocalVariables: false, enableLogs: false, attachStacktrace: true, maxBreadcrumbs: 0, ignoreErrors: ignoredServerErrors, + integrations: supabaseTracingIntegrations(), beforeSend(event) { if (isBotTrafficEvent(event)) return null; return privacySafeErrorEvent(event); }, + beforeSendTransaction(event) { + if (isBotTrafficEvent(event)) return null; + return privacySafeTransactionEvent(event); + }, }); } catch { // Optional observability must never take down the clinical server. diff --git a/tests/error-tracking.test.ts b/tests/error-tracking.test.ts index 7c8fd7b469..f1e920fd6d 100644 --- a/tests/error-tracking.test.ts +++ b/tests/error-tracking.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { privacySafeErrorEvent } from "@/lib/observability/error-tracking"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + privacySafeErrorEvent, + privacySafeTransactionEvent, + resolveTracesSampleRate, +} from "@/lib/observability/error-tracking"; describe("production error tracking privacy boundary", () => { it("removes clinical text, identifiers, request data, breadcrumbs, and frame locals", () => { @@ -55,3 +59,97 @@ describe("production error tracking privacy boundary", () => { await expect(initializeErrorTracking()).resolves.toBeTypeOf("boolean"); }); }); + +describe("resolveTracesSampleRate", () => { + it("defaults to 0.1 and clamps invalid values", () => { + expect(resolveTracesSampleRate(undefined)).toBe(0.1); + expect(resolveTracesSampleRate("")).toBe(0.1); + expect(resolveTracesSampleRate("0")).toBe(0); + expect(resolveTracesSampleRate("0.25")).toBe(0.25); + expect(resolveTracesSampleRate("1")).toBe(1); + expect(resolveTracesSampleRate("2")).toBe(0.1); + expect(resolveTracesSampleRate("nope")).toBe(0.1); + }); +}); + +describe("privacySafeTransactionEvent", () => { + it("keeps table/operation DB span metadata and strips query filters, bodies, and request data", () => { + const event = privacySafeTransactionEvent({ + type: "transaction", + event_id: "txn-1", + transaction: "/api/answer", + request: { url: "https://example.test/api/answer?q=Jane%20Doe" }, + user: { id: "owner-id", email: "jane@example.test" }, + breadcrumbs: [{ message: "eq(owner_id, owner-id)", category: "db.select" }], + tags: { route_path: "/api/answer", patient_id: "owner-id" }, + contexts: { + trace: { + trace_id: "trace-1", + span_id: "span-root", + op: "http.server", + data: { "http.query": "q=Jane Doe MRN 123456" }, + }, + }, + spans: [ + { + span_id: "span-db", + trace_id: "trace-1", + op: "db", + origin: "auto.db.supabase", + description: "select eq(owner_id, owner-secret) from(documents)", + start_timestamp: 1, + timestamp: 1.2, + data: { + "db.table": "documents", + "db.schema": "public", + "db.system": "postgresql", + "db.operation": "select", + "db.sdk": "supabase-js-node/2.0.0", + "db.url": "https://sjrfecxgysukkwxsowpy.supabase.co", + "db.query": ["eq(owner_id, owner-secret)", "ilike(title, %Jane Doe%)"], + // Cast: SDK may attach object bodies at runtime when sendOperationData is on. + "db.body": { title: "clinical note about Jane" } as unknown as string[], + "sentry.op": "db", + "sentry.origin": "auto.db.supabase", + }, + }, + ], + }); + + expect(JSON.stringify(event)).not.toMatch(/Jane|owner-secret|owner-id|clinical note|123456/); + expect(event).not.toHaveProperty("request"); + expect(event).not.toHaveProperty("user"); + expect(event).not.toHaveProperty("breadcrumbs"); + expect(event.transaction).toBe("/api/answer"); + expect(event.tags).toEqual({ route_path: "/api/answer" }); + expect(event.spans?.[0]).toMatchObject({ + description: "select from(documents)", + data: { + "db.table": "documents", + "db.schema": "public", + "db.system": "postgresql", + "db.operation": "select", + "db.sdk": "supabase-js-node/2.0.0", + "sentry.op": "db", + "sentry.origin": "auto.db.supabase", + }, + }); + expect(event.spans?.[0]?.data).not.toHaveProperty("db.query"); + expect(event.spans?.[0]?.data).not.toHaveProperty("db.body"); + expect(event.spans?.[0]?.data).not.toHaveProperty("db.url"); + expect(event.contexts?.trace?.data).toBeUndefined(); + }); +}); + +describe("instrumentSupabaseClientForTracing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("is a no-op when SENTRY_DSN is unset", async () => { + vi.stubEnv("SENTRY_DSN", ""); + const { instrumentSupabaseClientForTracing } = await import("@/lib/observability/error-tracking"); + expect(() => instrumentSupabaseClientForTracing({})).not.toThrow(); + }); +}); From 0dfb7a35a3a2623794a2d3e4c657268018604d18 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 13:13:52 +0000 Subject: [PATCH 2/4] fix(sentry): drop unlisted @sentry/core type import for knip Static PR checks failed check:knip on an unlisted @sentry/core import. Use local structural transaction/span types instead so the privacy scrubber stays dependency-clean. Co-authored-by: BigSimmo --- src/lib/observability/error-tracking.ts | 61 ++++++++++++++++++++++--- src/sentry.edge.config.ts | 3 +- src/sentry.server.config.ts | 3 +- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/lib/observability/error-tracking.ts b/src/lib/observability/error-tracking.ts index 8b13498bda..5bebb14b23 100644 --- a/src/lib/observability/error-tracking.ts +++ b/src/lib/observability/error-tracking.ts @@ -1,5 +1,4 @@ import { createRequire } from "node:module"; -import type { SpanJSON, TransactionEvent } from "@sentry/core"; import type { ErrorEvent } from "@sentry/nextjs"; import type { Instrumentation } from "next"; @@ -32,11 +31,60 @@ const SAFE_SPAN_DATA_KEYS = [ const DEFAULT_TRACES_SAMPLE_RATE = 0.1; +/** + * Structural transaction/span shapes for privacy scrubbing. + * Kept local so knip does not require a direct `@sentry/core` dependency — + * `@sentry/nextjs` does not re-export `TransactionEvent` / `SpanJSON`. + */ +type ScrubbedSpan = { + span_id: string; + trace_id: string; + parent_span_id?: string; + op?: string; + origin?: string; + status?: string; + start_timestamp: number; + timestamp?: number; + exclusive_time?: number; + description?: string; + data?: Record; +}; + +type ScrubbedTransactionEvent = { + type: "transaction"; + event_id?: string; + timestamp?: number; + start_timestamp?: number; + platform?: string; + level?: ErrorEvent["level"]; + release?: string; + environment?: string; + transaction?: string; + transaction_info?: { source: string }; + measurements?: ErrorEvent["measurements"]; + contexts?: { + trace?: { + trace_id?: string; + span_id?: string; + parent_span_id?: string; + op?: string; + status?: string; + origin?: string; + data?: Record; + }; + }; + spans?: ScrubbedSpan[]; + tags?: ErrorEvent["tags"]; + request?: ErrorEvent["request"]; + user?: ErrorEvent["user"]; + breadcrumbs?: ErrorEvent["breadcrumbs"]; +}; + function privacySafeExceptionType(value: string | undefined) { return value && SAFE_EXCEPTION_TYPES.has(value) ? value : "Error"; } -function privacySafeTags(event: { tags?: ErrorEvent["tags"] | TransactionEvent["tags"] }) { +function privacySafeTags(event: { tags?: ErrorEvent["tags"] | ScrubbedTransactionEvent["tags"] }) { return Object.fromEntries( SAFE_TAGS.flatMap((key) => (typeof event.tags?.[key] === "string" ? [[key, event.tags[key]]] : [])), ); @@ -86,7 +134,7 @@ function privacySafeSpanDescription(data: Record, fallback: str return undefined; } -function privacySafeSpan(span: NonNullable[number]): SpanJSON { +function privacySafeSpan(span: ScrubbedSpan): ScrubbedSpan { const rawData = (span.data ?? {}) as Record; const data = Object.fromEntries( SAFE_SPAN_DATA_KEYS.flatMap((key) => (rawData[key] === undefined ? [] : [[key, rawData[key]]])), @@ -104,18 +152,18 @@ function privacySafeSpan(span: NonNullable[number]): exclusive_time: span.exclusive_time, description: privacySafeSpanDescription(rawData, span.description), data: Object.keys(data).length ? data : {}, - } as SpanJSON; + }; } /** Keep timing + safe DB metadata; strip query filters, bodies, request/PII payloads. */ -export function privacySafeTransactionEvent(event: TransactionEvent): TransactionEvent { +export function privacySafeTransactionEvent(event: ScrubbedTransactionEvent): ScrubbedTransactionEvent { const tags = privacySafeTags(event); const transaction = typeof event.transaction === "string" && !event.transaction.includes("?") && !event.transaction.includes("=") ? event.transaction : undefined; - const scrubbed: TransactionEvent = { + return { type: "transaction", event_id: event.event_id, timestamp: event.timestamp, @@ -142,7 +190,6 @@ export function privacySafeTransactionEvent(event: TransactionEvent): Transactio spans: event.spans?.map(privacySafeSpan), tags: Object.keys(tags).length ? tags : undefined, }; - return scrubbed; } /** Keep code locations while removing all free-form/request data before export. */ diff --git a/src/sentry.edge.config.ts b/src/sentry.edge.config.ts index 736829bb07..a7edbe6ea1 100644 --- a/src/sentry.edge.config.ts +++ b/src/sentry.edge.config.ts @@ -27,7 +27,8 @@ try { return privacySafeErrorEvent(event); }, beforeSendTransaction(event) { - return privacySafeTransactionEvent(event); + // Local scrubber shape is structural; cast back to the SDK transaction type. + return privacySafeTransactionEvent(event) as typeof event; }, }); } catch { diff --git a/src/sentry.server.config.ts b/src/sentry.server.config.ts index bced134b99..ab75c145ba 100644 --- a/src/sentry.server.config.ts +++ b/src/sentry.server.config.ts @@ -85,7 +85,8 @@ try { }, beforeSendTransaction(event) { if (isBotTrafficEvent(event)) return null; - return privacySafeTransactionEvent(event); + // Local scrubber shape is structural; cast back to the SDK transaction type. + return privacySafeTransactionEvent(event) as typeof event; }, }); } catch { From 97cb26062819c4a8ea9318daa9b30361409e64fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 13:21:50 +0000 Subject: [PATCH 3/4] docs(ledger): record pr-1540 unblock at knip-fix tip Append the pr-1540-unblock review row for 0dfb7a35 after the static-pr knip fix landed. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 96a0b52373..8dce260cbf 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -566,3 +566,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | claude/fable-implementation-fc937c | e87d4e583cec7277f6c1ff2fc5c3a08b2d1b37aa | pr-1531 unblock | fixed static-pr eslint anon-export + build bundle-budget (-client Sentry); merge-tree-clean; 0 blocking threads | eslint next.config --max-warnings 0; check:bundle-budget within tolerance (1394.0/1278.6 KiB gzip); vitest error-tracking+env-sentry 6/6 | | 2026-07-31 | 1489 | 67d5cb91083f9b0e9d3017816cbf68abab102688 | PR 1489 review — Therapy startup/sidebar perf, catalogue split, bundle-budget, phone-chrome | approved with follow-ups; merged 945148251. No P0/P1. Findings fixed on claude/pr-1489-review-786e01: inferred modality mislabelled ECT/rTMS as ACT and Psychoanalysis as CBT (pre-existing on main); hashed catalogue assets never pruned (2 stranded in-PR); classifyPullRequestFiles returned clinicalRisk:false for 205 clinical records; viewportHeightChanged guard outranked topRevealOffset; guard keyed innerHeight not visualViewport; sk-proj- keys unescaped; bundle-budget step timeout 3m too tight. Bundling note: operationalRisk+clinicalRisk in one squash, so no per-item revert. | verify:cheap static gates pass; lint pass; typecheck exit 0; vitest 449 files/4700 pass; verify:phone-chrome contracts 116 pass + focused browser 13 pass; verify:ui 342 pass/2 fail, both pass isolated (composer hero-vs-dock hydration race, no position: assignment in use-hide-on-scroll) | | 2026-07-31 | claude/fable-implementation-fc937c | 2147572428271278462db2aac169a5c3c0a2bcc7 | pr-1531 unblock | reverted CodeRabbit autofix (next.config env import broke build; prettier docs); merged origin/main behind-but-clean; merge-tree-clean | prettier design-system+next.config; eslint next.config --max-warnings 0; merge-tree clean vs origin/main | +| 2026-07-31 | cursor/sentry-db-query-tracing-0546 | 0dfb7a35a3a2623794a2d3e4c657268018604d18 | pr-1540-unblock | fixed static-pr knip unlisted @sentry/core; merge-tree clean; no blocking threads | check:knip:pass; vitest:error-tracking:5/5; tsc:pass; merge-tree:clean | From f20e875e9b7004637c6d187a259547eed5a2ffbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 13:49:26 +0000 Subject: [PATCH 4/4] fix(sentry): keep Edge scrubbers Node-free and gate DB tracing Move Supabase client instrumentation into a server-only module so error-tracking stays Edge-safe, and install DB tracing only when a DSN is set and SENTRY_TRACES_SAMPLE_RATE resolves above zero. Co-authored-by: BigSimmo --- docs/error-tracking.md | 2 +- src/lib/observability/error-tracking.ts | 25 ++++------------ src/lib/observability/supabase-tracing.ts | 22 ++++++++++++++ src/lib/supabase/admin.ts | 4 +-- src/sentry.server.config.ts | 9 +++++- tests/error-tracking.test.ts | 36 +++++++++++++++++++++-- 6 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 src/lib/observability/supabase-tracing.ts diff --git a/docs/error-tracking.md b/docs/error-tracking.md index 1db6134112..4ff6735455 100644 --- a/docs/error-tracking.md +++ b/docs/error-tracking.md @@ -12,7 +12,7 @@ The static route pattern (for example `/api/documents/[id]`) is safe operational ### Performance traces (DB query visibility) -Server/edge tracing is enabled at a low default sample rate (`tracesSampleRate` defaults to `0.1`; override with `SENTRY_TRACES_SAMPLE_RATE`, or set `0` to disable). The Supabase JS integration instruments PostgREST operations so Sentry's Queries dashboard can show slowest tables/operations. +Server/edge tracing is enabled at a low default sample rate (`tracesSampleRate` defaults to `0.1`; override with `SENTRY_TRACES_SAMPLE_RATE`, or set `0` to disable). The Supabase JS integration (Node server only — `src/lib/observability/supabase-tracing.ts`) instruments PostgREST operations so Sentry's Queries dashboard can show slowest tables/operations. Integration and admin-client instrumentation stay inert unless `SENTRY_DSN` is set and the resolved sample rate is greater than zero. Privacy constraints for traces: diff --git a/src/lib/observability/error-tracking.ts b/src/lib/observability/error-tracking.ts index 5bebb14b23..a344c09fb1 100644 --- a/src/lib/observability/error-tracking.ts +++ b/src/lib/observability/error-tracking.ts @@ -1,4 +1,3 @@ -import { createRequire } from "node:module"; import type { ErrorEvent } from "@sentry/nextjs"; import type { Instrumentation } from "next"; @@ -105,6 +104,11 @@ export function resolveTracesSampleRate(rawValue: string | undefined = process.e return parsed; } +/** True when a DSN is configured and the resolved traces sample rate is > 0. */ +export function isSentryDbTracingEnabled(): boolean { + return Boolean(process.env.SENTRY_DSN?.trim()) && resolveTracesSampleRate() > 0; +} + function privacySafeSpanDescription(data: Record, fallback: string | undefined): string | undefined { const operation = typeof data["db.operation"] === "string" ? data["db.operation"] : undefined; const table = typeof data["db.table"] === "string" ? data["db.table"] : undefined; @@ -236,25 +240,6 @@ export function privacySafeErrorEvent(event: ErrorEvent): ErrorEvent { }; } -/** - * Instrument a Supabase JS client for DB spans without shipping filter values - * or mutation bodies (`sendOperationData: false`). Safe to call for every - * client — the SDK marks the constructor prototype once. - */ -export function instrumentSupabaseClientForTracing(supabaseClient: unknown): void { - if (!process.env.SENTRY_DSN?.trim()) { - return; - } - - try { - const require = createRequire(import.meta.url); - const Sentry = require("@sentry/nextjs") as typeof import("@sentry/nextjs"); - Sentry.instrumentSupabaseClient(supabaseClient, { sendOperationData: false }); - } catch { - // Optional observability must never take down Supabase access. - } -} - /** * Status probe only. Runtime init is owned by `src/sentry.server.config.ts` * (loaded once from `instrumentation.register`) so privacy scrubbing cannot race diff --git a/src/lib/observability/supabase-tracing.ts b/src/lib/observability/supabase-tracing.ts new file mode 100644 index 0000000000..82828fa7f8 --- /dev/null +++ b/src/lib/observability/supabase-tracing.ts @@ -0,0 +1,22 @@ +import "server-only"; + +import * as Sentry from "@sentry/nextjs"; + +import { isSentryDbTracingEnabled } from "@/lib/observability/error-tracking"; + +/** + * Node-only Supabase client instrumentation for privacy-safe DB spans. + * Kept out of `error-tracking.ts` so Edge instrumentation can import scrubbers + * without pulling Node core modules or the Node Sentry entry. + */ +export function instrumentSupabaseClientForTracing(supabaseClient: unknown): void { + if (!isSentryDbTracingEnabled() || !supabaseClient) { + return; + } + + try { + Sentry.instrumentSupabaseClient(supabaseClient, { sendOperationData: false }); + } catch { + // Optional observability must never take down Supabase access. + } +} diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index e738d575c3..96dbeca27e 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -1,6 +1,6 @@ import { createClient } from "@supabase/supabase-js"; import { requireServerEnv } from "@/lib/env"; -import { instrumentSupabaseClientForTracing } from "@/lib/observability/error-tracking"; +import { instrumentSupabaseClientForTracing } from "@/lib/observability/supabase-tracing"; import type { Database } from "./database.types"; // Cache the admin client as a module-level singleton so that every API request @@ -20,7 +20,7 @@ export function createAdminClient() { }, }); // Constructor-level DB instrumentation (shared with SSR clients) plus admin auth spans. - // No-op when SENTRY_DSN is unset; never attaches query filters/bodies. + // No-op when DSN is unset or SENTRY_TRACES_SAMPLE_RATE=0; never attaches filters/bodies. instrumentSupabaseClientForTracing(adminClient); } return adminClient; diff --git a/src/sentry.server.config.ts b/src/sentry.server.config.ts index ab75c145ba..94fa9fac75 100644 --- a/src/sentry.server.config.ts +++ b/src/sentry.server.config.ts @@ -2,6 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { createClient } from "@supabase/supabase-js"; import { + isSentryDbTracingEnabled, privacySafeErrorEvent, privacySafeTransactionEvent, resolveTracesSampleRate, @@ -9,6 +10,7 @@ import { const sentryEnvironment = process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || "development"; const sentryDsn = process.env.SENTRY_DSN?.trim(); +const tracesSampleRate = resolveTracesSampleRate(); const sentryRelease = process.env.SENTRY_RELEASE ?? process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "dev"; @@ -36,6 +38,11 @@ function isBotTrafficEvent(event: Sentry.Event): boolean { /** Bootstrap client used only to attach constructor-level Supabase DB instrumentation. */ function supabaseTracingIntegrations() { + // Inert unless DSN is set and tracing sample rate is > 0 (docs/error-tracking.md). + if (!isSentryDbTracingEnabled()) { + return []; + } + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY?.trim() || process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); if (!url || !key) { @@ -68,7 +75,7 @@ try { environment: sentryEnvironment, // Performance tracing for DB query dashboards. Override with SENTRY_TRACES_SAMPLE_RATE // (0 disables). Query filters/bodies stay redacted — see docs/error-tracking.md. - tracesSampleRate: resolveTracesSampleRate(), + tracesSampleRate, sendDefaultPii: false, dataCollection: { databaseQueryData: false, diff --git a/tests/error-tracking.test.ts b/tests/error-tracking.test.ts index f1e920fd6d..5c3ec714ea 100644 --- a/tests/error-tracking.test.ts +++ b/tests/error-tracking.test.ts @@ -141,15 +141,47 @@ describe("privacySafeTransactionEvent", () => { }); }); +describe("isSentryDbTracingEnabled", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("requires a DSN and a positive sample rate", async () => { + vi.stubEnv("SENTRY_DSN", ""); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.1"); + const disabled = await import("@/lib/observability/error-tracking"); + expect(disabled.isSentryDbTracingEnabled()).toBe(false); + + vi.resetModules(); + vi.stubEnv("SENTRY_DSN", "https://public@o0.ingest.sentry.io/1"); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0"); + const rateZero = await import("@/lib/observability/error-tracking"); + expect(rateZero.isSentryDbTracingEnabled()).toBe(false); + + vi.resetModules(); + vi.stubEnv("SENTRY_DSN", "https://public@o0.ingest.sentry.io/1"); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0.1"); + const enabled = await import("@/lib/observability/error-tracking"); + expect(enabled.isSentryDbTracingEnabled()).toBe(true); + }); +}); + describe("instrumentSupabaseClientForTracing", () => { afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); }); - it("is a no-op when SENTRY_DSN is unset", async () => { + it("is a no-op when SENTRY_DSN is unset or tracing sample rate is 0", async () => { vi.stubEnv("SENTRY_DSN", ""); - const { instrumentSupabaseClientForTracing } = await import("@/lib/observability/error-tracking"); + const { instrumentSupabaseClientForTracing } = await import("@/lib/observability/supabase-tracing"); expect(() => instrumentSupabaseClientForTracing({})).not.toThrow(); + + vi.resetModules(); + vi.stubEnv("SENTRY_DSN", "https://public@o0.ingest.sentry.io/1"); + vi.stubEnv("SENTRY_TRACES_SAMPLE_RATE", "0"); + const gated = await import("@/lib/observability/supabase-tracing"); + expect(() => gated.instrumentSupabaseClientForTracing({})).not.toThrow(); }); });