refactor: move umami and sentry to server side
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m39s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 2m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s

This commit is contained in:
2026-02-07 09:58:31 +01:00
parent 5b43349205
commit 5cfcc16dc2
15 changed files with 250 additions and 104 deletions

View File

@@ -20,7 +20,7 @@ TARGET=development
# Analytics (Umami) # Analytics (Umami)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable analytics # Optional: Leave empty to disable analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID= UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

View File

@@ -12,7 +12,7 @@ NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics (Umami) # Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID= UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry) # Error Tracking (GlitchTip/Sentry)
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com> MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com 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 (optional)
VARNISH_CACHE_SIZE=256m VARNISH_CACHE_SIZE=256m

View File

@@ -212,16 +212,12 @@ jobs:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
TARGET: ${{ needs.prepare.outputs.target }} TARGET: ${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} 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 }} DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
run: | run: |
docker buildx build \ docker buildx build \
--pull \ --pull \
--platform linux/arm64 \ --platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \ --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 NEXT_PUBLIC_TARGET="$TARGET" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \ --build-arg DIRECTUS_URL="$DIRECTUS_URL" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \ -t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
@@ -245,9 +241,9 @@ jobs:
ENV_FILE: ${{ needs.prepare.outputs.env_file }} ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }} TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} 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) }} 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_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_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))) }} 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) # Generated by CI - $TARGET - $(date -u)
IMAGE_TAG=$IMAGE_TAG IMAGE_TAG=$IMAGE_TAG
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL 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 UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
SENTRY_DSN=$SENTRY_DSN SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$LOG_LEVEL LOG_LEVEL=$LOG_LEVEL

View File

