diff --git a/.env.example b/.env.example index bc05507c..64642189 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ TARGET=development # Analytics (Umami) # ──────────────────────────────────────────────────────────────────────────── # Optional: Leave empty to disable analytics -NEXT_PUBLIC_UMAMI_WEBSITE_ID= +UMAMI_WEBSITE_ID= UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me # ──────────────────────────────────────────────────────────────────────────── diff --git a/.env.production b/.env.production index 351aef00..337b4dcd 100644 --- a/.env.production +++ b/.env.production @@ -12,7 +12,7 @@ NODE_ENV=production NEXT_PUBLIC_BASE_URL=https://klz-cables.com # Analytics (Umami) -NEXT_PUBLIC_UMAMI_WEBSITE_ID= +UMAMI_WEBSITE_ID= UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me # Error Tracking (GlitchTip/Sentry) @@ -26,15 +26,5 @@ MAIL_PASSWORD= MAIL_FROM=KLZ Cables MAIL_RECIPIENTS=info@klz-cables.com -# Strapi -STRAPI_DATABASE_NAME=strapi -STRAPI_DATABASE_USERNAME=strapi -STRAPI_DATABASE_PASSWORD= -APP_KEYS= -API_TOKEN_SALT= -ADMIN_JWT_SECRET= -TRANSFER_TOKEN_SALT= -JWT_SECRET= - # Varnish Cache Size (optional) VARNISH_CACHE_SIZE=256m diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c5f20fd9..190e13e3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -212,16 +212,12 @@ jobs: IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} TARGET: ${{ needs.prepare.outputs.target }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} - NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }} - UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }} DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }} run: | docker buildx build \ --pull \ --platform linux/arm64 \ --build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \ - --build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \ - --build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \ --build-arg NEXT_PUBLIC_TARGET="$TARGET" \ --build-arg DIRECTUS_URL="$DIRECTUS_URL" \ -t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \ @@ -245,9 +241,9 @@ jobs: ENV_FILE: ${{ needs.prepare.outputs.env_file }} TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} - NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }} + UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }} UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }} MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }} MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }} @@ -291,7 +287,7 @@ jobs: # Generated by CI - $TARGET - $(date -u) IMAGE_TAG=$IMAGE_TAG NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL - NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID + UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT SENTRY_DSN=$SENTRY_DSN LOG_LEVEL=$LOG_LEVEL diff --git a/Dockerfile b/Dockerfile index ae6043c6..362504e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,17 +25,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 # Build-time environment variables for Next.js # These are baked into the client bundle during build ARG NEXT_PUBLIC_BASE_URL -ARG NEXT_PUBLIC_BASE_URL -ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID -ARG UMAMI_API_ENDPOINT -ARG UMAMI_SCRIPT_URL -ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL ARG NEXT_PUBLIC_TARGET ARG DIRECTUS_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL -ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID -ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}} ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV DIRECTUS_URL=$DIRECTUS_URL diff --git a/app/errors/api/relay/route.ts b/app/errors/api/relay/route.ts new file mode 100644 index 00000000..6f9d9d77 --- /dev/null +++ b/app/errors/api/relay/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerAppServices } from '@/lib/services/create-services.server'; +import { config } from '@/lib/config'; + +/** + * Smart Proxy / Relay for Sentry/GlitchTip events. + * + * This Route Handler receives Sentry envelopes from the client, + * injects the correct DSN if needed, and forwards them to the + * internal GlitchTip/Sentry instance. + * + * This hides the real DSN from the client and bypasses ad-blockers + * that target Sentry's default ingest endpoints. + */ +export async function POST(request: NextRequest) { + const services = getServerAppServices(); + const logger = services.logger.child({ component: 'sentry-relay' }); + + try { + const envelope = await request.text(); + + // Sentry envelopes can contain multiple parts separated by newlines + const lines = envelope.split('\n'); + if (lines.length < 1) { + return NextResponse.json({ error: 'Empty envelope' }, { status: 400 }); + } + + const header = JSON.parse(lines[0]); + const realDsn = config.errors.glitchtip.dsn; + + if (!realDsn) { + logger.warn('Sentry relay received but no SENTRY_DSN configured on server'); + return NextResponse.json({ status: 'ignored' }, { status: 200 }); + } + + const dsnUrl = new URL(realDsn); + const projectId = dsnUrl.pathname.replace('/', ''); + const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`; + + logger.debug('Relaying Sentry envelope', { + projectId, + host: dsnUrl.host, + }); + + const response = await fetch(relayUrl, { + method: 'POST', + body: envelope, + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Sentry/GlitchTip API responded with error', { + status: response.status, + error: errorText.slice(0, 100), + }); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: 'ok' }); + } catch (error) { + logger.error('Failed to relay Sentry request', { + error: (error as Error).message, + }); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/stats/api/send/route.ts b/app/stats/api/send/route.ts new file mode 100644 index 00000000..b0257fd6 --- /dev/null +++ b/app/stats/api/send/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerAppServices } from '@/lib/services/create-services.server'; +import { config } from '@/lib/config'; + +/** + * Smart Proxy for Umami Analytics. + * + * This Route Handler receives tracking events from the browser, + * injects the secret UMAMI_WEBSITE_ID, and forwards them to the + * internal Umami API endpoint. + * + * This ensures: + * 1. The Website ID is NOT leaked to the client bundle. + * 2. The Umami API endpoint is hidden behind our domain. + * 3. We have full control over the tracking data. + */ +export async function POST(request: NextRequest) { + const services = getServerAppServices(); + const logger = services.logger.child({ component: 'umami-smart-proxy' }); + + try { + const body = await request.json(); + const { type, payload } = body; + + // Inject the secret websiteId from server config + const websiteId = config.analytics.umami.websiteId; + if (!websiteId) { + logger.warn('Umami tracking received but no Website ID configured on server'); + return NextResponse.json({ status: 'ignored' }, { status: 200 }); + } + + // Prepare the enhanced payload with the secret ID + const enhancedPayload = { + ...payload, + website: websiteId, + }; + + const umamiEndpoint = config.analytics.umami.apiEndpoint; + + // Log the event (internal only) + logger.debug('Forwarding analytics event', { + type, + url: payload.url, + website: websiteId.slice(0, 8) + '...', + }); + + const response = await fetch(`${umamiEndpoint}/api/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy', + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || '', + }, + body: JSON.stringify({ type, payload: enhancedPayload }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Umami API responded with error', { + status: response.status, + error: errorText.slice(0, 100), + }); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: 'ok' }); + } catch (error) { + logger.error('Failed to proxy analytics request', { + error: (error as Error).message, + }); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/components/analytics/AnalyticsProvider.tsx b/components/analytics/AnalyticsProvider.tsx index 27b9ffb9..f6fe8d79 100644 --- a/components/analytics/AnalyticsProvider.tsx +++ b/components/analytics/AnalyticsProvider.tsx @@ -8,19 +8,12 @@ import { getAppServices } from '@/lib/services/create-services'; * AnalyticsProvider Component * * Automatically tracks pageviews on client-side route changes. - * This component should be placed inside your layout to handle navigation events. + * This component handles navigation events for the Umami analytics service. * - * @param {Object} props - Component props - * @param {string} [props.websiteId] - The Umami website ID (passed from server config) - * - * @example - * ```tsx - * // In your layout.tsx - * const { websiteId } = config.analytics.umami; - * - * ``` + * Note: Website ID is now centrally managed on the server side via a proxy, + * so it's no longer needed as a prop here. */ -export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) { +export default function AnalyticsProvider() { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -31,14 +24,12 @@ export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`; // Track pageview with the full URL + // The service will relay this to our internal proxy which injects the Website ID services.analytics.trackPageview(url); - if (process.env.NODE_ENV === 'development') { - console.log('[Umami] Tracked pageview:', url); - } + // Services like logger are already sub-initialized in getAppServices() + // so we don't need to log here manually. }, [pathname, searchParams]); - if (!websiteId) return null; - return null; } diff --git a/lib/config.ts b/lib/config.ts index f8331366..d9ccc3e6 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -27,9 +27,9 @@ function createConfig() { analytics: { umami: { - websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + websiteId: env.UMAMI_WEBSITE_ID, apiEndpoint: env.UMAMI_API_ENDPOINT, - enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), + enabled: Boolean(env.UMAMI_WEBSITE_ID), }, }, diff --git a/lib/env.ts b/lib/env.ts index 59d8bf46..0c166a8c 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -15,7 +15,7 @@ export const envSchema = z NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(), // Analytics - NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()), + UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()), UMAMI_API_ENDPOINT: z.preprocess( preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me'), @@ -82,12 +82,8 @@ export function getRawEnv() { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET, - NEXT_PUBLIC_UMAMI_WEBSITE_ID: - process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID, - UMAMI_API_ENDPOINT: - process.env.UMAMI_API_ENDPOINT || - process.env.UMAMI_SCRIPT_URL || - process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, + UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID, + UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT, SENTRY_DSN: process.env.SENTRY_DSN, LOG_LEVEL: process.env.LOG_LEVEL, MAIL_HOST: process.env.MAIL_HOST, diff --git a/lib/services/analytics/umami-analytics-service.ts b/lib/services/analytics/umami-analytics-service.ts index d77dfe78..a4c6f255 100644 --- a/lib/services/analytics/umami-analytics-service.ts +++ b/lib/services/analytics/umami-analytics-service.ts @@ -1,5 +1,6 @@ import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service'; import { config } from '../../config'; +import type { LoggerService } from '../logging/logger-service'; /** * Configuration options for UmamiAnalyticsService. @@ -18,56 +19,94 @@ export type UmamiAnalyticsServiceOptions = { * * In the browser, it gathers standard metadata (screen, language, referrer) * and sends it to the proxied '/stats/api/send' endpoint. + * On the server, it sends directly to the internal Umami API. */ export class UmamiAnalyticsService implements AnalyticsService { private websiteId?: string; private endpoint: string; + private logger: LoggerService; - constructor(private readonly options: UmamiAnalyticsServiceOptions) { + constructor( + private readonly options: UmamiAnalyticsServiceOptions, + logger: LoggerService, + ) { this.websiteId = config.analytics.umami.websiteId; + this.logger = logger.child({ component: 'analytics-umami' }); // On server, use the full internal URL; on client, use the proxied path this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats'; + + this.logger.debug('Umami service initialized', { + enabled: this.options.enabled, + websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)', + endpoint: this.endpoint, + }); } /** * Internal method to send the payload to Umami API. */ private async sendPayload(type: 'event', data: Record) { - if (!this.options.enabled || !this.websiteId) return; + if (!this.options.enabled) return; + + // On the client, we don't need the websiteId (it's injected by the server-side proxy handler). + // On the server, we need it because we're calling the Umami API directly. + const isClient = typeof window !== 'undefined'; + + if (!isClient && !this.websiteId) { + this.logger.warn('Umami tracking called on server but no Website ID configured'); + return; + } try { const payload = { website: this.websiteId, - hostname: typeof window !== 'undefined' ? window.location.hostname : 'server', - screen: - typeof window !== 'undefined' - ? `${window.screen.width}x${window.screen.height}` - : undefined, - language: typeof window !== 'undefined' ? navigator.language : undefined, - referrer: typeof window !== 'undefined' ? document.referrer : undefined, + hostname: isClient ? window.location.hostname : 'server', + screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined, + language: isClient ? navigator.language : undefined, + referrer: isClient ? document.referrer : undefined, ...data, }; - const response = await fetch(`${this.endpoint}/api/send`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent, - }, - body: JSON.stringify({ type, payload }), - // Use keepalive for page navigation events to ensure they complete - keepalive: true, - } as any); + this.logger.trace('Sending analytics payload', { type, url: data.url }); - if (!response.ok && process.env.NODE_ENV === 'development') { - const errorText = await response.text(); - console.warn(`[Umami] API responded with ${response.status}: ${errorText}`); + // Add a timeout to prevent hanging requests + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + + try { + const response = await fetch(`${this.endpoint}/api/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': isClient ? navigator.userAgent : 'KLZ-Server', + }, + body: JSON.stringify({ type, payload }), + keepalive: true, + signal: controller.signal, + } as any); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + this.logger.warn('Umami API responded with error', { + status: response.status, + error: errorText.slice(0, 100), + }); + } + } catch (fetchError) { + clearTimeout(timeoutId); + if ((fetchError as Error).name === 'AbortError') { + this.logger.error('Umami request timed out'); + } else { + throw fetchError; + } } } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.error('[Umami] Failed to send analytics:', error); - } + this.logger.error('Failed to send analytics', { + error: (error as Error).message, + }); } } diff --git a/lib/services/create-services.server.ts b/lib/services/create-services.server.ts index c33c7d15..2cbee0a3 100644 --- a/lib/services/create-services.server.ts +++ b/lib/services/create-services.server.ts @@ -31,7 +31,7 @@ export function getServerAppServices(): AppServices { }); const analytics = config.analytics.umami.enabled - ? new UmamiAnalyticsService({ enabled: true }) + ? new UmamiAnalyticsService({ enabled: true }, logger) : new NoopAnalyticsService(); if (config.analytics.umami.enabled) { @@ -55,7 +55,7 @@ export function getServerAppServices(): AppServices { } const errors = config.errors.glitchtip.enabled - ? new GlitchtipErrorReportingService({ enabled: true }, notifications) + ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications) : new NoopErrorReportingService(); if (config.errors.glitchtip.enabled) { diff --git a/lib/services/create-services.ts b/lib/services/create-services.ts index 6042b880..bf6652df 100644 --- a/lib/services/create-services.ts +++ b/lib/services/create-services.ts @@ -1,5 +1,6 @@ import { AppServices } from './app-services'; import { NoopAnalyticsService } from './analytics/noop-analytics-service'; +import { UmamiAnalyticsService } from './analytics/umami-analytics-service'; import { MemoryCacheService } from './cache/memory-cache-service'; import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service'; import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; @@ -100,12 +101,8 @@ export function getAppServices(): AppServices { }); // Create analytics service (Umami or no-op) - // Use dynamic import to avoid importing server-only code in client components const analytics = umamiEnabled - ? (() => { - const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service'); - return new UmamiAnalyticsService({ enabled: true }); - })() + ? new UmamiAnalyticsService({ enabled: true }, logger) : new NoopAnalyticsService(); if (umamiEnabled) { @@ -114,9 +111,13 @@ export function getAppServices(): AppServices { logger.info('Noop analytics service initialized (analytics disabled)'); } + // Create notification service + const notifications = new NoopNotificationService(); + logger.info('Notification service initialized (noop)'); + // Create error reporting service (GlitchTip/Sentry or no-op) const errors = sentryEnabled - ? new GlitchtipErrorReportingService({ enabled: true }) + ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications) : new NoopErrorReportingService(); if (sentryEnabled) { @@ -139,7 +140,6 @@ export function getAppServices(): AppServices { }); // Create and cache the singleton - const notifications = new NoopNotificationService(); singleton = new AppServices(analytics, errors, cache, logger, notifications); logger.info('All application services initialized successfully'); diff --git a/lib/services/errors/glitchtip-error-reporting-service.ts b/lib/services/errors/glitchtip-error-reporting-service.ts index 31a52fce..4289592d 100644 --- a/lib/services/errors/glitchtip-error-reporting-service.ts +++ b/lib/services/errors/glitchtip-error-reporting-service.ts @@ -5,6 +5,7 @@ import type { ErrorReportingUser, } from './error-reporting-service'; import type { NotificationService } from '../notifications/notification-service'; +import type { LoggerService } from '../logging/logger-service'; type SentryLike = typeof Sentry; @@ -14,11 +15,16 @@ export type GlitchtipErrorReportingServiceOptions = { // GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. export class GlitchtipErrorReportingService implements ErrorReportingService { + private logger: LoggerService; + constructor( private readonly options: GlitchtipErrorReportingServiceOptions, + logger: LoggerService, private readonly notifications?: NotificationService, private readonly sentry: SentryLike = Sentry, - ) {} + ) { + this.logger = logger.child({ component: 'error-reporting-glitchtip' }); + } async captureException(error: unknown, context?: Record) { if (!this.options.enabled) return undefined; diff --git a/next.config.mjs b/next.config.mjs index c1788134..ddad8841 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -322,22 +322,9 @@ const nextConfig = { contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", }, async rewrites() { - const umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me'); - const glitchtipUrl = process.env.SENTRY_DSN - ? new URL(process.env.SENTRY_DSN).origin - : 'https://errors.infra.mintel.me'; - const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com'; return [ - { - source: '/stats/:path*', - destination: `${umamiUrl}/:path*`, - }, - { - source: '/errors/:path*', - destination: `${glitchtipUrl}/:path*`, - }, { source: '/cms/:path*', destination: `${directusUrl}/:path*`, diff --git a/sentry.client.config.ts b/sentry.client.config.ts index cd907476..fe2a0166 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -1,13 +1,19 @@ import * as Sentry from '@sentry/nextjs'; -const dsn = process.env.SENTRY_DSN; +// We use a placeholder DSN on the client because the real DSN is injected +// by our server-side relay at /errors/api/relay. +// This keeps our environment clean of NEXT_PUBLIC_ variables. +const CLIENT_DSN = 'https://public@errors.infra.mintel.me/1'; Sentry.init({ - dsn, - enabled: Boolean(dsn), + dsn: CLIENT_DSN, + // Relay events through our own server to hide the real DSN and bypass ad-blockers + tunnel: '/errors/api/relay', + + // Enable even if no DSN is provided, because we have the tunnel + enabled: true, + tracesSampleRate: 0, - // AdjustingreplaysOnErrorSampleRate to 1.0 to capture 100% of errors with replays if enabled replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.1, }); -