feat(deps): @sentry/nextjs error tracking (DSN-gated, dormant by default)

Wires the Sentry SDK shipped-but-dormant: no-op unless
`NEXT_PUBLIC_SENTRY_DSN` is set in the environment. Production opts
in via the deploy env; dev + CI stay quiet.

- `sentry.client.config.ts` / `sentry.server.config.ts` /
  `sentry.edge.config.ts` — runtime init, each guards on the DSN.
- `instrumentation.ts` — Next 13.4+ instrumentation hook that lazy-
  imports the server + edge configs when the DSN is present.
- `next.config.ts` — withSentryConfig only wraps the config when
  the DSN is set, so dev builds skip source-map upload + middleware
  injection.
- `src/lib/env.ts` — added optional NEXT_PUBLIC_SENTRY_DSN +
  SENTRY_ENVIRONMENT + SENTRY_TRACES_SAMPLE_RATE (defaults to 0.1).

Env vars to add to .env.example (blocked from this commit by the
.env hook — apply manually):

    # Sentry (optional — SDK is a no-op without a DSN)
    NEXT_PUBLIC_SENTRY_DSN=
    SENTRY_ENVIRONMENT=
    # Defaults to 0.1 (10%) when unset
    SENTRY_TRACES_SAMPLE_RATE=

Replay is opt-in only — disabled by default for now; we'd need to
audit privacy implications (PII redaction, GDPR) before enabling it.

Verified: tsc clean, vitest 1315/1315, next build green with DSN
unset (Sentry plumbing intact, runtime no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:38:18 +02:00
parent 699ae52827
commit 92975e6bf5
9 changed files with 1816 additions and 47 deletions

View File

@@ -170,7 +170,6 @@ Remaining (opportunistic, no concrete trigger):
| --------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`.toLocale*` remainder (93 sites)** | ~2-3h opportunistic | Migrate to `formatDate(...)` as you touch each file. Helper already shipped; 17 tests; sweep proven on PDF + template paths. |
| **drizzle-zod remainder (~28 simple validators)** | ~30 min per file | Migrate when a validator file is touched. Pattern proven in tags + brochures. |
| **PWA assets** (per `MEMORY.md`) | ~30 min | Add `public/icon-192.png`, `public/icon-512.png`, `public/icon-512-maskable.png` before shipping Phase B PWA scanner. |
| **Wire `<DataTable virtual />`** on big tables | ~15 min per site | Prop is shipped + opt-in. Apply to: admin/audit-log-list (10k rows possible), super-admin port switcher (50+ ports), client export modal preview. None blocking. |
| **Tier 2 polish — when product UX surfaces emerge** | each 30 min 1 h | `embla-carousel-react` + `yet-another-react-lightbox` for berth / yacht photo galleries · `react-resizable-panels` for docs hub sidebar · `@use-gesture/react` for kanban swipe. |
@@ -183,7 +182,7 @@ Decisions / parked:
- `@sentry/nextjs` — needs SaaS-dep decision.
- `@tiptap/core` upgrade — needs product decision on rich notes.
- `pdfjs-dist` / `@react-pdf-viewer/core` — in-browser PDF preview in docs hub (paired with Phase 2 docs-hub UX work).
- `next-pwa` / `@serwist/next`blocked on missing PWA icons per MEMORY.md.
- `next-pwa` / `@serwist/next`icons already in `public/`; revisit only when we want fuller service-worker integration (offline shell, install prompt UX).
- `next-intl` — no current i18n target.
- `posthog-js` — analytics scope decision.
- `react-virtuoso` — only useful if inbox grows past ~hundreds of items; current `<ScrollArea max-h-[400px]>` handles realistic volumes fine.

20
instrumentation.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Next.js instrumentation hook (Next 13.4+ / 15+ / 16+).
*
* Runs once at server startup. We use it to wire Sentry's server +
* edge runtimes. The client init is auto-bundled by withSentryConfig
* from `sentry.client.config.ts`.
*
* The Sentry imports are gated behind the DSN check so the SDK stays
* a no-op when unconfigured.
*/
export async function register() {
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}

View File

@@ -1,5 +1,6 @@
import type { NextConfig } from 'next';
import bundleAnalyzer from '@next/bundle-analyzer';
import { withSentryConfig } from '@sentry/nextjs';
const isProd = process.env.NODE_ENV === 'production';
@@ -117,4 +118,21 @@ const nextConfig: NextConfig = {
},
};
export default withBundleAnalyzer(nextConfig);
// Sentry wrapper is conditional: if NEXT_PUBLIC_SENTRY_DSN isn't set we
// skip its build-time source-map upload + middleware injection so dev
// builds stay fast and CI doesn't need credentials. When the DSN is
// present, withSentryConfig adds instrumentation hooks that route
// errors + traces to Sentry.
const withSentry = process.env.NEXT_PUBLIC_SENTRY_DSN
? (cfg: NextConfig) =>
withSentryConfig(cfg, {
silent: true,
widenClientFileUpload: true,
// We host on our own infra — disable Vercel-specific tunneling.
tunnelRoute: undefined,
// Strip Sentry debug logger from prod bundle.
disableLogger: true,
})
: (cfg: NextConfig) => cfg;
export default withSentry(withBundleAnalyzer(nextConfig));

View File

@@ -55,6 +55,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^1.0.12",
"@react-pdf/renderer": "^4.5.1",
"@sentry/nextjs": "^10.53.1",
"@socket.io/redis-adapter": "^8.3.0",
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
"@tanstack/react-query": "^5.100.10",

1754
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

25
sentry.client.config.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Sentry client-side init.
*
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset — Sentry stays
* shipped-but-dormant in dev. Production sets the DSN via the
* deploy env. Sampling rate is env-driven via
* `SENTRY_TRACES_SAMPLE_RATE` (defaults to 0.1 = 10% of transactions
* to avoid quota burn).
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
// Replay is opt-in — we'd need to verify privacy implications
// before enabling. Leave disabled by default.
replaysOnErrorSampleRate: 0,
replaysSessionSampleRate: 0,
});
}

17
sentry.edge.config.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Sentry edge-runtime init (proxy.ts / middleware).
*
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset.
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
});
}

18
sentry.server.config.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Sentry server-side init.
*
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset. Same DSN as the client
* config — Sentry routes events to the right project automatically.
*/
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
});
}

View File

@@ -61,6 +61,11 @@ const envSchema = z
// OpenAI (optional)
OPENAI_API_KEY: z.string().optional(),
// Sentry (optional — when unset the SDK is a no-op)
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(0.1),
// App
APP_URL: z.string().url(),
PUBLIC_SITE_URL: z.string().url(),