@@ -25,17 +25,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js # Build-time environment variables for Next.js
# These are baked into the client bundle during build # These are baked into the client bundle during build
ARG NEXT_PUBLIC_BASE_URL 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 NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_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 NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL ENV DIRECTUS_URL=$DIRECTUS_URL

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -8,19 +8,12 @@ import { getAppServices } from '@/lib/services/create-services';
* AnalyticsProvider Component * AnalyticsProvider Component
* *
* Automatically tracks pageviews on client-side route changes. * 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 * Note: Website ID is now centrally managed on the server side via a proxy,
* @param {string} [props.websiteId] - The Umami website ID (passed from server config) * so it's no longer needed as a prop here.
*
* @example
* ```tsx
* // In your layout.tsx
* const { websiteId } = config.analytics.umami;
* <AnalyticsProvider websiteId={websiteId} />
* ```
*/ */
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) { export default function AnalyticsProvider() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -31,14 +24,12 @@ export default function AnalyticsProvider({ websiteId }: { websiteId?: string })
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`; const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL // Track pageview with the full URL
// The service will relay this to our internal proxy which injects the Website ID
services.analytics.trackPageview(url); services.analytics.trackPageview(url);
if (process.env.NODE_ENV === 'development') { // Services like logger are already sub-initialized in getAppServices()
console.log('[Umami] Tracked pageview:', url); // so we don't need to log here manually.
}
}, [pathname, searchParams]); }, [pathname, searchParams]);
if (!websiteId) return null;
return null; return null;
} }

View File

@@ -27,9 +27,9 @@ function createConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT, apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), enabled: Boolean(env.UMAMI_WEBSITE_ID),
}, },
}, },

View File

@@ -15,7 +15,7 @@ export const envSchema = z
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(), NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
// Analytics // 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( UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me'), z.string().url().default('https://analytics.infra.mintel.me'),
@@ -82,12 +82,8 @@ export function getRawEnv() {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET, NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID, UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
UMAMI_API_ENDPOINT:
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN, SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST, MAIL_HOST: process.env.MAIL_HOST,

View File

@@ -1,5 +1,6 @@
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service'; import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
import { config } from '../../config'; import { config } from '../../config';
import type { LoggerService } from '../logging/logger-service';
/** /**
* Configuration options for UmamiAnalyticsService. * Configuration options for UmamiAnalyticsService.
@@ -18,56 +19,94 @@ export type UmamiAnalyticsServiceOptions = {
* *
* In the browser, it gathers standard metadata (screen, language, referrer) * In the browser, it gathers standard metadata (screen, language, referrer)
* and sends it to the proxied '/stats/api/send' endpoint. * 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 { export class UmamiAnalyticsService implements AnalyticsService {
private websiteId?: string; private websiteId?: string;
private endpoint: 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.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 // On server, use the full internal URL; on client, use the proxied path
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats'; 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. * Internal method to send the payload to Umami API.
*/ */
private async sendPayload(type: 'event', data: Record<string, any>) { private async sendPayload(type: 'event', data: Record<string, any>) {
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 { try {
const payload = { const payload = {
website: this.websiteId, website: this.websiteId,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server', hostname: isClient ? window.location.hostname : 'server',
screen: screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
typeof window !== 'undefined' language: isClient ? navigator.language : undefined,
? `${window.screen.width}x${window.screen.height}` referrer: isClient ? document.referrer : undefined,
: undefined,
language: typeof window !== 'undefined' ? navigator.language : undefined,
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
...data, ...data,
}; };
this.logger.trace('Sending analytics payload', { type, url: data.url });
// 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`, { const response = await fetch(`${this.endpoint}/api/send`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent, 'User-Agent': isClient ? navigator.userAgent : 'KLZ-Server',
}, },
body: JSON.stringify({ type, payload }), body: JSON.stringify({ type, payload }),
// Use keepalive for page navigation events to ensure they complete
keepalive: true, keepalive: true,
signal: controller.signal,
} as any); } as any);
if (!response.ok && process.env.NODE_ENV === 'development') { clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`); 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) { } catch (error) {
if (process.env.NODE_ENV === 'development') { this.logger.error('Failed to send analytics', {
console.error('[Umami] Failed to send analytics:', error); error: (error as Error).message,
} });
} }
} }

View File

@@ -31,7 +31,7 @@ export function getServerAppServices(): AppServices {
}); });
const analytics = config.analytics.umami.enabled const analytics = config.analytics.umami.enabled
? new UmamiAnalyticsService({ enabled: true }) ? new UmamiAnalyticsService({ enabled: true }, logger)
: new NoopAnalyticsService(); : new NoopAnalyticsService();
if (config.analytics.umami.enabled) { if (config.analytics.umami.enabled) {
@@ -55,7 +55,7 @@ export function getServerAppServices(): AppServices {
} }
const errors = config.errors.glitchtip.enabled const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, notifications) ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) { if (config.errors.glitchtip.enabled) {

View File

@@ -1,5 +1,6 @@
import { AppServices } from './app-services'; import { AppServices } from './app-services';
import { NoopAnalyticsService } from './analytics/noop-analytics-service'; import { NoopAnalyticsService } from './analytics/noop-analytics-service';
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
import { MemoryCacheService } from './cache/memory-cache-service'; import { MemoryCacheService } from './cache/memory-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service'; import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-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) // Create analytics service (Umami or no-op)
// Use dynamic import to avoid importing server-only code in client components
const analytics = umamiEnabled const analytics = umamiEnabled
? (() => { ? new UmamiAnalyticsService({ enabled: true }, logger)
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
return new UmamiAnalyticsService({ enabled: true });
})()
: new NoopAnalyticsService(); : new NoopAnalyticsService();
if (umamiEnabled) { if (umamiEnabled) {
@@ -114,9 +111,13 @@ export function getAppServices(): AppServices {
logger.info('Noop analytics service initialized (analytics disabled)'); 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) // Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled const errors = sentryEnabled
? new GlitchtipErrorReportingService({ enabled: true }) ? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (sentryEnabled) { if (sentryEnabled) {
@@ -139,7 +140,6 @@ export function getAppServices(): AppServices {
}); });
// Create and cache the singleton // Create and cache the singleton
const notifications = new NoopNotificationService();
singleton = new AppServices(analytics, errors, cache, logger, notifications); singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info('All application services initialized successfully'); logger.info('All application services initialized successfully');

View File

@@ -5,6 +5,7 @@ import type {
ErrorReportingUser, ErrorReportingUser,
} from './error-reporting-service'; } from './error-reporting-service';
import type { NotificationService } from '../notifications/notification-service'; import type { NotificationService } from '../notifications/notification-service';
import type { LoggerService } from '../logging/logger-service';
type SentryLike = typeof Sentry; type SentryLike = typeof Sentry;
@@ -14,11 +15,16 @@ export type GlitchtipErrorReportingServiceOptions = {
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. // GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
export class GlitchtipErrorReportingService implements ErrorReportingService { export class GlitchtipErrorReportingService implements ErrorReportingService {
private logger: LoggerService;
constructor( constructor(
private readonly options: GlitchtipErrorReportingServiceOptions, private readonly options: GlitchtipErrorReportingServiceOptions,
logger: LoggerService,
private readonly notifications?: NotificationService, private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry, private readonly sentry: SentryLike = Sentry,
) {} ) {
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
}
async captureException(error: unknown, context?: Record<string, unknown>) { async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined; if (!this.options.enabled) return undefined;

View File

@@ -322,22 +322,9 @@ const nextConfig = {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}, },
async rewrites() { 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'; const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
return [ return [
{
source: '/stats/:path*',
destination: `${umamiUrl}/:path*`,
},
{
source: '/errors/:path*',
destination: `${glitchtipUrl}/:path*`,
},
{ {
source: '/cms/:path*', source: '/cms/:path*',
destination: `${directusUrl}/:path*`, destination: `${directusUrl}/:path*`,

View File

@@ -1,13 +1,19 @@
import * as Sentry from '@sentry/nextjs'; 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({ Sentry.init({
dsn, dsn: CLIENT_DSN,
enabled: Boolean(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, tracesSampleRate: 0,
// AdjustingreplaysOnErrorSampleRate to 1.0 to capture 100% of errors with replays if enabled
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
}); });