diff --git a/.env.example b/.env.example index 1762b1ba5..8528ed564 100644 --- a/.env.example +++ b/.env.example @@ -111,4 +111,23 @@ CACHE_EXPIRATION_INTERVAL=3600000 # A DSN tells a Sentry SDK where to send events so the events are associated with the correct project. # You can find the DSN in your project settings by navigating to [Project] > Settings > Client Keys (DSN) in sentry.io. PUBLIC_FRONTEND_SENTRY_DSN=https://example.ingest.de.sentry.io/example -BACKEND_SENTRY_DSN=https://example.ingest.de.sentry.io/example \ No newline at end of file +# We recommend adjusting this value in production, or using tracesSampler +# for finer control +PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE=1.0 +# This sets the sample rate to be 10%. You may want this to be 100% while +# in development and sample at a lower rate in production +PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.1 +# If the entire session is not sampled, use the below sample rate to sample +# sessions when an error occurs. +PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1.0 + +# A DSN tells a Sentry SDK where to send events so the events are associated with the correct project. +# You can find the DSN in your project settings by navigating to [Project] > Settings > Client Keys (DSN) in sentry.io. +BACKEND_SENTRY_DSN=https://example.ingest.de.sentry.io/example + +# The auth token to use when uploading source maps to Sentry. +SENTRY_AUTH_TOKEN=auth_token +# The organization slug of your Sentry organization. +SENTRY_ORG=openvaa +# The project slug of your Sentry project. +SENTRY_PROJECT=openvaa-frontend \ No newline at end of file diff --git a/backend/vaa-strapi/config/plugins.ts b/backend/vaa-strapi/config/plugins.ts index 6dc186d0b..07c74cf68 100644 --- a/backend/vaa-strapi/config/plugins.ts +++ b/backend/vaa-strapi/config/plugins.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports const aws = require('@aws-sdk/client-ses'); +import { staticSettings } from '@openvaa/app-shared'; export default ({ env }) => { const isDev = env('NODE_ENV') === 'development'; @@ -85,7 +86,7 @@ export default ({ env }) => { resolve: './src/plugins/openvaa-admin-tools' }, sentry: { - enabled: true, + enabled: staticSettings.sentry.enabled ?? false, config: { dsn: env('BACKEND_SENTRY_DSN'), environment: env('NODE_ENV'), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de32be554..f31f0f8df 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,6 +20,12 @@ services: BACKEND_API_TOKEN: ${BACKEND_API_TOKEN} PUBLIC_DEBUG: ${PUBLIC_DEBUG} PUBLIC_FRONTEND_SENTRY_DSN: ${PUBLIC_FRONTEND_SENTRY_DSN} + PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE: ${PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE} + PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE: ${PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE} + PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: ${PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE} + SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN} + SENTRY_ORG: ${SENTRY_ORG} + SENTRY_PROJECT: ${SENTRY_PROJECT} awslocal: extends: file: ./backend/vaa-strapi/docker-compose.dev.yml diff --git a/frontend/.env.example b/frontend/.env.example index f05a0af92..ab17844af 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -12,4 +12,22 @@ PUBLIC_BROWSER_FRONTEND_URL=http://localhost:5173 # Used to reach frontend instance from a server (differs from `PUBLIC_BROWSER_FRONTEND_URL` when using Docker) PUBLIC_SERVER_FRONTEND_URL=http://frontend:5173 -PUBLIC_FRONTEND_SENTRY_DSN=https://example.ingest.de.sentry.io/example \ No newline at end of file +# A DSN tells a Sentry SDK where to send events so the events are associated with the correct project. +# You can find the DSN in your project settings by navigating to [Project] > Settings > Client Keys (DSN) in sentry.io. +PUBLIC_FRONTEND_SENTRY_DSN=https://example.ingest.de.sentry.io/example +# We recommend adjusting this value in production, or using tracesSampler +# for finer control +PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE=1.0 +# This sets the sample rate to be 10%. You may want this to be 100% while +# in development and sample at a lower rate in production +PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0.1 +# If the entire session is not sampled, use the below sample rate to sample +# sessions when an error occurs. +PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1.0 + +# The auth token to use when uploading source maps to Sentry. +SENTRY_AUTH_TOKEN=auth_token +# The organization slug of your Sentry organization. +SENTRY_ORG=openvaa +# The project slug of your Sentry project. +SENTRY_PROJECT=openvaa-frontend \ No newline at end of file diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index c22386b84..affecf914 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,23 +1,30 @@ +import { staticSettings } from '@openvaa/app-shared'; import { handleErrorWithSentry, replayIntegration } from '@sentry/sveltekit'; import * as Sentry from '@sentry/sveltekit'; import { constants } from '$lib/utils/constants'; +import type { HandleClientError } from '@sveltejs/kit'; -Sentry.init({ - dsn: constants.PUBLIC_FRONTEND_SENTRY_DSN, +if (staticSettings.sentry.enabled) { + Sentry.init({ + enabled: staticSettings.sentry.enabled, - tracesSampleRate: 1.0, + dsn: constants.PUBLIC_FRONTEND_SENTRY_DSN, - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, + tracesSampleRate: Number(constants.PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE), + replaysSessionSampleRate: Number(constants.PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE), + replaysOnErrorSampleRate: Number(constants.PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE), - // If the entire session is not sampled, use the below sample rate to sample - // sessions when an error occurs. - replaysOnErrorSampleRate: 1.0, + // If you don't want to use Session Replay, just remove the line below: + integrations: [replayIntegration()] + }); +} - // If you don't want to use Session Replay, just remove the line below: - integrations: [replayIntegration()] -}); +// The default behaviour is to log the error: +const handleErrorDefault = (async ({ error }) => { + console.error(error); +}) satisfies HandleClientError; // If you have a custom error handler, pass it to `handleErrorWithSentry` -export const handleError = handleErrorWithSentry(); +export const handleError = staticSettings.sentry.enabled + ? handleErrorWithSentry(handleErrorDefault) + : handleErrorDefault; diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 329e8ca8d..1451d0082 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,3 +1,4 @@ +import { staticSettings } from '@openvaa/app-shared'; import * as Sentry from '@sentry/sveltekit'; import { sequence } from '@sveltejs/kit/hooks'; import { API_ROOT } from '$lib/api/adapters/apiRoute/apiRoutes'; @@ -7,121 +8,124 @@ import { constants } from '$lib/utils/constants'; import { logDebugError } from '$lib/utils/logger'; import type { Handle, HandleServerError } from '@sveltejs/kit'; -Sentry.init({ - dsn: constants.PUBLIC_FRONTEND_SENTRY_DSN, - tracesSampleRate: 1 -}); - -// Handle and handleError based on sveltekit-i18n examples: https://github.com/sveltekit-i18n/lib/blob/master/examples/locale-router-advanced/src/hooks.server.js +if (staticSettings.sentry.enabled) { + Sentry.init({ + dsn: constants.PUBLIC_FRONTEND_SENTRY_DSN, + tracesSampleRate: 1 + }); +} /** Set to `true` to show debug log in console */ const DEBUG = false; -export const handle: Handle = sequence(Sentry.sentryHandle(), (async ({ event, resolve }) => { - const { params, route, url, request, isDataRequest } = event; - const { pathname, search } = url; - const requestedLocale = params.lang; - - const supportedLocales = locales.get(); - let cleanPath = requestedLocale ? pathname.replace(new RegExp(`^/${requestedLocale}`, 'i'), '') : pathname; - if (cleanPath === '') cleanPath = '/'; - - debug('Route: START', { params, pathname, isDataRequest, route }); - - ////////////////////////////////////////////////////////////////////////// - // 1. Handle non-route requests - ////////////////////////////////////////////////////////////////////////// - - // If this request is not a route request, resolve normally - // NB. If defining API routes that should return json, test cleanPath here and resolve - if (route?.id == null || pathname == null || pathname.startsWith(API_ROOT)) { - debug('Route: RESOLVE non-route request'); - return resolve(event); - } - - ////////////////////////////////////////////////////////////////////////// - // 2. Figure out which locale to serve - ////////////////////////////////////////////////////////////////////////// - - let preferredLocale: string | undefined; - let servedLocale: string | undefined; - - // Get preferred locale from request headers - const acceptLanguage = request.headers.get('accept-language'); - if (acceptLanguage) { - const preferredLocales = parseAcceptedLanguages(acceptLanguage); - preferredLocale = matchLocale(preferredLocales, supportedLocales); - } - - if (supportedLocales.length === 1) { - // No need for locale matching if there's only one locale - servedLocale = defaultLocale; - } else if (requestedLocale) { - // We use soft locale matching for route parameters, so we need to map the param to a supported one - servedLocale = matchLocale(requestedLocale, supportedLocales); - } - // If we still don't have a locale use the preferred one or the default one - servedLocale ??= preferredLocale ?? defaultLocale; - debug( - `Route: LOCALE parsed to ${servedLocale} • PATH to '${cleanPath}' (requested ${requestedLocale}, preferred ${preferredLocale})` - ); - - ////////////////////////////////////////////////////////////////////////// - // 3. Redirect if the locale param is not the same as the served locale - ////////////////////////////////////////////////////////////////////////// - - if (requestedLocale !== servedLocale) { - debug(`Route: REDIRECT to locale ${servedLocale}`); - return new Response(undefined, { - headers: { location: `/${servedLocale}${cleanPath}${search}` }, - status: 301 - }); - } - - ////////////////////////////////////////////////////////////////////////// - // 4. Handle candidate requests - ////////////////////////////////////////////////////////////////////////// - - if (pathname.startsWith(`/${servedLocale}/candidate`)) { - const token = event.cookies.get('token'); - - if (token && pathname.endsWith('candidate/login')) { - debug('Route: REDIRECT to home page'); - return new Response(undefined, { - headers: { location: `/${servedLocale}/candidate` }, - status: 303 - }); +export const handle: Handle = sequence( + staticSettings.sentry.enabled ? Sentry.sentryHandle() : async ({ event, resolve }) => resolve(event), // Use no-op if Sentry disabled. + (async ({ event, resolve }) => { + const { params, route, url, request, isDataRequest } = event; + const { pathname, search } = url; + const requestedLocale = params.lang; + + const supportedLocales = locales.get(); + let cleanPath = requestedLocale ? pathname.replace(new RegExp(`^/${requestedLocale}`, 'i'), '') : pathname; + if (cleanPath === '') cleanPath = '/'; + + debug('Route: START', { params, pathname, isDataRequest, route }); + + ////////////////////////////////////////////////////////////////////////// + // 1. Handle non-route requests + ////////////////////////////////////////////////////////////////////////// + + // If this request is not a route request, resolve normally + // NB. If defining API routes that should return json, test cleanPath here and resolve + if (route?.id == null || pathname == null || pathname.startsWith(API_ROOT)) { + debug('Route: RESOLVE non-route request'); + return resolve(event); } - if (!token && route.id.includes('(protected)')) { - debug('Route: REDIRECT to login page'); + ////////////////////////////////////////////////////////////////////////// + // 2. Figure out which locale to serve + ////////////////////////////////////////////////////////////////////////// + + let preferredLocale: string | undefined; + let servedLocale: string | undefined; + + // Get preferred locale from request headers + const acceptLanguage = request.headers.get('accept-language'); + if (acceptLanguage) { + const preferredLocales = parseAcceptedLanguages(acceptLanguage); + preferredLocale = matchLocale(preferredLocales, supportedLocales); + } + + if (supportedLocales.length === 1) { + // No need for locale matching if there's only one locale + servedLocale = defaultLocale; + } else if (requestedLocale) { + // We use soft locale matching for route parameters, so we need to map the param to a supported one + servedLocale = matchLocale(requestedLocale, supportedLocales); + } + // If we still don't have a locale use the preferred one or the default one + servedLocale ??= preferredLocale ?? defaultLocale; + debug( + `Route: LOCALE parsed to ${servedLocale} • PATH to '${cleanPath}' (requested ${requestedLocale}, preferred ${preferredLocale})` + ); + + ////////////////////////////////////////////////////////////////////////// + // 3. Redirect if the locale param is not the same as the served locale + ////////////////////////////////////////////////////////////////////////// + + if (requestedLocale !== servedLocale) { + debug(`Route: REDIRECT to locale ${servedLocale}`); return new Response(undefined, { - headers: { location: `/${servedLocale}/candidate/login?redirectTo=${cleanPath.substring(1)}` }, - status: 303 + headers: { location: `/${servedLocale}${cleanPath}${search}` }, + status: 301 }); } - } - - ////////////////////////////////////////////////////////////////////////// - // 5. Serve content in the requested locale - ////////////////////////////////////////////////////////////////////////// - - debug(`Route: SERVE with proper locale ${servedLocale}`); - return resolve( - { - ...event, - locals: { - currentLocale: servedLocale, - preferredLocale + + ////////////////////////////////////////////////////////////////////////// + // 4. Handle candidate requests + ////////////////////////////////////////////////////////////////////////// + + if (pathname.startsWith(`/${servedLocale}/candidate`)) { + const token = event.cookies.get('token'); + + if (token && pathname.endsWith('candidate/login')) { + debug('Route: REDIRECT to home page'); + return new Response(undefined, { + headers: { location: `/${servedLocale}/candidate` }, + status: 303 + }); + } + + if (!token && route.id.includes('(protected)')) { + debug('Route: REDIRECT to login page'); + return new Response(undefined, { + headers: { location: `/${servedLocale}/candidate/login?redirectTo=${cleanPath.substring(1)}` }, + status: 303 + }); } - }, - { - transformPageChunk: ({ html }) => html.replace('%lang%', `${servedLocale}`) } - ); -}) satisfies Handle); -export const handleError = Sentry.handleErrorWithSentry((async ({ error, event }) => { + ////////////////////////////////////////////////////////////////////////// + // 5. Serve content in the requested locale + ////////////////////////////////////////////////////////////////////////// + + debug(`Route: SERVE with proper locale ${servedLocale}`); + return resolve( + { + ...event, + locals: { + currentLocale: servedLocale, + preferredLocale + } + }, + { + transformPageChunk: ({ html }) => html.replace('%lang%', `${servedLocale}`) + } + ); + }) satisfies Handle +); + +const handleErrorDefault = (async ({ error, event }) => { const { locals } = event; const currentLocale = locals?.currentLocale; logDebugError('handleError', error); @@ -129,9 +133,12 @@ export const handleError = Sentry.handleErrorWithSentry((async ({ error, event } return { message: '500' }; -}) satisfies HandleServerError); +}) satisfies HandleServerError; + +export const handleError = staticSettings.sentry.enabled + ? Sentry.handleErrorWithSentry(handleErrorDefault) + : handleErrorDefault; -/** Show debug message if `DEBUG` is `true` */ function debug(message: unknown, error?: unknown) { if (DEBUG) logDebugError(message, error); } diff --git a/frontend/src/lib/utils/constants.ts b/frontend/src/lib/utils/constants.ts index 809a379af..f456d69d3 100644 --- a/frontend/src/lib/utils/constants.ts +++ b/frontend/src/lib/utils/constants.ts @@ -6,5 +6,8 @@ export const constants: Record = { PUBLIC_IDENTITY_PROVIDER_CLIENT_ID: env.PUBLIC_IDENTITY_PROVIDER_CLIENT_ID, PUBLIC_IDENTITY_PROVIDER_AUTHORIZATION_ENDPOINT: env.PUBLIC_IDENTITY_PROVIDER_AUTHORIZATION_ENDPOINT, PUBLIC_DEBUG: env.PUBLIC_DEBUG, - PUBLIC_FRONTEND_SENTRY_DSN: env.PUBLIC_FRONTEND_SENTRY_DSN + PUBLIC_FRONTEND_SENTRY_DSN: env.PUBLIC_FRONTEND_SENTRY_DSN, + PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE: env.PUBLIC_FRONTEND_SENTRY_TRACES_SAMPLE_RATE, + PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE: env.PUBLIC_FRONTEND_SENTRY_REPLAYS_SESSION_SAMPLE_RATE, + PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE: env.PUBLIC_FRONTEND_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d24c6717f..eb8c3cae0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,27 +1,15 @@ -import { sentrySvelteKit } from '@sentry/sveltekit'; import { sveltekit } from '@sveltejs/kit/vite'; import viteTsConfigPaths from 'vite-tsconfig-paths'; import type { UserConfig } from 'vite'; -const config: UserConfig = { - resolve: { - preserveSymlinks: true - }, - - plugins: [ - sentrySvelteKit({ - sourceMapsUploadOptions: { - org: 'openvaa', - project: 'openvaa-frontend' - } - }), - sveltekit(), - viteTsConfigPaths() - ], - - server: { - port: Number(process.env.FRONTEND_PORT) - } -}; - -export default config; +export default async function defineConfig(): Promise { + return { + resolve: { + preserveSymlinks: true + }, + plugins: [sveltekit(), viteTsConfigPaths()], + server: { + port: Number(process.env.FRONTEND_PORT) + } + }; +} diff --git a/packages/app-shared/src/settings/staticSettings.ts b/packages/app-shared/src/settings/staticSettings.ts index b42360a43..c69bc4c0c 100644 --- a/packages/app-shared/src/settings/staticSettings.ts +++ b/packages/app-shared/src/settings/staticSettings.ts @@ -62,5 +62,8 @@ export const staticSettings: StaticSettings = { }, preRegistration: { enabled: false + }, + sentry: { + enabled: false } }; diff --git a/packages/app-shared/src/settings/staticSettings.type.ts b/packages/app-shared/src/settings/staticSettings.type.ts index e72ebb822..60d1e4222 100644 --- a/packages/app-shared/src/settings/staticSettings.type.ts +++ b/packages/app-shared/src/settings/staticSettings.type.ts @@ -137,4 +137,13 @@ export type StaticSettings = { */ readonly enabled?: boolean; }; + /** + * Settings related to Sentry plugin. + */ + readonly sentry: { + /** + * Whether Sentry is enabled. + */ + readonly enabled?: boolean; + }; };