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/branch-review-ledger.md b/docs/branch-review-ledger.md
index dbfcfeaa3c..e57bad6745 100644
--- a/docs/branch-review-ledger.md
+++ b/docs/branch-review-ledger.md
@@ -567,3 +567,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 |
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..4ff6735455 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 (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:
+
+- `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..a344c09fb1 100644
--- a/src/lib/observability/error-tracking.ts
+++ b/src/lib/observability/error-tracking.ts
@@ -14,10 +14,188 @@ 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;
+
+/**
+ * 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"] | ScrubbedTransactionEvent["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;
+}
+
+/** 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;
+
+ 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: ScrubbedSpan): ScrubbedSpan {
+ 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 : {},
+ };
+}
+
+/** Keep timing + safe DB metadata; strip query filters, bodies, request/PII payloads. */
+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;
+
+ return {
+ 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,
+ };
+}
+
/** 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 +218,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);
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 572179baf0..96dbeca27e 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/supabase-tracing";
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 DSN is unset or SENTRY_TRACES_SAMPLE_RATE=0; never attaches filters/bodies.
+ instrumentSupabaseClientForTracing(adminClient);
}
return adminClient;
}
diff --git a/src/sentry.edge.config.ts b/src/sentry.edge.config.ts
index 31f2c0ec52..a7edbe6ea1 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,20 @@ 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) {
+ // Local scrubber shape is structural; cast back to the SDK transaction type.
+ return privacySafeTransactionEvent(event) as typeof 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..94fa9fac75 100644
--- a/src/sentry.server.config.ts
+++ b/src/sentry.server.config.ts
@@ -1,9 +1,16 @@
import * as Sentry from "@sentry/nextjs";
+import { createClient } from "@supabase/supabase-js";
-import { privacySafeErrorEvent } from "@/lib/observability/error-tracking";
+import {
+ isSentryDbTracingEnabled,
+ 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();
+const tracesSampleRate = resolveTracesSampleRate();
const sentryRelease =
process.env.SENTRY_RELEASE ?? process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "dev";
@@ -29,23 +36,65 @@ 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) {
+ 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,
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;
+ // Local scrubber shape is structural; cast back to the SDK transaction type.
+ return privacySafeTransactionEvent(event) as typeof 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..5c3ec714ea 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,129 @@ 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("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 or tracing sample rate is 0", async () => {
+ vi.stubEnv("SENTRY_DSN", "");
+ 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();
+ });
+});