refactor: move umami and sentry to server side
This commit is contained in:
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
10
lib/env.ts
10
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,
|
||||
|
||||
@@ -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<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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
if (!this.options.enabled) return undefined;
|
||||
|
||||
Reference in New Issue
Block a user