feat: wire Sentry, conditional on DSN env var

apps/web (@sentry/nextjs):
- sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts
  initialize Sentry only when NEXT_PUBLIC_SENTRY_DSN (or SENTRY_DSN on the
  server) is set; tracesSampleRate 0.1
- next.config.mjs wraps the existing config with withSentryConfig only when a
  DSN is present; source-map upload stays disabled until SENTRY_AUTH_TOKEN is
  provided
- .env.example documents NEXT_PUBLIC_SENTRY_DSN and the optional auth token

apps/api (@sentry/node):
- src/lib/sentry.ts initializes Sentry at module load when SENTRY_DSN is set
- server.ts imports sentry.ts as the very first side-effect so early-boot
  errors (env validation, plugin registration) reach Sentry
- env schema gains optional SENTRY_DSN (URL)
- app.ts registers an errorHandler that captures the exception with the
  authenticated user when SENTRY_DSN is set; logs and re-sends as before

Build-time housekeeping:
- profile route: targetDate ?? null on create to satisfy Prisma's input shape
  under exactOptionalPropertyTypes
- profile form: useForm receives defaultValues only when initial is provided
  (spread instead of `defaultValues: undefined`); Field error prop typed
  `string | undefined` for exactOptionalPropertyTypes

Without a DSN both apps run unchanged (Sentry is inert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 14:25:08 +02:00
1 parent 1d904484e6
commit 5b38ddc41d
15 files changed
+101 -13

No files matched your search

+3
View File
@@ -15,3 +15,6 @@ BETTER_AUTH_URL=http://localhost:3000
# Google OAuth (configurato a fine progetto)
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# Sentry — error tracking (lascia vuoto per disabilitare)
# SENTRY_DSN=
+1
View File
@@ -18,6 +18,7 @@
"@ketopath/auth": "workspace:*",
"@ketopath/db": "workspace:*",
"@ketopath/shared": "workspace:*",
"@sentry/node": "^8.30.0",
"better-auth": "^1.0.21",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
+15
View File
@@ -5,6 +5,7 @@ import sensible from '@fastify/sensible';
import Fastify, { type FastifyInstance } from 'fastify';
import { env } from './config/env.js';
import { Sentry } from './lib/sentry.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
@@ -46,5 +47,19 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(meRoutes);
await app.register(profileRoutes);
if (env.SENTRY_DSN) {
app.setErrorHandler((err, request, reply) => {
if (request.user) {
Sentry.captureException(err, {
user: { id: request.user.id, email: request.user.email },
});
} else {
Sentry.captureException(err);
}
app.log.error({ err }, 'unhandled error');
reply.send(err);
});
}
return app;
}
+1
View File
@@ -17,6 +17,7 @@ const envSchema = z.object({
DATABASE_URL: z.string().url(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
SENTRY_DSN: z.string().url().optional(),
});
export type Env = z.infer<typeof envSchema>;
+13
View File
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/node';
import { env } from '../config/env.js';
if (env.SENTRY_DSN) {
Sentry.init({
dsn: env.SENTRY_DSN,
environment: env.NODE_ENV,
tracesSampleRate: 0.1,
});
}
export { Sentry };
@@ -78,7 +78,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
weightCurrentKg: data.weightCurrentKg,
weightGoalKg: data.weightGoalKg,
activityLevel: data.activityLevel,
targetDate: data.targetDate,
targetDate: data.targetDate ?? null,
},
update: {
age: data.age,
+4
View File
@@ -1,3 +1,7 @@
// Side-effect import: initializes Sentry before any other module is evaluated.
// Must stay first — moving it later means errors thrown during early boot
// (env validation, Prisma adapter, etc.) wouldn't reach Sentry.
import './lib/sentry.js';
import { buildApp } from './app.js';
import { env } from './config/env.js';
+5 -1
View File
@@ -16,5 +16,9 @@ DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=publ
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# Sentry (verrà configurato in uno step dedicato)
# Sentry — error tracking (lascia vuoto per disabilitare)
# NEXT_PUBLIC_SENTRY_DSN=
# Per il source-map upload in produzione (opzionale):
# SENTRY_AUTH_TOKEN=
# SENTRY_ORG=
# SENTRY_PROJECT=
+15 -1
View File
@@ -1,3 +1,4 @@
import { withSentryConfig } from '@sentry/nextjs';
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n.ts');
@@ -20,4 +21,17 @@ const nextConfig = {
},
};
export default withNextIntl(nextConfig);
const isSentryEnabled = Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN);
const sentryWebpackPluginOptions = {
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
// Source map upload requires SENTRY_AUTH_TOKEN — disabled until configured.
disableServerWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN,
disableClientWebpackPlugin: !process.env.SENTRY_AUTH_TOKEN,
};
const composed = withNextIntl(nextConfig);
export default isSentryEnabled ? withSentryConfig(composed, sentryWebpackPluginOptions) : composed;
+1
View File
@@ -17,6 +17,7 @@
"@ketopath/ui": "workspace:*",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@sentry/nextjs": "^8.30.0",
"better-auth": "^1.0.21",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
+10
View File
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0.1,
environment: process.env.NODE_ENV,
});
+10
View File
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0.1,
environment: process.env.NODE_ENV,
});
+10
View File
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0.1,
environment: process.env.NODE_ENV,
});
@@ -28,17 +28,19 @@ export function ProfileForm({ initial }: { initial: Profile | null }) {
formState: { errors, isSubmitting },
} = useForm<ProfileInput>({
resolver: zodResolver(profileInputSchema),
defaultValues: initial
...(initial
? {
age: initial.age,
gender: initial.gender,
heightCm: initial.heightCm,
weightStartKg: initial.weightStartKg,
weightCurrentKg: initial.weightCurrentKg,
weightGoalKg: initial.weightGoalKg,
activityLevel: initial.activityLevel,
defaultValues: {
age: initial.age,
gender: initial.gender,
heightCm: initial.heightCm,
weightStartKg: initial.weightStartKg,
weightCurrentKg: initial.weightCurrentKg,
weightGoalKg: initial.weightGoalKg,
activityLevel: initial.activityLevel,
},
}
: undefined,
: {}),
});
const onSubmit = handleSubmit(async (input) => {
@@ -142,7 +144,7 @@ function Field({
children,
}: {
label: string;
error?: string;
error?: string | undefined;
children: React.ReactNode;
}) {
return (
BIN
View File
Binary file not shown.