diff --git a/apps/api/.env.example b/apps/api/.env.example index 3bd8fbb..523b11f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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= diff --git a/apps/api/package.json b/apps/api/package.json index 440bdc6..cc3f2ea 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a11c472..58d805b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 { 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; } diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 52adf2f..2d125ab 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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; diff --git a/apps/api/src/lib/sentry.ts b/apps/api/src/lib/sentry.ts new file mode 100644 index 0000000..914d729 --- /dev/null +++ b/apps/api/src/lib/sentry.ts @@ -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 }; diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index e878ad0..55475ba 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -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, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ebcc6f9..31540df 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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'; diff --git a/apps/web/.env.example b/apps/web/.env.example index ae0e216..096a9a4 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index caa3379..b04baf7 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json index 8b1b9f1..b6a4553 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts new file mode 100644 index 0000000..30b0ed3 --- /dev/null +++ b/apps/web/sentry.client.config.ts @@ -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, +}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts new file mode 100644 index 0000000..30b0ed3 --- /dev/null +++ b/apps/web/sentry.edge.config.ts @@ -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, +}); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts new file mode 100644 index 0000000..12435d5 --- /dev/null +++ b/apps/web/sentry.server.config.ts @@ -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, +}); diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 4e77f4f..88cede7 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -28,17 +28,19 @@ export function ProfileForm({ initial }: { initial: Profile | null }) { formState: { errors, isSubmitting }, } = useForm({ 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 ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74459b8..29dcf4c 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