Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
cursor[bot] marked this conversation as resolved.
# Omit for current defaults. Example (enable diversity demotion + linear freshness):
# RAG_RANKING_CONFIG={"documentDiversityPenalty":0.03,"freshness":{"mode":"linear"}}
Expand Down Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
12 changes: 6 additions & 6 deletions docs/codebase-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
21 changes: 19 additions & 2 deletions docs/error-tracking.md
Original file line number Diff line number Diff line change
@@ -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(<table>)` (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.
182 changes: 179 additions & 3 deletions src/lib/observability/error-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
};

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<string, unknown>;
};
};
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<string, unknown>, 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<string, unknown>;
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) => ({
Expand All @@ -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);
Expand Down
22 changes: 22 additions & 0 deletions src/lib/observability/supabase-tracing.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
4 changes: 4 additions & 0 deletions src/lib/supabase/admin.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
15 changes: 13 additions & 2 deletions src/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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.
Expand Down
Loading
Loading